pool: a standard for energy-accounted multi-tenant scheduling #323
17 changed files with 4967 additions and 0 deletions
1
.github/workflows/standards-build.yml
vendored
1
.github/workflows/standards-build.yml
vendored
|
|
@ -79,6 +79,7 @@ jobs:
|
|||
- { name: jcp-rs, dir: jcp/jcp-rs, runner: linux, conformance: jcp-conformance, feature_tests: "-p jcp-energy --features nvml" }
|
||||
- { name: sandbox-rs, dir: sandbox/sandbox-rs, runner: linux, conformance: sandbox-conformance }
|
||||
- { name: smart-byte-rs, dir: smart-byte/smart-byte-rs, runner: linux }
|
||||
- { name: pool-rs, dir: pool/pool-rs, runner: linux }
|
||||
- { name: openpay, dir: openpay, runner: linux-arm64 }
|
||||
- { name: joule-code-rs, dir: joule-code/joule-code-rs, runner: linux-arm64, z3: true }
|
||||
- { name: map-rs, dir: map/map-rs, runner: linux-arm64, conformance: map-conformance }
|
||||
|
|
|
|||
|
|
@ -123,6 +123,13 @@ The **serving + routing fabric**: the front door in front of many models and man
|
|||
|
||||
- **Licence:** Apache-2.0 for reference code (see [`mesh/LICENSE`](mesh/LICENSE)); CC-BY-4.0 for specification text (see [`mesh/LICENSE-SPEC`](mesh/LICENSE-SPEC)).
|
||||
- **Status:** v0.1.0-draft.1. Specification core published (`SPEC.md` + `conformance/v1/`); reference workspace `mesh-rs/` — `mesh-schema` (the content-addressed `ServeRequest` carrying joule ceiling + fidelity floor, the ordered `F0–F4` fidelity scale, the two-part `HwShunt`/`ModelBased`/`Estimator` `Energy` model with the coupling enforced, the model-as-recipe, the `RouteDecision`/`RouteCandidate` audit and `ServeOutcome` counters), `mesh-router` (energy-weighted cheapest-sufficient routing — admissibility by fidelity floor **and** joule ceiling, cheapest admissible chosen, every candidate and its reject reason recorded), `mesh-engine` (the `ServingEngine` interface plus a deterministic, weights-free `ReferenceEngine`), `mesh-meter` (the meter window — an always-available load estimator plus an optional bridge to the family hardware meter (RAPL / NVML / Apple) behind `hw-meter`, provenance preserved across the bridge), `mesh-receipt` (the `mesh/1` JCR-1 receipt — canonical det-CBOR + COSE_Sign1/EdDSA, byte-compatible with the family verifiers, the wedge metric `uj_per_accepted_token` sealed in, refuses to seal a dishonest `HwShunt`, hash-chained log), `mesh-core` (the orchestration — authorise the JCP grant, route, meter around the engine, seal — `infer` decided outside the model), `mesh-conformance` (the deterministic reference serve, field-by-field checks, vectors in `conformance/v1/`), and `mesh-openai` (an OpenAI-compatible `/v1/chat/completions` ingest — any OpenAI client drives metered serving and gets the sealed receipt back in `usage.energy`) — plus `mesh-engine-gguf`, a **real** backend behind the `gguf` feature wrapping the family's pure-Rust GGUF/Metal inference substrate, kept out of the default build so it needs no weights. The input token payload travels with the serve call and is never sealed — the receipt records only the content-addressed `prompt_hash`. `cargo test --workspace` green, `cargo clippy --workspace --all-targets` zero warnings.
|
||||
### [pool/](pool/) — Energy-accounted multi-tenant scheduling
|
||||
|
||||
The **entitlement layer** in front of a contended pool: who may start, on whose budget, and what gets reclaimed when capacity runs short. A cluster scheduler's quota is conventionally a device count, which is a proxy — the quantity paid for, drawn from a grid and reported under an energy-disclosure regime is energy, and a guarantee of "eight accelerators" cannot express "this team may spend 40 MJ this window". In `pool/1` an entitlement guarantees **microjoules and devices together**, and both bind. Three rules need that unit to state at all: an **`Estimator` figure may not draw on surplus** borrowed from a sibling (a tenant may bear its own mis-estimate; a neighbour's unspent joules are not the tenant's to gamble); a **gang is all-or-nothing in joules, not only in slots** (admitting part of a gang against a budget that cannot finish it spends real energy and delivers nothing); and **preemption follows evidence before declaration** — workloads with a measured cost per *completed* unit are reclaimed worst-first, while workloads that have completed nothing are unmeasured rather than wasteful and fall back to declared priority. That last rule is why the energy unit is load-bearing: ranking by cost per completed unit needs an outcome anchor, which device-seconds cannot supply. Surplus is shared by weight among borrowers under one parent and never crosses roots; a claim that cannot fit today queues, and one that can never fit is refused with the reason. Scope is narrow on purpose: `pool/1` decides, and does not place, meter or execute — the grade of every energy figure travels into the verdict rather than sitting beside it.
|
||||
|
||||
- **Licence:** Apache-2.0 for reference code (see [`pool/LICENSE`](pool/LICENSE)); CC-BY-4.0 for specification text (see [`pool/LICENSE-SPEC`](pool/LICENSE-SPEC)).
|
||||
- **Status:** v0.1.0-draft.1. `SPEC.md` published; reference workspace `pool-rs/` — `pool-core` carries the entitlement tree with weighted sibling surplus, the normative §4 admission order (impossibility before pendency, guarantee, then borrowing), and the §5.2 preemption order. `cargo test --workspace` green.
|
||||
|
||||
## How this is organised
|
||||
|
||||
Each subdirectory is self-contained: it carries its own README, its own licence, and its own contribution guidance. Cross-protocol consistency lives at this level — in this README and in [`CHARTER.md`](CHARTER.md).
|
||||
|
|
|
|||
202
pool/LICENSE
Normal file
202
pool/LICENSE
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
30
pool/LICENSE-SPEC
Normal file
30
pool/LICENSE-SPEC
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
Creative Commons Attribution 4.0 International
|
||||
|
||||
Copyright (c) 2026 Transaction Science, Inc.
|
||||
|
||||
This specification text and accompanying documents are licensed under the
|
||||
Creative Commons Attribution 4.0 International License (CC BY 4.0).
|
||||
|
||||
You are free to:
|
||||
- Share — copy and redistribute the material in any medium or format
|
||||
- Adapt — remix, transform, and build upon the material for any purpose,
|
||||
even commercially
|
||||
|
||||
Under the following terms:
|
||||
- Attribution — You must give appropriate credit, provide a link to the
|
||||
license, and indicate if changes were made. You may do so in any
|
||||
reasonable manner, but not in any way that suggests the licensor
|
||||
endorses you or your use.
|
||||
|
||||
No additional restrictions — You may not apply legal terms or technological
|
||||
measures that legally restrict others from doing anything the license
|
||||
permits.
|
||||
|
||||
Full licence text: https://creativecommons.org/licenses/by/4.0/legalcode
|
||||
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference implementations and software accompanying this specification, if
|
||||
and when published in this directory, may be released under a separate
|
||||
licence (typically Apache-2.0). Refer to the licence file alongside each
|
||||
artefact.
|
||||
141
pool/README.md
Normal file
141
pool/README.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
# Pool — Energy-Accounted Multi-Tenant Scheduling
|
||||
|
||||
**Version:** 0.1.0-draft.1 · **Status:** Draft · **Profile:** `pool/1`
|
||||
**Specification text:** CC-BY-4.0 · **Reference code (`pool-rs/`):** Apache-2.0
|
||||
|
||||
Pool is the open standard for **deciding who gets contended compute, on a
|
||||
budget denominated in energy**. An entitlement guarantees microjoules and
|
||||
devices together; a gang is admitted whole or not at all; and when capacity
|
||||
must be reclaimed, the order follows what workloads have delivered per joule.
|
||||
|
||||
Pool is one of the [Transaction Science open standards](../README.md).
|
||||
|
||||
## Why energy is the unit
|
||||
|
||||
A device count is a proxy for the thing consumed. The quantity that is paid
|
||||
for, drawn from a grid and reported under an energy-disclosure regime is
|
||||
energy — so a guarantee expressed only in accelerators leaves the energy budget
|
||||
enforced nowhere and reconciled after the fact, if at all. In `pool/1` both
|
||||
bind: devices describe what is occupied, joules describe what is spent.
|
||||
|
||||
## Three rules that need the energy unit
|
||||
|
||||
1. **A guess may not spend a neighbour's capacity** (SPEC §4.4). A claim whose
|
||||
estimate is `Estimator` — nobody measured or modelled it — is admitted only
|
||||
inside its own guarantee, never against surplus borrowed from a sibling. A
|
||||
tenant may bear its own mis-estimate; a sibling's unspent joules are not
|
||||
the tenant's to gamble.
|
||||
2. **A gang is all-or-nothing in joules, not only in slots** (SPEC §2.4).
|
||||
Admitting part of a gang against a budget that cannot finish the whole gang
|
||||
spends real energy and delivers nothing.
|
||||
3. **Preemption follows evidence, then declaration** (SPEC §5.2). Workloads
|
||||
with a measured cost per *completed* unit are reclaimed worst-first.
|
||||
Workloads that have completed nothing are unmeasured rather than wasteful,
|
||||
and fall back to declared priority — reclaiming them first would destroy
|
||||
the work that would have produced the measurement.
|
||||
|
||||
## Sharing a device, and what it costs a figure
|
||||
|
||||
Fractional accelerators stop capacity being stranded, and their memory fence is
|
||||
enforceable because memory is partitioned. **Energy is not** — a device counter
|
||||
reports one number for the whole device and cannot see tenants. So a per-tenant
|
||||
figure is a split of a measurement, not a measurement (SPEC §2.5):
|
||||
|
||||
- **Provenance degrades on division.** A `HwShunt` device total becomes
|
||||
`ModelBased` as soon as it is split between two occupants. It never improves:
|
||||
dividing an `Estimator` guess does not make it a model. Dividing by one is
|
||||
not a division, so a sole occupant keeps the device's grade.
|
||||
- **The shares sum to the measurement exactly** — integer microjoules, largest
|
||||
remainder, deterministic tie-break. Conservation is what makes a tenant's
|
||||
figure reconcilable against the device that produced it.
|
||||
|
||||
## A guess earns its way out
|
||||
|
||||
The admission rule that a guess may not spend a neighbour's capacity is only
|
||||
fair if a guess can stop being one. The evidence is already in the ledger —
|
||||
every completed claim recorded what it actually consumed — so an estimate can
|
||||
be derived from what this *kind* of work really cost (SPEC §2.6):
|
||||
|
||||
- **A guess is not evidence.** `Estimator` observations are excluded, not
|
||||
averaged in. A tenant earns an observed estimate by being measured, not by
|
||||
having run.
|
||||
- **A derived estimate is a model, never a reading** — capped at `ModelBased`
|
||||
even where every observation came from a counter, because a figure for the
|
||||
*next* run is a prediction.
|
||||
- **One run is not a sample.** Below the required count nothing is derived, and
|
||||
the counts are reported: history that is all guesses needs a meter, while
|
||||
little history needs patience.
|
||||
- **The absence of a class is not a class.** Comparability is the operator's
|
||||
assertion, so an empty class is refused rather than pooling unlike work.
|
||||
|
||||
The reservation takes the largest observed figure rather than the mean, because
|
||||
under-reserving is the failure the borrowing rule exists to prevent. The limit
|
||||
is stated: the maximum of a small sample underestimates the true tail.
|
||||
|
||||
## Reclaiming what is not working
|
||||
|
||||
A workload holding accelerators while spending almost no energy is holding
|
||||
capacity it is not using — something a device count cannot see, since a held
|
||||
device is a held device whether saturated or parked. It is also the most
|
||||
dangerous question here, because the obvious implementation destroys running
|
||||
work: *"spent 0 µJ"* arrives identically from a parked workload and from a
|
||||
meter that is not there (SPEC §2.7).
|
||||
|
||||
```
|
||||
meter unavailable → 0 µJ → "idle" → reclaim → running work destroyed
|
||||
```
|
||||
|
||||
So **unknown is not idle**, and the grade is tested *before* the value meets
|
||||
any threshold — no threshold setting can let a guess through. Idleness is a
|
||||
rate over a stated window, never an instant; startup is not idleness; an empty
|
||||
window yields no rate rather than a rate of zero. A workload with no verdict is
|
||||
**absent** from a reclamation ordering rather than ranked last in it: it is not
|
||||
a worse candidate than an idle one, it is not a candidate.
|
||||
|
||||
There is no default threshold, because one has to sit above the hardware's idle
|
||||
draw and below its working draw, and both belong to the device. An
|
||||
unconfigured policy reclaims nothing — the failure of an unconfigured
|
||||
destructive action should be a missed optimisation, not destroyed work.
|
||||
|
||||
## Usage survives the process
|
||||
|
||||
A scheduler that restarts with empty usage hands every tenant its whole
|
||||
guarantee back. `pool/1` rebuilds every entitlement's usage by replaying its
|
||||
own hash-chained ledger (SPEC §6), so the ledger is the durable state rather
|
||||
than a report about it. On release the **actual** figure replaces the
|
||||
reservation — the hold bounded what could start, not what was spent — and a
|
||||
stream that does not verify is refused at the index of its first broken link.
|
||||
|
||||
Each entry is a **`pool/1` JCR-1 receipt** — canonical det-CBOR under
|
||||
COSE_Sign1 with Ed25519, the wire format the rest of this family seals with,
|
||||
so it verifies under a tool that has never heard of Pool (SPEC §6.5).
|
||||
Authenticity and sequence are checked separately because they fail separately:
|
||||
signatures without a chain admit a bag of valid receipts with any subset
|
||||
removed, and a chain without signatures admits a consistent stream written by
|
||||
anyone. A core that decodes but does not re-encode to the bytes it arrived as
|
||||
is refused, so two encodings cannot share one signature (§6.6).
|
||||
|
||||
A **witness** — a party holding a key the steward does not — can sign the
|
||||
ledger's head at a stated length (SPEC §6.7). A steward that later rewrites
|
||||
its history produces a different head, and the receipt stops covering it. A
|
||||
witness key equal to the steward's is refused outright: a countersignature
|
||||
from the party being countersigned establishes nothing.
|
||||
|
||||
An unwitnessed ledger is still a ledger, graded `self-attested` rather than
|
||||
refused (§6.8) — the reader weighs the difference, and the standard's duty is
|
||||
to make it legible. What a witness establishes is bounded too: it is evidence
|
||||
against revision, not a second opinion on whether an admission was right (§8).
|
||||
|
||||
## What it does not do
|
||||
|
||||
`pool/1` decides admission and preemption order. It does not place workloads,
|
||||
read counters or execute anything (SPEC §1.1, §8). Energy figures arrive
|
||||
already graded from whatever meter produced them, and a verdict is only as
|
||||
sound as the figure behind it — which is why the grade is part of the verdict
|
||||
rather than a footnote to it.
|
||||
|
||||
## Reference
|
||||
|
||||
[`pool-rs/crates/pool-core`](pool-rs/crates/pool-core) — the admission and
|
||||
preemption kernel: the entitlement tree, weighted surplus, the §4 admission
|
||||
order, and the §5.2 preemption order.
|
||||
549
pool/SPEC.md
Normal file
549
pool/SPEC.md
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
# Pool v0.1 — A Standard for Energy-Accounted Multi-Tenant Scheduling
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Standard** | pool |
|
||||
| **Status** | v0.1.0 draft for review |
|
||||
| **Profile** | `pool/1` |
|
||||
| **Licence** | Specification text CC-BY-4.0 ([`LICENSE-SPEC`](LICENSE-SPEC)); reference code Apache-2.0 ([`LICENSE`](LICENSE)) |
|
||||
| **Reference** | [`pool-rs/`](pool-rs/) |
|
||||
|
||||
A vendor-neutral standard for **deciding who gets contended compute, on a
|
||||
budget denominated in energy**. Pool specifies an entitlement tree whose
|
||||
guarantees are microjoules and devices together, an admission rule for
|
||||
all-or-nothing gangs, and a preemption order derived from what workloads have
|
||||
actually delivered per joule.
|
||||
|
||||
Pool is one of the [Transaction Science open standards](../README.md).
|
||||
Transaction Science is the steward, not the proprietor.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope and doctrine
|
||||
|
||||
### 1.1 What Pool decides
|
||||
|
||||
Pool decides three questions and no others: **may this claim start**, **what is
|
||||
reserved against whom**, and **if capacity must be reclaimed, in what order**.
|
||||
It does not place workloads on hosts, does not read counters, and does not
|
||||
execute anything. Placement is the caller's; energy figures arrive already
|
||||
graded from whatever meter produced them (§2.3).
|
||||
|
||||
### 1.2 Energy is the unit of entitlement
|
||||
|
||||
A device count is a proxy for the thing consumed. The quantity that is paid
|
||||
for, drawn from a grid, and reported under an energy-disclosure regime is
|
||||
energy. A guarantee of "eight accelerators" cannot express "this team may spend
|
||||
40 MJ this window", so under a device-denominated quota the energy budget is
|
||||
enforced nowhere and reconciled, if at all, after the fact.
|
||||
|
||||
In `pool/1` an entitlement carries **both** a microjoule guarantee and a device
|
||||
guarantee, and both bind (§2.1). Devices describe what is occupied; joules
|
||||
describe what is spent. A claim that fits one and not the other does not fit.
|
||||
|
||||
### 1.3 A figure's grade is part of the decision
|
||||
|
||||
Every energy figure in `pool/1` carries the grade of whatever produced it
|
||||
(§2.3). The grade is not a label attached after the decision; it changes the
|
||||
decision (§4.3). An estimate nobody measured is the figure most likely to be
|
||||
wrong in the direction that overcommits, and a scheduler that cannot tell a
|
||||
reading from a guess cannot bound that error.
|
||||
|
||||
### 1.4 Unknown is not an extreme
|
||||
|
||||
Where `pool/1` lacks a figure it says so, and never substitutes a best or worst
|
||||
value for it. A ratio with no denominator is undefined (§5.1); a claim that
|
||||
cannot be satisfied today is distinct from one that can never be satisfied
|
||||
(§4.5). Both substitutions change outcomes: an undefined cost read as zero
|
||||
makes a workload unreclaimable, and read as infinity makes it the first victim.
|
||||
|
||||
---
|
||||
|
||||
## 2. Objects
|
||||
|
||||
### 2.1 The entitlement
|
||||
|
||||
An **entitlement** is a node of a tree — a department, a project, a team. It
|
||||
carries:
|
||||
|
||||
| field | meaning |
|
||||
|---|---|
|
||||
| `id` | the node's name |
|
||||
| `parent` | its parent, or absent for a root |
|
||||
| `guarantee_uj` | microjoules it is owed and may always spend |
|
||||
| `guarantee_devices` | devices it is owed and may always occupy |
|
||||
| `over_quota_weight` | its relative share of a parent's surplus; `0` never borrows |
|
||||
| `limit_uj` | a hard ceiling including anything borrowed; absent means the only ceiling is available surplus |
|
||||
| `priority` | declared ordering, used only where evidence is absent (§5.2) |
|
||||
|
||||
Both guarantees bind. An implementation **MUST** refuse a claim that exceeds
|
||||
either (§4).
|
||||
|
||||
### 2.2 Surplus and borrowing
|
||||
|
||||
Surplus is the part of a sibling's guarantee it has not spent. Siblings are
|
||||
nodes sharing one parent; surplus **MUST NOT** cross roots.
|
||||
|
||||
A borrower's share is its `over_quota_weight` against the summed weight of
|
||||
every node under that parent, rounded down. A borrower **MUST NOT** receive
|
||||
more than its weighted share merely by claiming first: the share is a property
|
||||
of the tree, not of arrival order.
|
||||
|
||||
### 2.3 Energy and its grade
|
||||
|
||||
An energy figure is a microjoule count and a **provenance**, one of:
|
||||
|
||||
| grade | meaning |
|
||||
|---|---|
|
||||
| `HwShunt` | a counter read for this work, at its honest resolution |
|
||||
| `ModelBased` | a model the host calibrated — a real curve, not a reading |
|
||||
| `Estimator` | a default-power guess; nobody measured or modelled this host |
|
||||
|
||||
`HwShunt` and `ModelBased` are **observed**. `Estimator` is not. The grade
|
||||
never silently improves.
|
||||
|
||||
### 2.4 The claim
|
||||
|
||||
A **claim** requests capacity for a **gang** — one or more members that must
|
||||
start together. Its figures describe the whole gang, never a member:
|
||||
`gang_size`, `devices_per_member`, and an `estimate` for the gang's total
|
||||
energy at its grade.
|
||||
|
||||
### 2.5 Shared devices, and what division does to a figure
|
||||
|
||||
Fractional accelerators are how a scheduler stops stranding capacity: several
|
||||
workloads on one device, each bounded by a memory fence. The fence is
|
||||
enforceable because memory is partitioned. **Energy is not.** A device-level
|
||||
counter reports one figure for the whole device and has no notion of which
|
||||
tenant caused which joule.
|
||||
|
||||
An attributed per-tenant figure is therefore a **split of a measurement, not a
|
||||
measurement**, and two rules follow.
|
||||
|
||||
**Provenance degrades on division.** A `HwShunt` device total becomes
|
||||
`ModelBased` the moment it is divided between two or more occupants, because
|
||||
the division is a model however good the counter was. It degrades no further
|
||||
and never improves: an `Estimator` total stays `Estimator`, since dividing a
|
||||
guess does not make it a model. Dividing by one is not a division, so a sole
|
||||
occupant keeps the device's own grade.
|
||||
|
||||
An implementation **MUST NOT** report a per-tenant figure from a shared device
|
||||
as `HwShunt`. That grade asserts a counter was read for *this* work, and on a
|
||||
shared device none was.
|
||||
|
||||
**The shares sum to the measurement, exactly.** Attribution is integer
|
||||
microjoules and **MUST** conserve the total: no joule invented, none lost. The
|
||||
remainder left by flooring is distributed by a deterministic rule — largest
|
||||
remainder first, ties broken on a stable key — so two implementations divide
|
||||
the same device the same way. Conservation is what makes a tenant's figure
|
||||
reconcilable against the device that produced it.
|
||||
|
||||
A weight basis is the operator's to choose (allotted memory, time slices,
|
||||
partitions). `pool/1` requires only that one basis is used for every occupant
|
||||
of one device, and that the result is graded as the model it is.
|
||||
|
||||
An implementation **MUST** refuse to attribute a device with no occupants, or
|
||||
one whose occupants all carry zero weight: there is then no basis to divide
|
||||
on, and discarding the figure would lose energy the counter really read.
|
||||
|
||||
### 2.6 Earning an observed estimate
|
||||
|
||||
§4.4 refuses a guess the right to spend a neighbour's capacity. That rule is
|
||||
only fair if a guess can **stop** being a guess. On its own it is a permanent
|
||||
penalty with no exit: a tenant nobody has measured can never be measured,
|
||||
because it can never run against surplus.
|
||||
|
||||
The evidence already exists. Every completed claim appended a `Released`
|
||||
carrying what it actually consumed, at the grade of whatever read it (§6.2).
|
||||
An implementation **MAY** derive a claim's estimate from that history, and
|
||||
where it does, four rules bind.
|
||||
|
||||
**A guess is not evidence.** An observation whose own figure is `Estimator` is
|
||||
**excluded**, not averaged in. Nobody measured or modelled it, so it says
|
||||
nothing about what the work costs, and counting it would launder guesses into a
|
||||
grade asserting they were observed. This is what makes the exit real rather
|
||||
than circular: a tenant earns an observed estimate by being *measured*, not by
|
||||
having run.
|
||||
|
||||
**A derived estimate is a model, never a reading.** Its grade **MUST** be
|
||||
capped at `ModelBased`, even where every observation was read by a hardware
|
||||
counter, because a figure for the *next* run is a prediction. The cap is a
|
||||
ceiling and not a floor: modelled inputs give a modelled output.
|
||||
|
||||
**One run is not a sample.** Below a required count of evidence an
|
||||
implementation **MUST NOT** derive an estimate, and **MUST** report what it
|
||||
had — how many observations of the class it saw, and how many of those were
|
||||
evidence. The two say different things to an operator: plenty of history that
|
||||
is all guesses needs a meter, while little history needs patience. The required
|
||||
count is a **policy value, not a derived one**; `pool/1` states a default of
|
||||
three and does not pretend to know the right number for a workload it has never
|
||||
seen.
|
||||
|
||||
**The absence of a class is not a class.** Comparability is the operator's
|
||||
assertion (§2.4), and an empty class asserts nothing. Deriving across an empty
|
||||
class, or across differing classes, **MUST** be refused rather than pooling
|
||||
unlike work into one figure.
|
||||
|
||||
A derived reservation **SHOULD** take the upper end of the observed figures
|
||||
rather than their mean. Under-reserving is the failure §4.4 exists to prevent —
|
||||
a reservation below what the work costs is admitted and then overspends, and
|
||||
the joules it overspends may be a sibling's — and a mean under-reserves roughly
|
||||
half the time by construction. The spread **SHOULD** be reported beside the
|
||||
reservation so a reader can see how thin the evidence is.
|
||||
|
||||
**The limit.** The maximum of a small sample is itself an underestimate of the
|
||||
true tail. A derived reservation is better evidence than a guess; it is not a
|
||||
bound on what the work can cost, and `pool/1` does not claim it is one.
|
||||
|
||||
### 2.7 Reclaiming capacity from work that is not working
|
||||
|
||||
A workload holding accelerators while consuming almost no energy is holding
|
||||
capacity it is not using. A device ledger cannot see this: a held device is a
|
||||
held device, whether it is saturated or parked at an interactive prompt. Energy
|
||||
can, which makes idle reclamation a question `pool/1` can ask at all.
|
||||
|
||||
It is also the most dangerous question in this standard, because the obvious
|
||||
implementation destroys running work. The reading *"this workload spent 0 µJ"*
|
||||
arrives identically from a workload that is genuinely parked and from a meter
|
||||
that is not there:
|
||||
|
||||
```
|
||||
meter unavailable → 0 µJ → "idle" → reclaim → running work destroyed
|
||||
```
|
||||
|
||||
**Unknown is not idle.** A figure nobody measured or modelled (`Estimator`)
|
||||
**MUST NOT** establish idleness, whatever its value. A workload is reclaimed on
|
||||
evidence that it *is* idle, never on the absence of evidence that it is busy.
|
||||
An implementation **MUST** test the grade **before** comparing the value to any
|
||||
threshold, so that no threshold setting can let a guess through.
|
||||
|
||||
**Idleness is a rate over a stated window, never an instant.** Any workload is
|
||||
momentarily between steps. An implementation **MUST** state the window a
|
||||
verdict rests on, and **MUST NOT** reach a verdict from an observation shorter
|
||||
than the window it requires: startup is not idleness. A window of zero yields
|
||||
no rate — **not** a rate of zero, which is the one value that reads as idle.
|
||||
|
||||
**A workload holding no devices is not a candidate.** It occupies no contended
|
||||
capacity, so there is nothing to reclaim.
|
||||
|
||||
**The verdicts are distinct.** *Idle*, *working*, *no verdict* and *nothing
|
||||
held* are four answers, and only the first permits reclamation. A workload with
|
||||
no verdict **MUST** be absent from a reclamation ordering rather than ranked
|
||||
last in it: it is not a worse candidate than an idle workload, it is not a
|
||||
candidate.
|
||||
|
||||
The check order is therefore normative: **nothing held, then the window, then
|
||||
the grade, then the rate.** A later check never rescues an earlier refusal.
|
||||
|
||||
**Why there is no default threshold.** A parked accelerator still draws power.
|
||||
A threshold has to sit *above* the hardware's idle draw or nothing is ever
|
||||
idle, and *below* its working draw or everything is — and both are properties
|
||||
of the device, not of this standard. `pool/1` does not guess them. An
|
||||
unconfigured policy **MUST** reclaim nothing: the failure of an unconfigured
|
||||
destructive action should be a missed optimisation, not destroyed work.
|
||||
|
||||
**This is not preemption.** §5.2 answers *who is spending badly under
|
||||
contention*, ranking by cost per completed unit. This answers *who is not
|
||||
spending at all*. A workload burning energy while completing nothing is
|
||||
thrashing, not idle, and §5.2 is the question that catches it.
|
||||
|
||||
---
|
||||
|
||||
## 3. Reservation
|
||||
|
||||
On admission the claim's whole estimated energy is **reserved** against the
|
||||
entitlement before the work runs. Two claims decided in the same window
|
||||
therefore cannot both spend the same joule.
|
||||
|
||||
A reservation is only as sound as the figure behind it, so the verdict carries
|
||||
that figure's grade (§4.6). An implementation **MUST NOT** report a reservation
|
||||
without it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Admission
|
||||
|
||||
An implementation **MUST** evaluate a claim in the order below. The order is
|
||||
normative because a different order yields a different verdict.
|
||||
|
||||
### 4.1 Resolution and shape
|
||||
|
||||
A claim naming an entitlement the pool does not hold is refused. A gang of no
|
||||
members, or members occupying no devices, is refused: it is not a claim on
|
||||
anything.
|
||||
|
||||
### 4.2 Impossibility before pendency
|
||||
|
||||
A claim whose devices exceed `guarantee_devices`, or whose energy exceeds
|
||||
`limit_uj`, is refused **before** any test that could queue it. Queueing a
|
||||
claim that can never be satisfied fills a queue with work that will never run
|
||||
and reports it as pending.
|
||||
|
||||
### 4.3 Inside the guarantee
|
||||
|
||||
If the entitlement's spent energy plus the claim's estimate is within
|
||||
`guarantee_uj`, and its held devices plus the claim's devices are within
|
||||
`guarantee_devices`, the claim is **admitted** — at any grade. A tenant may
|
||||
spend its own guarantee on a guess.
|
||||
|
||||
### 4.4 Past the guarantee: an unobserved estimate does not borrow
|
||||
|
||||
A claim exceeding `guarantee_uj` may be admitted only against borrowed surplus,
|
||||
and only if **all** hold:
|
||||
|
||||
1. its estimate is **observed** (§2.3) — an `Estimator` figure **MUST NOT**
|
||||
draw on surplus;
|
||||
2. its entitlement's `over_quota_weight` is non-zero;
|
||||
3. the total after admission is within `limit_uj`, where one is set;
|
||||
4. the borrowed part is within the entitlement's weighted share (§2.2).
|
||||
|
||||
Rule 1 is the substance of §1.3. Inside its own guarantee a tenant bears its
|
||||
own mis-estimate. Surplus belongs to a sibling that has not spent it, and an
|
||||
unobserved estimate is not a sound basis for spending it.
|
||||
|
||||
### 4.5 Pending and refused are different verdicts
|
||||
|
||||
A claim that does not fit **now** is `Queue`, with the reason: the guarantee is
|
||||
exhausted and it may not borrow, the surplus is insufficient, or the devices
|
||||
are held. A claim that can never fit **as written** is `Deny`, with the reason.
|
||||
An implementation **MUST NOT** report the second as the first.
|
||||
|
||||
### 4.6 The verdict
|
||||
|
||||
An admission carries the reserved microjoules, the devices, how much of the
|
||||
reservation was borrowed, and the grade of the estimate it rests on.
|
||||
|
||||
---
|
||||
|
||||
## 5. Preemption
|
||||
|
||||
### 5.1 Realised cost
|
||||
|
||||
A running workload's realised cost is its spent microjoules divided by the
|
||||
units it has **completed** — not attempted. Where nothing has completed the
|
||||
ratio is **undefined**, and an implementation **MUST** represent it as such
|
||||
(§1.4).
|
||||
|
||||
Completion is what makes the ratio meaningful: energy spent on work that was
|
||||
abandoned is waste, and a denominator of attempts would score a workload well
|
||||
for failing quickly.
|
||||
|
||||
### 5.2 The order
|
||||
|
||||
Capacity is reclaimed in this order:
|
||||
|
||||
1. **Workloads with a defined realised cost, most expensive first.** There is
|
||||
evidence they are spending badly.
|
||||
2. **Workloads with an undefined realised cost, lowest `priority` first.** They
|
||||
are unmeasured, not wasteful.
|
||||
|
||||
Ties **MUST** break on a stable key so the order is total and a replay
|
||||
reproduces it.
|
||||
|
||||
An implementation **MUST NOT** rank an unmeasured workload as though its cost
|
||||
were known. Reclaiming unmeasured work first destroys the work that would have
|
||||
produced the measurement, so the scheduler never learns anything about it.
|
||||
|
||||
---
|
||||
|
||||
## 6. The entitlement ledger
|
||||
|
||||
### 6.1 Usage is derived, not stored
|
||||
|
||||
A scheduler that restarts with empty usage hands every tenant its whole
|
||||
guarantee back, and admits the next claim against a budget already spent. A
|
||||
quota that refills on every deploy, crash or config reload is not a quota.
|
||||
|
||||
An implementation **MUST** be able to rebuild every entitlement's usage by
|
||||
replaying its own ledger. The ledger is the durable state; there is no second
|
||||
store to drift from it.
|
||||
|
||||
### 6.2 What moves the ledger
|
||||
|
||||
Two events, and only two:
|
||||
|
||||
| event | effect |
|
||||
|---|---|
|
||||
| `Admitted` | the claim's estimate is reserved against its entitlement, and the **class** it declared is recorded beside it |
|
||||
| `Released` | the reservation is lifted and the **actual** figure charged in its place |
|
||||
|
||||
The class is recorded in the `Admitted` entry rather than kept by the
|
||||
scheduler. §6.1 makes the ledger the only durable state, and a caller-side map
|
||||
from claim to class would be exactly the second store that can drift from it.
|
||||
Without it the history cannot be read back as evidence about a *kind* of work,
|
||||
which is what §2.6 needs.
|
||||
|
||||
A `Queue` or a `Deny` reserves nothing and changes no tenant's usage, so it is
|
||||
not a ledger event. Recording it would make the ledger a decision log — a
|
||||
different artifact, with a different retention story.
|
||||
|
||||
On release the actual figure **replaces** the reservation; it is not added to
|
||||
it. The reservation was a hold on capacity, and what the tenant spent is what
|
||||
it spent. An admission still open at the end of a stream keeps its
|
||||
reservation, because the work is still running and the capacity is still held.
|
||||
|
||||
### 6.3 Overrun
|
||||
|
||||
A workload that exceeds its estimate is charged what it cost (§6.2). The
|
||||
reservation bounded what could **start**, not what was ultimately **spent**
|
||||
(§8). Attribution of the overrun is what the grade on each figure is for.
|
||||
|
||||
### 6.4 The chain
|
||||
|
||||
Entries are hash-chained: each records the hash of the entry before it, from a
|
||||
genesis sentinel. Replay **MUST** verify the chain before deriving any usage,
|
||||
and **MUST** report the index of the first broken link rather than a shorter
|
||||
history.
|
||||
|
||||
A release naming a claim that was never admitted, or was released already, is
|
||||
an inconsistent stream and **MUST** be refused at its index. It is not a
|
||||
shorter history; it is a history that did not happen.
|
||||
|
||||
### 6.5 The sealed entry
|
||||
|
||||
Each entry is a **`pool/1` JCR-1 receipt**: a canonical det-CBOR signed core
|
||||
(RFC 8949 §4.2.1 — length-first key order, shortest integer form, no floats,
|
||||
bools or nulls) inside a COSE_Sign1 envelope (RFC 9052, EdDSA/Ed25519,
|
||||
`alg = -8`), with the steward's identifier as `kid`. The core carries
|
||||
`pool_receipt`, `seq`, `prev`, `steward`, and the event's own fields.
|
||||
|
||||
This is the wire format the rest of this family seals with, so a `pool/1`
|
||||
receipt verifies under a tool that has never heard of Pool.
|
||||
|
||||
Two properties, kept distinct because they fail separately, and an
|
||||
implementation **MUST** check both:
|
||||
|
||||
- **Authenticity.** Every entry verifies under the steward's key. An entry
|
||||
this steward did not seal is refused, whoever wrote the file.
|
||||
- **Sequence.** Every entry's `seq` is its position and its `prev` is the
|
||||
content address of the entry before it, from a genesis sentinel.
|
||||
|
||||
Neither alone suffices. Signatures without a chain admit a bag of valid
|
||||
receipts in any order with any subset removed; a chain without signatures
|
||||
admits a consistent stream written by anyone.
|
||||
|
||||
### 6.6 Canonicity is checked, not assumed
|
||||
|
||||
A signature covers exact bytes. A verifier that accepted a non-canonical
|
||||
encoding of the same document would let two distinct byte strings share one
|
||||
signature, so an implementation **MUST** refuse a core that decodes but does
|
||||
not re-encode to the bytes it arrived as.
|
||||
|
||||
### 6.7 Witnessing
|
||||
|
||||
A steward seals its own decisions, so a ledger alone establishes that *this*
|
||||
steward wrote *this* sequence — nothing more. A steward able to rewrite its
|
||||
whole stream can rewrite a consistent one, because it holds the only key
|
||||
involved.
|
||||
|
||||
A **witness** is a party holding a key the steward does not. It signs the
|
||||
ledger's head at a stated length, producing a `pool-witness/1` receipt: a
|
||||
statement that the chain reached exactly that head at exactly that length. A
|
||||
steward that later rewrites its history produces a different head, and the
|
||||
receipt no longer covers it.
|
||||
|
||||
An implementation **MUST** refuse a witness whose key is the steward's. A
|
||||
countersignature from the party being countersigned establishes nothing, and
|
||||
reporting it as a witnessed ledger would be the most misleading reading of the
|
||||
word available.
|
||||
|
||||
### 6.8 Attestation is a grade, not a gate
|
||||
|
||||
An unwitnessed ledger is still a ledger. An implementation **MUST** grade it
|
||||
`self-attested` rather than refuse it, and **MUST NOT** report a self-attested
|
||||
ledger and a witnessed one as the same thing. The reader weighs the
|
||||
difference; the standard's duty is to make it legible.
|
||||
|
||||
A receipt that verifies but covers a different head or length is distinct from
|
||||
one that does not verify at all, and an implementation **MUST** distinguish
|
||||
them. The second is a bad signature; the first is what a rewritten history
|
||||
looks like from the outside, and it is the more interesting of the two.
|
||||
|
||||
---
|
||||
|
||||
## 7. Conformance
|
||||
|
||||
A conforming implementation **MUST**:
|
||||
|
||||
1. Enforce both guarantees of §2.1, refusing a claim that exceeds either.
|
||||
2. Confine surplus to siblings under one parent, and bound a borrower by its
|
||||
weighted share (§2.2).
|
||||
3. Carry a grade with every energy figure, and never improve it silently
|
||||
(§2.3).
|
||||
4. Evaluate admission in the order of §4, refusing the impossible before
|
||||
queueing anything (§4.2).
|
||||
5. Refuse surplus to an unobserved estimate (§4.4).
|
||||
6. Distinguish `Queue` from `Deny`, with a reason on each (§4.5).
|
||||
7. Report a reservation together with the grade it rests on (§3, §4.6).
|
||||
8. Represent an undefined realised cost as undefined (§5.1).
|
||||
9. Order preemption by evidence before declaration (§5.2).
|
||||
10. Rebuild usage by replaying its own ledger (§6.1), verifying the chain
|
||||
before deriving anything from it (§6.4).
|
||||
11. Charge the actual figure in place of the reservation on release, not in
|
||||
addition to it (§6.2).
|
||||
12. Refuse an inconsistent stream at its index rather than treating it as a
|
||||
shorter history (§6.4).
|
||||
13. Seal every ledger entry as a `pool/1` JCR-1 receipt, and verify both its
|
||||
authenticity and its place in the chain before deriving anything from it
|
||||
(§6.5).
|
||||
14. Refuse a signed core that is not canonical (§6.6).
|
||||
15. Refuse a witness whose key is the steward's (§6.7).
|
||||
16. Grade an unwitnessed ledger `self-attested` rather than refusing it, and
|
||||
never report it as witnessed (§6.8).
|
||||
17. Distinguish a witness receipt that does not verify from one that verifies
|
||||
and covers a different head (§6.8).
|
||||
18. Degrade a divided figure's provenance, never report a shared device's
|
||||
per-tenant figure as `HwShunt`, and never improve a grade by dividing it
|
||||
(§2.5).
|
||||
19. Conserve a device total exactly across its occupants, distributing the
|
||||
remainder by a deterministic rule (§2.5).
|
||||
20. Refuse to attribute a device with no occupants or no weight (§2.5).
|
||||
|
||||
An implementation that derives estimates from history **MUST** also:
|
||||
|
||||
21. Exclude `Estimator` observations from the evidence rather than averaging
|
||||
them in, and report the two counts separately when it declines (§2.6).
|
||||
22. Cap a derived estimate's grade at `ModelBased`, however the observations
|
||||
were read (§2.6).
|
||||
23. Derive nothing below the required count of evidence, and treat a required
|
||||
count of zero as one (§2.6).
|
||||
24. Refuse an empty class, and never pool observations of differing classes
|
||||
into one figure (§2.6).
|
||||
|
||||
An implementation that reclaims idle capacity **MUST** also:
|
||||
|
||||
25. Refuse to establish idleness from an `Estimator` figure, testing the grade
|
||||
before comparing any value to a threshold (§2.7).
|
||||
26. Require a stated window, reach no verdict below it, and treat an empty
|
||||
window as no rate rather than a rate of zero (§2.7).
|
||||
27. Omit a workload with no verdict from a reclamation ordering rather than
|
||||
ranking it last (§2.7).
|
||||
28. Reclaim nothing under an unconfigured policy (§2.7).
|
||||
|
||||
---
|
||||
|
||||
## 8. The honest limit
|
||||
|
||||
`pool/1` decides. It does not measure, place or execute, and a verdict is only
|
||||
as sound as the figures handed to it: a mis-graded estimate produces a sound
|
||||
decision about an unsound number. The grade travels with every figure (§2.3) so
|
||||
that a reader of a verdict can see which it was, but nothing here can detect a
|
||||
meter that lies about its own provenance.
|
||||
|
||||
Reservation (§3) bounds what may be *started*, not what is ultimately *spent*.
|
||||
A workload that overruns its estimate is a reconciliation problem for the
|
||||
accounting layer, not an admission problem; `pool/1` reserves the estimate and
|
||||
records its grade so that the overrun is attributable.
|
||||
|
||||
**A witness is available, and nothing compels one.** §6.7 gives a second party
|
||||
a way to sign the ledger's head, so a rewritten history stops being covered.
|
||||
What `pool/1` does not do is require it: an unwitnessed ledger is graded
|
||||
`self-attested` and still verifies (§6.8). So the guarantee a reader actually
|
||||
holds depends on a deployment choice the standard makes legible rather than
|
||||
makes for them.
|
||||
|
||||
**A witness attests the sequence, not the decisions.** A countersigned head
|
||||
establishes that the steward did not rewrite what it had already published. It
|
||||
does not establish that an admission was correct, that an estimate was honest,
|
||||
or that the witness examined anything beyond the head it signed. A witness is
|
||||
evidence against revision, not a second opinion on the scheduling.
|
||||
1
pool/pool-rs/.gitignore
vendored
Normal file
1
pool/pool-rs/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
442
pool/pool-rs/Cargo.lock
generated
Normal file
442
pool/pool-rs/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"rand_core",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pool-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ed25519-dalek",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
15
pool/pool-rs/Cargo.toml
Normal file
15
pool/pool-rs/Cargo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/pool-core"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://git.transaction.science/Transaction-Science/open-standards"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
blake3 = "1"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
thiserror = "2"
|
||||
17
pool/pool-rs/crates/pool-core/Cargo.toml
Normal file
17
pool/pool-rs/crates/pool-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "pool-core"
|
||||
version = "0.1.0"
|
||||
description = "Pool — the reference admission and preemption kernel for energy-accounted multi-tenant scheduling (pool/1): joule-denominated entitlement, all-or-nothing gang admission, and preemption ordered by realised efficiency."
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[lints.rust]
|
||||
missing_docs = "warn"
|
||||
510
pool/pool-rs/crates/pool-core/src/jcr1.rs
Normal file
510
pool/pool-rs/crates/pool-core/src/jcr1.rs
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
//! JCR-1 wire primitives — canonical det-CBOR + COSE_Sign1.
|
||||
//!
|
||||
//! Pool receipts seal with the **JouleClaw Receipt Specification (JCR-1)**
|
||||
//! envelope, the signed-receipt wire format shared across the Transaction
|
||||
//! Science family: canonical det-CBOR (RFC 8949 §4.2.1, length-first key order,
|
||||
//! no floats/bools/nulls) inside a COSE_Sign1 envelope (RFC 9052, EdDSA/Ed25519,
|
||||
//! `alg = -8`). JCR-1 is a published spec implementable with standard COSE/CBOR
|
||||
//! tooling and no Pool dependency; these primitives are that reference
|
||||
//! algorithm, vendored so the Pool reference library stays self-contained — the
|
||||
//! same arrangement `mesh-receipt` documents for the same reason. They are
|
||||
//! byte-compatible with JCR-1's cross-language (Python/Go) verifiers and with
|
||||
//! the other family standards' receipts, which is the point of vendoring rather
|
||||
//! than re-deriving: a Pool receipt is verifiable by a tool that has never
|
||||
//! heard of Pool.
|
||||
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Errors from JCR-1 canonical encoding or envelope handling.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Jcr1Error {
|
||||
/// A type not permitted in the canonical signed core (float, bool, null,
|
||||
/// non-uint number).
|
||||
#[error("disallowed type in canonical core: {0}")]
|
||||
DisallowedType(&'static str),
|
||||
/// The COSE_Sign1 envelope was malformed.
|
||||
#[error("malformed COSE_Sign1 envelope: {0}")]
|
||||
Envelope(String),
|
||||
/// An unsupported COSE algorithm (only EdDSA `-8` is accepted).
|
||||
#[error("unsupported COSE alg: {0} (only EdDSA -8)")]
|
||||
UnsupportedAlg(i64),
|
||||
/// The signature did not verify.
|
||||
#[error("COSE_Sign1 signature did not verify")]
|
||||
BadSignature,
|
||||
/// The envelope's `kid` did not match the expected steward.
|
||||
#[error("kid mismatch: expected {expected}, got {got}")]
|
||||
KidMismatch {
|
||||
/// The steward the verifier required.
|
||||
expected: String,
|
||||
/// The `kid` the envelope actually carried.
|
||||
got: String,
|
||||
},
|
||||
/// The canonical core did not decode, or decoded to something that does
|
||||
/// not re-encode to the bytes it came from.
|
||||
#[error("malformed canonical core: {0}")]
|
||||
MalformedCore(&'static str),
|
||||
}
|
||||
|
||||
/// COSE algorithm tag for EdDSA (RFC 8152). The only `alg` JCR-1 v1 accepts.
|
||||
pub const COSE_ALG_EDDSA: i64 = -8;
|
||||
|
||||
fn cbor_uint(out: &mut Vec<u8>, major: u8, n: u64) {
|
||||
let mt = major << 5;
|
||||
if n < 24 {
|
||||
out.push(mt | n as u8);
|
||||
} else if n < 1 << 8 {
|
||||
out.push(mt | 24);
|
||||
out.push(n as u8);
|
||||
} else if n < 1 << 16 {
|
||||
out.push(mt | 25);
|
||||
out.extend_from_slice(&(n as u16).to_be_bytes());
|
||||
} else if n < 1 << 32 {
|
||||
out.push(mt | 26);
|
||||
out.extend_from_slice(&(n as u32).to_be_bytes());
|
||||
} else {
|
||||
out.push(mt | 27);
|
||||
out.extend_from_slice(&n.to_be_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
fn cbor_text(out: &mut Vec<u8>, s: &str) {
|
||||
cbor_uint(out, 3, s.len() as u64);
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
}
|
||||
|
||||
fn cbor_bytes(out: &mut Vec<u8>, b: &[u8]) {
|
||||
cbor_uint(out, 2, b.len() as u64);
|
||||
out.extend_from_slice(b);
|
||||
}
|
||||
|
||||
fn cbor_value(out: &mut Vec<u8>, v: &Value) -> Result<(), Jcr1Error> {
|
||||
match v {
|
||||
Value::String(s) => {
|
||||
cbor_text(out, s);
|
||||
Ok(())
|
||||
}
|
||||
Value::Number(n) => {
|
||||
let u = n.as_u64().ok_or(Jcr1Error::DisallowedType("non-uint number"))?;
|
||||
cbor_uint(out, 0, u);
|
||||
Ok(())
|
||||
}
|
||||
Value::Array(a) => {
|
||||
cbor_uint(out, 4, a.len() as u64);
|
||||
for e in a {
|
||||
cbor_value(out, e)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Value::Object(m) => {
|
||||
// length-first, then bytewise on the key (RFC 8949 §4.2.1).
|
||||
let mut keys: Vec<&String> = m.keys().collect();
|
||||
keys.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.as_str().cmp(b.as_str())));
|
||||
cbor_uint(out, 5, keys.len() as u64);
|
||||
for k in keys {
|
||||
cbor_text(out, k);
|
||||
cbor_value(out, &m[k])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Value::Bool(_) => Err(Jcr1Error::DisallowedType("bool")),
|
||||
Value::Null => Err(Jcr1Error::DisallowedType("null")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a JSON value as canonical det-CBOR (RFC 8949 §4.2.1): length-first key
|
||||
/// ordering, shortest integer form, no floats/bools/nulls.
|
||||
pub fn canonical_cbor(value: &Value) -> Result<Vec<u8>, Jcr1Error> {
|
||||
let mut out = Vec::new();
|
||||
cbor_value(&mut out, value)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The canonical signed-core bytes for a JCR-1 wire document: drop null-valued
|
||||
/// fields, then canonical-CBOR encode. This is exactly what the envelope signs
|
||||
/// over.
|
||||
pub fn signed_core_bytes(wire: &Value) -> Result<Vec<u8>, Jcr1Error> {
|
||||
let obj = wire
|
||||
.as_object()
|
||||
.ok_or(Jcr1Error::DisallowedType("non-object root"))?;
|
||||
let mut core = serde_json::Map::new();
|
||||
for (k, v) in obj {
|
||||
if v.is_null() {
|
||||
continue;
|
||||
}
|
||||
core.insert(k.clone(), v.clone());
|
||||
}
|
||||
canonical_cbor(&Value::Object(core))
|
||||
}
|
||||
|
||||
// COSE_Sign1 (RFC 9052), EdDSA / Ed25519 only.
|
||||
|
||||
fn build_protected_header(steward: &str) -> Vec<u8> {
|
||||
// map(2) { 1: -8 (EdDSA), 4: bstr(steward) }
|
||||
let mut p = vec![0xa2, 0x01, 0x27, 0x04];
|
||||
cbor_bytes(&mut p, steward.as_bytes());
|
||||
p
|
||||
}
|
||||
|
||||
fn build_sig_structure(protected: &[u8], payload: &[u8]) -> Vec<u8> {
|
||||
// ["Signature1", protected, external_aad (empty), payload]
|
||||
let mut s = vec![0x84];
|
||||
cbor_text(&mut s, "Signature1");
|
||||
cbor_bytes(&mut s, protected);
|
||||
cbor_bytes(&mut s, &[]);
|
||||
cbor_bytes(&mut s, payload);
|
||||
s
|
||||
}
|
||||
|
||||
/// Build a tagged COSE_Sign1 envelope (CBOR tag 18) over the canonical signed
|
||||
/// core. `steward` becomes the `kid` a verifier resolves.
|
||||
pub fn cose_sign1_envelope(core: &[u8], signing_key: &SigningKey, steward: &str) -> Vec<u8> {
|
||||
let protected = build_protected_header(steward);
|
||||
let sig_struct = build_sig_structure(&protected, core);
|
||||
let signature = signing_key.sign(&sig_struct).to_bytes();
|
||||
let mut envelope = vec![0xd2, 0x84]; // tag(18), array(4)
|
||||
cbor_bytes(&mut envelope, &protected);
|
||||
envelope.push(0xa0); // unprotected = {}
|
||||
cbor_bytes(&mut envelope, core);
|
||||
cbor_bytes(&mut envelope, &signature);
|
||||
envelope
|
||||
}
|
||||
|
||||
struct ProtectedHeader {
|
||||
alg: i64,
|
||||
kid: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Verify a tagged COSE_Sign1 envelope against a public key, returning the inner
|
||||
/// payload (the signed-core bytes) on success. Rejects unknown `alg`, a `kid`
|
||||
/// other than `expected_steward`, and signature failure.
|
||||
pub fn verify_cose_sign1(
|
||||
envelope: &[u8],
|
||||
expected_steward: &str,
|
||||
pubkey: &[u8; 32],
|
||||
) -> Result<Vec<u8>, Jcr1Error> {
|
||||
let (protected, payload, signature) = parse_cose_sign1(envelope)?;
|
||||
let header = parse_protected_header(&protected)?;
|
||||
if header.alg != COSE_ALG_EDDSA {
|
||||
return Err(Jcr1Error::UnsupportedAlg(header.alg));
|
||||
}
|
||||
let kid = std::str::from_utf8(&header.kid)
|
||||
.map_err(|_| Jcr1Error::Envelope("kid is not UTF-8".into()))?;
|
||||
if kid != expected_steward {
|
||||
return Err(Jcr1Error::KidMismatch {
|
||||
expected: expected_steward.to_string(),
|
||||
got: kid.to_string(),
|
||||
});
|
||||
}
|
||||
let sig_struct = build_sig_structure(&protected, &payload);
|
||||
let vk = VerifyingKey::from_bytes(pubkey)
|
||||
.map_err(|e| Jcr1Error::Envelope(format!("bad pubkey: {e}")))?;
|
||||
let sig = Signature::from_slice(&signature)
|
||||
.map_err(|e| Jcr1Error::Envelope(format!("bad signature shape: {e}")))?;
|
||||
vk.verify(&sig_struct, &sig).map_err(|_| Jcr1Error::BadSignature)?;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
// Minimal CBOR reader for the fixed COSE_Sign1 shape.
|
||||
|
||||
fn read_len(b: &[u8], pos: &mut usize, major: u8) -> Result<u64, Jcr1Error> {
|
||||
let head = *b.get(*pos).ok_or(Jcr1Error::Envelope("truncated".into()))?;
|
||||
*pos += 1;
|
||||
if head >> 5 != major {
|
||||
return Err(Jcr1Error::Envelope(format!(
|
||||
"expected major {major}, got {}",
|
||||
head >> 5
|
||||
)));
|
||||
}
|
||||
let info = head & 0x1f;
|
||||
let n = match info {
|
||||
0..=23 => info as u64,
|
||||
24 => {
|
||||
let v = *b.get(*pos).ok_or(Jcr1Error::Envelope("truncated".into()))? as u64;
|
||||
*pos += 1;
|
||||
v
|
||||
}
|
||||
25 => {
|
||||
let s = b.get(*pos..*pos + 2).ok_or(Jcr1Error::Envelope("truncated".into()))?;
|
||||
*pos += 2;
|
||||
u16::from_be_bytes([s[0], s[1]]) as u64
|
||||
}
|
||||
26 => {
|
||||
let s = b.get(*pos..*pos + 4).ok_or(Jcr1Error::Envelope("truncated".into()))?;
|
||||
*pos += 4;
|
||||
u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as u64
|
||||
}
|
||||
27 => {
|
||||
let s = b.get(*pos..*pos + 8).ok_or(Jcr1Error::Envelope("truncated".into()))?;
|
||||
*pos += 8;
|
||||
u64::from_be_bytes(s.try_into().expect("8 bytes"))
|
||||
}
|
||||
_ => return Err(Jcr1Error::Envelope("indefinite length".into())),
|
||||
};
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn read_bstr(b: &[u8], pos: &mut usize) -> Result<Vec<u8>, Jcr1Error> {
|
||||
let len = read_len(b, pos, 2)? as usize;
|
||||
let s = b
|
||||
.get(*pos..*pos + len)
|
||||
.ok_or(Jcr1Error::Envelope("truncated bstr".into()))?;
|
||||
*pos += len;
|
||||
Ok(s.to_vec())
|
||||
}
|
||||
|
||||
/// (protected header bytes, payload bytes, signature bytes) of a COSE_Sign1.
|
||||
type CoseSign1Parts = (Vec<u8>, Vec<u8>, Vec<u8>);
|
||||
|
||||
fn parse_cose_sign1(envelope: &[u8]) -> Result<CoseSign1Parts, Jcr1Error> {
|
||||
let mut pos = 0;
|
||||
// tag(18) = 0xd2
|
||||
if envelope.first() != Some(&0xd2) {
|
||||
return Err(Jcr1Error::Envelope("missing COSE_Sign1 tag 18".into()));
|
||||
}
|
||||
pos += 1;
|
||||
// array(4) = 0x84
|
||||
if envelope.get(pos) != Some(&0x84) {
|
||||
return Err(Jcr1Error::Envelope("expected array(4)".into()));
|
||||
}
|
||||
pos += 1;
|
||||
let protected = read_bstr(envelope, &mut pos)?;
|
||||
// unprotected map — expect empty map 0xa0
|
||||
if envelope.get(pos) != Some(&0xa0) {
|
||||
return Err(Jcr1Error::Envelope("expected empty unprotected map".into()));
|
||||
}
|
||||
pos += 1;
|
||||
let payload = read_bstr(envelope, &mut pos)?;
|
||||
let signature = read_bstr(envelope, &mut pos)?;
|
||||
Ok((protected, payload, signature))
|
||||
}
|
||||
|
||||
fn parse_protected_header(protected: &[u8]) -> Result<ProtectedHeader, Jcr1Error> {
|
||||
let mut pos = 0;
|
||||
let n = read_len(protected, &mut pos, 5)?; // map
|
||||
let mut alg = None;
|
||||
let mut kid = None;
|
||||
for _ in 0..n {
|
||||
let key = read_len(protected, &mut pos, 0)?; // small uint key
|
||||
match key {
|
||||
1 => {
|
||||
// alg: negative int (major 1) → value = -1 - n
|
||||
let head = *protected
|
||||
.get(pos)
|
||||
.ok_or(Jcr1Error::Envelope("truncated alg".into()))?;
|
||||
if head >> 5 != 1 {
|
||||
return Err(Jcr1Error::Envelope("alg not negative int".into()));
|
||||
}
|
||||
let v = read_len(protected, &mut pos, 1)?;
|
||||
alg = Some(-1 - v as i64);
|
||||
}
|
||||
4 => kid = Some(read_bstr(protected, &mut pos)?),
|
||||
_ => return Err(Jcr1Error::Envelope("unexpected protected header key".into())),
|
||||
}
|
||||
}
|
||||
Ok(ProtectedHeader {
|
||||
alg: alg.ok_or(Jcr1Error::Envelope("missing alg".into()))?,
|
||||
kid: kid.ok_or(Jcr1Error::Envelope("missing kid".into()))?,
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────── canonical decoding ───────────────────────
|
||||
//
|
||||
// The encoder above admits exactly four types in a signed core: unsigned
|
||||
// integers, text strings, arrays and maps (byte strings appear only in the
|
||||
// COSE envelope around it). The decoder therefore accepts exactly those and
|
||||
// refuses the rest, rather than accepting a wider grammar and hoping the
|
||||
// encoder never meets it.
|
||||
//
|
||||
// Canonicity is then checked by construction: the decoded value is re-encoded
|
||||
// and compared with the input. A core that decodes but does not re-encode to
|
||||
// its own bytes is not canonical, and since the signature covers those exact
|
||||
// bytes, accepting it would let two different documents share one signature.
|
||||
|
||||
fn read_head(b: &[u8], i: &mut usize) -> Result<(u8, u64), Jcr1Error> {
|
||||
let ib = *b.get(*i).ok_or(Jcr1Error::MalformedCore("truncated head"))?;
|
||||
*i += 1;
|
||||
let major = ib >> 5;
|
||||
let ai = ib & 0x1f;
|
||||
let take = |i: &mut usize, n: usize| -> Result<u64, Jcr1Error> {
|
||||
let end = i.checked_add(n).ok_or(Jcr1Error::MalformedCore("length overflow"))?;
|
||||
let slice = b.get(*i..end).ok_or(Jcr1Error::MalformedCore("truncated argument"))?;
|
||||
*i = end;
|
||||
Ok(slice.iter().fold(0u64, |acc, byte| (acc << 8) | u64::from(*byte)))
|
||||
};
|
||||
let val = match ai {
|
||||
0..=23 => u64::from(ai),
|
||||
24 => take(i, 1)?,
|
||||
25 => take(i, 2)?,
|
||||
26 => take(i, 4)?,
|
||||
27 => take(i, 8)?,
|
||||
_ => return Err(Jcr1Error::MalformedCore("indefinite or reserved length")),
|
||||
};
|
||||
Ok((major, val))
|
||||
}
|
||||
|
||||
fn read_value(b: &[u8], i: &mut usize) -> Result<Value, Jcr1Error> {
|
||||
let (major, val) = read_head(b, i)?;
|
||||
match major {
|
||||
0 => Ok(Value::from(val)),
|
||||
3 => {
|
||||
let n = usize::try_from(val).map_err(|_| Jcr1Error::MalformedCore("huge string"))?;
|
||||
let end = i.checked_add(n).ok_or(Jcr1Error::MalformedCore("length overflow"))?;
|
||||
let raw = b.get(*i..end).ok_or(Jcr1Error::MalformedCore("truncated string"))?;
|
||||
*i = end;
|
||||
let s = core::str::from_utf8(raw)
|
||||
.map_err(|_| Jcr1Error::MalformedCore("text is not UTF-8"))?;
|
||||
Ok(Value::from(s))
|
||||
}
|
||||
4 => {
|
||||
let n = usize::try_from(val).map_err(|_| Jcr1Error::MalformedCore("huge array"))?;
|
||||
let mut out = Vec::with_capacity(n.min(1024));
|
||||
for _ in 0..n {
|
||||
out.push(read_value(b, i)?);
|
||||
}
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
5 => {
|
||||
let n = usize::try_from(val).map_err(|_| Jcr1Error::MalformedCore("huge map"))?;
|
||||
let mut m = serde_json::Map::new();
|
||||
for _ in 0..n {
|
||||
let Value::String(k) = read_value(b, i)? else {
|
||||
return Err(Jcr1Error::MalformedCore("non-text map key"));
|
||||
};
|
||||
if m.insert(k, read_value(b, i)?).is_some() {
|
||||
return Err(Jcr1Error::MalformedCore("duplicate map key"));
|
||||
}
|
||||
}
|
||||
Ok(Value::Object(m))
|
||||
}
|
||||
_ => Err(Jcr1Error::MalformedCore("type not permitted in a signed core")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a canonical signed core back to JSON.
|
||||
///
|
||||
/// The inverse of [`canonical_cbor`] over the grammar a signed core may use.
|
||||
/// The result is checked by re-encoding: a core that decodes but does not
|
||||
/// re-encode to the bytes it came from is refused, because the signature
|
||||
/// covers those exact bytes and a second encoding of the same value would
|
||||
/// otherwise ride the same signature.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Jcr1Error::MalformedCore`] if the bytes are truncated, use a type the
|
||||
/// core grammar excludes, carry trailing data, or are not canonical.
|
||||
pub fn from_canonical_cbor(bytes: &[u8]) -> Result<Value, Jcr1Error> {
|
||||
let mut i = 0usize;
|
||||
let v = read_value(bytes, &mut i)?;
|
||||
if i != bytes.len() {
|
||||
return Err(Jcr1Error::MalformedCore("trailing bytes after the core"));
|
||||
}
|
||||
if canonical_cbor(&v)? != bytes {
|
||||
return Err(Jcr1Error::MalformedCore("core is not canonical"));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── the decoder (written here, not vendored, so tested here) ──
|
||||
|
||||
#[test]
|
||||
fn the_decoder_round_trips_a_core() {
|
||||
let v = json!({"a": 1, "bb": "x", "ccc": [1, 2], "d": {"e": 9}, "": "empty key"});
|
||||
let bytes = canonical_cbor(&v).expect("encodes");
|
||||
assert_eq!(from_canonical_cbor(&bytes).expect("decodes"), v);
|
||||
}
|
||||
|
||||
/// A value that decodes but was not written canonically is refused: the
|
||||
/// signature covers exact bytes, so a second encoding of the same value
|
||||
/// must not ride the same signature.
|
||||
#[test]
|
||||
fn a_non_shortest_integer_is_not_canonical() {
|
||||
// 5 the long way: major 0, additional-info 24, then the byte.
|
||||
assert!(from_canonical_cbor(&[0x18, 0x05]).is_err());
|
||||
// The same value, shortest form.
|
||||
assert_eq!(from_canonical_cbor(&[0x05]).expect("decodes"), json!(5));
|
||||
}
|
||||
|
||||
/// Length-first, then bytewise (RFC 8949 §4.2.1). `a` must precede `bb`.
|
||||
#[test]
|
||||
fn keys_out_of_canonical_order_are_refused() {
|
||||
let good = canonical_cbor(&json!({"a": 2, "bb": 1})).expect("encodes");
|
||||
assert!(from_canonical_cbor(&good).is_ok());
|
||||
// The same map written "bb" first.
|
||||
let bad = [0xa2, 0x62, b'b', b'b', 0x01, 0x61, b'a', 0x02];
|
||||
assert!(from_canonical_cbor(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_bytes_are_refused() {
|
||||
let mut b = canonical_cbor(&json!(1)).expect("encodes");
|
||||
b.push(0x01);
|
||||
assert!(
|
||||
matches!(from_canonical_cbor(&b), Err(Jcr1Error::MalformedCore(m)) if m.contains("trailing")),
|
||||
"refused for the trailing bytes specifically, not merely refused"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_type_outside_the_core_grammar_is_refused() {
|
||||
assert!(from_canonical_cbor(&[0xf5]).is_err(), "true (major 7)");
|
||||
assert!(from_canonical_cbor(&[0xf6]).is_err(), "null (major 7)");
|
||||
assert!(from_canonical_cbor(&[0x20]).is_err(), "-1 (major 1)");
|
||||
assert!(from_canonical_cbor(&[0x41, 0x00]).is_err(), "a byte string (major 2)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_core_is_refused_rather_than_guessed() {
|
||||
let full = canonical_cbor(&json!({"a": "xyz"})).expect("encodes");
|
||||
for n in 1..full.len() {
|
||||
assert!(from_canonical_cbor(&full[..n]).is_err(), "prefix of {n} bytes");
|
||||
}
|
||||
assert!(from_canonical_cbor(&full).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duplicate_map_key_is_refused() {
|
||||
// {"a": 1, "a": 2} — well-formed CBOR, not a well-formed core.
|
||||
let bad = [0xa2, 0x61, b'a', 0x01, 0x61, b'a', 0x02];
|
||||
assert!(
|
||||
matches!(from_canonical_cbor(&bad), Err(Jcr1Error::MalformedCore(m)) if m.contains("duplicate")),
|
||||
"refused as a duplicate key specifically, not merely refused"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every signed core this crate produces decodes back to what it encoded.
|
||||
#[test]
|
||||
fn a_signed_core_decodes_to_the_document_it_sealed() {
|
||||
let wire = json!({"pool_receipt": "pool/1", "seq": 0, "prev": "00", "steward": "did:x"});
|
||||
let core = signed_core_bytes(&wire).expect("canonical");
|
||||
assert_eq!(from_canonical_cbor(&core).expect("decodes"), wire);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_roundtrip() {
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let steward = "did:example:endpoint1";
|
||||
let core = canonical_cbor(&json!({"a": 1, "bb": "x"})).unwrap();
|
||||
let env = cose_sign1_envelope(&core, &sk, steward);
|
||||
let payload = verify_cose_sign1(&env, steward, &sk.verifying_key().to_bytes()).unwrap();
|
||||
assert_eq!(payload, core);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_rejects_wrong_steward() {
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let core = canonical_cbor(&json!({"a": 1})).unwrap();
|
||||
let env = cose_sign1_envelope(&core, &sk, "did:example:a");
|
||||
assert!(verify_cose_sign1(&env, "did:example:b", &sk.verifying_key().to_bytes()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bool_is_rejected_in_core() {
|
||||
// The signed core forbids bools — fidelity_met et al. must be uints.
|
||||
assert!(canonical_cbor(&json!({"x": true})).is_err());
|
||||
}
|
||||
}
|
||||
681
pool/pool-rs/crates/pool-core/src/ledger.rs
Normal file
681
pool/pool-rs/crates/pool-core/src/ledger.rs
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
//! The entitlement ledger: what was admitted, what it actually cost, sealed so
|
||||
//! a third party can check it, and replayable so it survives the process.
|
||||
//!
|
||||
//! [`Pool`](crate::Pool) holds usage in memory. A scheduler that restarts with
|
||||
//! an empty one hands every tenant its whole guarantee back, and the next claim
|
||||
//! is admitted against a budget that was already spent — a quota that refills
|
||||
//! on every deploy, crash or config reload is not a quota. The same shape has
|
||||
//! been found and fixed twice elsewhere in this family, so `pool/1` carries the
|
||||
//! replay from the start rather than acquiring it later.
|
||||
//!
|
||||
//! Every decision that moves the ledger is appended as an [`Event`], sealed as
|
||||
//! a **`pool/1` JCR-1 receipt** — canonical det-CBOR under COSE_Sign1 with
|
||||
//! Ed25519 ([`crate::jcr1`]) — and chained onto the content address of the
|
||||
//! envelope before it. [`replay`] verifies every signature and every link
|
||||
//! before deriving anything, so the ledger IS the durable state: there is no
|
||||
//! second store to drift from it, and no unverified byte reaches the
|
||||
//! entitlement ledger.
|
||||
//!
|
||||
//! Two properties, kept distinct because they fail separately:
|
||||
//!
|
||||
//! - **Authenticity.** Every entry is signed by the steward. An entry this
|
||||
//! steward did not seal does not verify, whoever wrote the file.
|
||||
//! - **Sequence.** Each entry commits to the content address of its
|
||||
//! predecessor, so reordering, removing or splicing one breaks the chain at
|
||||
//! that point and [`replay`] reports the index.
|
||||
//!
|
||||
//! Neither alone is enough: signatures without a chain permit a bag of valid
|
||||
//! receipts in any order with any subset removed, and a chain without
|
||||
//! signatures permits a consistent stream written by anyone.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::jcr1;
|
||||
use crate::{Energy, EntitlementId, Provenance, Usage};
|
||||
|
||||
/// The profile every `pool/1` ledger entry declares.
|
||||
pub const PROFILE: &str = "pool/1";
|
||||
|
||||
/// The genesis predecessor: the all-zero content address the first entry's
|
||||
/// `prev` links to.
|
||||
#[must_use]
|
||||
pub fn genesis_prev() -> String {
|
||||
"0".repeat(64)
|
||||
}
|
||||
|
||||
/// A decision that moves the ledger.
|
||||
///
|
||||
/// Only these two move it. A `Queue` or a `Deny` reserves nothing, so it
|
||||
/// changes no tenant's usage and is not recorded here — recording it would
|
||||
/// make the ledger a decision log, which is a different artifact with a
|
||||
/// different retention story.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Event {
|
||||
/// A claim was admitted and its estimate reserved.
|
||||
Admitted {
|
||||
/// The claim's id, which a later [`Event::Released`] names.
|
||||
claim: String,
|
||||
/// Who pays.
|
||||
entitlement: EntitlementId,
|
||||
/// What kind of work this is, as the operator declared it
|
||||
/// ([`crate::Claim::class`]).
|
||||
///
|
||||
/// It is recorded **here**, beside the reservation, rather than kept
|
||||
/// by the caller: SPEC §6.1 makes the ledger the only durable state, and
|
||||
/// a caller-side map from claim to class would be exactly the second
|
||||
/// store that can drift from it. Without the class the history cannot
|
||||
/// be read back as evidence about a *kind* of work, which is what
|
||||
/// SPEC §2.6 and [`crate::observe::derive`] need.
|
||||
class: String,
|
||||
/// Microjoules reserved up front.
|
||||
reserved_uj: u64,
|
||||
/// Devices reserved.
|
||||
devices: u32,
|
||||
},
|
||||
/// An admitted claim finished. Its reservation is released and what it
|
||||
/// actually spent is charged in place of the estimate.
|
||||
Released {
|
||||
/// The claim's id, which must name an open admission.
|
||||
claim: String,
|
||||
/// What it actually consumed, at its honest grade.
|
||||
actual: Energy,
|
||||
},
|
||||
}
|
||||
|
||||
/// Why a stream could not be replayed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReplayError {
|
||||
/// The entry at this index does not verify under the steward's key, is not
|
||||
/// a `pool/1` receipt, or does not decode.
|
||||
Unverifiable(usize),
|
||||
/// The chain breaks at this index: the entry's `seq` is not its position,
|
||||
/// or its `prev` is not the content address of the entry before it.
|
||||
ChainBroken(usize),
|
||||
/// The entry at this index releases a claim that was never admitted, or
|
||||
/// was released already. The stream is internally inconsistent, which is
|
||||
/// not a shorter history — it is a history that did not happen.
|
||||
UnmatchedRelease(usize),
|
||||
}
|
||||
|
||||
/// Pass one, shared by every reader of a stream: authenticity and sequence
|
||||
/// over the **whole** stream, before a single byte of it is interpreted.
|
||||
///
|
||||
/// There is one of these rather than one per reader on purpose. A second
|
||||
/// verification path is a second place for the checks to drift, and a reader
|
||||
/// that derived anything from an unverified entry would be reading a history
|
||||
/// that did not happen.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ReplayError::Unverifiable`] or [`ReplayError::ChainBroken`] with the
|
||||
/// index of the first entry at fault.
|
||||
fn verified_events(
|
||||
entries: &[Vec<u8>],
|
||||
steward: &str,
|
||||
pubkey: &[u8; 32],
|
||||
) -> Result<Vec<Event>, ReplayError> {
|
||||
let mut expected_prev = genesis_prev();
|
||||
let mut events: Vec<Event> = Vec::with_capacity(entries.len());
|
||||
for (i, envelope) in entries.iter().enumerate() {
|
||||
let payload = jcr1::verify_cose_sign1(envelope, steward, pubkey)
|
||||
.map_err(|_| ReplayError::Unverifiable(i))?;
|
||||
let v: Value =
|
||||
jcr1::from_canonical_cbor(&payload).map_err(|_| ReplayError::Unverifiable(i))?;
|
||||
if v.get("pool_receipt").and_then(Value::as_str) != Some(PROFILE) {
|
||||
return Err(ReplayError::Unverifiable(i));
|
||||
}
|
||||
if v.get("seq").and_then(Value::as_u64) != Some(i as u64)
|
||||
|| v.get("prev").and_then(Value::as_str) != Some(expected_prev.as_str())
|
||||
{
|
||||
return Err(ReplayError::ChainBroken(i));
|
||||
}
|
||||
events.push(from_wire(&v).ok_or(ReplayError::Unverifiable(i))?);
|
||||
expected_prev = address(envelope);
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// The content address of one sealed envelope — what the next entry's `prev`
|
||||
/// commits to.
|
||||
fn address(envelope: &[u8]) -> String {
|
||||
let d = blake3::hash(envelope);
|
||||
let mut out = String::with_capacity(64);
|
||||
for b in d.as_bytes() {
|
||||
out.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn provenance_label(p: Provenance) -> &'static str {
|
||||
match p {
|
||||
Provenance::Estimator => "Estimator",
|
||||
Provenance::ModelBased => "ModelBased",
|
||||
Provenance::HwShunt => "HwShunt",
|
||||
}
|
||||
}
|
||||
|
||||
fn provenance_of(label: &str) -> Option<Provenance> {
|
||||
match label {
|
||||
"Estimator" => Some(Provenance::Estimator),
|
||||
"ModelBased" => Some(Provenance::ModelBased),
|
||||
"HwShunt" => Some(Provenance::HwShunt),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Project one event onto the JCR-1 wire: integers and strings only, as the
|
||||
/// profile requires (no floats, bools or nulls).
|
||||
fn wire(event: &Event, seq: u64, prev: &str, steward: &str) -> Value {
|
||||
let mut o = match event {
|
||||
Event::Admitted { claim, entitlement, class, reserved_uj, devices } => json!({
|
||||
"event": "admitted",
|
||||
"claim": claim,
|
||||
"entitlement": entitlement.0,
|
||||
"class": class,
|
||||
"reserved_uj": reserved_uj,
|
||||
"devices": u64::from(*devices),
|
||||
}),
|
||||
Event::Released { claim, actual } => json!({
|
||||
"event": "released",
|
||||
"claim": claim,
|
||||
"actual_uj": actual.uj,
|
||||
"provenance": provenance_label(actual.provenance),
|
||||
}),
|
||||
};
|
||||
let Value::Object(ref mut m) = o else { unreachable!("json! built an object") };
|
||||
m.insert("pool_receipt".into(), json!(PROFILE));
|
||||
m.insert("seq".into(), json!(seq));
|
||||
m.insert("prev".into(), json!(prev));
|
||||
m.insert("steward".into(), json!(steward));
|
||||
o
|
||||
}
|
||||
|
||||
/// Recover an event from a verified wire object.
|
||||
fn from_wire(v: &Value) -> Option<Event> {
|
||||
let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
|
||||
let u = |k: &str| v.get(k).and_then(Value::as_u64);
|
||||
match v.get("event").and_then(Value::as_str)? {
|
||||
"admitted" => Some(Event::Admitted {
|
||||
claim: s("claim")?,
|
||||
entitlement: EntitlementId(s("entitlement")?),
|
||||
class: s("class")?,
|
||||
reserved_uj: u("reserved_uj")?,
|
||||
devices: u32::try_from(u("devices")?).ok()?,
|
||||
}),
|
||||
"released" => Some(Event::Released {
|
||||
claim: s("claim")?,
|
||||
actual: Energy::new(u("actual_uj")?, provenance_of(&s("provenance")?)?),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// An append-only ledger of sealed `pool/1` receipts.
|
||||
pub struct Ledger {
|
||||
steward_sk: SigningKey,
|
||||
steward: String,
|
||||
head: String,
|
||||
seq: u64,
|
||||
entries: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Ledger {
|
||||
/// The signing key is deliberately not printed.
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Ledger")
|
||||
.field("steward", &self.steward)
|
||||
.field("head", &self.head)
|
||||
.field("seq", &self.seq)
|
||||
.field("entries", &self.entries.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Ledger {
|
||||
/// Open an empty ledger sealing under `steward_sk` as `steward`.
|
||||
#[must_use]
|
||||
pub fn new(steward_sk: SigningKey, steward: impl Into<String>) -> Self {
|
||||
Self {
|
||||
steward_sk,
|
||||
steward: steward.into(),
|
||||
head: genesis_prev(),
|
||||
seq: 0,
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The steward these entries are sealed under.
|
||||
#[must_use]
|
||||
pub fn steward(&self) -> &str {
|
||||
&self.steward
|
||||
}
|
||||
|
||||
/// The steward's public key — what [`replay`] checks the stream against.
|
||||
#[must_use]
|
||||
pub fn steward_pubkey(&self) -> [u8; 32] {
|
||||
self.steward_sk.verifying_key().to_bytes()
|
||||
}
|
||||
|
||||
/// Seal one event as the next entry, returning its envelope bytes.
|
||||
///
|
||||
/// # Panics
|
||||
/// If the event cannot be projected onto a canonical JCR-1 core, which
|
||||
/// would mean this crate built a wire object the profile forbids.
|
||||
pub fn append(&mut self, event: &Event) -> Vec<u8> {
|
||||
let w = wire(event, self.seq, &self.head, &self.steward);
|
||||
let core = jcr1::signed_core_bytes(&w).expect("pool/1 wire is a JCR-1 object");
|
||||
let envelope = jcr1::cose_sign1_envelope(&core, &self.steward_sk, &self.steward);
|
||||
self.head = address(&envelope);
|
||||
self.seq += 1;
|
||||
self.entries.push(envelope.clone());
|
||||
envelope
|
||||
}
|
||||
|
||||
/// The sealed entries, in order.
|
||||
#[must_use]
|
||||
pub fn entries(&self) -> &[Vec<u8>] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// The content address of the most recent entry, or the genesis sentinel.
|
||||
#[must_use]
|
||||
pub fn head(&self) -> &str {
|
||||
&self.head
|
||||
}
|
||||
|
||||
/// How many entries the ledger holds.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Whether the ledger holds nothing.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Adopt a prior stream as this ledger's history, so the next
|
||||
/// [`Self::append`] chains onto its real head rather than onto genesis.
|
||||
///
|
||||
/// The stream is verified end to end before anything is adopted.
|
||||
///
|
||||
/// # Errors
|
||||
/// Whatever [`replay`] reports. A ledger that has already appended is not
|
||||
/// a candidate for adoption, and the call leaves it untouched.
|
||||
pub fn resume(&mut self, prior: Vec<Vec<u8>>) -> Result<usize, ReplayError> {
|
||||
if !self.entries.is_empty() {
|
||||
return Err(ReplayError::ChainBroken(0));
|
||||
}
|
||||
replay(&prior, &self.steward, &self.steward_pubkey())?;
|
||||
self.head = prior.last().map_or_else(genesis_prev, |e| address(e));
|
||||
self.seq = prior.len() as u64;
|
||||
let n = prior.len();
|
||||
self.entries = prior;
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a sealed stream and rebuild every entitlement's usage from it.
|
||||
///
|
||||
/// Verification comes first and completely: every envelope's signature under
|
||||
/// `pubkey`, every `seq`, and every `prev` from genesis. Only then is any
|
||||
/// usage derived, so a stream that does not verify never reaches the
|
||||
/// entitlement ledger.
|
||||
///
|
||||
/// A reservation is replaced by the actual figure on release, not added to it:
|
||||
/// the estimate was a hold on capacity, and what the tenant spent is what it
|
||||
/// spent. An admission still open at the end of the stream keeps its
|
||||
/// reservation, because the work is still running and the capacity is still
|
||||
/// held.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ReplayError::Unverifiable`] at the first entry that is not a `pool/1`
|
||||
/// receipt of this steward, [`ReplayError::ChainBroken`] at the first broken
|
||||
/// link, [`ReplayError::UnmatchedRelease`] at a release with no open
|
||||
/// admission.
|
||||
pub fn replay(
|
||||
entries: &[Vec<u8>],
|
||||
steward: &str,
|
||||
pubkey: &[u8; 32],
|
||||
) -> Result<BTreeMap<EntitlementId, Usage>, ReplayError> {
|
||||
let events = verified_events(entries, steward, pubkey)?;
|
||||
|
||||
// Pass two: the ledger itself, over verified events only.
|
||||
let mut usage: BTreeMap<EntitlementId, Usage> = BTreeMap::new();
|
||||
let mut open: BTreeMap<String, (EntitlementId, u64, u32)> = BTreeMap::new();
|
||||
for (i, event) in events.into_iter().enumerate() {
|
||||
match event {
|
||||
Event::Admitted { claim, entitlement, class: _, reserved_uj, devices } => {
|
||||
let u = usage.entry(entitlement.clone()).or_default();
|
||||
u.spent_uj = u.spent_uj.saturating_add(reserved_uj);
|
||||
u.devices_held = u.devices_held.saturating_add(devices);
|
||||
open.insert(claim, (entitlement, reserved_uj, devices));
|
||||
}
|
||||
Event::Released { claim, actual } => {
|
||||
let Some((entitlement, reserved_uj, devices)) = open.remove(&claim) else {
|
||||
return Err(ReplayError::UnmatchedRelease(i));
|
||||
};
|
||||
let u = usage.entry(entitlement).or_default();
|
||||
// The hold is lifted and the real figure charged in its place.
|
||||
u.spent_uj = u.spent_uj.saturating_sub(reserved_uj).saturating_add(actual.uj);
|
||||
u.devices_held = u.devices_held.saturating_sub(devices);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(usage)
|
||||
}
|
||||
|
||||
/// Read a verified stream back as evidence: what each **completed** claim of
|
||||
/// each class actually cost.
|
||||
///
|
||||
/// Only a matched [`Event::Released`] yields an observation. An open admission
|
||||
/// has a reservation, not a result, and reserving a figure is not evidence that
|
||||
/// the work costs it — the reservation is the guess this evidence exists to
|
||||
/// replace.
|
||||
///
|
||||
/// Verification runs over the whole stream first, by the same pass [`replay`]
|
||||
/// uses, so an
|
||||
/// estimate is never derived from an entry that does not verify or a chain
|
||||
/// that does not hold.
|
||||
///
|
||||
/// # Errors
|
||||
/// The same [`ReplayError`]s as [`replay`], at the same indices.
|
||||
pub fn observations(
|
||||
entries: &[Vec<u8>],
|
||||
steward: &str,
|
||||
pubkey: &[u8; 32],
|
||||
) -> Result<Vec<crate::observe::Observation>, ReplayError> {
|
||||
let events = verified_events(entries, steward, pubkey)?;
|
||||
let mut open: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut out = Vec::new();
|
||||
for (i, event) in events.into_iter().enumerate() {
|
||||
match event {
|
||||
Event::Admitted { claim, class, .. } => {
|
||||
open.insert(claim, class);
|
||||
}
|
||||
Event::Released { claim, actual } => {
|
||||
let Some(class) = open.remove(&claim) else {
|
||||
return Err(ReplayError::UnmatchedRelease(i));
|
||||
};
|
||||
out.push(crate::observe::Observation { class, claim, actual });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Claim, Entitlement, Pool, QueueReason, Verdict, admit};
|
||||
|
||||
const STEWARD: &str = "did:pool:steward";
|
||||
|
||||
fn key(seed: u8) -> SigningKey {
|
||||
SigningKey::from_bytes(&[seed; 32])
|
||||
}
|
||||
|
||||
fn ledger(seed: u8) -> Ledger {
|
||||
Ledger::new(key(seed), STEWARD)
|
||||
}
|
||||
|
||||
fn pool() -> Pool {
|
||||
let mut p = Pool::new();
|
||||
p.insert(Entitlement {
|
||||
id: "team".into(),
|
||||
parent: None,
|
||||
guarantee_uj: 10_000_000,
|
||||
guarantee_devices: 8,
|
||||
over_quota_weight: 0,
|
||||
limit_uj: None,
|
||||
priority: 10,
|
||||
});
|
||||
p
|
||||
}
|
||||
|
||||
fn claim(id: &str, uj: u64) -> Claim {
|
||||
Claim {
|
||||
id: id.to_string(),
|
||||
entitlement: "team".into(),
|
||||
class: "train".into(),
|
||||
gang_size: 1,
|
||||
devices_per_member: 1,
|
||||
estimate: Energy::new(uj, Provenance::HwShunt),
|
||||
}
|
||||
}
|
||||
|
||||
fn admitted(c: &Claim, reserved_uj: u64, devices: u32) -> Event {
|
||||
Event::Admitted {
|
||||
claim: c.id.clone(),
|
||||
entitlement: c.entitlement.clone(),
|
||||
class: c.class.clone(),
|
||||
reserved_uj,
|
||||
devices,
|
||||
}
|
||||
}
|
||||
|
||||
/// The property this module exists for, and its discriminating half: the
|
||||
/// same tenant, the same spent guarantee, with and without the replay.
|
||||
#[test]
|
||||
fn a_restart_does_not_refill_a_spent_guarantee() {
|
||||
let mut p = pool();
|
||||
let mut log = ledger(1);
|
||||
|
||||
for (id, uj) in [("a", 6_000_000u64), ("b", 4_000_000)] {
|
||||
let c = claim(id, uj);
|
||||
let Verdict::Admit { reserved_uj, devices, .. } = admit(&p, &c) else {
|
||||
panic!("expected Admit for {id}")
|
||||
};
|
||||
log.append(&admitted(&c, reserved_uj, devices));
|
||||
let mut u = p.usage(&"team".into());
|
||||
u.spent_uj += reserved_uj;
|
||||
u.devices_held += devices;
|
||||
p.set_usage(&"team".into(), u);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
admit(&p, &claim("c", 1_000_000)),
|
||||
Verdict::Queue { reason: QueueReason::GuaranteeExhausted }
|
||||
);
|
||||
let stream: Vec<Vec<u8>> = log.entries().to_vec();
|
||||
let pubkey = log.steward_pubkey();
|
||||
drop(p);
|
||||
|
||||
let mut resumed = pool();
|
||||
let usage = replay(&stream, STEWARD, &pubkey).expect("our own chain verifies");
|
||||
assert_eq!(usage[&EntitlementId::from("team")].spent_uj, 10_000_000);
|
||||
for (id, u) in usage {
|
||||
resumed.set_usage(&id, u);
|
||||
}
|
||||
assert_eq!(
|
||||
admit(&resumed, &claim("c", 1_000_000)),
|
||||
Verdict::Queue { reason: QueueReason::GuaranteeExhausted },
|
||||
"the spend survived the restart"
|
||||
);
|
||||
|
||||
let fresh = pool();
|
||||
assert!(
|
||||
matches!(admit(&fresh, &claim("c", 1_000_000)), Verdict::Admit { .. }),
|
||||
"a pool that does not replay starts the guarantee over"
|
||||
);
|
||||
}
|
||||
|
||||
/// Authenticity: a stream this steward did not seal does not verify,
|
||||
/// however well-formed and however consistent its chain.
|
||||
#[test]
|
||||
fn another_stewards_stream_does_not_verify() {
|
||||
let mut theirs = ledger(9);
|
||||
theirs.append(&admitted(&claim("a", 1), 1, 1));
|
||||
let stream = theirs.entries().to_vec();
|
||||
|
||||
// Internally consistent under its own key.
|
||||
assert!(replay(&stream, STEWARD, &theirs.steward_pubkey()).is_ok());
|
||||
// Not under ours.
|
||||
assert_eq!(
|
||||
replay(&stream, STEWARD, &ledger(1).steward_pubkey()),
|
||||
Err(ReplayError::Unverifiable(0))
|
||||
);
|
||||
}
|
||||
|
||||
/// A flipped byte in a sealed entry fails the signature.
|
||||
#[test]
|
||||
fn a_tampered_entry_does_not_verify() {
|
||||
let mut log = ledger(1);
|
||||
log.append(&admitted(&claim("a", 1), 1, 1));
|
||||
log.append(&admitted(&claim("b", 1), 1, 1));
|
||||
let pubkey = log.steward_pubkey();
|
||||
|
||||
let mut stream = log.entries().to_vec();
|
||||
*stream[1].last_mut().expect("bytes") ^= 1;
|
||||
assert_eq!(replay(&stream, STEWARD, &pubkey), Err(ReplayError::Unverifiable(1)));
|
||||
}
|
||||
|
||||
/// Sequence: every entry is individually valid, and removing one still
|
||||
/// breaks the stream. This is why the chain is kept alongside the
|
||||
/// signatures — a bag of valid receipts is not a ledger.
|
||||
#[test]
|
||||
fn a_removed_entry_breaks_the_chain_though_each_entry_is_still_signed() {
|
||||
let mut log = ledger(1);
|
||||
for id in ["a", "b", "c"] {
|
||||
log.append(&admitted(&claim(id, 1), 1, 1));
|
||||
}
|
||||
let pubkey = log.steward_pubkey();
|
||||
let mut stream = log.entries().to_vec();
|
||||
stream.remove(1);
|
||||
|
||||
// Entry 1 of the shortened stream still verifies on its own...
|
||||
assert!(jcr1::verify_cose_sign1(&stream[1], STEWARD, &pubkey).is_ok());
|
||||
// ...and the stream is still refused, at the splice.
|
||||
assert_eq!(replay(&stream, STEWARD, &pubkey), Err(ReplayError::ChainBroken(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_replaces_the_reservation_with_the_actual() {
|
||||
let mut log = ledger(1);
|
||||
let pubkey = log.steward_pubkey();
|
||||
log.append(&Event::Admitted {
|
||||
claim: "w".into(),
|
||||
entitlement: "team".into(),
|
||||
class: "train".into(),
|
||||
reserved_uj: 5_000_000,
|
||||
devices: 2,
|
||||
});
|
||||
|
||||
let held = replay(log.entries(), STEWARD, &pubkey).expect("verifies");
|
||||
assert_eq!(held[&EntitlementId::from("team")].spent_uj, 5_000_000);
|
||||
assert_eq!(held[&EntitlementId::from("team")].devices_held, 2);
|
||||
|
||||
log.append(&Event::Released {
|
||||
claim: "w".into(),
|
||||
actual: Energy::new(3_000_000, Provenance::HwShunt),
|
||||
});
|
||||
|
||||
let settled = replay(log.entries(), STEWARD, &pubkey).expect("verifies");
|
||||
assert_eq!(
|
||||
settled[&EntitlementId::from("team")].spent_uj,
|
||||
3_000_000,
|
||||
"the actual replaces the hold, rather than adding to it"
|
||||
);
|
||||
assert_eq!(settled[&EntitlementId::from("team")].devices_held, 0);
|
||||
}
|
||||
|
||||
/// An overrun is charged as it happened. The hold bounded what could
|
||||
/// start, not what was ultimately spent (SPEC §8).
|
||||
#[test]
|
||||
fn an_overrun_is_charged_at_what_it_cost() {
|
||||
let mut log = ledger(1);
|
||||
let pubkey = log.steward_pubkey();
|
||||
log.append(&Event::Admitted {
|
||||
claim: "w".into(),
|
||||
entitlement: "team".into(),
|
||||
class: "train".into(),
|
||||
reserved_uj: 1_000_000,
|
||||
devices: 1,
|
||||
});
|
||||
log.append(&Event::Released {
|
||||
claim: "w".into(),
|
||||
actual: Energy::new(9_000_000, Provenance::HwShunt),
|
||||
});
|
||||
|
||||
let usage = replay(log.entries(), STEWARD, &pubkey).expect("verifies");
|
||||
assert_eq!(usage[&EntitlementId::from("team")].spent_uj, 9_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_of_a_claim_never_admitted_is_refused() {
|
||||
let mut log = ledger(1);
|
||||
let pubkey = log.steward_pubkey();
|
||||
log.append(&Event::Released {
|
||||
claim: "ghost".into(),
|
||||
actual: Energy::new(1, Provenance::HwShunt),
|
||||
});
|
||||
assert_eq!(
|
||||
replay(log.entries(), STEWARD, &pubkey),
|
||||
Err(ReplayError::UnmatchedRelease(0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resumed_ledger_continues_the_chain() {
|
||||
let mut first = ledger(1);
|
||||
first.append(&admitted(&claim("a", 1), 1, 1));
|
||||
let head = first.head().to_string();
|
||||
let stream = first.entries().to_vec();
|
||||
|
||||
let mut second = ledger(1);
|
||||
assert_eq!(second.resume(stream), Ok(1));
|
||||
assert_eq!(second.head(), head, "it continues from the real head");
|
||||
second.append(&admitted(&claim("b", 1), 1, 1));
|
||||
assert_eq!(
|
||||
replay(second.entries(), STEWARD, &second.steward_pubkey())
|
||||
.expect("verifies")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(second.len(), 2);
|
||||
}
|
||||
|
||||
/// Without the resume the second run seals `seq = 0, prev = 0…0` on top of
|
||||
/// a chain that already has entries, and the accumulated stream — the only
|
||||
/// record of the spend — stops verifying at that point.
|
||||
#[test]
|
||||
fn a_ledger_that_does_not_resume_breaks_the_accumulated_stream() {
|
||||
let mut first = ledger(1);
|
||||
first.append(&admitted(&claim("a", 1), 1, 1));
|
||||
let pubkey = first.steward_pubkey();
|
||||
let mut accumulated = first.entries().to_vec();
|
||||
|
||||
let mut second = ledger(1);
|
||||
accumulated.push(second.append(&admitted(&claim("b", 1), 1, 1)));
|
||||
|
||||
assert_eq!(replay(&accumulated, STEWARD, &pubkey), Err(ReplayError::ChainBroken(1)));
|
||||
}
|
||||
|
||||
/// The wire carries integers and strings only, as JCR-1 requires.
|
||||
#[test]
|
||||
fn the_wire_holds_no_float_bool_or_null() {
|
||||
for e in [
|
||||
admitted(&claim("a", 7), 7, 2),
|
||||
Event::Released {
|
||||
claim: "a".into(),
|
||||
actual: Energy::new(7, Provenance::ModelBased),
|
||||
},
|
||||
] {
|
||||
let w = wire(&e, 0, &genesis_prev(), STEWARD);
|
||||
let Value::Object(m) = &w else { panic!("an object") };
|
||||
for (k, v) in m {
|
||||
assert!(
|
||||
v.is_string() || v.is_u64(),
|
||||
"{k} is {v:?}; JCR-1 admits integers and strings only"
|
||||
);
|
||||
}
|
||||
// And it survives the canonical encoder.
|
||||
jcr1::signed_core_bytes(&w).expect("canonical");
|
||||
}
|
||||
}
|
||||
}
|
||||
751
pool/pool-rs/crates/pool-core/src/lib.rs
Normal file
751
pool/pool-rs/crates/pool-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,751 @@
|
|||
//! Pool — the admission and preemption kernel for energy-accounted
|
||||
//! multi-tenant scheduling (`pool/1`).
|
||||
//!
|
||||
//! A cluster scheduler decides who gets contended capacity. Every scheduler in
|
||||
//! deployment denominates that entitlement in **devices**: whole accelerators,
|
||||
//! fractions of one, or a share of wall-clock. Devices are a proxy. The thing
|
||||
//! actually consumed, paid for and reported to a regulator is **energy**, and a
|
||||
//! device-count quota cannot express "this team may spend 40 MJ this week" — so
|
||||
//! the budget that matters is enforced nowhere and reconciled afterwards, if at
|
||||
//! all.
|
||||
//!
|
||||
//! Pool denominates entitlement in **microjoules first and devices second**,
|
||||
//! and enforces both. Three rules carry the weight, and each exists because a
|
||||
//! device-denominated scheduler cannot state it:
|
||||
//!
|
||||
//! 1. **A guess may not spend a neighbour's capacity** ([`admit`]). A claim
|
||||
//! whose energy estimate is [`Provenance::Estimator`] — nobody measured it,
|
||||
//! nobody modelled it — is admitted only inside its own guaranteed quota,
|
||||
//! never against surplus borrowed from a sibling. You may gamble your own
|
||||
//! guarantee on a guess; you may not gamble someone else's.
|
||||
//! 2. **A gang is all-or-nothing in joules, not just in slots**
|
||||
//! ([`Claim::demand_uj`]). Gang scheduling exists because half a distributed
|
||||
//! job is worse than none of it. The same is true of its energy: admitting
|
||||
//! half a gang's slots against a budget that cannot finish the whole gang
|
||||
//! buys nothing and spends real joules doing it.
|
||||
//! 3. **Preemption follows evidence, not declaration**
|
||||
//! ([`preemption_order`]). When capacity must be reclaimed, the workloads
|
||||
//! with a *measured* cost per completed unit are reclaimed worst-first,
|
||||
//! because there is evidence they are expensive. Workloads that have
|
||||
//! completed nothing yet are **not** treated as infinitely wasteful — that
|
||||
//! is an unknown, not a maximum — and fall back to declared priority.
|
||||
//!
|
||||
//! That third rule is the one this kernel exists to make possible, and it needs
|
||||
//! an outcome anchor: cost per *completed* unit, not cost per attempt. A
|
||||
//! scheduler that only knows device-seconds cannot rank by it at all.
|
||||
//!
|
||||
//! Nothing here reads a counter or places a pod. This crate decides; the energy
|
||||
//! figures arrive already graded from whatever meter produced them, and
|
||||
//! placement is the caller's.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod jcr1;
|
||||
pub mod ledger;
|
||||
pub mod observe;
|
||||
pub mod reclaim;
|
||||
pub mod share;
|
||||
pub mod witness;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ───────────────────────────── energy ─────────────────────────────
|
||||
|
||||
/// How an energy figure was obtained. The ladder only ever moves trust
|
||||
/// *down*: a figure nobody measured is never graded as one that was.
|
||||
///
|
||||
/// Ordered worst-to-best so comparisons read the way the grading does.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum Provenance {
|
||||
/// Nobody measured or modelled this host: a default-power guess.
|
||||
Estimator,
|
||||
/// A model the host calibrated — a real curve, not a reading.
|
||||
ModelBased,
|
||||
/// A counter read for this work, at its honest resolution.
|
||||
HwShunt,
|
||||
}
|
||||
|
||||
impl Provenance {
|
||||
/// Whether a figure at this grade rests on something the host actually
|
||||
/// observed. `Estimator` does not.
|
||||
#[must_use]
|
||||
pub const fn is_observed(self) -> bool {
|
||||
matches!(self, Self::ModelBased | Self::HwShunt)
|
||||
}
|
||||
}
|
||||
|
||||
/// An energy figure and the grade of the thing that produced it. The grade
|
||||
/// travels with the number because the number alone cannot say whether anyone
|
||||
/// looked.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Energy {
|
||||
/// Microjoules.
|
||||
pub uj: u64,
|
||||
/// How `uj` was obtained.
|
||||
pub provenance: Provenance,
|
||||
}
|
||||
|
||||
impl Energy {
|
||||
/// A figure at a stated grade.
|
||||
#[must_use]
|
||||
pub const fn new(uj: u64, provenance: Provenance) -> Self {
|
||||
Self { uj, provenance }
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────── entitlement ──────────────────────────
|
||||
|
||||
/// The name of an entitlement node — a department, a project, a team.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct EntitlementId(pub String);
|
||||
|
||||
impl From<&str> for EntitlementId {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// One node of the entitlement tree: what a tenant is guaranteed, what it may
|
||||
/// borrow, and what it may never exceed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Entitlement {
|
||||
/// This node's name.
|
||||
pub id: EntitlementId,
|
||||
/// Its parent, or `None` for a root. Surplus is lent between siblings
|
||||
/// under one parent, never across roots.
|
||||
pub parent: Option<EntitlementId>,
|
||||
/// Guaranteed microjoules over the accounting window. This is the figure
|
||||
/// the tenant is owed and may always spend.
|
||||
pub guarantee_uj: u64,
|
||||
/// Guaranteed device count, enforced alongside the joule guarantee. Both
|
||||
/// bind: energy is what is consumed, devices are what is occupied, and a
|
||||
/// claim that fits one but not the other does not fit.
|
||||
pub guarantee_devices: u32,
|
||||
/// Relative share of a parent's unspent surplus. `0` means this node never
|
||||
/// borrows and is capped at its guarantee.
|
||||
pub over_quota_weight: u32,
|
||||
/// A hard ceiling on total spend including anything borrowed. `None` means
|
||||
/// the only ceiling is the surplus actually available.
|
||||
pub limit_uj: Option<u64>,
|
||||
/// Declared ordering, used only where evidence is absent
|
||||
/// ([`preemption_order`]). Lower is reclaimed first.
|
||||
pub priority: u8,
|
||||
}
|
||||
|
||||
/// What a tenant has already consumed in the current window.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Usage {
|
||||
/// Microjoules spent so far.
|
||||
pub spent_uj: u64,
|
||||
/// Devices currently held.
|
||||
pub devices_held: u32,
|
||||
}
|
||||
|
||||
/// The entitlement tree plus each node's current usage: everything
|
||||
/// [`admit`] needs to decide, and nothing it does not.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Pool {
|
||||
nodes: BTreeMap<EntitlementId, Entitlement>,
|
||||
usage: BTreeMap<EntitlementId, Usage>,
|
||||
}
|
||||
|
||||
impl Pool {
|
||||
/// An empty pool.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Add or replace an entitlement node.
|
||||
pub fn insert(&mut self, e: Entitlement) {
|
||||
self.nodes.insert(e.id.clone(), e);
|
||||
}
|
||||
|
||||
/// Record a node's usage for the current window.
|
||||
pub fn set_usage(&mut self, id: &EntitlementId, usage: Usage) {
|
||||
self.usage.insert(id.clone(), usage);
|
||||
}
|
||||
|
||||
/// A node, if the pool holds it.
|
||||
#[must_use]
|
||||
pub fn get(&self, id: &EntitlementId) -> Option<&Entitlement> {
|
||||
self.nodes.get(id)
|
||||
}
|
||||
|
||||
/// A node's usage; a node with no recorded usage has spent nothing.
|
||||
#[must_use]
|
||||
pub fn usage(&self, id: &EntitlementId) -> Usage {
|
||||
self.usage.get(id).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The surplus a node's siblings have left unspent and lent out — the pool
|
||||
/// a borrower may draw on.
|
||||
///
|
||||
/// Only siblings under the same parent contribute, and only the part of
|
||||
/// each sibling's guarantee it has not spent. A node borrows in proportion
|
||||
/// to its [`Entitlement::over_quota_weight`] against the total weight of
|
||||
/// every borrower under that parent, so one tenant cannot take the whole
|
||||
/// surplus merely by asking first.
|
||||
#[must_use]
|
||||
pub fn borrowable_uj(&self, id: &EntitlementId) -> u64 {
|
||||
let Some(me) = self.nodes.get(id) else {
|
||||
return 0;
|
||||
};
|
||||
if me.over_quota_weight == 0 {
|
||||
return 0;
|
||||
}
|
||||
let Some(parent) = me.parent.as_ref() else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let siblings = || {
|
||||
self.nodes
|
||||
.values()
|
||||
.filter(move |n| n.parent.as_ref() == Some(parent))
|
||||
};
|
||||
|
||||
let surplus: u64 = siblings()
|
||||
.filter(|n| n.id != *id)
|
||||
.map(|n| n.guarantee_uj.saturating_sub(self.usage(&n.id).spent_uj))
|
||||
.sum();
|
||||
|
||||
let total_weight: u64 = siblings().map(|n| u64::from(n.over_quota_weight)).sum();
|
||||
if total_weight == 0 {
|
||||
return 0;
|
||||
}
|
||||
// Integer share, rounded down: a borrower never receives more than its
|
||||
// weight entitles it to.
|
||||
surplus.saturating_mul(u64::from(me.over_quota_weight)) / total_weight
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────── claims ────────────────────────────
|
||||
|
||||
/// A request for capacity. A gang is admitted whole or not at all, so the
|
||||
/// figures here are for the **whole gang**, never per member.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Claim {
|
||||
/// Caller's id for the workload.
|
||||
pub id: String,
|
||||
/// Which entitlement pays for it.
|
||||
pub entitlement: EntitlementId,
|
||||
/// Members that must be placed together. `1` is an ordinary workload.
|
||||
pub gang_size: u32,
|
||||
/// Devices each member occupies.
|
||||
pub devices_per_member: u32,
|
||||
/// Estimated energy for the whole gang, at the grade of whatever produced
|
||||
/// the estimate.
|
||||
pub estimate: Energy,
|
||||
/// What kind of work this is, as the operator declares it.
|
||||
///
|
||||
/// `pool/1` does not decide which workloads are comparable — it cannot —
|
||||
/// so the class is the operator's assertion that two claims carrying it
|
||||
/// are the same kind of work. It is recorded in the ledger beside the
|
||||
/// reservation ([`ledger::Event::Admitted`]) so a later estimate can be
|
||||
/// derived from what this kind of work actually cost
|
||||
/// ([`observe::derive`]). An empty class asserts nothing, and
|
||||
/// [`observe::derive`] refuses it rather than pooling unlike work.
|
||||
pub class: String,
|
||||
}
|
||||
|
||||
impl Claim {
|
||||
/// Devices the whole gang occupies.
|
||||
#[must_use]
|
||||
pub const fn demand_devices(&self) -> u32 {
|
||||
self.gang_size.saturating_mul(self.devices_per_member)
|
||||
}
|
||||
|
||||
/// Microjoules the whole gang is expected to consume. All-or-nothing: this
|
||||
/// is the figure admission tests, never a per-member share, because half a
|
||||
/// gang's slots against a budget that cannot finish the gang spends real
|
||||
/// joules and delivers nothing.
|
||||
#[must_use]
|
||||
pub const fn demand_uj(&self) -> u64 {
|
||||
self.estimate.uj
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── verdicts ───────────────────────────
|
||||
|
||||
/// Why a claim was not admitted now but may be later.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum QueueReason {
|
||||
/// The guarantee would be exceeded, and the claim may not borrow — either
|
||||
/// its weight is zero or its estimate is unobserved (rule 1).
|
||||
GuaranteeExhausted,
|
||||
/// Guarantee plus every joule currently borrowable is still not enough.
|
||||
SurplusInsufficient,
|
||||
/// The joules fit; the devices do not.
|
||||
DevicesHeld,
|
||||
}
|
||||
|
||||
/// Why a claim can never be admitted as written.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DenyReason {
|
||||
/// The pool holds no such entitlement.
|
||||
UnknownEntitlement,
|
||||
/// The gang needs more devices than the entitlement can ever hold, so
|
||||
/// queueing it would be queueing forever.
|
||||
ExceedsDeviceGuarantee,
|
||||
/// The gang needs more joules than [`Entitlement::limit_uj`] permits, ever.
|
||||
ExceedsHardLimit,
|
||||
/// A gang of no members, or members occupying no devices, is not a claim
|
||||
/// on anything.
|
||||
Empty,
|
||||
}
|
||||
|
||||
/// What admission decided.
|
||||
///
|
||||
/// `Queue` and `Deny` are deliberately distinct: a claim that cannot fit
|
||||
/// *today* is pending, and a claim that cannot fit *ever* is refused. Reporting
|
||||
/// the second as the first is how a queue fills with work that will never run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Verdict {
|
||||
/// Admitted. `reserved_uj` is charged to the entitlement up front, so two
|
||||
/// claims decided in the same window cannot both spend the same joule.
|
||||
Admit {
|
||||
/// Microjoules reserved against the entitlement.
|
||||
reserved_uj: u64,
|
||||
/// Devices reserved.
|
||||
devices: u32,
|
||||
/// Of `reserved_uj`, how much came from a sibling's surplus. Zero when
|
||||
/// the claim fit inside its own guarantee.
|
||||
borrowed_uj: u64,
|
||||
/// The grade of the estimate this reservation rests on. A reservation
|
||||
/// is only as honest as the figure behind it, so the figure's grade is
|
||||
/// part of the decision, not a footnote to it.
|
||||
basis: Provenance,
|
||||
},
|
||||
/// Not now.
|
||||
Queue {
|
||||
/// Why not now.
|
||||
reason: QueueReason,
|
||||
},
|
||||
/// Not ever, as written.
|
||||
Deny {
|
||||
/// Why not ever.
|
||||
reason: DenyReason,
|
||||
},
|
||||
}
|
||||
|
||||
/// Decide one claim against the pool.
|
||||
///
|
||||
/// The order of tests is itself normative, because a different order gives a
|
||||
/// different answer: impossibility first (so nothing is queued forever), then
|
||||
/// devices, then the guarantee, then borrowing.
|
||||
///
|
||||
/// **Rule 1 lives here.** A claim whose estimate is [`Provenance::Estimator`]
|
||||
/// is never admitted against borrowed surplus. Inside its own guarantee a
|
||||
/// tenant may spend on a guess; a neighbour's lent capacity is not the
|
||||
/// tenant's to gamble, and an unobserved estimate is exactly the figure most
|
||||
/// likely to be wrong in the direction that overcommits.
|
||||
#[must_use]
|
||||
pub fn admit(pool: &Pool, claim: &Claim) -> Verdict {
|
||||
let Some(ent) = pool.get(&claim.entitlement) else {
|
||||
return Verdict::Deny { reason: DenyReason::UnknownEntitlement };
|
||||
};
|
||||
|
||||
let devices = claim.demand_devices();
|
||||
let uj = claim.demand_uj();
|
||||
if claim.gang_size == 0 || claim.devices_per_member == 0 {
|
||||
return Verdict::Deny { reason: DenyReason::Empty };
|
||||
}
|
||||
|
||||
// Impossible-ever, before anything that could put it in a queue.
|
||||
if devices > ent.guarantee_devices {
|
||||
return Verdict::Deny { reason: DenyReason::ExceedsDeviceGuarantee };
|
||||
}
|
||||
if let Some(limit) = ent.limit_uj
|
||||
&& uj > limit
|
||||
{
|
||||
return Verdict::Deny { reason: DenyReason::ExceedsHardLimit };
|
||||
}
|
||||
|
||||
let used = pool.usage(&claim.entitlement);
|
||||
if used.devices_held.saturating_add(devices) > ent.guarantee_devices {
|
||||
return Verdict::Queue { reason: QueueReason::DevicesHeld };
|
||||
}
|
||||
|
||||
// Inside the guarantee: always allowed, whatever the estimate's grade.
|
||||
let after = used.spent_uj.saturating_add(uj);
|
||||
if after <= ent.guarantee_uj {
|
||||
return Verdict::Admit {
|
||||
reserved_uj: uj,
|
||||
devices,
|
||||
borrowed_uj: 0,
|
||||
basis: claim.estimate.provenance,
|
||||
};
|
||||
}
|
||||
|
||||
// Past the guarantee. Rule 1: a guess does not spend a neighbour's joules.
|
||||
if !claim.estimate.provenance.is_observed() {
|
||||
return Verdict::Queue { reason: QueueReason::GuaranteeExhausted };
|
||||
}
|
||||
if ent.over_quota_weight == 0 {
|
||||
return Verdict::Queue { reason: QueueReason::GuaranteeExhausted };
|
||||
}
|
||||
|
||||
let need_borrowed = after - ent.guarantee_uj;
|
||||
if let Some(limit) = ent.limit_uj
|
||||
&& after > limit
|
||||
{
|
||||
return Verdict::Queue { reason: QueueReason::SurplusInsufficient };
|
||||
}
|
||||
if need_borrowed > pool.borrowable_uj(&claim.entitlement) {
|
||||
return Verdict::Queue { reason: QueueReason::SurplusInsufficient };
|
||||
}
|
||||
|
||||
Verdict::Admit {
|
||||
reserved_uj: uj,
|
||||
devices,
|
||||
borrowed_uj: need_borrowed,
|
||||
basis: claim.estimate.provenance,
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────── preemption ──────────────────────────
|
||||
|
||||
/// A workload already running, as preemption sees it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Running {
|
||||
/// Caller's id.
|
||||
pub id: String,
|
||||
/// Which entitlement pays for it.
|
||||
pub entitlement: EntitlementId,
|
||||
/// Microjoules it has consumed so far.
|
||||
pub spent_uj: u64,
|
||||
/// Units of work it has **completed** — not attempted. Zero means it has
|
||||
/// finished nothing yet, which is an absence of evidence rather than
|
||||
/// evidence of waste.
|
||||
pub completed_units: u64,
|
||||
/// Declared ordering, used only where evidence is absent. Lower is
|
||||
/// reclaimed first.
|
||||
pub priority: u8,
|
||||
}
|
||||
|
||||
impl Running {
|
||||
/// Microjoules per completed unit, or `None` when nothing has completed.
|
||||
///
|
||||
/// `None` is not zero and not infinity. A ratio with no denominator is
|
||||
/// undefined, and both substitutes are wrong in a way that changes who
|
||||
/// gets evicted: zero makes a new workload un-evictable, infinity makes
|
||||
/// every new workload the first victim.
|
||||
#[must_use]
|
||||
pub const fn uj_per_completed_unit(&self) -> Option<u64> {
|
||||
self.spent_uj.checked_div(self.completed_units)
|
||||
}
|
||||
}
|
||||
|
||||
/// The order in which running workloads should be reclaimed, worst first.
|
||||
///
|
||||
/// **Rule 3.** Evidence outranks declaration:
|
||||
///
|
||||
/// 1. Workloads with a **measured** cost per completed unit come first,
|
||||
/// most-expensive first. There is evidence they are spending badly.
|
||||
/// 2. Workloads that have completed nothing follow, lowest declared priority
|
||||
/// first. They are *unmeasured*, not wasteful — and preempting them first
|
||||
/// would destroy exactly the work that would have produced the measurement,
|
||||
/// guaranteeing the scheduler never learns anything about them.
|
||||
///
|
||||
/// Ties break on `id` so the order is total and a replay reproduces it.
|
||||
#[must_use]
|
||||
pub fn preemption_order(running: &[Running]) -> Vec<String> {
|
||||
let mut measured: Vec<&Running> =
|
||||
running.iter().filter(|r| r.uj_per_completed_unit().is_some()).collect();
|
||||
let mut unmeasured: Vec<&Running> =
|
||||
running.iter().filter(|r| r.uj_per_completed_unit().is_none()).collect();
|
||||
|
||||
measured.sort_by(|a, b| {
|
||||
b.uj_per_completed_unit()
|
||||
.cmp(&a.uj_per_completed_unit())
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
unmeasured.sort_by(|a, b| a.priority.cmp(&b.priority).then_with(|| a.id.cmp(&b.id)));
|
||||
|
||||
measured.into_iter().chain(unmeasured).map(|r| r.id.clone()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pool() -> Pool {
|
||||
let mut p = Pool::new();
|
||||
p.insert(Entitlement {
|
||||
id: "org".into(),
|
||||
parent: None,
|
||||
guarantee_uj: 100_000_000,
|
||||
guarantee_devices: 16,
|
||||
over_quota_weight: 0,
|
||||
limit_uj: None,
|
||||
priority: 100,
|
||||
});
|
||||
for (id, g) in [("research", 40_000_000u64), ("prod", 40_000_000)] {
|
||||
p.insert(Entitlement {
|
||||
id: id.into(),
|
||||
parent: Some("org".into()),
|
||||
guarantee_uj: g,
|
||||
guarantee_devices: 8,
|
||||
over_quota_weight: 1,
|
||||
limit_uj: None,
|
||||
priority: 50,
|
||||
});
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn claim(ent: &str, gang: u32, dev: u32, uj: u64, prov: Provenance) -> Claim {
|
||||
Claim {
|
||||
class: "train".into(),
|
||||
id: format!("w-{ent}-{gang}"),
|
||||
entitlement: ent.into(),
|
||||
gang_size: gang,
|
||||
devices_per_member: dev,
|
||||
estimate: Energy::new(uj, prov),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_the_guarantee_a_claim_is_admitted_at_any_grade() {
|
||||
let p = pool();
|
||||
for prov in [Provenance::Estimator, Provenance::ModelBased, Provenance::HwShunt] {
|
||||
let v = admit(&p, &claim("research", 2, 2, 10_000_000, prov));
|
||||
assert_eq!(
|
||||
v,
|
||||
Verdict::Admit {
|
||||
reserved_uj: 10_000_000,
|
||||
devices: 4,
|
||||
borrowed_uj: 0,
|
||||
basis: prov
|
||||
},
|
||||
"a tenant may spend its own guarantee on a guess"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule 1, and the discriminating pair: the same claim, the same spare
|
||||
/// surplus, differing only in whether anyone measured the estimate.
|
||||
#[test]
|
||||
fn a_guess_may_not_spend_a_neighbours_surplus() {
|
||||
let mut p = pool();
|
||||
// research has used 35 of its 40 MJ; prod has used nothing, so 40 MJ
|
||||
// of sibling surplus is lent out.
|
||||
p.set_usage(&"research".into(), Usage { spent_uj: 35_000_000, devices_held: 0 });
|
||||
|
||||
let over = 10_000_000; // 35 + 10 > 40: needs 5 MJ of surplus.
|
||||
|
||||
let measured = admit(&p, &claim("research", 1, 1, over, Provenance::HwShunt));
|
||||
assert_eq!(
|
||||
measured,
|
||||
Verdict::Admit {
|
||||
reserved_uj: over,
|
||||
devices: 1,
|
||||
borrowed_uj: 5_000_000,
|
||||
basis: Provenance::HwShunt
|
||||
},
|
||||
"an observed estimate may borrow"
|
||||
);
|
||||
|
||||
let guessed = admit(&p, &claim("research", 1, 1, over, Provenance::Estimator));
|
||||
assert_eq!(
|
||||
guessed,
|
||||
Verdict::Queue { reason: QueueReason::GuaranteeExhausted },
|
||||
"an unobserved estimate may not; it waits for its own guarantee"
|
||||
);
|
||||
}
|
||||
|
||||
/// Rule 2: the gang is tested whole. Each member would fit; the gang does
|
||||
/// not, and admitting part of it would spend real joules for nothing.
|
||||
#[test]
|
||||
fn a_gang_is_tested_whole_not_per_member() {
|
||||
let p = pool();
|
||||
// 8 members × 1 device = 8 devices, exactly the guarantee.
|
||||
let fits_devices = claim("prod", 8, 1, 39_000_000, Provenance::HwShunt);
|
||||
assert!(matches!(admit(&p, &fits_devices), Verdict::Admit { .. }));
|
||||
|
||||
// One more member: 9 devices against a guarantee of 8 — refused
|
||||
// outright, because no amount of waiting makes 9 fit in 8.
|
||||
let too_wide = claim("prod", 9, 1, 39_000_000, Provenance::HwShunt);
|
||||
assert_eq!(
|
||||
admit(&p, &too_wide),
|
||||
Verdict::Deny { reason: DenyReason::ExceedsDeviceGuarantee }
|
||||
);
|
||||
}
|
||||
|
||||
/// Cannot-now and cannot-ever are different answers.
|
||||
#[test]
|
||||
fn impossible_is_denied_rather_than_queued_forever() {
|
||||
let mut p = pool();
|
||||
p.insert(Entitlement {
|
||||
id: "capped".into(),
|
||||
parent: Some("org".into()),
|
||||
guarantee_uj: 10_000_000,
|
||||
guarantee_devices: 4,
|
||||
over_quota_weight: 1,
|
||||
limit_uj: Some(12_000_000),
|
||||
priority: 10,
|
||||
});
|
||||
|
||||
// Over the hard limit on its own: never admissible.
|
||||
assert_eq!(
|
||||
admit(&p, &claim("capped", 1, 1, 20_000_000, Provenance::HwShunt)),
|
||||
Verdict::Deny { reason: DenyReason::ExceedsHardLimit }
|
||||
);
|
||||
|
||||
// Under the limit but over the guarantee, with the surplus to cover
|
||||
// it — admitted, borrowing.
|
||||
let v = admit(&p, &claim("capped", 1, 1, 11_000_000, Provenance::HwShunt));
|
||||
assert!(matches!(v, Verdict::Admit { borrowed_uj: 1_000_000, .. }), "{v:?}");
|
||||
|
||||
// Devices already held: pending, not refused.
|
||||
p.set_usage(&"capped".into(), Usage { spent_uj: 0, devices_held: 4 });
|
||||
assert_eq!(
|
||||
admit(&p, &claim("capped", 1, 1, 1_000_000, Provenance::HwShunt)),
|
||||
Verdict::Queue { reason: QueueReason::DevicesHeld }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_entitlement_and_an_empty_gang_are_refused() {
|
||||
let p = pool();
|
||||
assert_eq!(
|
||||
admit(&p, &claim("nobody", 1, 1, 1, Provenance::HwShunt)),
|
||||
Verdict::Deny { reason: DenyReason::UnknownEntitlement }
|
||||
);
|
||||
assert_eq!(
|
||||
admit(&p, &claim("research", 0, 1, 1, Provenance::HwShunt)),
|
||||
Verdict::Deny { reason: DenyReason::Empty }
|
||||
);
|
||||
assert_eq!(
|
||||
admit(&p, &claim("research", 1, 0, 1, Provenance::HwShunt)),
|
||||
Verdict::Deny { reason: DenyReason::Empty }
|
||||
);
|
||||
}
|
||||
|
||||
/// A node that never borrows is capped at its guarantee however much
|
||||
/// surplus its siblings are lending.
|
||||
#[test]
|
||||
fn weight_zero_never_borrows() {
|
||||
let mut p = pool();
|
||||
p.insert(Entitlement {
|
||||
id: "fixed".into(),
|
||||
parent: Some("org".into()),
|
||||
guarantee_uj: 10_000_000,
|
||||
guarantee_devices: 4,
|
||||
over_quota_weight: 0,
|
||||
limit_uj: None,
|
||||
priority: 10,
|
||||
});
|
||||
assert_eq!(p.borrowable_uj(&"fixed".into()), 0);
|
||||
assert_eq!(
|
||||
admit(&p, &claim("fixed", 1, 1, 11_000_000, Provenance::HwShunt)),
|
||||
Verdict::Queue { reason: QueueReason::GuaranteeExhausted }
|
||||
);
|
||||
}
|
||||
|
||||
/// Rule 3, and the reason it is not merely "evict the worst number":
|
||||
/// a workload that has completed nothing has no number at all.
|
||||
#[test]
|
||||
fn preemption_follows_evidence_then_declaration() {
|
||||
let running = vec![
|
||||
Running {
|
||||
id: "cheap".into(),
|
||||
entitlement: "prod".into(),
|
||||
spent_uj: 1_000,
|
||||
completed_units: 10,
|
||||
priority: 1,
|
||||
},
|
||||
Running {
|
||||
id: "expensive".into(),
|
||||
entitlement: "prod".into(),
|
||||
spent_uj: 900_000,
|
||||
completed_units: 3,
|
||||
priority: 90,
|
||||
},
|
||||
Running {
|
||||
id: "just-started".into(),
|
||||
entitlement: "prod".into(),
|
||||
spent_uj: 500_000,
|
||||
completed_units: 0,
|
||||
priority: 40,
|
||||
},
|
||||
Running {
|
||||
id: "also-new".into(),
|
||||
entitlement: "prod".into(),
|
||||
spent_uj: 0,
|
||||
completed_units: 0,
|
||||
priority: 5,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
preemption_order(&running),
|
||||
vec![
|
||||
"expensive".to_string(), // 300_000 µJ/unit, measured
|
||||
"cheap".to_string(), // 100 µJ/unit, measured
|
||||
"also-new".to_string(), // unmeasured, priority 5
|
||||
"just-started".to_string() // unmeasured, priority 40
|
||||
],
|
||||
"measured worst-first, then unmeasured by declared priority"
|
||||
);
|
||||
|
||||
// The point of the split, stated as an assertion: a high-priority
|
||||
// workload that has burned 500 mJ without completing anything is NOT
|
||||
// the first victim, though a naive "unknown = infinitely bad" rule
|
||||
// would make it so.
|
||||
let order = preemption_order(&running);
|
||||
assert!(
|
||||
order.iter().position(|id| id == "just-started").unwrap()
|
||||
> order.iter().position(|id| id == "expensive").unwrap(),
|
||||
"an unmeasured workload is not ranked above a measurably wasteful one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undefined_ratio_is_none_rather_than_zero() {
|
||||
let r = Running {
|
||||
id: "x".into(),
|
||||
entitlement: "prod".into(),
|
||||
spent_uj: 7,
|
||||
completed_units: 0,
|
||||
priority: 1,
|
||||
};
|
||||
assert_eq!(r.uj_per_completed_unit(), None);
|
||||
}
|
||||
|
||||
/// Surplus is shared by weight, not first-come: two equal borrowers each
|
||||
/// see half of what one borrower alone would see.
|
||||
#[test]
|
||||
fn surplus_is_shared_by_weight() {
|
||||
let mut p = pool();
|
||||
p.set_usage(&"prod".into(), Usage { spent_uj: 0, devices_held: 0 });
|
||||
// research and prod each weight 1; research may draw half of prod's
|
||||
// unspent 40 MJ.
|
||||
assert_eq!(p.borrowable_uj(&"research".into()), 20_000_000);
|
||||
|
||||
p.insert(Entitlement {
|
||||
id: "research".into(),
|
||||
parent: Some("org".into()),
|
||||
guarantee_uj: 40_000_000,
|
||||
guarantee_devices: 8,
|
||||
over_quota_weight: 3,
|
||||
limit_uj: None,
|
||||
priority: 50,
|
||||
});
|
||||
// Weight 3 of 4 total: three quarters of the same surplus.
|
||||
assert_eq!(p.borrowable_uj(&"research".into()), 30_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wire_round_trips() {
|
||||
let c = claim("research", 2, 2, 10_000_000, Provenance::HwShunt);
|
||||
let j = serde_json::to_string(&c).expect("serialises");
|
||||
assert_eq!(serde_json::from_str::<Claim>(&j).expect("parses"), c);
|
||||
|
||||
let v = admit(&pool(), &c);
|
||||
let j = serde_json::to_string(&v).expect("serialises");
|
||||
assert_eq!(serde_json::from_str::<Verdict>(&j).expect("parses"), v);
|
||||
}
|
||||
}
|
||||
492
pool/pool-rs/crates/pool-core/src/observe.rs
Normal file
492
pool/pool-rs/crates/pool-core/src/observe.rs
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
//! Deriving an estimate from what the work actually cost — how a guess earns
|
||||
//! its way out of being one.
|
||||
//!
|
||||
//! SPEC §4.4 refuses a guess the right to spend a neighbour's capacity: a
|
||||
//! claim whose estimate is [`Provenance::Estimator`] is admitted only inside
|
||||
//! its own guarantee. That rule is only fair if a guess can *stop* being a
|
||||
//! guess, and on its own it is a permanent penalty with no exit: a tenant that
|
||||
//! has never been measured can never be measured, because it can never run
|
||||
//! against surplus.
|
||||
//!
|
||||
//! The normative rules this module implements are SPEC §2.6.
|
||||
//!
|
||||
//! The evidence already exists. Every completed claim appended a
|
||||
//! [`Released`](crate::ledger::Event::Released) carrying what it actually
|
||||
//! consumed, at the grade of whatever read it. This module turns that history
|
||||
//! into an estimate, under four rules.
|
||||
//!
|
||||
//! ## 1. A guess is not evidence
|
||||
//!
|
||||
//! An observation whose own figure is `Estimator` is **excluded**, not
|
||||
//! averaged in. Nobody measured or modelled it, so it says nothing about what
|
||||
//! the work costs; counting it would launder guesses into a grade that claims
|
||||
//! they were observed. This is the rule that makes the exit real rather than
|
||||
//! circular: a tenant earns an observed estimate by being *measured*, not by
|
||||
//! having run.
|
||||
//!
|
||||
//! ## 2. A derived estimate is a model, never a reading
|
||||
//!
|
||||
//! Even where every observation was read by a hardware counter, a figure for
|
||||
//! the *next* run is a prediction. So the derived grade is capped at
|
||||
//! [`Provenance::ModelBased`] — the same cap [`crate::share`] applies when a
|
||||
//! device total is divided, and for the same reason: the output is a model of
|
||||
//! something, not a measurement of it.
|
||||
//!
|
||||
//! ## 3. One run is not a sample
|
||||
//!
|
||||
//! Below a required count of evidence, nothing is derived and the caller is
|
||||
//! told what it had ([`DeriveError::NotEnoughEvidence`]). The required count
|
||||
//! is a **policy value, not a derived one**: `pool/1` offers
|
||||
//! [`DEFAULT_MIN_EVIDENCE`] and does not pretend to know the right number for
|
||||
//! a workload it has never seen.
|
||||
//!
|
||||
//! ## 4. The absence of a class is not a class
|
||||
//!
|
||||
//! Comparability is the operator's assertion ([`crate::Claim::class`]), and an
|
||||
//! empty class asserts nothing. [`derive()`] refuses it rather than pooling
|
||||
//! unlike work into one average.
|
||||
//!
|
||||
//! ## What the reservation takes, and why not the mean
|
||||
//!
|
||||
//! [`Derived::reserve`] is the **largest** observed figure, not the mean.
|
||||
//! Under-reserving is the failure SPEC §4.4 exists to prevent: a reservation
|
||||
//! below what the work costs is admitted and then overspends, and the joules it
|
||||
//! overspends may be a sibling's. A mean under-reserves roughly half the time
|
||||
//! by construction. The spread is reported beside it so a caller can see how
|
||||
//! thin the evidence is.
|
||||
//!
|
||||
//! The honest limit is in [`Derived`]: the maximum of a small sample is itself
|
||||
//! an underestimate of the true tail. A derived reservation is better evidence
|
||||
//! than a guess, not a bound on what the work can cost.
|
||||
|
||||
use crate::{Energy, Provenance};
|
||||
|
||||
/// How much evidence `pool/1` asks for before deriving an estimate.
|
||||
///
|
||||
/// Three is a floor on arithmetic, not a claim about any workload: below it
|
||||
/// there is no spread to see at all. An operator who knows its workload should
|
||||
/// set its own figure — this constant exists so the default is stated rather
|
||||
/// than improvised at each call site.
|
||||
pub const DEFAULT_MIN_EVIDENCE: usize = 3;
|
||||
|
||||
/// What one completed claim actually cost.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Observation {
|
||||
/// The class the claim declared.
|
||||
pub class: String,
|
||||
/// The claim's id, so a caller can trace a figure back to its receipt.
|
||||
pub claim: String,
|
||||
/// What it consumed, at the grade of whatever read it.
|
||||
pub actual: Energy,
|
||||
}
|
||||
|
||||
/// Why no estimate could be derived.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DeriveError {
|
||||
/// The class is empty, which asserts nothing about comparability.
|
||||
Unclassified,
|
||||
/// Not enough observations of this class carried a figure anyone measured
|
||||
/// or modelled.
|
||||
///
|
||||
/// The three counts are reported separately because they say different
|
||||
/// things to an operator: plenty of history that is all guesses needs a
|
||||
/// meter, while little history needs patience.
|
||||
NotEnoughEvidence {
|
||||
/// Observations of this class, whatever their grade.
|
||||
of_class: usize,
|
||||
/// How many of those were evidence rather than guesses.
|
||||
evidence: usize,
|
||||
/// How many were required.
|
||||
required: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// An estimate derived from history, with the evidence it rests on.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Derived {
|
||||
/// The class this describes.
|
||||
pub class: String,
|
||||
/// How many observations were evidence. Guesses are excluded and are not
|
||||
/// counted here.
|
||||
pub evidence: usize,
|
||||
/// What to reserve: the largest observed figure, graded as the model it
|
||||
/// is.
|
||||
///
|
||||
/// **Limit.** The maximum of a small sample underestimates the true tail.
|
||||
/// This is better evidence than a guess; it is not a bound on what the
|
||||
/// work can cost, and `pool/1` does not claim it is one.
|
||||
pub reserve: Energy,
|
||||
/// The smallest observed figure, in microjoules.
|
||||
pub min_uj: u64,
|
||||
/// The median observed figure, in microjoules — the lower of the two
|
||||
/// middle values at an even count, so no figure is invented by averaging.
|
||||
pub median_uj: u64,
|
||||
}
|
||||
|
||||
/// Derive an estimate for one class from a history of completed claims.
|
||||
///
|
||||
/// Guesses are excluded, the result is capped at [`Provenance::ModelBased`],
|
||||
/// and nothing is derived below `required` pieces of evidence.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`DeriveError::Unclassified`] for an empty class,
|
||||
/// [`DeriveError::NotEnoughEvidence`] with the counts it had.
|
||||
pub fn derive(
|
||||
class: &str,
|
||||
history: &[Observation],
|
||||
required: usize,
|
||||
) -> Result<Derived, DeriveError> {
|
||||
if class.is_empty() {
|
||||
return Err(DeriveError::Unclassified);
|
||||
}
|
||||
let of_class: Vec<&Observation> = history.iter().filter(|o| o.class == class).collect();
|
||||
|
||||
// Rule 1: a guess is not evidence. Excluded, not averaged in.
|
||||
let mut uj: Vec<u64> = of_class
|
||||
.iter()
|
||||
.filter(|o| o.actual.provenance.is_observed())
|
||||
.map(|o| o.actual.uj)
|
||||
.collect();
|
||||
|
||||
if uj.len() < required.max(1) {
|
||||
return Err(DeriveError::NotEnoughEvidence {
|
||||
of_class: of_class.len(),
|
||||
evidence: uj.len(),
|
||||
required: required.max(1),
|
||||
});
|
||||
}
|
||||
|
||||
// Rule 2: the grade of the inputs, capped — a prediction is a model even
|
||||
// where every input was a counter reading.
|
||||
let grade = of_class
|
||||
.iter()
|
||||
.filter(|o| o.actual.provenance.is_observed())
|
||||
.map(|o| o.actual.provenance)
|
||||
.min()
|
||||
.unwrap_or(Provenance::ModelBased)
|
||||
.min(Provenance::ModelBased);
|
||||
|
||||
uj.sort_unstable();
|
||||
let max = *uj.last().expect("non-empty: the count was checked above");
|
||||
Ok(Derived {
|
||||
class: class.to_string(),
|
||||
evidence: uj.len(),
|
||||
reserve: Energy::new(max, grade),
|
||||
min_uj: uj[0],
|
||||
// The lower middle value at an even count: a real observation rather
|
||||
// than an average of two, which would be a figure nothing measured.
|
||||
median_uj: uj[(uj.len() - 1) / 2],
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Claim, Entitlement, Pool, QueueReason, Verdict, admit};
|
||||
use crate::ledger::{Event, Ledger, observations};
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
||||
fn obs(class: &str, claim: &str, uj: u64, p: Provenance) -> Observation {
|
||||
Observation { class: class.into(), claim: claim.into(), actual: Energy::new(uj, p) }
|
||||
}
|
||||
|
||||
/// Rule 1, and the point of the module: history made of guesses does not
|
||||
/// become evidence by being plentiful.
|
||||
#[test]
|
||||
fn a_guess_is_not_evidence_however_much_of_it_there_is() {
|
||||
let all_guesses: Vec<Observation> = (0..50)
|
||||
.map(|i| obs("train", &format!("w{i}"), 1_000, Provenance::Estimator))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
derive("train", &all_guesses, DEFAULT_MIN_EVIDENCE),
|
||||
Err(DeriveError::NotEnoughEvidence { of_class: 50, evidence: 0, required: 3 }),
|
||||
"50 guesses are 50 guesses, not evidence"
|
||||
);
|
||||
|
||||
// Three measured runs beside them, and the same call succeeds —
|
||||
// resting on the three, not the fifty-three.
|
||||
let mut mixed = all_guesses;
|
||||
for (i, uj) in [900u64, 1_100, 1_000].into_iter().enumerate() {
|
||||
mixed.push(obs("train", &format!("m{i}"), uj, Provenance::HwShunt));
|
||||
}
|
||||
let d = derive("train", &mixed, DEFAULT_MIN_EVIDENCE).expect("three measured runs");
|
||||
assert_eq!(d.evidence, 3, "the guesses are excluded, not averaged in");
|
||||
assert_eq!(d.reserve.uj, 1_100, "the largest measured figure");
|
||||
}
|
||||
|
||||
/// Rule 2: a prediction is a model even where every input was a counter.
|
||||
#[test]
|
||||
fn a_derived_estimate_is_never_graded_as_a_reading() {
|
||||
let metered: Vec<Observation> = (0..5)
|
||||
.map(|i| obs("train", &format!("w{i}"), 1_000 + i, Provenance::HwShunt))
|
||||
.collect();
|
||||
let d = derive("train", &metered, 3).expect("derives");
|
||||
assert_eq!(
|
||||
d.reserve.provenance,
|
||||
Provenance::ModelBased,
|
||||
"a figure for the next run is a prediction, not a reading of it"
|
||||
);
|
||||
assert!(d.reserve.provenance.is_observed(), "but it is observed enough to borrow");
|
||||
}
|
||||
|
||||
/// And it is not improved past its inputs either: modelled inputs give a
|
||||
/// modelled output, which is the same grade — the cap is a ceiling, not a
|
||||
/// floor.
|
||||
#[test]
|
||||
fn modelled_inputs_give_a_modelled_estimate() {
|
||||
let modelled: Vec<Observation> = (0..4)
|
||||
.map(|i| obs("train", &format!("w{i}"), 500, Provenance::ModelBased))
|
||||
.collect();
|
||||
let d = derive("train", &modelled, 3).expect("derives");
|
||||
assert_eq!(d.reserve.provenance, Provenance::ModelBased);
|
||||
}
|
||||
|
||||
/// Rule 3: below the required count nothing is derived — and the counts
|
||||
/// are reported, because "no history" and "history full of guesses" need
|
||||
/// different fixes.
|
||||
#[test]
|
||||
fn one_run_is_not_a_sample() {
|
||||
let one = vec![obs("train", "w0", 1_000, Provenance::HwShunt)];
|
||||
assert_eq!(
|
||||
derive("train", &one, DEFAULT_MIN_EVIDENCE),
|
||||
Err(DeriveError::NotEnoughEvidence { of_class: 1, evidence: 1, required: 3 })
|
||||
);
|
||||
// Two is not three either.
|
||||
let two = vec![
|
||||
obs("train", "w0", 1_000, Provenance::HwShunt),
|
||||
obs("train", "w1", 2_000, Provenance::HwShunt),
|
||||
];
|
||||
assert!(derive("train", &two, DEFAULT_MIN_EVIDENCE).is_err());
|
||||
assert!(derive("train", &two, 2).is_ok(), "the required count is the caller's");
|
||||
}
|
||||
|
||||
/// A caller asking for zero evidence still gets none from an empty
|
||||
/// history: the floor is one, so `required: 0` cannot mean "derive from
|
||||
/// nothing".
|
||||
#[test]
|
||||
fn zero_required_still_needs_one_observation() {
|
||||
assert_eq!(
|
||||
derive("train", &[], 0),
|
||||
Err(DeriveError::NotEnoughEvidence { of_class: 0, evidence: 0, required: 1 })
|
||||
);
|
||||
}
|
||||
|
||||
/// Rule 4: an empty class asserts nothing about comparability.
|
||||
#[test]
|
||||
fn an_empty_class_is_refused_rather_than_pooled() {
|
||||
let history: Vec<Observation> = (0..9)
|
||||
.map(|i| obs("", &format!("w{i}"), 1_000, Provenance::HwShunt))
|
||||
.collect();
|
||||
assert_eq!(derive("", &history, 3), Err(DeriveError::Unclassified));
|
||||
}
|
||||
|
||||
/// Classes do not mix: evidence about one kind of work is not evidence
|
||||
/// about another.
|
||||
#[test]
|
||||
fn evidence_does_not_cross_classes() {
|
||||
let mut history: Vec<Observation> = (0..6)
|
||||
.map(|i| obs("serve", &format!("s{i}"), 10, Provenance::HwShunt))
|
||||
.collect();
|
||||
history.extend((0..3).map(|i| obs("train", &format!("t{i}"), 9_000, Provenance::HwShunt)));
|
||||
|
||||
let serve = derive("serve", &history, 3).expect("derives");
|
||||
assert_eq!(serve.reserve.uj, 10, "the training runs are not evidence about serving");
|
||||
let train = derive("train", &history, 3).expect("derives");
|
||||
assert_eq!(train.reserve.uj, 9_000);
|
||||
assert_eq!(train.evidence, 3);
|
||||
}
|
||||
|
||||
/// The reservation takes the upper end. A mean would under-reserve, and
|
||||
/// what it under-reserves may be a sibling's joules.
|
||||
#[test]
|
||||
fn the_reservation_takes_the_largest_not_the_mean() {
|
||||
let spiky = vec![
|
||||
obs("train", "a", 100, Provenance::HwShunt),
|
||||
obs("train", "b", 100, Provenance::HwShunt),
|
||||
obs("train", "c", 100, Provenance::HwShunt),
|
||||
obs("train", "d", 10_000, Provenance::HwShunt),
|
||||
];
|
||||
let d = derive("train", &spiky, 3).expect("derives");
|
||||
assert_eq!(d.reserve.uj, 10_000, "a mean here would reserve 2_575 and overspend");
|
||||
assert_eq!(d.min_uj, 100);
|
||||
assert_eq!(d.median_uj, 100, "the spread is visible beside the reservation");
|
||||
}
|
||||
|
||||
/// The median is a figure something actually measured, not an average of
|
||||
/// two.
|
||||
#[test]
|
||||
fn the_median_is_an_observed_figure_at_an_even_count() {
|
||||
let even = vec![
|
||||
obs("train", "a", 10, Provenance::HwShunt),
|
||||
obs("train", "b", 20, Provenance::HwShunt),
|
||||
obs("train", "c", 30, Provenance::HwShunt),
|
||||
obs("train", "d", 40, Provenance::HwShunt),
|
||||
];
|
||||
let d = derive("train", &even, 3).expect("derives");
|
||||
assert_eq!(d.median_uj, 20, "the lower middle value, not 25, which nothing measured");
|
||||
assert!(
|
||||
even.iter().any(|o| o.actual.uj == d.median_uj),
|
||||
"the median must be a figure some run really produced"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- end to end: the exit from rule 1 actually opens --------------------
|
||||
|
||||
const STEWARD: &str = "did:pool:steward";
|
||||
|
||||
fn pool() -> Pool {
|
||||
let mut p = Pool::new();
|
||||
p.insert(Entitlement {
|
||||
id: "root".into(),
|
||||
parent: None,
|
||||
guarantee_uj: 10_000_000,
|
||||
guarantee_devices: 8,
|
||||
over_quota_weight: 0,
|
||||
limit_uj: None,
|
||||
priority: 10,
|
||||
});
|
||||
// Two siblings under one root. `a` is spent; `b` is idle, so its
|
||||
// guarantee is the surplus `a` would have to borrow.
|
||||
for id in ["a", "b"] {
|
||||
p.insert(Entitlement {
|
||||
id: id.into(),
|
||||
parent: Some("root".into()),
|
||||
guarantee_uj: 5_000_000,
|
||||
guarantee_devices: 4,
|
||||
over_quota_weight: 1,
|
||||
limit_uj: None,
|
||||
priority: 10,
|
||||
});
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn claim_with(estimate: Energy) -> Claim {
|
||||
Claim {
|
||||
id: "next".into(),
|
||||
entitlement: "a".into(),
|
||||
class: "train".into(),
|
||||
gang_size: 1,
|
||||
devices_per_member: 1,
|
||||
estimate,
|
||||
}
|
||||
}
|
||||
|
||||
/// The discriminating pair for the whole slice, end to end through a
|
||||
/// sealed ledger: the same tenant, the same exhausted guarantee, the same
|
||||
/// available surplus, the same number of microjoules — queued while that
|
||||
/// figure is a guess, admitted once the ledger holds enough measured
|
||||
/// history of that class to derive it.
|
||||
#[test]
|
||||
fn history_turns_a_queued_guess_into_an_admitted_estimate() {
|
||||
let sk = SigningKey::from_bytes(&[7; 32]);
|
||||
let mut log = Ledger::new(sk, STEWARD);
|
||||
|
||||
// Three completed `train` claims, each measured at ~1.5 MJ against a
|
||||
// 5 MJ guarantee. That leaves `a` inside its guarantee but with less
|
||||
// than this class of work costs, so a fourth run of it can only be
|
||||
// admitted by borrowing `b`'s unspent joules.
|
||||
for i in 0..3u64 {
|
||||
let id = format!("done{i}");
|
||||
log.append(&Event::Admitted {
|
||||
claim: id.clone(),
|
||||
entitlement: "a".into(),
|
||||
class: "train".into(),
|
||||
reserved_uj: 1_500_000,
|
||||
devices: 1,
|
||||
});
|
||||
log.append(&Event::Released {
|
||||
claim: id,
|
||||
actual: Energy::new(1_500_000 + i, Provenance::HwShunt),
|
||||
});
|
||||
}
|
||||
|
||||
let mut p = pool();
|
||||
let usage = crate::ledger::replay(log.entries(), STEWARD, &log.steward_pubkey())
|
||||
.expect("verifies");
|
||||
for (id, u) in usage {
|
||||
p.set_usage(&id, u);
|
||||
}
|
||||
|
||||
// The figure comes out of the history, not out of this test.
|
||||
let history = observations(log.entries(), STEWARD, &log.steward_pubkey())
|
||||
.expect("verifies");
|
||||
let d = derive("train", &history, DEFAULT_MIN_EVIDENCE).expect("three measured runs");
|
||||
assert_eq!(d.evidence, 3);
|
||||
assert_eq!(d.reserve.uj, 1_500_002, "the largest of the three measured runs");
|
||||
assert_eq!(
|
||||
p.usage(&"a".into()).spent_uj,
|
||||
4_500_003,
|
||||
"the history leaves `a` inside its 5 MJ guarantee — the next run is what exceeds it"
|
||||
);
|
||||
|
||||
// The same microjoules, twice, differing only in whether anyone
|
||||
// measured them.
|
||||
let guessed = claim_with(Energy::new(d.reserve.uj, Provenance::Estimator));
|
||||
assert_eq!(
|
||||
admit(&p, &guessed),
|
||||
Verdict::Queue { reason: QueueReason::GuaranteeExhausted },
|
||||
"a guess may not spend a neighbour's capacity"
|
||||
);
|
||||
|
||||
let earned = claim_with(d.reserve);
|
||||
let Verdict::Admit { borrowed_uj, reserved_uj, .. } = admit(&p, &earned) else {
|
||||
panic!("expected Admit once the estimate is observed, got {:?}", admit(&p, &earned))
|
||||
};
|
||||
assert_eq!(reserved_uj, d.reserve.uj, "it reserves what the history says it costs");
|
||||
assert!(borrowed_uj > 0, "and it is admitted by borrowing, which is the right it earned");
|
||||
}
|
||||
|
||||
/// Evidence is only what *completed*. An open admission is a reservation,
|
||||
/// not a result, and reserving a figure is not evidence that the work
|
||||
/// costs it.
|
||||
#[test]
|
||||
fn an_open_admission_is_not_evidence() {
|
||||
let sk = SigningKey::from_bytes(&[8; 32]);
|
||||
let mut log = Ledger::new(sk, STEWARD);
|
||||
for i in 0..9 {
|
||||
log.append(&Event::Admitted {
|
||||
claim: format!("open{i}"),
|
||||
entitlement: "a".into(),
|
||||
class: "train".into(),
|
||||
reserved_uj: 1_000_000,
|
||||
devices: 1,
|
||||
});
|
||||
}
|
||||
let history = observations(log.entries(), STEWARD, &log.steward_pubkey())
|
||||
.expect("verifies");
|
||||
assert!(history.is_empty(), "nine reservations, nothing completed");
|
||||
assert_eq!(
|
||||
derive("train", &history, DEFAULT_MIN_EVIDENCE),
|
||||
Err(DeriveError::NotEnoughEvidence { of_class: 0, evidence: 0, required: 3 })
|
||||
);
|
||||
}
|
||||
|
||||
/// Nothing is derived from a stream that does not verify: the chain is
|
||||
/// checked before any figure is read out of it.
|
||||
#[test]
|
||||
fn a_broken_stream_yields_no_evidence_at_all() {
|
||||
let sk = SigningKey::from_bytes(&[9; 32]);
|
||||
let mut log = Ledger::new(sk, STEWARD);
|
||||
for i in 0..4 {
|
||||
log.append(&Event::Admitted {
|
||||
claim: format!("w{i}"),
|
||||
entitlement: "a".into(),
|
||||
class: "train".into(),
|
||||
reserved_uj: 10,
|
||||
devices: 1,
|
||||
});
|
||||
log.append(&Event::Released {
|
||||
claim: format!("w{i}"),
|
||||
actual: Energy::new(10, Provenance::HwShunt),
|
||||
});
|
||||
}
|
||||
let pubkey = log.steward_pubkey();
|
||||
let mut entries = log.entries().to_vec();
|
||||
entries.remove(2); // every survivor still verifies; the chain does not
|
||||
|
||||
let err = observations(&entries, STEWARD, &pubkey)
|
||||
.expect_err("a spliced stream is not a history");
|
||||
assert_eq!(err, crate::ledger::ReplayError::ChainBroken(2));
|
||||
}
|
||||
}
|
||||
390
pool/pool-rs/crates/pool-core/src/reclaim.rs
Normal file
390
pool/pool-rs/crates/pool-core/src/reclaim.rs
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
//! Reclaiming capacity from work that is not working — and refusing to do it
|
||||
//! on the strength of a missing number.
|
||||
//!
|
||||
//! A workload holding accelerators while consuming almost no energy is holding
|
||||
//! capacity it is not using. Device counts cannot express that: from a device
|
||||
//! ledger's point of view a held device is a held device, whether it is
|
||||
//! saturated or parked at an interactive prompt. Energy can, which makes idle
|
||||
//! reclamation a question `pool/1` can actually ask.
|
||||
//!
|
||||
//! It is also the most dangerous question in this standard, because the
|
||||
//! obvious implementation destroys running work. The reading "this workload
|
||||
//! spent 0 µJ" arrives identically from a workload that is genuinely parked
|
||||
//! and from a meter that is not there. Treat the second as the first and a
|
||||
//! scheduler kills every tenant on the cluster the moment its meter breaks —
|
||||
//! the family's "unknown conflated with a value" defect, in its most
|
||||
//! destructive form:
|
||||
//!
|
||||
//! ```text
|
||||
//! meter unavailable → 0 µJ → "idle" → reclaim → running work destroyed
|
||||
//! ```
|
||||
//!
|
||||
//! So the rules, in the order they matter (SPEC §2.7):
|
||||
//!
|
||||
//! 1. **Unknown is not idle.** A figure nobody measured or modelled
|
||||
//! ([`crate::Provenance::Estimator`]) cannot establish idleness, whatever
|
||||
//! its value. The verdict is [`Verdict::Unmeasured`], which is not a
|
||||
//! candidate for reclamation. A workload is reclaimed on evidence that it
|
||||
//! is idle, never on the absence of evidence that it is busy.
|
||||
//! 2. **Idleness is a rate over a stated window**, never an instant. Any
|
||||
//! workload is momentarily between steps.
|
||||
//! 3. **Startup is not idleness.** A workload observed for less than the
|
||||
//! policy's window has not had time to spend anything, and gets no verdict
|
||||
//! rather than an idle one.
|
||||
//! 4. **A workload holding nothing is not a candidate.** It occupies no
|
||||
//! contended capacity, so there is nothing to reclaim and no reason to
|
||||
//! disturb it.
|
||||
//!
|
||||
//! ## Why there is no useful default floor
|
||||
//!
|
||||
//! A parked accelerator still draws power. A floor has to sit **above** the
|
||||
//! hardware's idle draw or nothing is ever idle, and **below** its working
|
||||
//! draw or everything is — and both figures are properties of the device, not
|
||||
//! of this standard. `pool/1` will not guess them.
|
||||
//!
|
||||
//! [`IdlePolicy::default`] therefore has a floor of zero, which reclaims
|
||||
//! **nothing**: no rate is strictly below zero. That is deliberate. The
|
||||
//! failure of an unconfigured policy should be "reclaimed nobody", not
|
||||
//! "reclaimed everybody", because one of those is a missed optimisation and
|
||||
//! the other is destroyed work.
|
||||
//!
|
||||
//! ## What this is not
|
||||
//!
|
||||
//! This is not preemption. [`crate::preemption_order`] answers "who is
|
||||
//! spending badly under contention", ranking by cost per completed unit. This
|
||||
//! answers "who is not spending at all". A workload burning energy and
|
||||
//! completing nothing is *thrashing*, not idle, and the first question is the
|
||||
//! one that catches it.
|
||||
|
||||
use crate::Energy;
|
||||
|
||||
/// A workload's observed consumption over a window.
|
||||
///
|
||||
/// The window is given in nanoseconds rather than as a clock reading: nothing
|
||||
/// here reads a clock, so a verdict is reproducible from a record.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Activity {
|
||||
/// The claim's id.
|
||||
pub claim: String,
|
||||
/// Devices the workload is holding.
|
||||
pub devices_held: u32,
|
||||
/// How long it was observed for, in nanoseconds.
|
||||
pub window_ns: u64,
|
||||
/// What it consumed over that window, at the grade of whatever read it.
|
||||
pub energy: Energy,
|
||||
}
|
||||
|
||||
impl Activity {
|
||||
/// Mean power over the window, in microwatts, or `None` where the window
|
||||
/// is empty.
|
||||
///
|
||||
/// `None` rather than zero: a rate over no time is undefined, and zero is
|
||||
/// the value that would read as "idle" — the one reading that must never
|
||||
/// come from an absence.
|
||||
#[must_use]
|
||||
pub fn mean_uw(&self) -> Option<u64> {
|
||||
if self.window_ns == 0 {
|
||||
return None;
|
||||
}
|
||||
// µW = µJ / s = µJ * 1e9 / ns. u128 so a long window cannot overflow.
|
||||
let uw = u128::from(self.energy.uj) * 1_000_000_000 / u128::from(self.window_ns);
|
||||
Some(u64::try_from(uw).unwrap_or(u64::MAX))
|
||||
}
|
||||
}
|
||||
|
||||
/// When a workload counts as idle.
|
||||
///
|
||||
/// [`Self::default`] reclaims nothing. See the module header for why a
|
||||
/// standard cannot supply a meaningful floor.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct IdlePolicy {
|
||||
/// The shortest observation that may produce a verdict, in nanoseconds.
|
||||
pub min_window_ns: u64,
|
||||
/// Mean power strictly below this, in microwatts, is idle.
|
||||
///
|
||||
/// Zero reclaims nothing, which is the safe failure for a destructive
|
||||
/// action.
|
||||
pub floor_uw: u64,
|
||||
}
|
||||
|
||||
/// Why no idleness verdict could be reached.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Unknown {
|
||||
/// The figure is a guess. A guess cannot establish idleness whatever its
|
||||
/// value — this is the arm that stops a broken meter reclaiming the
|
||||
/// cluster.
|
||||
GradeIsAGuess,
|
||||
/// Observed for less than the policy's window. Startup is not idleness.
|
||||
WindowTooShort {
|
||||
/// How long it was observed for.
|
||||
observed_ns: u64,
|
||||
/// How long the policy requires.
|
||||
required_ns: u64,
|
||||
},
|
||||
/// The window is empty, so there is no rate to speak of.
|
||||
NoWindow,
|
||||
}
|
||||
|
||||
/// A workload's activity verdict.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
/// Measured below the floor over a long enough window: reclaimable.
|
||||
Idle {
|
||||
/// Its mean power, in microwatts.
|
||||
uw: u64,
|
||||
},
|
||||
/// Measured at or above the floor: it is working.
|
||||
Working {
|
||||
/// Its mean power, in microwatts.
|
||||
uw: u64,
|
||||
},
|
||||
/// No verdict. **Not** idle, and never a candidate for reclamation.
|
||||
Unmeasured(Unknown),
|
||||
/// Holding no devices, so there is nothing to reclaim.
|
||||
NothingHeld,
|
||||
}
|
||||
|
||||
impl Verdict {
|
||||
/// Whether this workload may be reclaimed for being idle.
|
||||
///
|
||||
/// Only [`Verdict::Idle`] answers yes. `Unmeasured` deliberately does not:
|
||||
/// that is the whole point of the module.
|
||||
#[must_use]
|
||||
pub const fn is_reclaimable(&self) -> bool {
|
||||
matches!(self, Self::Idle { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify one workload against a policy.
|
||||
///
|
||||
/// The order of the checks is normative (SPEC §2.7): nothing held, then the
|
||||
/// window, then the grade, then the rate. A later check never rescues an
|
||||
/// earlier refusal, and in particular the grade is tested **before** the value
|
||||
/// is compared to the floor, so a guess of zero can never read as idle.
|
||||
#[must_use]
|
||||
pub fn classify(activity: &Activity, policy: &IdlePolicy) -> Verdict {
|
||||
if activity.devices_held == 0 {
|
||||
return Verdict::NothingHeld;
|
||||
}
|
||||
let Some(uw) = activity.mean_uw() else {
|
||||
return Verdict::Unmeasured(Unknown::NoWindow);
|
||||
};
|
||||
if activity.window_ns < policy.min_window_ns {
|
||||
return Verdict::Unmeasured(Unknown::WindowTooShort {
|
||||
observed_ns: activity.window_ns,
|
||||
required_ns: policy.min_window_ns,
|
||||
});
|
||||
}
|
||||
// Rule 1, and it is tested before the value is looked at: a guess cannot
|
||||
// establish idleness however small it is.
|
||||
if !activity.energy.provenance.is_observed() {
|
||||
return Verdict::Unmeasured(Unknown::GradeIsAGuess);
|
||||
}
|
||||
if uw < policy.floor_uw {
|
||||
Verdict::Idle { uw }
|
||||
} else {
|
||||
Verdict::Working { uw }
|
||||
}
|
||||
}
|
||||
|
||||
/// The order in which idle workloads should be reclaimed.
|
||||
///
|
||||
/// Only workloads [`classify`] finds [`Verdict::Idle`] appear. Most devices
|
||||
/// first, because reclaiming one of those frees the most contended capacity
|
||||
/// per workload disturbed; ties break on the lowest rate, then on `claim`, so
|
||||
/// the order is total and a replay reproduces it.
|
||||
///
|
||||
/// Everything else is **absent**, not ranked last: an unmeasured workload is
|
||||
/// not a worse candidate than an idle one, it is not a candidate.
|
||||
#[must_use]
|
||||
pub fn reclamation_order(activity: &[Activity], policy: &IdlePolicy) -> Vec<String> {
|
||||
let mut idle: Vec<(&Activity, u64)> = activity
|
||||
.iter()
|
||||
.filter_map(|a| match classify(a, policy) {
|
||||
Verdict::Idle { uw } => Some((a, uw)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
idle.sort_by(|(a, a_uw), (b, b_uw)| {
|
||||
b.devices_held
|
||||
.cmp(&a.devices_held)
|
||||
.then_with(|| a_uw.cmp(b_uw))
|
||||
.then_with(|| a.claim.cmp(&b.claim))
|
||||
});
|
||||
idle.into_iter().map(|(a, _)| a.claim.clone()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Provenance;
|
||||
|
||||
const SEC: u64 = 1_000_000_000;
|
||||
|
||||
fn policy() -> IdlePolicy {
|
||||
IdlePolicy { min_window_ns: 60 * SEC, floor_uw: 5_000_000 } // 5 W
|
||||
}
|
||||
|
||||
fn activity(claim: &str, devices: u32, window_ns: u64, uj: u64, p: Provenance) -> Activity {
|
||||
Activity {
|
||||
claim: claim.into(),
|
||||
devices_held: devices,
|
||||
window_ns,
|
||||
energy: Energy::new(uj, p),
|
||||
}
|
||||
}
|
||||
|
||||
/// **The rule the module exists for.** The same workload, the same devices,
|
||||
/// the same window, the same near-zero figure — differing only in whether
|
||||
/// anyone measured it. Reclaimable when metered; protected when guessed.
|
||||
///
|
||||
/// Without this, a meter that fails closed to 0 µJ reclaims the cluster.
|
||||
#[test]
|
||||
fn a_guess_of_zero_is_not_idle_though_a_reading_of_zero_is() {
|
||||
let metered = activity("w", 4, 300 * SEC, 0, Provenance::HwShunt);
|
||||
assert_eq!(classify(&metered, &policy()), Verdict::Idle { uw: 0 });
|
||||
assert!(classify(&metered, &policy()).is_reclaimable());
|
||||
|
||||
let guessed = activity("w", 4, 300 * SEC, 0, Provenance::Estimator);
|
||||
assert_eq!(
|
||||
classify(&guessed, &policy()),
|
||||
Verdict::Unmeasured(Unknown::GradeIsAGuess),
|
||||
"a meter that is not there reports the same zero as a parked device"
|
||||
);
|
||||
assert!(
|
||||
!classify(&guessed, &policy()).is_reclaimable(),
|
||||
"reclaiming on a missing number destroys running work"
|
||||
);
|
||||
}
|
||||
|
||||
/// The grade is tested before the value, so no floor setting can let a
|
||||
/// guess through — including a floor high enough to catch anything.
|
||||
#[test]
|
||||
fn no_floor_setting_lets_a_guess_be_reclaimed() {
|
||||
let greedy = IdlePolicy { min_window_ns: SEC, floor_uw: u64::MAX };
|
||||
for uj in [0u64, 1, 1_000_000, u64::MAX] {
|
||||
let guessed = activity("w", 1, 300 * SEC, uj, Provenance::Estimator);
|
||||
assert_eq!(
|
||||
classify(&guessed, &greedy),
|
||||
Verdict::Unmeasured(Unknown::GradeIsAGuess),
|
||||
"{uj} µJ, guessed, under a floor that catches everything"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A modelled figure is evidence enough: the bar is "someone measured or
|
||||
/// modelled it", not "a hardware counter read it".
|
||||
#[test]
|
||||
fn a_modelled_figure_can_establish_idleness() {
|
||||
let modelled = activity("w", 2, 300 * SEC, 100, Provenance::ModelBased);
|
||||
assert!(classify(&modelled, &policy()).is_reclaimable());
|
||||
}
|
||||
|
||||
/// Rule 3: startup is not idleness.
|
||||
#[test]
|
||||
fn a_workload_younger_than_the_window_gets_no_verdict() {
|
||||
let young = activity("w", 4, 10 * SEC, 0, Provenance::HwShunt);
|
||||
assert_eq!(
|
||||
classify(&young, &policy()),
|
||||
Verdict::Unmeasured(Unknown::WindowTooShort {
|
||||
observed_ns: 10 * SEC,
|
||||
required_ns: 60 * SEC
|
||||
})
|
||||
);
|
||||
assert!(!classify(&young, &policy()).is_reclaimable());
|
||||
}
|
||||
|
||||
/// An empty window has no rate. Not zero — undefined.
|
||||
#[test]
|
||||
fn an_empty_window_has_no_rate_rather_than_a_rate_of_zero() {
|
||||
let none = activity("w", 4, 0, 0, Provenance::HwShunt);
|
||||
assert_eq!(none.mean_uw(), None, "a rate over no time is undefined");
|
||||
assert_eq!(classify(&none, &policy()), Verdict::Unmeasured(Unknown::NoWindow));
|
||||
}
|
||||
|
||||
/// Rule 4: nothing held, nothing to reclaim.
|
||||
#[test]
|
||||
fn a_workload_holding_nothing_is_not_a_candidate() {
|
||||
let unheld = activity("w", 0, 300 * SEC, 0, Provenance::HwShunt);
|
||||
assert_eq!(classify(&unheld, &policy()), Verdict::NothingHeld);
|
||||
assert!(!classify(&unheld, &policy()).is_reclaimable());
|
||||
}
|
||||
|
||||
/// The default policy reclaims nothing, however idle the workload looks.
|
||||
#[test]
|
||||
fn the_default_policy_reclaims_nothing() {
|
||||
let parked = activity("w", 8, 3_600 * SEC, 0, Provenance::HwShunt);
|
||||
assert_eq!(
|
||||
classify(&parked, &IdlePolicy::default()),
|
||||
Verdict::Working { uw: 0 },
|
||||
"a floor of zero admits no rate below it, so nothing is idle"
|
||||
);
|
||||
assert!(reclamation_order(&[parked], &IdlePolicy::default()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_floor_is_exclusive_at_its_boundary() {
|
||||
let at = activity("at", 1, 300 * SEC, 5_000_000 * 300, Provenance::HwShunt);
|
||||
assert_eq!(at.mean_uw(), Some(5_000_000));
|
||||
assert_eq!(
|
||||
classify(&at, &policy()),
|
||||
Verdict::Working { uw: 5_000_000 },
|
||||
"at the floor is working; strictly below it is idle"
|
||||
);
|
||||
|
||||
let below = activity("below", 1, 300 * SEC, 5_000_000 * 300 - 300, Provenance::HwShunt);
|
||||
assert_eq!(below.mean_uw(), Some(4_999_999));
|
||||
assert!(classify(&below, &policy()).is_reclaimable());
|
||||
}
|
||||
|
||||
/// Only idle workloads appear in the order. The unmeasured are absent, not
|
||||
/// ranked last — they are not candidates at all.
|
||||
#[test]
|
||||
fn the_unmeasured_are_absent_from_the_order_not_ranked_last() {
|
||||
let set = vec![
|
||||
activity("idle-1dev", 1, 300 * SEC, 0, Provenance::HwShunt),
|
||||
activity("idle-8dev", 8, 300 * SEC, 0, Provenance::HwShunt),
|
||||
activity("guessed", 8, 300 * SEC, 0, Provenance::Estimator),
|
||||
activity("busy", 8, 300 * SEC, 9_000_000 * 300, Provenance::HwShunt),
|
||||
activity("young", 8, SEC, 0, Provenance::HwShunt),
|
||||
];
|
||||
let order = reclamation_order(&set, &policy());
|
||||
assert_eq!(
|
||||
order,
|
||||
vec!["idle-8dev".to_string(), "idle-1dev".to_string()],
|
||||
"most devices first; the guessed, the busy and the young are absent"
|
||||
);
|
||||
for absent in ["guessed", "busy", "young"] {
|
||||
assert!(!order.contains(&absent.to_string()), "{absent} must not be a candidate");
|
||||
}
|
||||
}
|
||||
|
||||
/// Ties are broken to a total order so a replay reproduces the decision.
|
||||
#[test]
|
||||
fn the_order_is_total_and_reproducible() {
|
||||
let mk = |claim: &str, uj: u64| activity(claim, 4, 300 * SEC, uj, Provenance::HwShunt);
|
||||
// Equal devices: the lower rate first, then the claim id.
|
||||
let set = vec![mk("b", 0), mk("a", 0), mk("c", 300)];
|
||||
assert_eq!(
|
||||
reclamation_order(&set, &policy()),
|
||||
vec!["a".to_string(), "b".to_string(), "c".to_string()]
|
||||
);
|
||||
// And reversing the input does not change the answer.
|
||||
let mut rev = set;
|
||||
rev.reverse();
|
||||
assert_eq!(
|
||||
reclamation_order(&rev, &policy()),
|
||||
vec!["a".to_string(), "b".to_string(), "c".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
/// A long window at a high rate does not overflow the microwatt
|
||||
/// conversion, which multiplies by 1e9 before dividing.
|
||||
#[test]
|
||||
fn a_long_window_does_not_overflow_the_rate() {
|
||||
let year = activity("w", 1, 31_536_000 * SEC, u64::MAX, Provenance::HwShunt);
|
||||
let uw = year.mean_uw().expect("a rate");
|
||||
assert!(uw > 0, "u64::MAX µJ over a year is a real rate, not an overflow");
|
||||
assert_eq!(uw, 584_942_417_355, "u64::MAX µJ / 31_536_000 s, in µW");
|
||||
}
|
||||
}
|
||||
386
pool/pool-rs/crates/pool-core/src/share.rs
Normal file
386
pool/pool-rs/crates/pool-core/src/share.rs
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
//! Sharing one device between tenants, and what that does to a joule figure.
|
||||
//!
|
||||
//! Fractional accelerators are how a scheduler stops stranding capacity: several
|
||||
//! workloads on one physical device, each bounded by a memory fence. The fence
|
||||
//! is enforceable because memory is partitioned. **Energy is not partitioned.**
|
||||
//! A device-level counter reports one number for the whole device and has no
|
||||
//! notion of which tenant caused which joule.
|
||||
//!
|
||||
//! So an attributed per-tenant figure is a **split of a measurement, not a
|
||||
//! measurement**. Two rules follow, and this module exists to make them hold
|
||||
//! rather than to be remembered:
|
||||
//!
|
||||
//! 1. **Provenance degrades on division** ([`attribute`]). A `HwShunt` device
|
||||
//! total becomes `ModelBased` the moment it is divided between two or more
|
||||
//! occupants, because the division is a model however good the counter was.
|
||||
//! It never degrades further than that and never improves: an `Estimator`
|
||||
//! total stays `Estimator`, since dividing a guess does not make it a model.
|
||||
//! Dividing by one is not a division, so a sole occupant keeps the device's
|
||||
//! own grade.
|
||||
//! 2. **The shares sum to the measurement, exactly** ([`attribute`]). Integer
|
||||
//! microjoules, largest-remainder distribution, deterministic tie-break. No
|
||||
//! joule is invented and none is lost, so a tenant's share plus every other
|
||||
//! tenant's share is the number the counter actually read — which is what
|
||||
//! makes per-tenant figures reconcilable against the device at all.
|
||||
//!
|
||||
//! What this module will not do is report a per-tenant figure as `HwShunt`.
|
||||
//! That grade means a counter was read for *this* work, and on a shared device
|
||||
//! no counter was.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{Energy, Provenance};
|
||||
|
||||
/// One tenant's claim on a shared device, and its relative weight.
|
||||
///
|
||||
/// The weight is whatever basis the operator can defend — allotted memory
|
||||
/// fraction, time slices, SM partitions. `pool/1` does not choose it; it
|
||||
/// requires only that the same basis is used for every occupant of one device
|
||||
/// and that the resulting figure is graded as the model it is.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Occupant {
|
||||
/// The claim occupying the share.
|
||||
pub claim: String,
|
||||
/// Relative weight against the other occupants of the same device.
|
||||
pub weight: u64,
|
||||
}
|
||||
|
||||
impl Occupant {
|
||||
/// An occupant at a given weight.
|
||||
#[must_use]
|
||||
pub fn new(claim: impl Into<String>, weight: u64) -> Self {
|
||||
Self { claim: claim.into(), weight }
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a device total could not be attributed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ShareError {
|
||||
/// No occupants: there is nobody to attribute the device's energy to, and
|
||||
/// silently discarding it would lose joules the counter really read.
|
||||
NoOccupants,
|
||||
/// Every weight is zero. There is no basis on which to divide, and an
|
||||
/// equal split would be an invention rather than a reading of the
|
||||
/// operator's intent.
|
||||
NoWeight,
|
||||
}
|
||||
|
||||
/// The grade an attributed share carries.
|
||||
///
|
||||
/// `HwShunt` survives only where there was no division to model: one occupant
|
||||
/// holding the whole device. Otherwise the best available grade is
|
||||
/// `ModelBased`, and a grade already below it is left alone.
|
||||
#[must_use]
|
||||
pub fn attributed_grade(device: Provenance, occupants: usize) -> Provenance {
|
||||
if occupants <= 1 {
|
||||
device
|
||||
} else {
|
||||
// `Provenance` is ordered worst-to-best, so `min` degrades and never
|
||||
// improves: HwShunt → ModelBased, ModelBased → ModelBased,
|
||||
// Estimator → Estimator.
|
||||
device.min(Provenance::ModelBased)
|
||||
}
|
||||
}
|
||||
|
||||
/// Divide a device's measured energy among its occupants.
|
||||
///
|
||||
/// The shares sum to `total.uj` exactly. Each occupant receives the floor of
|
||||
/// its weighted share, and the microjoules left over by that flooring are
|
||||
/// handed out one at a time to the largest remainders, ties broken by claim id
|
||||
/// so the result is reproducible on any machine.
|
||||
///
|
||||
/// Every returned figure carries [`attributed_grade`], never the device's own
|
||||
/// grade where a division happened.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ShareError::NoOccupants`] if the slice is empty, [`ShareError::NoWeight`]
|
||||
/// if every weight is zero.
|
||||
pub fn attribute(
|
||||
total: Energy,
|
||||
occupants: &[Occupant],
|
||||
) -> Result<Vec<(String, Energy)>, ShareError> {
|
||||
if occupants.is_empty() {
|
||||
return Err(ShareError::NoOccupants);
|
||||
}
|
||||
let total_weight: u128 = occupants.iter().map(|o| u128::from(o.weight)).sum();
|
||||
if total_weight == 0 {
|
||||
return Err(ShareError::NoWeight);
|
||||
}
|
||||
|
||||
let grade = attributed_grade(total.provenance, occupants.len());
|
||||
let uj = u128::from(total.uj);
|
||||
|
||||
// Floor each share, and keep the remainder for the second pass. u128
|
||||
// throughout: `uj * weight` overflows u64 for realistic windows.
|
||||
let mut floors: Vec<u128> = Vec::with_capacity(occupants.len());
|
||||
let mut remainders: Vec<(u128, &str, usize)> = Vec::with_capacity(occupants.len());
|
||||
for (i, o) in occupants.iter().enumerate() {
|
||||
let scaled = uj * u128::from(o.weight);
|
||||
floors.push(scaled / total_weight);
|
||||
remainders.push((scaled % total_weight, o.claim.as_str(), i));
|
||||
}
|
||||
|
||||
// Largest remainder first, then by claim id: a total order, so two
|
||||
// implementations hand the leftover to the same tenants.
|
||||
remainders.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(b.1)));
|
||||
|
||||
let assigned: u128 = floors.iter().sum();
|
||||
let mut leftover = uj - assigned;
|
||||
for (_, _, i) in &remainders {
|
||||
if leftover == 0 {
|
||||
break;
|
||||
}
|
||||
floors[*i] += 1;
|
||||
leftover -= 1;
|
||||
}
|
||||
|
||||
Ok(occupants
|
||||
.iter()
|
||||
.zip(floors)
|
||||
.map(|(o, share)| {
|
||||
let share = u64::try_from(share).unwrap_or(u64::MAX);
|
||||
(o.claim.clone(), Energy::new(share, grade))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Sum attributed shares back to a device total, for reconciliation.
|
||||
///
|
||||
/// The grade of the sum is the grade of the shares: adding modelled splits
|
||||
/// back up does not recover the counter's own grade, because the recovered
|
||||
/// number is only as good as the split that produced the parts.
|
||||
#[must_use]
|
||||
pub fn reconcile(shares: &[(String, Energy)]) -> Energy {
|
||||
let uj = shares.iter().map(|(_, e)| e.uj).fold(0u64, u64::saturating_add);
|
||||
let grade = shares
|
||||
.iter()
|
||||
.map(|(_, e)| e.provenance)
|
||||
.min()
|
||||
.unwrap_or(Provenance::Estimator);
|
||||
Energy::new(uj, grade)
|
||||
}
|
||||
|
||||
/// Group occupants by the device they share, so a caller holding a flat list
|
||||
/// can attribute each device's own total.
|
||||
#[must_use]
|
||||
pub fn by_device<'a>(
|
||||
assignments: impl IntoIterator<Item = (&'a str, Occupant)>,
|
||||
) -> BTreeMap<String, Vec<Occupant>> {
|
||||
let mut out: BTreeMap<String, Vec<Occupant>> = BTreeMap::new();
|
||||
for (device, o) in assignments {
|
||||
out.entry(device.to_string()).or_default().push(o);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn occupants(ws: &[(&str, u64)]) -> Vec<Occupant> {
|
||||
ws.iter().map(|(c, w)| Occupant::new(*c, *w)).collect()
|
||||
}
|
||||
|
||||
/// Rule 1, and the discriminating pair: the same device counter, one
|
||||
/// occupant versus two.
|
||||
#[test]
|
||||
fn provenance_degrades_on_division_but_not_on_a_sole_occupant() {
|
||||
let total = Energy::new(1_000, Provenance::HwShunt);
|
||||
|
||||
let alone = attribute(total, &occupants(&[("a", 1)])).expect("attributes");
|
||||
assert_eq!(
|
||||
alone[0].1.provenance,
|
||||
Provenance::HwShunt,
|
||||
"dividing by one is not a division"
|
||||
);
|
||||
|
||||
let shared = attribute(total, &occupants(&[("a", 1), ("b", 1)])).expect("attributes");
|
||||
for (claim, e) in &shared {
|
||||
assert_eq!(
|
||||
e.provenance,
|
||||
Provenance::ModelBased,
|
||||
"{claim}: a split of a counter is a model, not a reading"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// It degrades, and it never improves: dividing a guess does not make it
|
||||
/// a model.
|
||||
#[test]
|
||||
fn an_estimator_total_is_not_improved_by_being_split() {
|
||||
for n in [1usize, 2, 7] {
|
||||
let os: Vec<Occupant> = (0..n).map(|i| Occupant::new(format!("c{i}"), 1)).collect();
|
||||
let shares =
|
||||
attribute(Energy::new(100, Provenance::Estimator), &os).expect("attributes");
|
||||
for (_, e) in shares {
|
||||
assert_eq!(e.provenance, Provenance::Estimator);
|
||||
}
|
||||
}
|
||||
assert_eq!(attributed_grade(Provenance::Estimator, 9), Provenance::Estimator);
|
||||
assert_eq!(attributed_grade(Provenance::ModelBased, 9), Provenance::ModelBased);
|
||||
assert_eq!(attributed_grade(Provenance::HwShunt, 9), Provenance::ModelBased);
|
||||
}
|
||||
|
||||
/// Rule 2: no joule invented, none lost — including the awkward divisions
|
||||
/// where flooring alone would lose several.
|
||||
#[test]
|
||||
fn the_shares_sum_to_the_measurement_exactly() {
|
||||
let cases: &[(u64, &[(&str, u64)])] = &[
|
||||
(1_000, &[("a", 1), ("b", 1)]),
|
||||
(1_000, &[("a", 1), ("b", 1), ("c", 1)]),
|
||||
(7, &[("a", 1), ("b", 1), ("c", 1)]),
|
||||
(1, &[("a", 1), ("b", 1), ("c", 1), ("d", 1)]),
|
||||
(0, &[("a", 3), ("b", 5)]),
|
||||
(999_999_937, &[("a", 7), ("b", 11), ("c", 13), ("d", 17)]),
|
||||
(u64::from(u32::MAX), &[("a", 1), ("b", 2), ("c", 3)]),
|
||||
];
|
||||
for (uj, ws) in cases {
|
||||
let shares = attribute(Energy::new(*uj, Provenance::HwShunt), &occupants(ws))
|
||||
.expect("attributes");
|
||||
let summed: u64 = shares.iter().map(|(_, e)| e.uj).sum();
|
||||
assert_eq!(summed, *uj, "{uj} µJ across {ws:?} lost or gained joules");
|
||||
assert_eq!(reconcile(&shares).uj, *uj);
|
||||
}
|
||||
}
|
||||
|
||||
/// A huge total times a weight overflows u64 long before it overflows the
|
||||
/// u128 the arithmetic actually uses.
|
||||
#[test]
|
||||
fn a_large_total_does_not_overflow_the_split() {
|
||||
let total = Energy::new(u64::MAX, Provenance::HwShunt);
|
||||
let shares =
|
||||
attribute(total, &occupants(&[("a", 1), ("b", 1)])).expect("attributes");
|
||||
let summed: u64 = shares.iter().map(|(_, e)| e.uj).sum();
|
||||
assert_eq!(summed, u64::MAX);
|
||||
}
|
||||
|
||||
/// The leftover goes to the largest remainders, and ties break on the
|
||||
/// claim id — so the same inputs give the same answer anywhere.
|
||||
#[test]
|
||||
fn the_leftover_is_distributed_deterministically() {
|
||||
// 10 µJ over weights 1,1,1: floors are 3,3,3 and one µJ is left.
|
||||
let shares = attribute(
|
||||
Energy::new(10, Provenance::HwShunt),
|
||||
&occupants(&[("b", 1), ("a", 1), ("c", 1)]),
|
||||
)
|
||||
.expect("attributes");
|
||||
let got: Vec<(String, u64)> =
|
||||
shares.iter().map(|(c, e)| (c.clone(), e.uj)).collect();
|
||||
// Equal remainders, so the tie breaks on id: "a" takes the extra µJ.
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![("b".into(), 3u64), ("a".into(), 4), ("c".into(), 3)],
|
||||
"the leftover went somewhere other than the lowest id"
|
||||
);
|
||||
|
||||
// And it is stable across input order.
|
||||
let again = attribute(
|
||||
Energy::new(10, Provenance::HwShunt),
|
||||
&occupants(&[("c", 1), ("b", 1), ("a", 1)]),
|
||||
)
|
||||
.expect("attributes");
|
||||
let a_share = again.iter().find(|(c, _)| c == "a").expect("a is present").1.uj;
|
||||
assert_eq!(a_share, 4, "the same tenant takes the leftover regardless of order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weights_decide_the_split() {
|
||||
let shares = attribute(
|
||||
Energy::new(1_000, Provenance::HwShunt),
|
||||
&occupants(&[("a", 3), ("b", 1)]),
|
||||
)
|
||||
.expect("attributes");
|
||||
assert_eq!(shares[0].1.uj, 750);
|
||||
assert_eq!(shares[1].1.uj, 250);
|
||||
}
|
||||
|
||||
/// Both refusals exist so energy the counter really read is never silently
|
||||
/// dropped on the floor.
|
||||
#[test]
|
||||
fn a_device_with_nobody_on_it_and_a_weightless_set_are_refused() {
|
||||
assert_eq!(
|
||||
attribute(Energy::new(5, Provenance::HwShunt), &[]),
|
||||
Err(ShareError::NoOccupants)
|
||||
);
|
||||
assert_eq!(
|
||||
attribute(
|
||||
Energy::new(5, Provenance::HwShunt),
|
||||
&occupants(&[("a", 0), ("b", 0)])
|
||||
),
|
||||
Err(ShareError::NoWeight)
|
||||
);
|
||||
}
|
||||
|
||||
/// A zero-weight occupant beside a weighted one receives nothing, and the
|
||||
/// weighted one receives everything — the total is still conserved.
|
||||
#[test]
|
||||
fn a_zero_weight_occupant_receives_nothing() {
|
||||
let shares = attribute(
|
||||
Energy::new(100, Provenance::HwShunt),
|
||||
&occupants(&[("idle", 0), ("busy", 1)]),
|
||||
)
|
||||
.expect("attributes");
|
||||
assert_eq!(shares[0].1.uj, 0);
|
||||
assert_eq!(shares[1].1.uj, 100);
|
||||
}
|
||||
|
||||
/// Summing modelled parts does not recover the counter's grade.
|
||||
#[test]
|
||||
fn reconciling_shares_keeps_the_weaker_grade() {
|
||||
let shares = attribute(
|
||||
Energy::new(100, Provenance::HwShunt),
|
||||
&occupants(&[("a", 1), ("b", 1)]),
|
||||
)
|
||||
.expect("attributes");
|
||||
let back = reconcile(&shares);
|
||||
assert_eq!(back.uj, 100);
|
||||
assert_eq!(
|
||||
back.provenance,
|
||||
Provenance::ModelBased,
|
||||
"adding modelled parts back up does not restore a reading"
|
||||
);
|
||||
}
|
||||
|
||||
/// Reconciling *across* devices is where the grade matters: one device
|
||||
/// read by a counter, another only modelled, folded into one figure. The
|
||||
/// weakest part governs, because a sum is no better graded than its worst
|
||||
/// term.
|
||||
#[test]
|
||||
fn reconciling_across_devices_takes_the_weakest_grade() {
|
||||
let mixed = vec![
|
||||
("a".to_string(), Energy::new(60, Provenance::HwShunt)),
|
||||
("b".to_string(), Energy::new(30, Provenance::ModelBased)),
|
||||
("c".to_string(), Energy::new(10, Provenance::Estimator)),
|
||||
];
|
||||
let back = reconcile(&mixed);
|
||||
assert_eq!(back.uj, 100);
|
||||
assert_eq!(
|
||||
back.provenance,
|
||||
Provenance::Estimator,
|
||||
"a sum is no better graded than its worst term"
|
||||
);
|
||||
|
||||
// Drop the guess and the grade rises only as far as the next-worst
|
||||
// term — never to the counter's own grade.
|
||||
assert_eq!(reconcile(&mixed[..2]).provenance, Provenance::ModelBased);
|
||||
assert_eq!(reconcile(&mixed[..1]).provenance, Provenance::HwShunt);
|
||||
}
|
||||
|
||||
/// Reconciling nothing is zero at the weakest grade, not a reading.
|
||||
#[test]
|
||||
fn reconciling_an_empty_set_is_not_a_reading() {
|
||||
assert_eq!(reconcile(&[]), Energy::new(0, Provenance::Estimator));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occupants_group_by_device() {
|
||||
let grouped = by_device([
|
||||
("gpu0", Occupant::new("a", 1)),
|
||||
("gpu1", Occupant::new("b", 1)),
|
||||
("gpu0", Occupant::new("c", 2)),
|
||||
]);
|
||||
assert_eq!(grouped["gpu0"].len(), 2);
|
||||
assert_eq!(grouped["gpu1"].len(), 1);
|
||||
assert_eq!(grouped["gpu0"][1].weight, 2);
|
||||
}
|
||||
}
|
||||
352
pool/pool-rs/crates/pool-core/src/witness.rs
Normal file
352
pool/pool-rs/crates/pool-core/src/witness.rs
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
//! Witnessing: a second party's signature over the ledger's head.
|
||||
//!
|
||||
//! A steward seals its own decisions, so a ledger on its own proves that *this*
|
||||
//! steward wrote *this* sequence — not that the decisions were right, and not
|
||||
//! that anyone else saw them. A steward that can rewrite its whole stream can
|
||||
//! rewrite a consistent one, because it holds the only key involved.
|
||||
//!
|
||||
//! A **witness** is a party holding a key the steward does not. It signs the
|
||||
//! ledger's head at a stated sequence, producing a `pool-witness/1` receipt: a
|
||||
//! statement that the chain reached exactly that head at exactly that length.
|
||||
//! A steward that later rewrites its history produces a different head, and the
|
||||
//! witness's receipt no longer covers it ([`verify`]).
|
||||
//!
|
||||
//! ## Demotion, not refusal
|
||||
//!
|
||||
//! An unwitnessed ledger is still a ledger. It is graded
|
||||
//! [`Attestation::SelfAttested`] rather than refused, so a reader can weigh it
|
||||
//! — which is the shape this family already uses where a figure resolves to no
|
||||
//! independent attestation. What an implementation must not do is report the
|
||||
//! two as the same thing.
|
||||
//!
|
||||
//! ## The one thing this refuses outright
|
||||
//!
|
||||
//! A witness key equal to the steward's is not a witness. [`verify`] refuses
|
||||
//! it rather than reporting a witnessed ledger, because a countersignature
|
||||
//! from the party being countersigned establishes nothing and would be the
|
||||
//! most misleading possible reading of the word.
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::jcr1;
|
||||
|
||||
/// The profile a witness receipt declares.
|
||||
pub const PROFILE: &str = "pool-witness/1";
|
||||
|
||||
/// How much independent backing a ledger carries.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Attestation {
|
||||
/// Countersigned at this head by a witness holding a key the steward does
|
||||
/// not.
|
||||
Witnessed {
|
||||
/// The witness's identifier.
|
||||
witness: String,
|
||||
/// The head the witness covered.
|
||||
head: String,
|
||||
/// The length the witness covered.
|
||||
seq: u64,
|
||||
},
|
||||
/// Sealed only by the steward whose decisions it records. A real ledger,
|
||||
/// weighed accordingly.
|
||||
SelfAttested,
|
||||
}
|
||||
|
||||
impl Attestation {
|
||||
/// Whether a party other than the steward signed for this ledger.
|
||||
#[must_use]
|
||||
pub const fn is_witnessed(&self) -> bool {
|
||||
matches!(self, Self::Witnessed { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a witness receipt did not establish what it claims.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WitnessError {
|
||||
/// The receipt is not a `pool-witness/1` document, does not decode, or
|
||||
/// does not verify under the witness's key.
|
||||
Unverifiable,
|
||||
/// The witness's key is the steward's. A countersignature from the party
|
||||
/// being countersigned establishes nothing.
|
||||
WitnessIsTheSteward,
|
||||
/// The receipt names a different steward than the ledger it was offered
|
||||
/// for.
|
||||
StewardMismatch,
|
||||
/// The receipt covers a different head or length than the ledger has —
|
||||
/// which is what a rewritten history looks like from here.
|
||||
CoversADifferentLedger {
|
||||
/// What the witness signed for.
|
||||
signed: (String, u64),
|
||||
/// What the ledger actually holds.
|
||||
found: (String, u64),
|
||||
},
|
||||
}
|
||||
|
||||
/// Seal a witness receipt over a ledger's head.
|
||||
///
|
||||
/// The witness states: this steward's chain reached `head` at length `seq`.
|
||||
///
|
||||
/// # Panics
|
||||
/// If the receipt cannot be projected onto a canonical JCR-1 core, which would
|
||||
/// mean this crate built a wire object the profile forbids.
|
||||
#[must_use]
|
||||
pub fn attest(
|
||||
head: &str,
|
||||
seq: u64,
|
||||
steward: &str,
|
||||
witness_sk: &SigningKey,
|
||||
witness: &str,
|
||||
) -> Vec<u8> {
|
||||
let w = json!({
|
||||
"pool_witness": PROFILE,
|
||||
"head": head,
|
||||
"seq": seq,
|
||||
"steward": steward,
|
||||
"witness": witness,
|
||||
});
|
||||
let core = jcr1::signed_core_bytes(&w).expect("pool-witness/1 wire is a JCR-1 object");
|
||||
jcr1::cose_sign1_envelope(&core, witness_sk, witness)
|
||||
}
|
||||
|
||||
/// Check a witness receipt against the ledger it is offered for.
|
||||
///
|
||||
/// Verifies the receipt under `witness_pubkey`, refuses a witness key equal to
|
||||
/// the steward's, and checks that the head and length it covers are the ones
|
||||
/// the ledger actually has.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`WitnessError`], which distinguishes an unverifiable receipt from one that
|
||||
/// verifies but covers a different ledger — the second is the interesting case,
|
||||
/// because it is what a rewritten history looks like.
|
||||
pub fn verify(
|
||||
receipt: &[u8],
|
||||
witness: &str,
|
||||
witness_pubkey: &[u8; 32],
|
||||
steward: &str,
|
||||
steward_pubkey: &[u8; 32],
|
||||
head: &str,
|
||||
seq: u64,
|
||||
) -> Result<Attestation, WitnessError> {
|
||||
if witness_pubkey == steward_pubkey {
|
||||
return Err(WitnessError::WitnessIsTheSteward);
|
||||
}
|
||||
let payload = jcr1::verify_cose_sign1(receipt, witness, witness_pubkey)
|
||||
.map_err(|_| WitnessError::Unverifiable)?;
|
||||
let v: Value = jcr1::from_canonical_cbor(&payload).map_err(|_| WitnessError::Unverifiable)?;
|
||||
if v.get("pool_witness").and_then(Value::as_str) != Some(PROFILE)
|
||||
|| v.get("witness").and_then(Value::as_str) != Some(witness)
|
||||
{
|
||||
return Err(WitnessError::Unverifiable);
|
||||
}
|
||||
if v.get("steward").and_then(Value::as_str) != Some(steward) {
|
||||
return Err(WitnessError::StewardMismatch);
|
||||
}
|
||||
|
||||
let signed_head = v.get("head").and_then(Value::as_str).ok_or(WitnessError::Unverifiable)?;
|
||||
let signed_seq = v.get("seq").and_then(Value::as_u64).ok_or(WitnessError::Unverifiable)?;
|
||||
if signed_head != head || signed_seq != seq {
|
||||
return Err(WitnessError::CoversADifferentLedger {
|
||||
signed: (signed_head.to_string(), signed_seq),
|
||||
found: (head.to_string(), seq),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Attestation::Witnessed {
|
||||
witness: witness.to_string(),
|
||||
head: head.to_string(),
|
||||
seq,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ledger::{Event, Ledger};
|
||||
|
||||
const STEWARD: &str = "did:pool:steward";
|
||||
const WITNESS: &str = "did:pool:witness";
|
||||
|
||||
fn key(seed: u8) -> SigningKey {
|
||||
SigningKey::from_bytes(&[seed; 32])
|
||||
}
|
||||
|
||||
fn ledger_with(n: usize) -> Ledger {
|
||||
let mut l = Ledger::new(key(1), STEWARD);
|
||||
for i in 0..n {
|
||||
l.append(&Event::Admitted {
|
||||
claim: format!("w{i}"),
|
||||
entitlement: "team".into(),
|
||||
class: "train".into(),
|
||||
reserved_uj: 1_000,
|
||||
devices: 1,
|
||||
});
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_witness_covers_the_head_it_signed() {
|
||||
let l = ledger_with(3);
|
||||
let wk = key(2);
|
||||
let r = attest(l.head(), l.len() as u64, STEWARD, &wk, WITNESS);
|
||||
|
||||
let a = verify(
|
||||
&r,
|
||||
WITNESS,
|
||||
&wk.verifying_key().to_bytes(),
|
||||
STEWARD,
|
||||
&l.steward_pubkey(),
|
||||
l.head(),
|
||||
l.len() as u64,
|
||||
)
|
||||
.expect("the witness covers this ledger");
|
||||
assert!(a.is_witnessed());
|
||||
assert_eq!(
|
||||
a,
|
||||
Attestation::Witnessed {
|
||||
witness: WITNESS.into(),
|
||||
head: l.head().to_string(),
|
||||
seq: 3
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The point of the whole module: a steward that rewrites its history
|
||||
/// produces a different head, and the witness's receipt stops covering it.
|
||||
#[test]
|
||||
fn a_rewritten_history_is_no_longer_covered() {
|
||||
let honest = ledger_with(3);
|
||||
let wk = key(2);
|
||||
let r = attest(honest.head(), 3, STEWARD, &wk, WITNESS);
|
||||
|
||||
// The steward rewrites: same key, same profile, one entry fewer. Every
|
||||
// entry is still validly sealed — it is the head that moved.
|
||||
let rewritten = ledger_with(2);
|
||||
|
||||
let err = verify(
|
||||
&r,
|
||||
WITNESS,
|
||||
&wk.verifying_key().to_bytes(),
|
||||
STEWARD,
|
||||
&rewritten.steward_pubkey(),
|
||||
rewritten.head(),
|
||||
rewritten.len() as u64,
|
||||
)
|
||||
.expect_err("the receipt does not cover the rewritten chain");
|
||||
let WitnessError::CoversADifferentLedger { signed, found } = &err else {
|
||||
panic!("expected CoversADifferentLedger, got {err:?}")
|
||||
};
|
||||
assert_eq!(*signed, (honest.head().to_string(), 3), "the witness signed the honest head");
|
||||
assert_eq!(*found, (rewritten.head().to_string(), 2), "the ledger offered a different one");
|
||||
}
|
||||
|
||||
/// A countersignature from the party being countersigned establishes
|
||||
/// nothing, and is refused rather than reported as a witnessed ledger.
|
||||
#[test]
|
||||
fn the_steward_cannot_witness_itself() {
|
||||
let l = ledger_with(1);
|
||||
let sk = key(1); // the steward's own key
|
||||
let r = attest(l.head(), 1, STEWARD, &sk, WITNESS);
|
||||
|
||||
assert_eq!(
|
||||
verify(
|
||||
&r,
|
||||
WITNESS,
|
||||
&sk.verifying_key().to_bytes(),
|
||||
STEWARD,
|
||||
&l.steward_pubkey(),
|
||||
l.head(),
|
||||
1
|
||||
),
|
||||
Err(WitnessError::WitnessIsTheSteward)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_receipt_from_another_key_does_not_verify() {
|
||||
let l = ledger_with(1);
|
||||
let r = attest(l.head(), 1, STEWARD, &key(2), WITNESS);
|
||||
// Offered as though some third key had signed it.
|
||||
assert_eq!(
|
||||
verify(
|
||||
&r,
|
||||
WITNESS,
|
||||
&key(3).verifying_key().to_bytes(),
|
||||
STEWARD,
|
||||
&l.steward_pubkey(),
|
||||
l.head(),
|
||||
1
|
||||
),
|
||||
Err(WitnessError::Unverifiable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_receipt_for_another_steward_is_refused() {
|
||||
let l = ledger_with(1);
|
||||
let wk = key(2);
|
||||
let r = attest(l.head(), 1, "did:pool:someone-else", &wk, WITNESS);
|
||||
assert_eq!(
|
||||
verify(
|
||||
&r,
|
||||
WITNESS,
|
||||
&wk.verifying_key().to_bytes(),
|
||||
STEWARD,
|
||||
&l.steward_pubkey(),
|
||||
l.head(),
|
||||
1
|
||||
),
|
||||
Err(WitnessError::StewardMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
/// A tampered receipt fails on the signature, not on the comparison.
|
||||
#[test]
|
||||
fn a_tampered_receipt_does_not_verify() {
|
||||
let l = ledger_with(1);
|
||||
let wk = key(2);
|
||||
let mut r = attest(l.head(), 1, STEWARD, &wk, WITNESS);
|
||||
*r.last_mut().expect("bytes") ^= 1;
|
||||
assert_eq!(
|
||||
verify(
|
||||
&r,
|
||||
WITNESS,
|
||||
&wk.verifying_key().to_bytes(),
|
||||
STEWARD,
|
||||
&l.steward_pubkey(),
|
||||
l.head(),
|
||||
1
|
||||
),
|
||||
Err(WitnessError::Unverifiable)
|
||||
);
|
||||
}
|
||||
|
||||
/// An unwitnessed ledger is a ledger. The grade is the output, not a
|
||||
/// refusal.
|
||||
#[test]
|
||||
fn self_attested_is_a_grade_not_an_error() {
|
||||
let a = Attestation::SelfAttested;
|
||||
assert!(!a.is_witnessed());
|
||||
assert_ne!(
|
||||
a,
|
||||
Attestation::Witnessed { witness: WITNESS.into(), head: "x".into(), seq: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
/// The witness wire carries integers and strings only, as JCR-1 requires.
|
||||
#[test]
|
||||
fn the_witness_wire_holds_no_float_bool_or_null() {
|
||||
let l = ledger_with(1);
|
||||
let r = attest(l.head(), 1, STEWARD, &key(2), WITNESS);
|
||||
let payload = jcr1::verify_cose_sign1(&r, WITNESS, &key(2).verifying_key().to_bytes())
|
||||
.expect("verifies");
|
||||
let v: Value = jcr1::from_canonical_cbor(&payload).expect("decodes");
|
||||
let Value::Object(m) = &v else { panic!("an object") };
|
||||
for (k, value) in m {
|
||||
assert!(
|
||||
value.is_string() || value.is_u64(),
|
||||
"{k} is {value:?}; JCR-1 admits integers and strings only"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue