diff --git a/.github/workflows/standards-build.yml b/.github/workflows/standards-build.yml index 8fd496328..ca3c39a7f 100644 --- a/.github/workflows/standards-build.yml +++ b/.github/workflows/standards-build.yml @@ -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 } diff --git a/README.md b/README.md index 9f9d1eb41..7e6a2d7fd 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/pool/LICENSE b/pool/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/pool/LICENSE @@ -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. diff --git a/pool/LICENSE-SPEC b/pool/LICENSE-SPEC new file mode 100644 index 000000000..bd36f8a68 --- /dev/null +++ b/pool/LICENSE-SPEC @@ -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. diff --git a/pool/README.md b/pool/README.md new file mode 100644 index 000000000..c959f902f --- /dev/null +++ b/pool/README.md @@ -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. diff --git a/pool/SPEC.md b/pool/SPEC.md new file mode 100644 index 000000000..80dbcbe82 --- /dev/null +++ b/pool/SPEC.md @@ -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. diff --git a/pool/pool-rs/.gitignore b/pool/pool-rs/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/pool/pool-rs/.gitignore @@ -0,0 +1 @@ +/target diff --git a/pool/pool-rs/Cargo.lock b/pool/pool-rs/Cargo.lock new file mode 100644 index 000000000..b5f753f7c --- /dev/null +++ b/pool/pool-rs/Cargo.lock @@ -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" diff --git a/pool/pool-rs/Cargo.toml b/pool/pool-rs/Cargo.toml new file mode 100644 index 000000000..2aa06cb04 --- /dev/null +++ b/pool/pool-rs/Cargo.toml @@ -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" diff --git a/pool/pool-rs/crates/pool-core/Cargo.toml b/pool/pool-rs/crates/pool-core/Cargo.toml new file mode 100644 index 000000000..a32bcec87 --- /dev/null +++ b/pool/pool-rs/crates/pool-core/Cargo.toml @@ -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" diff --git a/pool/pool-rs/crates/pool-core/src/jcr1.rs b/pool/pool-rs/crates/pool-core/src/jcr1.rs new file mode 100644 index 000000000..4fe507117 --- /dev/null +++ b/pool/pool-rs/crates/pool-core/src/jcr1.rs @@ -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, 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, s: &str) { + cbor_uint(out, 3, s.len() as u64); + out.extend_from_slice(s.as_bytes()); +} + +fn cbor_bytes(out: &mut Vec, b: &[u8]) { + cbor_uint(out, 2, b.len() as u64); + out.extend_from_slice(b); +} + +fn cbor_value(out: &mut Vec, 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, 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, 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 { + // 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 { + // ["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 { + 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, +} + +/// 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, 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 { + 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, 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, Vec, Vec); + +fn parse_cose_sign1(envelope: &[u8]) -> Result { + 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 { + 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 { + 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 { + 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 { + 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()); + } +} diff --git a/pool/pool-rs/crates/pool-core/src/ledger.rs b/pool/pool-rs/crates/pool-core/src/ledger.rs new file mode 100644 index 000000000..e99085d03 --- /dev/null +++ b/pool/pool-rs/crates/pool-core/src/ledger.rs @@ -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], + steward: &str, + pubkey: &[u8; 32], +) -> Result, ReplayError> { + let mut expected_prev = genesis_prev(); + let mut events: Vec = 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 { + 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 { + 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>, +} + +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) -> 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 { + 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] { + &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>) -> Result { + 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], + steward: &str, + pubkey: &[u8; 32], +) -> Result, ReplayError> { + let events = verified_events(entries, steward, pubkey)?; + + // Pass two: the ledger itself, over verified events only. + let mut usage: BTreeMap = BTreeMap::new(); + let mut open: BTreeMap = 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], + steward: &str, + pubkey: &[u8; 32], +) -> Result, ReplayError> { + let events = verified_events(entries, steward, pubkey)?; + let mut open: BTreeMap = 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> = 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"); + } + } +} diff --git a/pool/pool-rs/crates/pool-core/src/lib.rs b/pool/pool-rs/crates/pool-core/src/lib.rs new file mode 100644 index 000000000..3cc6738be --- /dev/null +++ b/pool/pool-rs/crates/pool-core/src/lib.rs @@ -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, + /// 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, + /// 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, + usage: BTreeMap, +} + +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 { + 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 { + 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::(&j).expect("parses"), c); + + let v = admit(&pool(), &c); + let j = serde_json::to_string(&v).expect("serialises"); + assert_eq!(serde_json::from_str::(&j).expect("parses"), v); + } +} diff --git a/pool/pool-rs/crates/pool-core/src/observe.rs b/pool/pool-rs/crates/pool-core/src/observe.rs new file mode 100644 index 000000000..410832ed8 --- /dev/null +++ b/pool/pool-rs/crates/pool-core/src/observe.rs @@ -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 { + 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 = 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 = (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 = (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 = (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 = (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 = (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)); + } +} diff --git a/pool/pool-rs/crates/pool-core/src/reclaim.rs b/pool/pool-rs/crates/pool-core/src/reclaim.rs new file mode 100644 index 000000000..a8d5add0d --- /dev/null +++ b/pool/pool-rs/crates/pool-core/src/reclaim.rs @@ -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 { + 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 { + 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"); + } +} diff --git a/pool/pool-rs/crates/pool-core/src/share.rs b/pool/pool-rs/crates/pool-core/src/share.rs new file mode 100644 index 000000000..9c4a89006 --- /dev/null +++ b/pool/pool-rs/crates/pool-core/src/share.rs @@ -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, 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, 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 = 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, +) -> BTreeMap> { + let mut out: BTreeMap> = 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 { + 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 = (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); + } +} diff --git a/pool/pool-rs/crates/pool-core/src/witness.rs b/pool/pool-rs/crates/pool-core/src/witness.rs new file mode 100644 index 000000000..66fb7494b --- /dev/null +++ b/pool/pool-rs/crates/pool-core/src/witness.rs @@ -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 { + 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 { + 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" + ); + } + } +}