From e559d020bb5a78d561500d89ff08d0ad4baf1814 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 18 Oct 2026 13:42:26 -0400 Subject: [PATCH 1/9] pool: a standard for energy-accounted multi-tenant scheduling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every deployed cluster scheduler denominates entitlement in devices — whole accelerators, fractions of one, or a share of wall clock. Devices are a proxy. The quantity actually consumed, paid for, and reported under an energy-disclosure regime is energy, and a device-count quota cannot express "this team may spend 40 MJ this window". So the budget that matters is enforced nowhere and reconciled after the fact, if at all. A survey of our own tree found the gap on both sides: `over-quota` and `fair-share` appear in zero files, so we had no entitlement model either. `pool/1` denominates entitlement in microjoules AND devices, and enforces both. Three rules carry it, and each needs the energy unit to state at all: 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, and an unobserved estimate is the figure most likely to be wrong in the direction that overcommits. 2. **A gang is all-or-nothing in joules, not only in slots** (§2.4). Gang scheduling exists because half a distributed job is worse than none of it; the same holds for its energy. Admitting part of a gang against a budget that cannot finish the whole gang spends real joules and delivers nothing. 3. **Preemption follows evidence, then declaration** (§5.2). Workloads with a measured cost per COMPLETED unit are reclaimed worst-first. Workloads that have completed nothing are unmeasured, not wasteful, and fall back to declared priority. Rule 3 is why the energy unit is load-bearing rather than decorative: it ranks by cost per completed unit, which needs an outcome anchor. A scheduler that knows only device-seconds cannot express the ordering. Two disciplines from the family are built in from the start rather than retrofitted: - **Unknown is never an extreme.** `uj_per_completed_unit` returns `None` with no completed unit (§5.1) — not zero, which would make a new workload unreclaimable, and not infinity, which would make every new workload the first victim and destroy the very work that would have produced the measurement. - **Pending and refused are different verdicts** (§4.5). A claim that cannot fit today queues; one that can never fit as written is denied, with the reason. Reporting the second as the first is how a queue fills with work that will never run. Admission order is normative (§4), because a different order gives a different verdict: impossibility first, then devices, then the guarantee, then borrowing. Surplus is shared by weight against every borrower under one parent, so a tenant cannot take it all by claiming first, and never crosses roots. Scope is deliberately narrow (§1.1, §7): `pool/1` decides admission and preemption order. It does not place, meter or execute. A verdict is only as sound as the figure handed to it, so the grade travels into the verdict itself rather than sitting beside it. 10 tests, including the discriminating pair for rule 1 — the same claim with the same surplus available, differing only in whether anyone measured the estimate. Co-Authored-By: Claude Opus 5 --- .github/workflows/standards-build.yml | 1 + pool/LICENSE | 202 +++++++ pool/LICENSE-SPEC | 30 + pool/README.md | 49 ++ pool/SPEC.md | 241 ++++++++ pool/pool-rs/.gitignore | 1 + pool/pool-rs/Cargo.lock | 107 ++++ pool/pool-rs/Cargo.toml | 12 + pool/pool-rs/crates/pool-core/Cargo.toml | 16 + pool/pool-rs/crates/pool-core/src/lib.rs | 733 +++++++++++++++++++++++ 10 files changed, 1392 insertions(+) create mode 100644 pool/LICENSE create mode 100644 pool/LICENSE-SPEC create mode 100644 pool/README.md create mode 100644 pool/SPEC.md create mode 100644 pool/pool-rs/.gitignore create mode 100644 pool/pool-rs/Cargo.lock create mode 100644 pool/pool-rs/Cargo.toml create mode 100644 pool/pool-rs/crates/pool-core/Cargo.toml create mode 100644 pool/pool-rs/crates/pool-core/src/lib.rs 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/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..ce9cb2ab2 --- /dev/null +++ b/pool/README.md @@ -0,0 +1,49 @@ +# 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. + +## 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, §7). 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..af89f968a --- /dev/null +++ b/pool/SPEC.md @@ -0,0 +1,241 @@ +# 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. + +--- + +## 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. 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). + +--- + +## 7. 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. 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..ee8ae3890 --- /dev/null +++ b/pool/pool-rs/Cargo.lock @@ -0,0 +1,107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "pool-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[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 = "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", +] + +[[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 = "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 = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[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..bfc422b50 --- /dev/null +++ b/pool/pool-rs/Cargo.toml @@ -0,0 +1,12 @@ +[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" 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..fd5cd7f0e --- /dev/null +++ b/pool/pool-rs/crates/pool-core/Cargo.toml @@ -0,0 +1,16 @@ +[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 } + +[dev-dependencies] +serde_json = { workspace = true } + +[lints.rust] +missing_docs = "warn" 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..be788d6d3 --- /dev/null +++ b/pool/pool-rs/crates/pool-core/src/lib.rs @@ -0,0 +1,733 @@ +//! 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)] + +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, +} + +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 { + 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); + } +} -- 2.54.0 (Apple Git-157) From 010fce3efe422b1c20de13cb76ad7d112d77c026 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 18 Oct 2026 13:43:55 -0400 Subject: [PATCH 2/9] README: index pool/ among the standards The 16th standard directory needs its entry in the root index, in the same shape as the others: what it decides, the three rules that need the energy unit, and the honest scope line. Co-Authored-By: Claude Opus 5 --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) 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). -- 2.54.0 (Apple Git-157) From 8bc1d3bcddfc8d77d2af889656c9c06029bc1226 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 18 Oct 2026 15:12:59 -0400 Subject: [PATCH 3/9] pool: usage is derived from a replayable ledger, not held in memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. That defect has been found and fixed twice elsewhere in this family this week — the JCP gateway refilled every grant on restart, and the JouleHook gate's budget died with each per-action process — so `pool/1` carries the replay from the start rather than acquiring it after someone notices. SPEC §6 and `ledger`: - **Usage is derived, not stored** (§6.1). An implementation MUST 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. - **Two events move it** (§6.2): `Admitted` reserves the estimate, `Released` lifts the hold and charges the **actual** figure in its place. A `Queue` or a `Deny` reserves nothing and is not recorded — that would make this a decision log, a different artifact with a different retention story. - **The actual replaces the reservation, it is not added to it.** The hold bounded what could start; what the tenant spent is what it spent. An admission still open at the end of a stream keeps its reservation, because the capacity is still held. - **An overrun is charged at what it cost** (§6.3), which is why the grade travels with every figure: the overrun is attributable. - **The chain is verified before anything is derived** (§6.4), and a broken link is reported at its index rather than as a shorter history. A release naming a claim never admitted is refused at its index too: that is not a shorter history, it is a history that did not happen. §6.5 states what this is not. The chain is tamper-evident as a *sequence* — reorder, remove or alter an entry and the link breaks — but it is **not authenticated**: nothing in v0.1 signs an entry, so a party who can rewrite the whole stream can rewrite a consistent one. The signed `pool/1` receipt (JCR-1 canonical CBOR under COSE_Sign1, as the rest of the family seals) is a later slice, and until it exists §6.5 forbids an implementation from calling a ledger entry sealed, signed or non-repudiable. The type is named `Ledger` and not `Receipt` for the same reason: the word would claim the half that is missing. Conformance gains four duties (§7.10–13). The honest limit moves to §8; the two citations that pointed at the old number are updated, which `check-spec-refs` would have caught either way. The headline test is the discriminating pair the family now uses for this defect: the same tenant with its guarantee spent, replayed — still queued; not replayed — admitted. 17 tests. Co-Authored-By: Claude Opus 5 --- pool/README.md | 15 +- pool/SPEC.md | 72 ++- pool/pool-rs/Cargo.lock | 69 +++ pool/pool-rs/Cargo.toml | 1 + pool/pool-rs/crates/pool-core/Cargo.toml | 3 +- pool/pool-rs/crates/pool-core/src/ledger.rs | 462 ++++++++++++++++++++ pool/pool-rs/crates/pool-core/src/lib.rs | 2 + 7 files changed, 619 insertions(+), 5 deletions(-) create mode 100644 pool/pool-rs/crates/pool-core/src/ledger.rs diff --git a/pool/README.md b/pool/README.md index ce9cb2ab2..d2046b83c 100644 --- a/pool/README.md +++ b/pool/README.md @@ -34,10 +34,23 @@ bind: devices describe what is occupied, joules describe what is spent. and fall back to declared priority — reclaiming them first would destroy the work that would have produced the measurement. +## 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. + +The chain is tamper-evident as a *sequence*; it is not yet authenticated +(SPEC §6.5). Nothing in v0.1 signs a ledger entry, so the spec forbids calling +one sealed or non-repudiable until the signed receipt lands. + ## 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, §7). Energy figures arrive +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. diff --git a/pool/SPEC.md b/pool/SPEC.md index af89f968a..209a14974 100644 --- a/pool/SPEC.md +++ b/pool/SPEC.md @@ -208,7 +208,68 @@ produced the measurement, so the scheduler never learns anything about it. --- -## 6. Conformance +## 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 | +| `Released` | the reservation is lifted and the **actual** figure charged in its place | + +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 honest state of this chain + +The chain is **tamper-evident as a sequence**: reorder, remove or alter an +entry and the link breaks at that point. It is **not authenticated**. Nothing +in `pool/1` v0.1 signs a ledger entry, so a party able to rewrite the whole +stream can rewrite a consistent one. + +A signed `pool/1` receipt — JCR-1 canonical CBOR under COSE_Sign1, as the rest +of this family seals — is specified separately and is not part of v0.1. Until +it exists, an implementation **MUST NOT** describe a ledger entry as sealed, +signed or non-repudiable. + +--- + +## 7. Conformance A conforming implementation **MUST**: @@ -224,10 +285,17 @@ A conforming implementation **MUST**: 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. Not describe an unsigned ledger entry as sealed or non-repudiable (§6.5). --- -## 7. The honest limit +## 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 diff --git a/pool/pool-rs/Cargo.lock b/pool/pool-rs/Cargo.lock index ee8ae3890..6504d2042 100644 --- a/pool/pool-rs/Cargo.lock +++ b/pool/pool-rs/Cargo.lock @@ -2,12 +2,74 @@ # 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 = "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", +] + +[[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 = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + [[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" @@ -18,6 +80,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" name = "pool-core" version = "0.1.0" dependencies = [ + "blake3", "serde", "serde_json", ] @@ -83,6 +146,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "syn" version = "3.0.9" diff --git a/pool/pool-rs/Cargo.toml b/pool/pool-rs/Cargo.toml index bfc422b50..207401371 100644 --- a/pool/pool-rs/Cargo.toml +++ b/pool/pool-rs/Cargo.toml @@ -10,3 +10,4 @@ repository = "https://git.transaction.science/Transaction-Science/open-standards [workspace.dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +blake3 = "1" diff --git a/pool/pool-rs/crates/pool-core/Cargo.toml b/pool/pool-rs/crates/pool-core/Cargo.toml index fd5cd7f0e..b5cca078f 100644 --- a/pool/pool-rs/crates/pool-core/Cargo.toml +++ b/pool/pool-rs/crates/pool-core/Cargo.toml @@ -8,9 +8,8 @@ repository.workspace = true [dependencies] serde = { workspace = true } - -[dev-dependencies] serde_json = { workspace = true } +blake3 = { workspace = true } [lints.rust] missing_docs = "warn" 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..af3ef6cf3 --- /dev/null +++ b/pool/pool-rs/crates/pool-core/src/ledger.rs @@ -0,0 +1,462 @@ +//! The entitlement ledger: what was admitted, what it actually cost, and how +//! that 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`], each entry +//! hash-chained onto the previous one. [`replay`] rebuilds every entitlement's +//! [`Usage`](crate::Usage) from the stream, so the ledger IS the durable state — +//! there is no second database to drift from it. +//! +//! ## What this is not, yet +//! +//! The chain is **tamper-evident as a sequence**: reorder, remove or alter an +//! entry and the link breaks at that point ([`replay`] returns the index). It +//! is **not authenticated** — nothing here signs, so a party who can rewrite +//! the whole file can rewrite a consistent chain. The family's receipts are +//! JCR-1 canonical CBOR under COSE_Sign1, and the signed `pool/1` receipt is +//! the next slice. Until it exists this type is called a ledger and not a +//! receipt, because the word would claim the half that is missing. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::{Energy, EntitlementId, Usage}; + +/// The genesis predecessor: the "no prior entry" sentinel the first entry +/// links to. +const GENESIS: [u8; 32] = [0u8; 32]; + +/// 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, + /// 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 chain breaks at this index: the entry's recorded predecessor is not + /// the hash of the entry before it, or the entry does not parse. + 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), +} + +/// One appended entry: an event, its position, and the hash of the entry +/// before it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct Entry { + seq: u64, + prev: [u8; 32], + event: Event, +} + +/// An append-only, hash-chained ledger of the decisions that move usage. +#[derive(Debug, Clone)] +pub struct Ledger { + head: [u8; 32], + seq: u64, + entries: Vec>, +} + +impl Default for Ledger { + fn default() -> Self { + Self { head: GENESIS, seq: 0, entries: Vec::new() } + } +} + +/// The hash of one encoded entry. BLAKE3 over the entry's bytes, so the link +/// commits to exactly what was written rather than to a re-encoding of it. +fn link(encoded: &[u8]) -> [u8; 32] { + *blake3::hash(encoded).as_bytes() +} + +impl Ledger { + /// An empty ledger. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Append one event, returning the entry's encoded bytes. + /// + /// # Errors + /// [`ReplayError::ChainBroken`] carrying this entry's index if the event + /// cannot be encoded — which leaves the ledger unchanged, so a failed + /// append never advances the head. + pub fn append(&mut self, event: Event) -> Result, ReplayError> { + let entry = Entry { seq: self.seq, prev: self.head, event }; + let encoded = serde_json::to_vec(&entry) + .map_err(|_| ReplayError::ChainBroken(self.entries.len()))?; + self.head = link(&encoded); + self.seq += 1; + self.entries.push(encoded.clone()); + Ok(encoded) + } + + /// The entries, in order. + #[must_use] + pub fn entries(&self) -> &[Vec] { + &self.entries + } + + /// The current head — the value the next entry links to. + #[must_use] + pub const fn head(&self) -> [u8; 32] { + 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`] links 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.head = prior.last().map_or(GENESIS, |e| link(e)); + self.seq = prior.len() as u64; + let n = prior.len(); + self.entries = prior; + Ok(n) + } +} + +/// Rebuild every entitlement's usage from a ledger stream. +/// +/// The chain is checked first: each entry's recorded `prev` must be the hash +/// of the entry before it, 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::ChainBroken`] at the first broken link, +/// [`ReplayError::UnmatchedRelease`] at a release with no open admission. +pub fn replay(entries: &[Vec]) -> Result, ReplayError> { + let mut expected_prev = GENESIS; + let mut usage: BTreeMap = BTreeMap::new(); + let mut open: BTreeMap = BTreeMap::new(); + + for (i, encoded) in entries.iter().enumerate() { + let entry: Entry = + serde_json::from_slice(encoded).map_err(|_| ReplayError::ChainBroken(i))?; + if entry.seq != i as u64 || entry.prev != expected_prev { + return Err(ReplayError::ChainBroken(i)); + } + expected_prev = link(encoded); + + match entry.event { + Event::Admitted { claim, entitlement, 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) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Claim, Entitlement, Pool, Provenance, QueueReason, Verdict, admit}; + + 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(), + gang_size: 1, + devices_per_member: 1, + estimate: Energy::new(uj, Provenance::HwShunt), + } + } + + /// 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 ledger = Ledger::new(); + + // Spend the guarantee across two admissions. + 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}") + }; + ledger.append(Event::Admitted { + claim: c.id.clone(), + entitlement: c.entitlement.clone(), + reserved_uj, + devices, + }) + .expect("appends"); + let mut u = p.usage(&"team".into()); + u.spent_uj += reserved_uj; + u.devices_held += devices; + p.set_usage(&"team".into(), u); + } + + // The guarantee is gone: the next claim waits. + assert_eq!( + admit(&p, &claim("c", 1_000_000)), + Verdict::Queue { reason: QueueReason::GuaranteeExhausted } + ); + let stream: Vec> = ledger.entries().to_vec(); + drop(p); + + // The process restarts and replays its own stream. + let mut resumed = pool(); + let usage = replay(&stream).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" + ); + + // Without the replay the guarantee is handed back whole — the defect. + let fresh = pool(); + assert!( + matches!(admit(&fresh, &claim("c", 1_000_000)), Verdict::Admit { .. }), + "a pool that does not replay starts the guarantee over" + ); + } + + /// A release charges what was spent in place of what was held — it does + /// not add to it, and it does not leave the hold in place. + #[test] + fn a_release_replaces_the_reservation_with_the_actual() { + let mut ledger = Ledger::new(); + ledger + .append(Event::Admitted { + claim: "w".into(), + entitlement: "team".into(), + reserved_uj: 5_000_000, + devices: 2, + }) + .expect("appends"); + + let held = replay(ledger.entries()).expect("verifies"); + assert_eq!(held[&EntitlementId::from("team")].spent_uj, 5_000_000); + assert_eq!(held[&EntitlementId::from("team")].devices_held, 2); + + ledger + .append(Event::Released { + claim: "w".into(), + actual: Energy::new(3_000_000, Provenance::HwShunt), + }) + .expect("appends"); + + let settled = replay(ledger.entries()).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 ledger = Ledger::new(); + ledger + .append(Event::Admitted { + claim: "w".into(), + entitlement: "team".into(), + reserved_uj: 1_000_000, + devices: 1, + }) + .expect("appends"); + ledger + .append(Event::Released { + claim: "w".into(), + actual: Energy::new(9_000_000, Provenance::HwShunt), + }) + .expect("appends"); + + let usage = replay(ledger.entries()).expect("verifies"); + assert_eq!(usage[&EntitlementId::from("team")].spent_uj, 9_000_000); + } + + #[test] + fn a_broken_chain_is_refused_at_its_index() { + let mut ledger = Ledger::new(); + for id in ["a", "b", "c"] { + ledger + .append(Event::Admitted { + claim: id.into(), + entitlement: "team".into(), + reserved_uj: 1, + devices: 1, + }) + .expect("appends"); + } + let mut stream = ledger.entries().to_vec(); + // Drop the middle entry: entry 2's `prev` no longer matches. + stream.remove(1); + assert_eq!(replay(&stream), Err(ReplayError::ChainBroken(1))); + + // And a resumed ledger refuses it rather than adopting it. + let mut fresh = Ledger::new(); + assert!(fresh.resume(stream).is_err()); + assert!(fresh.is_empty(), "nothing was adopted"); + assert_eq!(fresh.head(), GENESIS, "and the head did not move"); + } + + #[test] + fn a_release_of_a_claim_never_admitted_is_refused() { + let mut ledger = Ledger::new(); + ledger + .append(Event::Released { + claim: "ghost".into(), + actual: Energy::new(1, Provenance::HwShunt), + }) + .expect("appends"); + assert_eq!(replay(ledger.entries()), Err(ReplayError::UnmatchedRelease(0))); + } + + #[test] + fn a_resumed_ledger_continues_the_chain() { + let mut first = Ledger::new(); + first + .append(Event::Admitted { + claim: "a".into(), + entitlement: "team".into(), + reserved_uj: 1, + devices: 1, + }) + .expect("appends"); + let head = first.head(); + let stream = first.entries().to_vec(); + + let mut second = Ledger::new(); + assert_eq!(second.resume(stream), Ok(1)); + assert_eq!(second.head(), head, "it continues from the real head"); + second + .append(Event::Admitted { + claim: "b".into(), + entitlement: "team".into(), + reserved_uj: 1, + devices: 1, + }) + .expect("appends"); + assert_eq!(replay(second.entries()).expect("verifies").len(), 1); + assert_eq!(second.len(), 2); + } + + /// Without the resume the second run writes `seq = 0, prev = genesis` 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::new(); + first + .append(Event::Admitted { + claim: "a".into(), + entitlement: "team".into(), + reserved_uj: 1, + devices: 1, + }) + .expect("appends"); + let mut accumulated = first.entries().to_vec(); + + let mut second = Ledger::new(); + accumulated.push( + second + .append(Event::Admitted { + claim: "b".into(), + entitlement: "team".into(), + reserved_uj: 1, + devices: 1, + }) + .expect("appends"), + ); + + assert_eq!(replay(&accumulated), Err(ReplayError::ChainBroken(1))); + } +} diff --git a/pool/pool-rs/crates/pool-core/src/lib.rs b/pool/pool-rs/crates/pool-core/src/lib.rs index be788d6d3..3ce95c78f 100644 --- a/pool/pool-rs/crates/pool-core/src/lib.rs +++ b/pool/pool-rs/crates/pool-core/src/lib.rs @@ -40,6 +40,8 @@ #![forbid(unsafe_code)] +pub mod ledger; + use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -- 2.54.0 (Apple Git-157) From 2bfaa00d225fdf2cd7568b2361ef3349b5764131 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 18 Oct 2026 15:30:07 -0400 Subject: [PATCH 4/9] pool: ledger entries are sealed pool/1 JCR-1 receipts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §6.5 previously said what the ledger was not: tamper-evident as a sequence, but unauthenticated, with the spec forbidding an implementation from calling an entry sealed until the signed form landed. It lands here, and §6.5 now says what the entry is. Each entry is a `pool/1` JCR-1 receipt — canonical det-CBOR (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`), the steward's identifier as `kid`. That 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. The JCR-1 primitives are **vendored**, which is what `mesh-receipt` documents doing for the same reason: the reference library stays self-contained while the bytes stay compatible with the family's cross-language verifiers. Vendoring also kept the dependency honest — `mesh-receipt` reaches into `jcp-schema` for the decoder, and taking that dependency here would have contradicted the "self-contained" rationale in the vendored header, so the decoder is written against JCR-1's own grammar instead. Two properties, checked separately because they fail separately: - **Authenticity** — every entry verifies under the steward's key. - **Sequence** — every `seq` is its position and every `prev` is the content address of the entry before it, from genesis. Neither alone is enough, and the tests say so rather than assuming it. `a_removed_entry_breaks_the_chain_though_each_entry_is_still_signed` deletes an entry, asserts the survivors STILL verify individually, and asserts the stream is refused anyway: signatures without a chain are a bag of receipts, not a ledger. §6.6 is new: canonicity is checked, not assumed. A signature covers exact bytes, so a verifier that accepted a non-canonical encoding of the same document would let two byte strings share one signature. The decoder re-encodes what it decoded and refuses any difference. The decoder is the one piece here that is new code rather than vendored, so it carries its own tests: round-trip, a non-shortest integer refused, map keys out of length-first order refused, trailing bytes refused, duplicate keys refused, every truncation of a valid core refused, and the types outside the core grammar (floats, bools, nulls, negative integers, byte strings) refused. §8 gains the limit this does NOT remove: the steward signs its own decisions, so a ledger is self-attested by default — it proves this steward wrote this sequence, not that the decisions were right or that a second party saw them. The family already holds the shape of the answer elsewhere, a meter attestation signed by a key distinct from the receipt signer, and `pool/1` has no analogue for the decision dimension yet. Said in the spec rather than left for a reader to discover. Conformance §7.13 is rewritten and §7.14 added. 31 tests. Co-Authored-By: Claude Opus 5 --- pool/README.md | 15 +- pool/SPEC.md | 51 +- pool/pool-rs/Cargo.lock | 270 +++++++++- pool/pool-rs/Cargo.toml | 2 + pool/pool-rs/crates/pool-core/Cargo.toml | 2 + pool/pool-rs/crates/pool-core/src/jcr1.rs | 499 ++++++++++++++++++ pool/pool-rs/crates/pool-core/src/ledger.rs | 557 ++++++++++++-------- pool/pool-rs/crates/pool-core/src/lib.rs | 1 + 8 files changed, 1176 insertions(+), 221 deletions(-) create mode 100644 pool/pool-rs/crates/pool-core/src/jcr1.rs diff --git a/pool/README.md b/pool/README.md index d2046b83c..93edd5704 100644 --- a/pool/README.md +++ b/pool/README.md @@ -43,9 +43,18 @@ 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. -The chain is tamper-evident as a *sequence*; it is not yet authenticated -(SPEC §6.5). Nothing in v0.1 signs a ledger entry, so the spec forbids calling -one sealed or non-repudiable until the signed receipt lands. +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). + +What it still does not establish: the steward signs its own decisions, so a +ledger is self-attested by default — it proves this steward wrote this +sequence, not that a second party saw it (§8). ## What it does not do diff --git a/pool/SPEC.md b/pool/SPEC.md index 209a14974..9afbed568 100644 --- a/pool/SPEC.md +++ b/pool/SPEC.md @@ -255,17 +255,35 @@ 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 honest state of this chain +### 6.5 The sealed entry -The chain is **tamper-evident as a sequence**: reorder, remove or alter an -entry and the link breaks at that point. It is **not authenticated**. Nothing -in `pool/1` v0.1 signs a ledger entry, so a party able to rewrite the whole -stream can rewrite a consistent one. +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. -A signed `pool/1` receipt — JCR-1 canonical CBOR under COSE_Sign1, as the rest -of this family seals — is specified separately and is not part of v0.1. Until -it exists, an implementation **MUST NOT** describe a ledger entry as sealed, -signed or non-repudiable. +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. --- @@ -291,7 +309,10 @@ A conforming implementation **MUST**: addition to it (§6.2). 12. Refuse an inconsistent stream at its index rather than treating it as a shorter history (§6.4). -13. Not describe an unsigned ledger entry as sealed or non-repudiable (§6.5). +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). --- @@ -307,3 +328,13 @@ 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. + +**The steward signs its own decisions.** `pool/1` v0.1 requires no separation +between the party that decides admission and the party that seals the record +of it, so a ledger is, by default, self-attested: it proves that *this* +steward wrote *this* sequence, not that the decisions were the right ones or +that a second party saw them. A reader weighing a ledger should know which it +is holding. Binding a decision to a witness distinct from the decider is a +known shape elsewhere in this family — a meter attestation signed by a key +distinct from the receipt signer — and `pool/1` does not yet carry its +analogue for the decision dimension. diff --git a/pool/pool-rs/Cargo.lock b/pool/pool-rs/Cargo.lock index 6504d2042..b5f753f7c 100644 --- a/pool/pool-rs/Cargo.lock +++ b/pool/pool-rs/Cargo.lock @@ -8,6 +8,12 @@ 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" @@ -18,7 +24,16 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "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]] @@ -37,12 +52,27 @@ 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" @@ -52,12 +82,121 @@ 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" @@ -76,13 +215,25 @@ 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]] @@ -103,6 +254,30 @@ 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" @@ -130,7 +305,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -146,12 +321,59 @@ dependencies = [ "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" @@ -163,12 +385,56 @@ dependencies = [ "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" diff --git a/pool/pool-rs/Cargo.toml b/pool/pool-rs/Cargo.toml index 207401371..2aa06cb04 100644 --- a/pool/pool-rs/Cargo.toml +++ b/pool/pool-rs/Cargo.toml @@ -11,3 +11,5 @@ repository = "https://git.transaction.science/Transaction-Science/open-standards 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 index b5cca078f..a32bcec87 100644 --- a/pool/pool-rs/crates/pool-core/Cargo.toml +++ b/pool/pool-rs/crates/pool-core/Cargo.toml @@ -10,6 +10,8 @@ repository.workspace = true 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..5ab947499 --- /dev/null +++ b/pool/pool-rs/crates/pool-core/src/jcr1.rs @@ -0,0 +1,499 @@ +//! 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 { expected: String, 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!(from_canonical_cbor(&b).is_err()); + } + + #[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!(from_canonical_cbor(&bad).is_err()); + } + + /// 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 index af3ef6cf3..9dda633f8 100644 --- a/pool/pool-rs/crates/pool-core/src/ledger.rs +++ b/pool/pool-rs/crates/pool-core/src/ledger.rs @@ -1,5 +1,5 @@ -//! The entitlement ledger: what was admitted, what it actually cost, and how -//! that survives the process. +//! 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 @@ -8,30 +8,44 @@ //! 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`], each entry -//! hash-chained onto the previous one. [`replay`] rebuilds every entitlement's -//! [`Usage`](crate::Usage) from the stream, so the ledger IS the durable state — -//! there is no second database to drift from it. +//! 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. //! -//! ## What this is not, yet +//! Two properties, kept distinct because they fail separately: //! -//! The chain is **tamper-evident as a sequence**: reorder, remove or alter an -//! entry and the link breaks at that point ([`replay`] returns the index). It -//! is **not authenticated** — nothing here signs, so a party who can rewrite -//! the whole file can rewrite a consistent chain. The family's receipts are -//! JCR-1 canonical CBOR under COSE_Sign1, and the signed `pool/1` receipt is -//! the next slice. Until it exists this type is called a ledger and not a -//! receipt, because the word would claim the half that is missing. +//! - **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::{Energy, EntitlementId, Usage}; +use crate::jcr1; +use crate::{Energy, EntitlementId, Provenance, Usage}; -/// The genesis predecessor: the "no prior entry" sentinel the first entry -/// links to. -const GENESIS: [u8; 32] = [0u8; 32]; +/// 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. /// @@ -65,8 +79,11 @@ pub enum Event { /// Why a stream could not be replayed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReplayError { - /// The chain breaks at this index: the entry's recorded predecessor is not - /// the hash of the entry before it, or the entry does not parse. + /// 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 @@ -74,68 +91,150 @@ pub enum ReplayError { UnmatchedRelease(usize), } -/// One appended entry: an event, its position, and the hash of the entry -/// before it. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct Entry { - seq: u64, - prev: [u8; 32], - event: Event, +/// 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 } -/// An append-only, hash-chained ledger of the decisions that move usage. -#[derive(Debug, Clone)] +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, reserved_uj, devices } => json!({ + "event": "admitted", + "claim": claim, + "entitlement": entitlement.0, + "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")?), + 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 { - head: [u8; 32], + steward_sk: SigningKey, + steward: String, + head: String, seq: u64, entries: Vec>, } -impl Default for Ledger { - fn default() -> Self { - Self { head: GENESIS, seq: 0, entries: Vec::new() } +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() } } -/// The hash of one encoded entry. BLAKE3 over the entry's bytes, so the link -/// commits to exactly what was written rather than to a re-encoding of it. -fn link(encoded: &[u8]) -> [u8; 32] { - *blake3::hash(encoded).as_bytes() -} - impl Ledger { - /// An empty ledger. + /// Open an empty ledger sealing under `steward_sk` as `steward`. #[must_use] - pub fn new() -> Self { - Self::default() + pub fn new(steward_sk: SigningKey, steward: impl Into) -> Self { + Self { + steward_sk, + steward: steward.into(), + head: genesis_prev(), + seq: 0, + entries: Vec::new(), + } } - /// Append one event, returning the entry's encoded bytes. + /// 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. /// - /// # Errors - /// [`ReplayError::ChainBroken`] carrying this entry's index if the event - /// cannot be encoded — which leaves the ledger unchanged, so a failed - /// append never advances the head. - pub fn append(&mut self, event: Event) -> Result, ReplayError> { - let entry = Entry { seq: self.seq, prev: self.head, event }; - let encoded = serde_json::to_vec(&entry) - .map_err(|_| ReplayError::ChainBroken(self.entries.len()))?; - self.head = link(&encoded); + /// # 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(encoded.clone()); - Ok(encoded) + self.entries.push(envelope.clone()); + envelope } - /// The entries, in order. + /// The sealed entries, in order. #[must_use] pub fn entries(&self) -> &[Vec] { &self.entries } - /// The current head — the value the next entry links to. + /// The content address of the most recent entry, or the genesis sentinel. #[must_use] - pub const fn head(&self) -> [u8; 32] { - self.head + pub fn head(&self) -> &str { + &self.head } /// How many entries the ledger holds. @@ -151,19 +250,19 @@ impl Ledger { } /// Adopt a prior stream as this ledger's history, so the next - /// [`Self::append`] links onto its real head rather than onto genesis. + /// [`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. + /// 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.head = prior.last().map_or(GENESIS, |e| link(e)); + 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; @@ -171,11 +270,12 @@ impl Ledger { } } -/// Rebuild every entitlement's usage from a ledger stream. +/// Verify a sealed stream and rebuild every entitlement's usage from it. /// -/// The chain is checked first: each entry's recorded `prev` must be the hash -/// of the entry before it, from genesis. Only then is any usage derived, so a -/// stream that does not verify never reaches the entitlement ledger. +/// 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 @@ -184,22 +284,40 @@ impl Ledger { /// held. /// /// # Errors -/// [`ReplayError::ChainBroken`] at the first broken link, -/// [`ReplayError::UnmatchedRelease`] at a release with no open admission. -pub fn replay(entries: &[Vec]) -> Result, ReplayError> { - let mut expected_prev = GENESIS; - let mut usage: BTreeMap = BTreeMap::new(); - let mut open: BTreeMap = BTreeMap::new(); - - for (i, encoded) in entries.iter().enumerate() { - let entry: Entry = - serde_json::from_slice(encoded).map_err(|_| ReplayError::ChainBroken(i))?; - if entry.seq != i as u64 || entry.prev != expected_prev { +/// [`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> { + // Pass one: authenticity and sequence, over the whole stream. + 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)); } - expected_prev = link(encoded); + events.push(from_wire(&v).ok_or(ReplayError::Unverifiable(i))?); + expected_prev = address(envelope); + } - match entry.event { + // 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, reserved_uj, devices } => { let u = usage.entry(entitlement.clone()).or_default(); u.spent_uj = u.spent_uj.saturating_add(reserved_uj); @@ -224,7 +342,17 @@ pub fn replay(entries: &[Vec]) -> Result, Rep #[cfg(test)] mod tests { use super::*; - use crate::{Claim, Entitlement, Pool, Provenance, QueueReason, Verdict, admit}; + 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(); @@ -250,43 +378,44 @@ mod tests { } } + fn admitted(c: &Claim, reserved_uj: u64, devices: u32) -> Event { + Event::Admitted { + claim: c.id.clone(), + entitlement: c.entitlement.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 ledger = Ledger::new(); + let mut log = ledger(1); - // Spend the guarantee across two admissions. 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}") }; - ledger.append(Event::Admitted { - claim: c.id.clone(), - entitlement: c.entitlement.clone(), - reserved_uj, - devices, - }) - .expect("appends"); + 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); } - // The guarantee is gone: the next claim waits. assert_eq!( admit(&p, &claim("c", 1_000_000)), Verdict::Queue { reason: QueueReason::GuaranteeExhausted } ); - let stream: Vec> = ledger.entries().to_vec(); + let stream: Vec> = log.entries().to_vec(); + let pubkey = log.steward_pubkey(); drop(p); - // The process restarts and replays its own stream. let mut resumed = pool(); - let usage = replay(&stream).expect("our own chain verifies"); + 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); @@ -297,7 +426,6 @@ mod tests { "the spend survived the restart" ); - // Without the replay the guarantee is handed back whole — the defect. let fresh = pool(); assert!( matches!(admit(&fresh, &claim("c", 1_000_000)), Verdict::Admit { .. }), @@ -305,32 +433,76 @@ mod tests { ); } - /// A release charges what was spent in place of what was held — it does - /// not add to it, and it does not leave the hold in place. + /// 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 ledger = Ledger::new(); - ledger - .append(Event::Admitted { - claim: "w".into(), - entitlement: "team".into(), - reserved_uj: 5_000_000, - devices: 2, - }) - .expect("appends"); + let mut log = ledger(1); + let pubkey = log.steward_pubkey(); + log.append(&Event::Admitted { + claim: "w".into(), + entitlement: "team".into(), + reserved_uj: 5_000_000, + devices: 2, + }); - let held = replay(ledger.entries()).expect("verifies"); + 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); - ledger - .append(Event::Released { - claim: "w".into(), - actual: Energy::new(3_000_000, Provenance::HwShunt), - }) - .expect("appends"); + log.append(&Event::Released { + claim: "w".into(), + actual: Energy::new(3_000_000, Provenance::HwShunt), + }); - let settled = replay(ledger.entries()).expect("verifies"); + let settled = replay(log.entries(), STEWARD, &pubkey).expect("verifies"); assert_eq!( settled[&EntitlementId::from("team")].spent_uj, 3_000_000, @@ -343,120 +515,93 @@ mod tests { /// start, not what was ultimately spent (SPEC §8). #[test] fn an_overrun_is_charged_at_what_it_cost() { - let mut ledger = Ledger::new(); - ledger - .append(Event::Admitted { - claim: "w".into(), - entitlement: "team".into(), - reserved_uj: 1_000_000, - devices: 1, - }) - .expect("appends"); - ledger - .append(Event::Released { - claim: "w".into(), - actual: Energy::new(9_000_000, Provenance::HwShunt), - }) - .expect("appends"); + let mut log = ledger(1); + let pubkey = log.steward_pubkey(); + log.append(&Event::Admitted { + claim: "w".into(), + entitlement: "team".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(ledger.entries()).expect("verifies"); + let usage = replay(log.entries(), STEWARD, &pubkey).expect("verifies"); assert_eq!(usage[&EntitlementId::from("team")].spent_uj, 9_000_000); } - #[test] - fn a_broken_chain_is_refused_at_its_index() { - let mut ledger = Ledger::new(); - for id in ["a", "b", "c"] { - ledger - .append(Event::Admitted { - claim: id.into(), - entitlement: "team".into(), - reserved_uj: 1, - devices: 1, - }) - .expect("appends"); - } - let mut stream = ledger.entries().to_vec(); - // Drop the middle entry: entry 2's `prev` no longer matches. - stream.remove(1); - assert_eq!(replay(&stream), Err(ReplayError::ChainBroken(1))); - - // And a resumed ledger refuses it rather than adopting it. - let mut fresh = Ledger::new(); - assert!(fresh.resume(stream).is_err()); - assert!(fresh.is_empty(), "nothing was adopted"); - assert_eq!(fresh.head(), GENESIS, "and the head did not move"); - } - #[test] fn a_release_of_a_claim_never_admitted_is_refused() { - let mut ledger = Ledger::new(); - ledger - .append(Event::Released { - claim: "ghost".into(), - actual: Energy::new(1, Provenance::HwShunt), - }) - .expect("appends"); - assert_eq!(replay(ledger.entries()), Err(ReplayError::UnmatchedRelease(0))); + 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::new(); - first - .append(Event::Admitted { - claim: "a".into(), - entitlement: "team".into(), - reserved_uj: 1, - devices: 1, - }) - .expect("appends"); - let head = first.head(); + 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::new(); + 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(Event::Admitted { - claim: "b".into(), - entitlement: "team".into(), - reserved_uj: 1, - devices: 1, - }) - .expect("appends"); - assert_eq!(replay(second.entries()).expect("verifies").len(), 1); + 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 writes `seq = 0, prev = genesis` on - /// top of a chain that already has entries, and the accumulated stream — - /// the only record of the spend — stops verifying at that point. + /// 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::new(); - first - .append(Event::Admitted { - claim: "a".into(), - entitlement: "team".into(), - reserved_uj: 1, - devices: 1, - }) - .expect("appends"); + 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::new(); - accumulated.push( - second - .append(Event::Admitted { - claim: "b".into(), - entitlement: "team".into(), - reserved_uj: 1, - devices: 1, - }) - .expect("appends"), - ); + let mut second = ledger(1); + accumulated.push(second.append(&admitted(&claim("b", 1), 1, 1))); - assert_eq!(replay(&accumulated), Err(ReplayError::ChainBroken(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 index 3ce95c78f..c4933b312 100644 --- a/pool/pool-rs/crates/pool-core/src/lib.rs +++ b/pool/pool-rs/crates/pool-core/src/lib.rs @@ -40,6 +40,7 @@ #![forbid(unsafe_code)] +pub mod jcr1; pub mod ledger; use std::collections::BTreeMap; -- 2.54.0 (Apple Git-157) From 471afd3c31c769800c50336c0502efea82ee7f84 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 18 Oct 2026 15:32:00 -0400 Subject: [PATCH 5/9] pool: two decoder tests assert the reason, not merely the refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Falsification caught these: deleting the trailing-bytes guard and the duplicate-key guard changed no test outcome. Both inputs are also rejected by the canonicity re-encode check (§6.6) — a shorter or smaller re-encoding never matches the input — so `is_err()` passed with the guard gone and pinned nothing. Each test now matches the specific `MalformedCore` reason, so the guard it names is the thing under test. Re-falsified: deleting the trailing-bytes guard now fails its test. The guards stay rather than being deleted as redundant: they give a precise reason where the canonicity check gives a general one, and a verifier's diagnostics are part of what makes a refusal actionable. Co-Authored-By: Claude Opus 5 --- pool/pool-rs/crates/pool-core/src/jcr1.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pool/pool-rs/crates/pool-core/src/jcr1.rs b/pool/pool-rs/crates/pool-core/src/jcr1.rs index 5ab947499..6176d26ee 100644 --- a/pool/pool-rs/crates/pool-core/src/jcr1.rs +++ b/pool/pool-rs/crates/pool-core/src/jcr1.rs @@ -438,7 +438,10 @@ mod tests { fn trailing_bytes_are_refused() { let mut b = canonical_cbor(&json!(1)).expect("encodes"); b.push(0x01); - assert!(from_canonical_cbor(&b).is_err()); + assert!( + matches!(from_canonical_cbor(&b), Err(Jcr1Error::MalformedCore(m)) if m.contains("trailing")), + "refused for the trailing bytes specifically, not merely refused" + ); } #[test] @@ -462,7 +465,10 @@ mod tests { 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!(from_canonical_cbor(&bad).is_err()); + 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. -- 2.54.0 (Apple Git-157) From be6e44ef82e53f3f173e088c862dc03035fba6a0 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 18 Oct 2026 15:42:36 -0400 Subject: [PATCH 6/9] =?UTF-8?q?pool:=20a=20witness=20distinct=20from=20the?= =?UTF-8?q?=20steward=20(SPEC=20=C2=A76.7-6.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §8 recorded the limit the last slice did not remove: the steward signs its own decisions, so a ledger establishes that THIS steward wrote THIS sequence and 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 steward that later rewrites its history produces a different head, and the receipt stops covering it — which is what `a_rewritten_history_is_no_longer_covered` asserts: the rewritten chain is internally perfect, every entry still validly sealed by the same steward key, and the witness's receipt refuses it anyway on the head. Two design choices, both taken from shapes this family already uses: - **Demotion, not refusal** (§6.8). An unwitnessed ledger is still a ledger: it is graded `self-attested` rather than rejected, as `joulecontract` grades a figure that resolves to no independent meter attestation. What an implementation MUST NOT do is report the two as the same thing. The reader weighs the difference; the standard's duty is to make it legible. - **A witness key equal to the steward's is refused outright** (§6.7). A countersignature from the party being countersigned establishes nothing, and calling it witnessed would be the most misleading reading of the word available. This is the one case the module refuses rather than grades. §6.8 also separates two failures that a single `is_err()` would blur: a receipt that does not verify is a bad signature; a receipt that verifies and covers a different head is what a rewritten history looks like from outside. The error type names them apart and carries both heads, so the diagnosis is in the value rather than in a log line. §8 is rewritten rather than deleted, because the limit narrowed instead of vanishing. Two things stay true: nothing compels a witness, so the guarantee a reader holds depends on a deployment choice; and a witness attests the SEQUENCE, not the decisions — it is evidence against revision, not a second opinion on whether an admission was right. Conformance gains §7.15-17. 39 tests, no warnings. Two pieces of my own housekeeping went with it: a filler test that existed only to justify an unused import is gone rather than kept to pad a count, and the vendored `KidMismatch` fields are documented rather than having the lint silenced. Co-Authored-By: Claude Opus 5 --- pool/README.md | 13 +- pool/SPEC.md | 56 ++- pool/pool-rs/crates/pool-core/src/jcr1.rs | 7 +- pool/pool-rs/crates/pool-core/src/lib.rs | 1 + pool/pool-rs/crates/pool-core/src/witness.rs | 351 +++++++++++++++++++ 5 files changed, 415 insertions(+), 13 deletions(-) create mode 100644 pool/pool-rs/crates/pool-core/src/witness.rs diff --git a/pool/README.md b/pool/README.md index 93edd5704..eaabc392e 100644 --- a/pool/README.md +++ b/pool/README.md @@ -52,9 +52,16 @@ 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). -What it still does not establish: the steward signs its own decisions, so a -ledger is self-attested by default — it proves this steward wrote this -sequence, not that a second party saw it (§8). +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 diff --git a/pool/SPEC.md b/pool/SPEC.md index 9afbed568..fea02c33c 100644 --- a/pool/SPEC.md +++ b/pool/SPEC.md @@ -285,6 +285,36 @@ 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 @@ -313,6 +343,11 @@ A conforming implementation **MUST**: 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). --- @@ -329,12 +364,15 @@ 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. -**The steward signs its own decisions.** `pool/1` v0.1 requires no separation -between the party that decides admission and the party that seals the record -of it, so a ledger is, by default, self-attested: it proves that *this* -steward wrote *this* sequence, not that the decisions were the right ones or -that a second party saw them. A reader weighing a ledger should know which it -is holding. Binding a decision to a witness distinct from the decider is a -known shape elsewhere in this family — a meter attestation signed by a key -distinct from the receipt signer — and `pool/1` does not yet carry its -analogue for the decision dimension. +**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/crates/pool-core/src/jcr1.rs b/pool/pool-rs/crates/pool-core/src/jcr1.rs index 6176d26ee..4fe507117 100644 --- a/pool/pool-rs/crates/pool-core/src/jcr1.rs +++ b/pool/pool-rs/crates/pool-core/src/jcr1.rs @@ -34,7 +34,12 @@ pub enum Jcr1Error { BadSignature, /// The envelope's `kid` did not match the expected steward. #[error("kid mismatch: expected {expected}, got {got}")] - KidMismatch { expected: String, got: String }, + 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}")] diff --git a/pool/pool-rs/crates/pool-core/src/lib.rs b/pool/pool-rs/crates/pool-core/src/lib.rs index c4933b312..765d0f397 100644 --- a/pool/pool-rs/crates/pool-core/src/lib.rs +++ b/pool/pool-rs/crates/pool-core/src/lib.rs @@ -42,6 +42,7 @@ pub mod jcr1; pub mod ledger; +pub mod witness; use std::collections::BTreeMap; 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..53236d5ab --- /dev/null +++ b/pool/pool-rs/crates/pool-core/src/witness.rs @@ -0,0 +1,351 @@ +//! 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 [`WitnessReceipt`]: 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(), + 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" + ); + } + } +} -- 2.54.0 (Apple Git-157) From feb6b9a13da511f6fdcc741c7ab50629f3cb0e2c Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 18 Oct 2026 19:06:32 -0400 Subject: [PATCH 7/9] pool/1: attribute a shared device's energy, and grade the split honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fractional accelerator's memory fence is enforceable because memory is partitioned. Energy is not: a device counter reports one figure for the whole device and cannot see tenants. A per-tenant figure from a shared device is therefore a split of a measurement, not a measurement. `share` encodes the two rules that follow (SPEC §2.5): - Provenance degrades on division. A `HwShunt` device total becomes `ModelBased` the moment two or more occupants divide it, because the division is a model however good the counter was. It never improves — dividing an `Estimator` guess does not make it a model — and dividing by one is not a division, so a sole occupant keeps the device's grade. An implementation may not report a shared device's per-tenant figure as `HwShunt`: that grade asserts a counter was read for *this* work, and none was. - The shares sum to the measurement exactly. Integer microjoules, largest remainder, ties broken on the claim id, arithmetic in u128 so a wide device total cannot overflow the weighting. Conservation is what makes a tenant's figure reconcilable against the device that produced it. `attribute` refuses a device with no occupants or no weight: there is then no basis to divide on, and discarding the figure would lose energy the counter really read. `reconcile` folds shares back to a total and keeps the weaker grade. `by_device` groups occupants so a caller attributes per device rather than across the pool. Conformance gains §7.18–20. Co-Authored-By: Claude Opus 5 --- pool/README.md | 15 + pool/SPEC.md | 43 +++ pool/pool-rs/crates/pool-core/src/lib.rs | 1 + pool/pool-rs/crates/pool-core/src/share.rs | 386 +++++++++++++++++++++ 4 files changed, 445 insertions(+) create mode 100644 pool/pool-rs/crates/pool-core/src/share.rs diff --git a/pool/README.md b/pool/README.md index eaabc392e..29a0cded5 100644 --- a/pool/README.md +++ b/pool/README.md @@ -34,6 +34,21 @@ bind: devices describe what is occupied, joules describe what is spent. 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. + ## Usage survives the process A scheduler that restarts with empty usage hands every tenant its whole diff --git a/pool/SPEC.md b/pool/SPEC.md index fea02c33c..b4e668a5d 100644 --- a/pool/SPEC.md +++ b/pool/SPEC.md @@ -109,6 +109,43 @@ 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. + --- ## 3. Reservation @@ -348,6 +385,12 @@ A conforming implementation **MUST**: 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). --- diff --git a/pool/pool-rs/crates/pool-core/src/lib.rs b/pool/pool-rs/crates/pool-core/src/lib.rs index 765d0f397..2cc24f91d 100644 --- a/pool/pool-rs/crates/pool-core/src/lib.rs +++ b/pool/pool-rs/crates/pool-core/src/lib.rs @@ -42,6 +42,7 @@ pub mod jcr1; pub mod ledger; +pub mod share; pub mod witness; use std::collections::BTreeMap; 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); + } +} -- 2.54.0 (Apple Git-157) From 668ccc310e1bb287201a86e229a9e2717d1040d8 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 18 Oct 2026 19:20:03 -0400 Subject: [PATCH 8/9] =?UTF-8?q?pool/1:=20a=20guess=20earns=20its=20way=20o?= =?UTF-8?q?ut=20(SPEC=20=C2=A72.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC §4.4 refuses a guess the right to spend a neighbour's capacity. That rule is only fair if a guess can stop being one, and 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 was already in the ledger — every completed claim recorded what it actually consumed, at the grade of whatever read it. What was missing was *what the reservation was for*, so `Event::Admitted` now records the claim's class. It goes in the entry rather than in a scheduler-side map because §6.1 makes the ledger the only durable state, and such a map is exactly the second store that can drift from it. `observe::derive` turns that history into an estimate under four rules: - **A guess is not evidence.** `Estimator` observations are excluded, not averaged in. Counting them would launder guesses into a grade asserting they were observed, and would make the exit circular: 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. The same cap `share` applies to a divided device total, for the same reason. A ceiling, not a floor. - **One run is not a sample.** Below the required count nothing is derived and the counts are reported separately, because history that is all guesses needs a meter while little history needs patience. `required: 0` floors at one, so it can never mean "derive from nothing". The count is a policy value, not a derived one. - **The absence of a class is not a class.** An empty class asserts nothing about comparability and is refused rather than pooling unlike work. The reservation takes the largest observed figure, not the mean: under-reserving is the failure §4.4 exists to prevent, and a mean under-reserves roughly half the time by construction. The median is the lower middle value at an even count, so no reported figure is an average nothing measured. §2.6 states the limit — the maximum of a small sample underestimates the true tail, so this is better evidence than a guess, not a bound. `ledger::observations` reads evidence back out of a verified stream, and the first pass is now extracted as `verified_events` and shared with `replay`: a second verification path is a second place for the checks to drift. Only a matched `Released` yields an observation — an open admission is a reservation, not a result. Conformance gains §7.21–24, and the spec citations in this crate are written in the form `check-spec-refs` actually validates (1614 → 1619 citations). Co-Authored-By: Claude Opus 5 --- pool/README.md | 23 + pool/SPEC.md | 68 ++- pool/pool-rs/crates/pool-core/src/ledger.rs | 115 ++++- pool/pool-rs/crates/pool-core/src/lib.rs | 12 + pool/pool-rs/crates/pool-core/src/observe.rs | 492 +++++++++++++++++++ pool/pool-rs/crates/pool-core/src/witness.rs | 1 + 6 files changed, 689 insertions(+), 22 deletions(-) create mode 100644 pool/pool-rs/crates/pool-core/src/observe.rs diff --git a/pool/README.md b/pool/README.md index 29a0cded5..905447f27 100644 --- a/pool/README.md +++ b/pool/README.md @@ -49,6 +49,29 @@ figure is a split of a measurement, not a measurement (SPEC §2.5): 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. + ## Usage survives the process A scheduler that restarts with empty usage hands every tenant its whole diff --git a/pool/SPEC.md b/pool/SPEC.md index b4e668a5d..3476f683e 100644 --- a/pool/SPEC.md +++ b/pool/SPEC.md @@ -146,6 +146,55 @@ 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. + --- ## 3. Reservation @@ -263,9 +312,15 @@ Two events, and only two: | event | effect | |---|---| -| `Admitted` | the claim's estimate is reserved against its entitlement | +| `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. @@ -392,6 +447,17 @@ A conforming implementation **MUST**: 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). + --- ## 8. The honest limit diff --git a/pool/pool-rs/crates/pool-core/src/ledger.rs b/pool/pool-rs/crates/pool-core/src/ledger.rs index 9dda633f8..7c3917399 100644 --- a/pool/pool-rs/crates/pool-core/src/ledger.rs +++ b/pool/pool-rs/crates/pool-core/src/ledger.rs @@ -61,6 +61,16 @@ pub enum Event { 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. @@ -91,6 +101,43 @@ pub enum ReplayError { 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 { @@ -123,10 +170,11 @@ fn provenance_of(label: &str) -> Option { /// 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, reserved_uj, devices } => json!({ + 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), }), @@ -153,6 +201,7 @@ fn from_wire(v: &Value) -> Option { "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()?, }), @@ -293,32 +342,14 @@ pub fn replay( steward: &str, pubkey: &[u8; 32], ) -> Result, ReplayError> { - // Pass one: authenticity and sequence, over the whole stream. - 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); - } + 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, reserved_uj, devices } => { + 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); @@ -339,6 +370,44 @@ pub fn replay( 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 ([`verified_events`]), 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::*; @@ -372,6 +441,7 @@ mod tests { Claim { id: id.to_string(), entitlement: "team".into(), + class: "train".into(), gang_size: 1, devices_per_member: 1, estimate: Energy::new(uj, Provenance::HwShunt), @@ -382,6 +452,7 @@ mod tests { Event::Admitted { claim: c.id.clone(), entitlement: c.entitlement.clone(), + class: c.class.clone(), reserved_uj, devices, } @@ -489,6 +560,7 @@ mod tests { log.append(&Event::Admitted { claim: "w".into(), entitlement: "team".into(), + class: "train".into(), reserved_uj: 5_000_000, devices: 2, }); @@ -520,6 +592,7 @@ mod tests { log.append(&Event::Admitted { claim: "w".into(), entitlement: "team".into(), + class: "train".into(), reserved_uj: 1_000_000, devices: 1, }); diff --git a/pool/pool-rs/crates/pool-core/src/lib.rs b/pool/pool-rs/crates/pool-core/src/lib.rs index 2cc24f91d..1ccd40179 100644 --- a/pool/pool-rs/crates/pool-core/src/lib.rs +++ b/pool/pool-rs/crates/pool-core/src/lib.rs @@ -42,6 +42,7 @@ pub mod jcr1; pub mod ledger; +pub mod observe; pub mod share; pub mod witness; @@ -236,6 +237,16 @@ pub struct Claim { /// 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 { @@ -486,6 +497,7 @@ mod tests { 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, 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..5c8bf956b --- /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/witness.rs b/pool/pool-rs/crates/pool-core/src/witness.rs index 53236d5ab..8fff4d9d8 100644 --- a/pool/pool-rs/crates/pool-core/src/witness.rs +++ b/pool/pool-rs/crates/pool-core/src/witness.rs @@ -176,6 +176,7 @@ mod tests { l.append(&Event::Admitted { claim: format!("w{i}"), entitlement: "team".into(), + class: "train".into(), reserved_uj: 1_000, devices: 1, }); -- 2.54.0 (Apple Git-157) From e5056334af79b3d30d1153268184d4a2bb8295f8 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 18 Oct 2026 19:25:23 -0400 Subject: [PATCH 9/9] =?UTF-8?q?pool/1:=20reclaim=20idle=20capacity=20witho?= =?UTF-8?q?ut=20reclaiming=20on=20a=20missing=20number=20(SPEC=20=C2=A72.7?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workload holding accelerators while consuming almost no energy is holding capacity it is not using. A device ledger cannot see that — a held device is a held device, saturated or parked at a prompt — so idle reclamation is a question only an energy-denominated scheduler can ask. It is also the most dangerous question in this standard, because the obvious implementation destroys running work. "This workload spent 0 µJ" arrives identically from a parked workload and from a meter that is not there: meter unavailable → 0 µJ → "idle" → reclaim → running work destroyed That is this family's "unknown conflated with a value" defect in its most destructive form, so the module is built around refusing it: - **Unknown is not idle.** An `Estimator` figure cannot establish idleness whatever its value, and the grade is tested **before** the value meets any threshold — so no threshold setting lets a guess through, including one high enough to catch everything. A workload is reclaimed on evidence that it is idle, never on the absence of evidence that it is busy. - **Idleness is a rate over a stated window, never an instant.** Any workload is momentarily between steps. An observation shorter than the policy's window yields no verdict: startup is not idleness. An empty window yields no rate — `mean_uw()` returns `None`, not zero, because zero is the one value that reads as idle. - **Nothing held, nothing to reclaim.** - **Four verdicts, and only one permits reclamation.** A workload with no verdict is *absent* from `reclamation_order`, not ranked last in it: it is not a worse candidate than an idle workload, it is not a candidate. Check order is normative: nothing held, then the window, then the grade, then the rate. A later check never rescues an earlier refusal. `IdlePolicy::default()` reclaims **nothing**. A threshold must 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 rather than of this standard — so §2.7 declines to guess and makes the unconfigured failure "nobody reclaimed" rather than "everybody reclaimed". This is not preemption. §5.2 ranks who is spending badly under contention; this asks who is not spending at all. A workload burning energy while completing nothing is thrashing, not idle, and §5.2 is what catches it. Conformance gains §7.25–28. Also fixes three broken intra-doc links left by earlier slices (`WitnessReceipt`, the `derive` function/attribute ambiguity, and a public doc linking the private first pass), so `cargo doc` is clean. Co-Authored-By: Claude Opus 5 --- pool/README.md | 25 ++ pool/SPEC.md | 62 +++ pool/pool-rs/crates/pool-core/src/ledger.rs | 7 +- pool/pool-rs/crates/pool-core/src/lib.rs | 1 + pool/pool-rs/crates/pool-core/src/observe.rs | 2 +- pool/pool-rs/crates/pool-core/src/reclaim.rs | 390 +++++++++++++++++++ pool/pool-rs/crates/pool-core/src/witness.rs | 2 +- 7 files changed, 484 insertions(+), 5 deletions(-) create mode 100644 pool/pool-rs/crates/pool-core/src/reclaim.rs diff --git a/pool/README.md b/pool/README.md index 905447f27..c959f902f 100644 --- a/pool/README.md +++ b/pool/README.md @@ -72,6 +72,31 @@ 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 diff --git a/pool/SPEC.md b/pool/SPEC.md index 3476f683e..80dbcbe82 100644 --- a/pool/SPEC.md +++ b/pool/SPEC.md @@ -195,6 +195,58 @@ reservation so a reader can see how thin the evidence is. 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 @@ -458,6 +510,16 @@ An implementation that derives estimates from history **MUST** also: 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 diff --git a/pool/pool-rs/crates/pool-core/src/ledger.rs b/pool/pool-rs/crates/pool-core/src/ledger.rs index 7c3917399..e99085d03 100644 --- a/pool/pool-rs/crates/pool-core/src/ledger.rs +++ b/pool/pool-rs/crates/pool-core/src/ledger.rs @@ -378,9 +378,10 @@ pub fn replay( /// the work costs it — the reservation is the guess this evidence exists to /// replace. /// -/// Verification runs over the whole stream first ([`verified_events`]), so an -/// estimate is never derived from an entry that does not verify or a chain that -/// does not hold. +/// 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. diff --git a/pool/pool-rs/crates/pool-core/src/lib.rs b/pool/pool-rs/crates/pool-core/src/lib.rs index 1ccd40179..3cc6738be 100644 --- a/pool/pool-rs/crates/pool-core/src/lib.rs +++ b/pool/pool-rs/crates/pool-core/src/lib.rs @@ -43,6 +43,7 @@ pub mod jcr1; pub mod ledger; pub mod observe; +pub mod reclaim; pub mod share; pub mod witness; diff --git a/pool/pool-rs/crates/pool-core/src/observe.rs b/pool/pool-rs/crates/pool-core/src/observe.rs index 5c8bf956b..410832ed8 100644 --- a/pool/pool-rs/crates/pool-core/src/observe.rs +++ b/pool/pool-rs/crates/pool-core/src/observe.rs @@ -43,7 +43,7 @@ //! ## 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 +//! empty class asserts nothing. [`derive()`] refuses it rather than pooling //! unlike work into one average. //! //! ## What the reservation takes, and why not the mean 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/witness.rs b/pool/pool-rs/crates/pool-core/src/witness.rs index 8fff4d9d8..66fb7494b 100644 --- a/pool/pool-rs/crates/pool-core/src/witness.rs +++ b/pool/pool-rs/crates/pool-core/src/witness.rs @@ -6,7 +6,7 @@ //! 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 [`WitnessReceipt`]: a +//! 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`]). -- 2.54.0 (Apple Git-157)