pool: a standard for energy-accounted multi-tenant scheduling #323

Merged
dcharlot merged 9 commits from feat/pool-energy-accounted-scheduling into main 2026-10-19 11:12:37 -04:00
17 changed files with 4967 additions and 0 deletions

View file

@ -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 }

View file

@ -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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
/target

442
pool/pool-rs/Cargo.lock generated Normal file
View 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
View 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"

View 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"

View 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());
}
}

View 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");
}
}
}

View 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);
}
}

View 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));
}
}

View 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");
}
}

View 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);
}
}

View 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"
);
}
}
}