diff --git a/jcp/SPEC.md b/jcp/SPEC.md index ee40a7a2a..0160e710a 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -476,6 +476,148 @@ interceptor chain (`jcp-intercept`) carries it as `coverage`, beside its `"Partial"`; a verifier MUST refuse any other value, `"Whole"` included, rather than read it either way. +### 5.3 Traceability across operation, activity and outcome (REQUIRED where an outcome claims energy) + +§5.1 grades a figure and §5.2 says how much of the work it covers. Neither says +which *level* the figure belongs to, and a deployed system is answerable at +three that are not interchangeable: + +| level | artifact | what it answers | +|---|---|---| +| **operation** | the `jcp/2` work receipt (§8) | what one unit of work cost | +| **activity** | the AR-1 action receipt | what one *authorised* thing did — grant checked, environment attested, work performed, effect committed, bytes egressed | +| **outcome** | `efficiency/1` | whether any of it bought anything | + +Two rules follow, and both exist because the levels were previously joined by +nothing. + +**An outcome's energy MUST name its activities by content-id.** An outcome that +identifies its unit of work by a label can assert any total it likes, and the +level where the number is most load-bearing is then the level with no binding +at all. A verifier MUST be able to resolve each named activity and re-add the +figures. An outcome naming no activity is not traceable and MUST NOT be +presented as though it were. + +**The difference MUST be named, not absorbed.** The sum of an outcome's +operations is not the outcome's energy. Authorising, attesting, verifying and +pushing bytes off the device all cost joules that no operation receipt claims — +AR-1 carries exactly one energy figure and it belongs to the work. That +difference is the cost of being accountable, and it is reported as a signed +residual in exact integer microjoules: + +```text +residual_uj = outcome_total_uj − Σ activity.joules_uj +``` + +The residual MUST NOT be clamped. Negative means the bound activities claim more +than the outcome admits — double-counted work, or an activity bound to the wrong +outcome — which is a finding. An activity bound twice MUST be refused before the +sum: counting it twice inflates the accounted figure and shrinks the residual, +flattering the trace in the one direction that matters. + +The composite provenance is the **floor** across the outcome and every activity, +never an average and never the best: one `Estimator` part makes the whole trace +estimated, because a trace cannot be more measured than the part of it that was +not measured. Coverage takes the floor the same way. Aggregation MUST NOT be a +step at which a grade improves. + +### 5.4 Dynamism: what a deviation establishes, and what it never does (OPTIONAL) + +A total cannot distinguish a throttled part from a contended host from a cold +cache, and on silicon nobody has profiled it cannot say what normal was. Where +an implementation carries energy as a time-resolved series, the following hold. + +**Provenance and coverage are per-window.** Counters do not have uniform +resolution — a package counter may be continuous while an accelerator counter is +live only while a queue is — so one grade for a series is a grade for its best +window. + +**A baseline MUST declare where it was measured, and MUST NOT be applied +outside it.** An expectation is a fit, and a fit is only a fit somewhere. +Comparing against a baseline taken on different silicon, at a different +occupancy, or at a coarser sampling window manufactures deviations that say +nothing about the workload. An undeclared baseline is inapplicable, so +forgetting to declare under-claims rather than inventing findings. + +**A cause explains a deviation only with evidence.** A named operational cause +— thermal limit, host contention, cold start, frequency scaling, a meter +coverage gap — MUST carry a content-id for the reading that supports it. +Otherwise "explained" becomes the laundering channel: anything inconvenient is +labelled and disappears. + +**Absence of detection is not absence.** A phenomenon shorter than one sampling +window is invisible to it. That case is *unresolvable*, never *within band*. +Reporting nothing found at a resolution that could not have found it is the +quietest available way to be wrong. + +**There MUST NOT be a verdict of intent.** Energy does not carry intent. A +deviation says the joules did not match the expectation; it cannot say why, and +on an uncharted stack it cannot say the expectation was right. An unexplained +window is a request for a look, not a finding of wrongdoing, and a conformant +implementation MUST NOT collapse unexplained, inapplicable and unresolvable +windows into a single score — they mean "look at this", "we have no yardstick" +and "we could not have seen it", which call for different responses. + +### 5.5 Conservation, not anomaly detection (REQUIRED where a grant is reconciled) + +The strong instrument is conservation, and it needs no model of normal. + +Because every figure is integer microjoules and the sums are exact, the smallest +representable discrepancy against a grant of `N` µJ is `1_000_000 / N` parts per +million — on a one-joule grant, **1 ppm**. A drift of a fraction of a percent is +three orders of magnitude above that floor. The question "do these joules fit +anywhere" is therefore answerable arithmetically, where "is this unusual" is not. + +This property does not survive the usual construction, and that is the point. +Where an energy figure is *derived from a performance proxy* — utilisation times +a thermal design figure, tokens times a coefficient, instructions times +picojoules-per-operation — its model error is tens of percent, and a +fractional-percent drift is invisible by construction: it sits far inside the +error bar of the estimator that produced it. Deriving energy from performance +does not merely lose accuracy, it destroys the ability to notice that the books +do not balance. A conformant implementation MUST report whether the floor it +quotes rests on a measured figure or a modelled one; quoting a 1 ppm floor over +`Estimator` windows is exact arithmetic over a model's opinion. + +### 5.6 The physical bound: refutation, never verification (OPTIONAL) + +The joules were spent and the heat left the die; that quantity is not +negotiable. The *measurement* of it is exactly as forgeable as any other report +— a counter can be lied about, a figure fabricated, a `HwShunt` grade stamped on +a number from nowhere. + +What energy has that a synthetic unit does not is an **external referent**. A +token count, a call count, a request rate can be any number whatsoever: nothing +outside the accounting system constrains them, so a fabricated one is +indistinguishable from a true one. A joule count is bounded by the part's power +envelope and the window's duration. + +An implementation MAY therefore refute a figure that exceeds what the part could +have drawn, or falls below what a powered, executing part must draw — the second +being the shape a frozen counter takes, and the reason §5's liveness rule +exists. Bounds SHOULD be deliberately permissive (burst rating for the ceiling, +absolute idle for the floor) so that a refutation is unassailable. + +Passing a physical bound MUST NOT be reported as verification. A fabricated +figure inside the envelope passes, and must: the bound establishes only that the +figure is not impossible. + +**The one thing that cannot be faked is how the energy was consumed.** A report +of a profile is just numbers, but a part cannot draw the profile of one +computation while performing another. This closes the hole conservation leaves: +a total can be balanced by padding — invent an operation and attribute the +surplus to it — but a fictional operation contributes no draw, so the observed +character stops matching the declared work mix. + +Character is also what survives unfamiliar hardware. The magnitudes move with +the process node, the power envelope and the memory hierarchy, which is why a +band baseline fails there; a compute-bound steady state is flat on any part, a +cold start ramps on any part, a queue-driven workload alternates on any part. A +declared shape MUST be declared in advance — classifying the observed shape and +then deciding what it must have been is post-hoc pattern matching. An +inconsistency refutes the declaration. It attributes no cause and names no +intent. + ## 6. The Frame (REQUIRED) Every message is a typed Frame — one envelope across the tool layer (MCP's job) diff --git a/jcp/conformance/v1/energy_trace.json b/jcp/conformance/v1/energy_trace.json new file mode 100644 index 000000000..05c344bdc --- /dev/null +++ b/jcp/conformance/v1/energy_trace.json @@ -0,0 +1,1003 @@ +{ + "budget": { + "negative": [ + { + "expected": { + "detectable_floor_ppm": null, + "drift_ppm": null + }, + "input": { + "accounted_uj": 5, + "granted_uj": 0 + }, + "note": "a ratio against a zero grant is not a figure" + } + ], + "positive": [ + { + "expected": { + "detectable_floor_ppm": 1, + "drift_is_resolvable": true, + "drift_ppm": 5000, + "floor_is_physical": true + }, + "input": { + "accounted_uj": 1005000, + "granted_uj": 1000000, + "provenance_floor": "HwShunt" + }, + "note": "half a percent over a one-joule grant is 5000 ppm, floor 1 ppm" + }, + { + "expected": { + "detectable_floor_ppm": 1, + "drift_ppm": 5, + "floor_is_physical": false + }, + "input": { + "accounted_uj": 1000005, + "granted_uj": 1000000, + "provenance_floor": "Estimator" + }, + "note": "the same floor over modelled windows is exact and meaningless" + }, + { + "expected": { + "drift_ppm": -700000 + }, + "input": { + "accounted_uj": 300000, + "granted_uj": 1000000, + "provenance_floor": "HwShunt" + }, + "note": "underspend is signed, not clamped: allowed joules nothing claimed" + } + ] + }, + "conservation": { + "negative": [ + { + "expected": { + "error": "Unbound" + }, + "input": { + "activities": [], + "completed": true, + "total": { + "estimated_uj": 1000000, + "measured_uj": 1000000, + "provenance": "HwShunt" + } + }, + "note": "an outcome traceable to nothing is refused" + }, + { + "expected": { + "error": "DuplicateActivity" + }, + "input": { + "activities": [ + { + "coverage": "Whole", + "joules_uj": 100, + "provenance": "HwShunt", + "receipt": [ + 131, + 254, + 130, + 87, + 58, + 181, + 54, + 207, + 32, + 202, + 241, + 199, + 142, + 152, + 1, + 167, + 222, + 190, + 185, + 106, + 127, + 54, + 150, + 55, + 244, + 56, + 187, + 18, + 192, + 232, + 2, + 31 + ] + }, + { + "coverage": "Whole", + "joules_uj": 100, + "provenance": "HwShunt", + "receipt": [ + 131, + 254, + 130, + 87, + 58, + 181, + 54, + 207, + 32, + 202, + 241, + 199, + 142, + 152, + 1, + 167, + 222, + 190, + 185, + 106, + 127, + 54, + 150, + 55, + 244, + 56, + 187, + 18, + 192, + 232, + 2, + 31 + ] + } + ], + "completed": true, + "total": { + "estimated_uj": 1000, + "measured_uj": 1000, + "provenance": "HwShunt" + } + }, + "note": "a double-bound activity is refused before the sum" + }, + { + "expected": { + "accounted_uj": 140, + "error": "OverAccounted", + "total_uj": 100 + }, + "input": { + "activities": [ + { + "coverage": "Whole", + "joules_uj": 90, + "provenance": "HwShunt", + "receipt": [ + 145, + 164, + 228, + 185, + 68, + 127, + 194, + 173, + 193, + 139, + 176, + 74, + 18, + 11, + 104, + 118, + 90, + 128, + 13, + 105, + 45, + 112, + 99, + 117, + 0, + 80, + 196, + 87, + 158, + 232, + 254, + 67 + ] + }, + { + "coverage": "Whole", + "joules_uj": 50, + "provenance": "HwShunt", + "receipt": [ + 214, + 244, + 83, + 42, + 36, + 238, + 178, + 159, + 163, + 59, + 43, + 88, + 40, + 16, + 8, + 50, + 57, + 110, + 181, + 174, + 173, + 88, + 149, + 104, + 17, + 233, + 101, + 109, + 180, + 224, + 255, + 116 + ] + } + ], + "completed": true, + "total": { + "estimated_uj": 100, + "measured_uj": 100, + "provenance": "HwShunt" + } + }, + "note": "activities claiming more than the outcome are refused" + } + ], + "positive": [ + { + "expected": { + "accounted_uj": 750000, + "coverage_floor": "Whole", + "provenance_floor": "HwShunt", + "residual_uj": 250000, + "unattributed_ppm": 250000 + }, + "input": { + "activities": [ + { + "coverage": "Whole", + "joules_uj": 400000, + "provenance": "HwShunt", + "receipt": [ + 145, + 164, + 228, + 185, + 68, + 127, + 194, + 173, + 193, + 139, + 176, + 74, + 18, + 11, + 104, + 118, + 90, + 128, + 13, + 105, + 45, + 112, + 99, + 117, + 0, + 80, + 196, + 87, + 158, + 232, + 254, + 67 + ] + }, + { + "coverage": "Whole", + "joules_uj": 350000, + "provenance": "HwShunt", + "receipt": [ + 214, + 244, + 83, + 42, + 36, + 238, + 178, + 159, + 163, + 59, + 43, + 88, + 40, + 16, + 8, + 50, + 57, + 110, + 181, + 174, + 173, + 88, + 149, + 104, + 17, + 233, + 101, + 109, + 180, + 224, + 255, + 116 + ] + } + ], + "completed": true, + "total": { + "estimated_uj": 1000000, + "measured_uj": 1000000, + "provenance": "HwShunt" + } + }, + "note": "the residual names what no operation claims" + }, + { + "expected": { + "accounted_uj": 600, + "coverage_floor": "Partial", + "provenance_floor": "Estimator", + "residual_uj": 400 + }, + "input": { + "activities": [ + { + "coverage": "Whole", + "joules_uj": 300, + "provenance": "HwShunt", + "receipt": [ + 145, + 164, + 228, + 185, + 68, + 127, + 194, + 173, + 193, + 139, + 176, + 74, + 18, + 11, + 104, + 118, + 90, + 128, + 13, + 105, + 45, + 112, + 99, + 117, + 0, + 80, + 196, + 87, + 158, + 232, + 254, + 67 + ] + }, + { + "coverage": "Partial", + "joules_uj": 300, + "provenance": "Estimator", + "receipt": [ + 214, + 244, + 83, + 42, + 36, + 238, + 178, + 159, + 163, + 59, + 43, + 88, + 40, + 16, + 8, + 50, + 57, + 110, + 181, + 174, + 173, + 88, + 149, + 104, + 17, + 233, + 101, + 109, + 180, + 224, + 255, + 116 + ] + } + ], + "completed": true, + "total": { + "estimated_uj": 1000, + "measured_uj": 1000, + "provenance": "HwShunt" + } + }, + "note": "provenance and coverage take the floor, never the average" + } + ] + }, + "dynamism": { + "cases": [ + { + "expected": { + "deviation": "BaselineInapplicable" + }, + "input": { + "baseline": { + "band_uj": 100, + "declared_applicable": false, + "expected_uj": 1000, + "fit_window_ns": 1000, + "source": "harness:run-1" + }, + "window": { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 5000, + "provenance": "HwShunt", + "started_ns": 0 + } + }, + "note": "an undeclared baseline makes no comparison" + }, + { + "expected": { + "deviation": "BaselineInapplicable" + }, + "input": { + "baseline": { + "band_uj": 100, + "declared_applicable": true, + "expected_uj": 1000, + "fit_window_ns": 10000000, + "source": "harness:coarse" + }, + "window": { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 5000, + "provenance": "HwShunt", + "started_ns": 0 + } + }, + "note": "a baseline fit coarser than the trace samples does not apply" + }, + { + "expected": { + "deviation": "Unexplained", + "excess_uj": 4000 + }, + "input": { + "baseline": { + "band_uj": 100, + "declared_applicable": true, + "expected_uj": 1000, + "fit_window_ns": 1000, + "source": "harness:run-1" + }, + "cause": null, + "window": { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 5000, + "provenance": "HwShunt", + "started_ns": 0 + } + }, + "note": "outside the band with no evidenced cause stays unexplained" + }, + { + "expected": { + "cause_token": "thermal_throttle", + "deviation": "Explained", + "evidence": "5bf7c3480486191c7a4765d7f463d0f30663489492c4d0eaab8d57749d2b2f1a", + "excess_uj": 4000 + }, + "input": { + "baseline": { + "band_uj": 100, + "declared_applicable": true, + "expected_uj": 1000, + "fit_window_ns": 1000, + "source": "harness:run-1" + }, + "cause": { + "ThermalThrottle": { + "reading": [ + 91, + 247, + 195, + 72, + 4, + 134, + 25, + 28, + 122, + 71, + 101, + 215, + 244, + 99, + 208, + 243, + 6, + 99, + 72, + 148, + 146, + 196, + 208, + 234, + 171, + 141, + 87, + 116, + 157, + 43, + 47, + 26 + ] + } + }, + "window": { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 5000, + "provenance": "HwShunt", + "started_ns": 0 + } + }, + "note": "a cause with evidence explains; the token is the claim vocabulary" + } + ], + "note": "there is no verdict of intent in this profile, by design" + }, + "physical": { + "cases": [ + { + "expected": { + "floor_uj": 400000, + "verdict": "RefutedBelow" + }, + "input": { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 400000000, + "joules_uj": 0, + "provenance": "HwShunt", + "started_ns": 0 + }, + "note": "a frozen counter on a busy window is refuted from below" + }, + { + "expected": { + "verdict": "RefutedAbove" + }, + "input": { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 10000000, + "provenance": "HwShunt", + "started_ns": 0 + }, + "note": "a figure past the burst envelope is refuted from above" + }, + { + "expected": { + "verdict": "NotRefuted" + }, + "input": { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 30000, + "provenance": "HwShunt", + "started_ns": 0 + }, + "note": "inside the envelope is NotRefuted — which is not verification" + }, + { + "expected": { + "verdict": "Unboundable" + }, + "input": { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 0, + "joules_uj": 999, + "provenance": "HwShunt", + "started_ns": 0 + }, + "note": "a zero-duration window is unboundable, not clean" + } + ], + "envelope": { + "idle_floor_mw": 1000, + "peak_mw": 60000, + "source": "burst rating, deliberately permissive" + } + }, + "profile": "energy_trace/1", + "shape": { + "cases": [ + { + "expected": { + "verdict": "Inconsistent" + }, + "input": { + "declared": "Sustained", + "windows": [ + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 300000, + "provenance": "HwShunt", + "started_ns": 0 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 10, + "provenance": "HwShunt", + "started_ns": 1000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 10, + "provenance": "HwShunt", + "started_ns": 2000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 10, + "provenance": "HwShunt", + "started_ns": 3000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 10, + "provenance": "HwShunt", + "started_ns": 4000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 10, + "provenance": "HwShunt", + "started_ns": 5000000 + } + ] + }, + "note": "a total balanced by padding does not read as sustained" + }, + { + "expected": { + "shape": "Sustained", + "verdict": "Consistent" + }, + "input": { + "declared": "Sustained", + "windows": [ + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 37000, + "provenance": "HwShunt", + "started_ns": 0 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 37740, + "provenance": "HwShunt", + "started_ns": 1000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 36260, + "provenance": "HwShunt", + "started_ns": 2000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 37370, + "provenance": "HwShunt", + "started_ns": 3000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 36630, + "provenance": "HwShunt", + "started_ns": 4000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 37000, + "provenance": "HwShunt", + "started_ns": 5000000 + } + ] + }, + "note": "character is scale-invariant: same shape at 37x the magnitude" + }, + { + "expected": { + "shape": "Sustained", + "verdict": "Consistent" + }, + "input": { + "declared": "Sustained", + "windows": [ + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 1000, + "provenance": "HwShunt", + "started_ns": 0 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 1020, + "provenance": "HwShunt", + "started_ns": 1000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 980, + "provenance": "HwShunt", + "started_ns": 2000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 1010, + "provenance": "HwShunt", + "started_ns": 3000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 990, + "provenance": "HwShunt", + "started_ns": 4000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 1000, + "provenance": "HwShunt", + "started_ns": 5000000 + } + ] + }, + "note": "flat is sustained" + }, + { + "expected": { + "shape": "Ramping", + "verdict": "Consistent" + }, + "input": { + "declared": "Ramping", + "windows": [ + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 100, + "provenance": "HwShunt", + "started_ns": 0 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 400, + "provenance": "HwShunt", + "started_ns": 1000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 700, + "provenance": "HwShunt", + "started_ns": 2000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 1000, + "provenance": "HwShunt", + "started_ns": 3000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 1100, + "provenance": "HwShunt", + "started_ns": 4000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 1120, + "provenance": "HwShunt", + "started_ns": 5000000 + } + ] + }, + "note": "rising then settling is a ramp" + }, + { + "expected": { + "shape": "Bursty", + "verdict": "Consistent" + }, + "input": { + "declared": "Bursty", + "windows": [ + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 100, + "provenance": "HwShunt", + "started_ns": 0 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 2000, + "provenance": "HwShunt", + "started_ns": 1000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 100, + "provenance": "HwShunt", + "started_ns": 2000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 2000, + "provenance": "HwShunt", + "started_ns": 3000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 100, + "provenance": "HwShunt", + "started_ns": 4000000 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 2000, + "provenance": "HwShunt", + "started_ns": 5000000 + } + ] + }, + "note": "alternating is bursty" + }, + { + "expected": { + "verdict": "Unresolvable", + "windows": 2 + }, + "input": { + "declared": "Sustained", + "windows": [ + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 1000, + "provenance": "HwShunt", + "started_ns": 0 + }, + { + "coverage": "Whole", + "declared_work": null, + "duration_ns": 1000000, + "joules_uj": 1000, + "provenance": "HwShunt", + "started_ns": 1000000 + } + ] + }, + "note": "under three windows character is unresolvable, never consistent" + } + ], + "idle_floor_uj": 100 + }, + "spec": "JCP v0.3 SPEC.md §5.3-5.6" +} diff --git a/jcp/jcp-rs/Cargo.lock b/jcp/jcp-rs/Cargo.lock index 8c5a1f0f6..d9fa7d15f 100644 --- a/jcp/jcp-rs/Cargo.lock +++ b/jcp/jcp-rs/Cargo.lock @@ -596,6 +596,7 @@ dependencies = [ "hex", "jcp-receipt", "jcp-schema", + "serde", "serde_json", "thiserror 2.0.20", ] diff --git a/jcp/jcp-rs/crates/jcp-efficiency/Cargo.toml b/jcp/jcp-rs/crates/jcp-efficiency/Cargo.toml index 25eebdd55..c2b2de374 100644 --- a/jcp/jcp-rs/crates/jcp-efficiency/Cargo.toml +++ b/jcp/jcp-rs/crates/jcp-efficiency/Cargo.toml @@ -11,6 +11,7 @@ description = "The energy-efficiency spec for agentic work, surfaced as a sealed jcp-schema = { workspace = true } jcp-receipt = { workspace = true } ed25519-dalek = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } hex = { workspace = true } thiserror = { workspace = true } diff --git a/jcp/jcp-rs/crates/jcp-efficiency/src/bin/gen_trace_vectors.rs b/jcp/jcp-rs/crates/jcp-efficiency/src/bin/gen_trace_vectors.rs new file mode 100644 index 000000000..9a9422b2c --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-efficiency/src/bin/gen_trace_vectors.rs @@ -0,0 +1,235 @@ +//! Emit the `energy_trace/1` conformance pack to `jcp/conformance/v1/`. +//! +//! SPEC §5.3–5.6 states rules; a rule nothing emits is a proposal. This writes +//! the vectors a third party replays to check an independent implementation — +//! positives *and* the refusals, since the refusals are the load-bearing half: +//! an implementation that accepts an unbound outcome, a double-counted +//! activity, or a frozen counter has failed in the direction that matters. +//! +//! Run: `cargo run -p jcp-efficiency --bin gen_trace_vectors -- ../conformance/v1` + +use std::path::PathBuf; + +use jcp_efficiency::dynamics::{ + Baseline, DrawShape, EnergyWindow, OperationalCause, PowerEnvelope, +}; +use jcp_efficiency::trace::{ActivityRef, OutcomeTrace}; +use jcp_schema::{ContentId, Coverage, Energy, Provenance}; +use serde_json::{json, Value}; + +fn energy(uj: u64, p: Provenance, c: Coverage) -> Energy { + Energy { measured_uj: Some(uj), estimated_uj: uj, provenance: p, coverage: c } +} + +fn act(tag: &[u8], uj: u64, p: Provenance, c: Coverage) -> ActivityRef { + ActivityRef::new(ContentId::of_bytes(tag), uj, p, c) +} + +fn win(i: u64, uj: u64, p: Provenance) -> EnergyWindow { + EnergyWindow { + started_ns: i * 1_000_000, + duration_ns: 1_000_000, + joules_uj: uj, + provenance: p, + coverage: Coverage::Whole, + declared_work: None, + } +} + +fn series(vals: &[u64]) -> Vec { + vals.iter().enumerate().map(|(i, &v)| win(i as u64, v, Provenance::HwShunt)).collect() +} + +/// A case carries the inputs and the verdict a conformant implementation must +/// reach. Reproducing the verdict is the whole test; a case that only carried +/// inputs would check that an implementation runs, not that it agrees. +fn case(note: &str, input: Value, expected: Value) -> Value { + json!({ "note": note, "input": input, "expected": expected }) +} + +fn main() { + let out = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../conformance/v1") + }); + std::fs::create_dir_all(&out).expect("create output dir"); + + // ---- §5.3 conservation ------------------------------------------------ + let overhead = OutcomeTrace::new( + energy(1_000_000, Provenance::HwShunt, Coverage::Whole), + true, + vec![ + act(b"a1", 400_000, Provenance::HwShunt, Coverage::Whole), + act(b"a2", 350_000, Provenance::HwShunt, Coverage::Whole), + ], + ); + let r = overhead.reconcile().expect("reconciles"); + assert_eq!(r.residual_uj, 250_000); + + let floored = OutcomeTrace::new( + energy(1_000, Provenance::HwShunt, Coverage::Whole), + true, + vec![ + act(b"a1", 300, Provenance::HwShunt, Coverage::Whole), + act(b"a2", 300, Provenance::Estimator, Coverage::Partial), + ], + ); + let fr = floored.reconcile().expect("reconciles"); + + let unbound = OutcomeTrace::new( + energy(1_000_000, Provenance::HwShunt, Coverage::Whole), true, vec![], + ); + let dup_act = act(b"same", 100, Provenance::HwShunt, Coverage::Whole); + let dup = OutcomeTrace::new( + energy(1_000, Provenance::HwShunt, Coverage::Whole), true, + vec![dup_act.clone(), dup_act], + ); + let over = OutcomeTrace::new( + energy(100, Provenance::HwShunt, Coverage::Whole), true, + vec![act(b"a1", 90, Provenance::HwShunt, Coverage::Whole), + act(b"a2", 50, Provenance::HwShunt, Coverage::Whole)], + ); + + let conservation = json!({ + "positive": [ + case("the residual names what no operation claims", + json!(&overhead), + json!({"accounted_uj": r.accounted_uj, "residual_uj": r.residual_uj, + "provenance_floor": r.provenance_floor, "coverage_floor": r.coverage_floor, + "unattributed_ppm": r.unattributed_ppm(overhead.total_uj())})), + case("provenance and coverage take the floor, never the average", + json!(&floored), + json!({"accounted_uj": fr.accounted_uj, "residual_uj": fr.residual_uj, + "provenance_floor": fr.provenance_floor, "coverage_floor": fr.coverage_floor})), + ], + "negative": [ + case("an outcome traceable to nothing is refused", json!(&unbound), + json!({"error": "Unbound"})), + case("a double-bound activity is refused before the sum", json!(&dup), + json!({"error": "DuplicateActivity"})), + case("activities claiming more than the outcome are refused", json!(&over), + json!({"error": "OverAccounted", "accounted_uj": 140, "total_uj": 100})), + ] + }); + + // ---- §5.5 conservation against a grant -------------------------------- + let budget = json!({ + "positive": [ + case("half a percent over a one-joule grant is 5000 ppm, floor 1 ppm", + json!({"granted_uj": 1_000_000u64, "accounted_uj": 1_005_000u64, + "provenance_floor": Provenance::HwShunt}), + json!({"drift_ppm": 5_000, "detectable_floor_ppm": 1, + "drift_is_resolvable": true, "floor_is_physical": true})), + case("the same floor over modelled windows is exact and meaningless", + json!({"granted_uj": 1_000_000u64, "accounted_uj": 1_000_005u64, + "provenance_floor": Provenance::Estimator}), + json!({"drift_ppm": 5, "detectable_floor_ppm": 1, + "floor_is_physical": false})), + case("underspend is signed, not clamped: allowed joules nothing claimed", + json!({"granted_uj": 1_000_000u64, "accounted_uj": 300_000u64, + "provenance_floor": Provenance::HwShunt}), + json!({"drift_ppm": -700_000})), + ], + "negative": [ + case("a ratio against a zero grant is not a figure", + json!({"granted_uj": 0u64, "accounted_uj": 5u64}), + json!({"drift_ppm": Value::Null, "detectable_floor_ppm": Value::Null})), + ] + }); + + // ---- §5.6 physical refutation ----------------------------------------- + let env = PowerEnvelope { + peak_mw: 60_000, + idle_floor_mw: 1_000, + source: "burst rating, deliberately permissive".into(), + }; + let frozen = EnergyWindow { duration_ns: 400_000_000, joules_uj: 0, ..win(0, 0, Provenance::HwShunt) }; + let impossible = EnergyWindow { duration_ns: 1_000_000, joules_uj: 10_000_000, ..win(0, 0, Provenance::HwShunt) }; + let plausible = EnergyWindow { duration_ns: 1_000_000, joules_uj: 30_000, ..win(0, 0, Provenance::HwShunt) }; + let instant = EnergyWindow { duration_ns: 0, joules_uj: 999, ..win(0, 0, Provenance::HwShunt) }; + + let physical = json!({ + "envelope": &env, + "cases": [ + case("a frozen counter on a busy window is refuted from below", + json!(&frozen), json!({"verdict": "RefutedBelow", "floor_uj": 400_000})), + case("a figure past the burst envelope is refuted from above", + json!(&impossible), json!({"verdict": "RefutedAbove"})), + case("inside the envelope is NotRefuted — which is not verification", + json!(&plausible), json!({"verdict": "NotRefuted"})), + case("a zero-duration window is unboundable, not clean", + json!(&instant), json!({"verdict": "Unboundable"})), + ] + }); + + // ---- §5.6 draw character ---------------------------------------------- + let padded = series(&[300_000, 10, 10, 10, 10, 10]); + let flat = series(&[1_000, 1_020, 980, 1_010, 990, 1_000]); + let scaled: Vec = [1_000u64, 1_020, 980, 1_010, 990, 1_000].iter().map(|v| v * 37).collect(); + let ramp = series(&[100, 400, 700, 1_000, 1_100, 1_120]); + let bursty = series(&[100, 2_000, 100, 2_000, 100, 2_000]); + + let shape = json!({ + "idle_floor_uj": 100, + "cases": [ + case("a total balanced by padding does not read as sustained", + json!({"declared": DrawShape::Sustained, "windows": &padded}), + json!({"verdict": "Inconsistent"})), + case("character is scale-invariant: same shape at 37x the magnitude", + json!({"declared": DrawShape::Sustained, "windows": &series(&scaled)}), + json!({"verdict": "Consistent", "shape": DrawShape::Sustained})), + case("flat is sustained", json!({"declared": DrawShape::Sustained, "windows": &flat}), + json!({"verdict": "Consistent", "shape": DrawShape::Sustained})), + case("rising then settling is a ramp", json!({"declared": DrawShape::Ramping, "windows": &ramp}), + json!({"verdict": "Consistent", "shape": DrawShape::Ramping})), + case("alternating is bursty", json!({"declared": DrawShape::Bursty, "windows": &bursty}), + json!({"verdict": "Consistent", "shape": DrawShape::Bursty})), + case("under three windows character is unresolvable, never consistent", + json!({"declared": DrawShape::Sustained, "windows": &series(&[1_000, 1_000])}), + json!({"verdict": "Unresolvable", "windows": 2})), + ] + }); + + // ---- §5.4 baselines and causes ---------------------------------------- + let w = win(0, 5_000, Provenance::HwShunt); + let undeclared = Baseline::new(1_000, 100, "harness:run-1", 1_000); + let declared = Baseline::new(1_000, 100, "harness:run-1", 1_000).declared_applicable(); + let coarse = Baseline::new(1_000, 100, "harness:coarse", 10_000_000).declared_applicable(); + let cause = OperationalCause::ThermalThrottle { reading: ContentId::of_bytes(b"thermal") }; + + let dynamism = json!({ + "cases": [ + case("an undeclared baseline makes no comparison", + json!({"window": &w, "baseline": &undeclared}), + json!({"deviation": "BaselineInapplicable"})), + case("a baseline fit coarser than the trace samples does not apply", + json!({"window": &w, "baseline": &coarse}), + json!({"deviation": "BaselineInapplicable"})), + case("outside the band with no evidenced cause stays unexplained", + json!({"window": &w, "baseline": &declared, "cause": Value::Null}), + json!({"deviation": "Unexplained", "excess_uj": 4_000})), + case("a cause with evidence explains; the token is the claim vocabulary", + json!({"window": &w, "baseline": &declared, "cause": &cause}), + json!({"deviation": "Explained", "cause_token": cause.token(), + "evidence": cause.evidence().to_hex(), "excess_uj": 4_000})), + ], + "note": "there is no verdict of intent in this profile, by design" + }); + + let pack = json!({ + "profile": "energy_trace/1", + "spec": "JCP v0.3 SPEC.md §5.3-5.6", + "conservation": conservation, + "budget": budget, + "physical": physical, + "shape": shape, + "dynamism": dynamism, + }); + + let path = out.join("energy_trace.json"); + let body = serde_json::to_string_pretty(&pack).expect("serialize") + "\n"; + std::fs::write(&path, body).unwrap_or_else(|e| panic!("write {}: {e}", path.display())); + println!("wrote {}", path.display()); +} diff --git a/jcp/jcp-rs/crates/jcp-efficiency/src/dynamics.rs b/jcp/jcp-rs/crates/jcp-efficiency/src/dynamics.rs new file mode 100644 index 000000000..eb7ff80a3 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-efficiency/src/dynamics.rs @@ -0,0 +1,904 @@ +//! Time-resolved energy, and the honest limit of what a deviation establishes. +//! +//! # Why a total is not enough +//! +//! [`crate::trace`] reconciles an outcome against its activities and names the +//! residual. That is conservation, and it is static: one scalar per outcome. +//! Real compute is not static. The same declared work costs different joules on +//! a throttled part, a contended host, a cold cache, or a frequency-scaled +//! core — and a single total cannot tell any of those apart from each other, +//! let alone from interference. +//! +//! So energy has to be carried as a **series**, each window graded on its own, +//! and compared against a baseline that says where it was measured. +//! +//! # The thing this module refuses to do +//! +//! There is no `Malicious` variant in [`Deviation`], and that is the most +//! important line in this file. +//! +//! Energy does not carry intent. A deviation says the joules did not match the +//! expectation; it cannot say why, and on a stack whose normal behaviour has +//! never been charted it cannot even say the expectation was right. A type with +//! a `Malicious` arm would be used — some caller would map `Unexplained` onto +//! it, a dashboard would colour it red, and a number with no evidential basis +//! would start informing decisions about people. That is the same failure as a +//! prover returning `Proved` because a stand-in said so, moved into security. +//! +//! What this module produces is a **partition**: attributable, explained by a +//! named cause *with evidence*, unexplained, or unresolvable. `Unexplained` is +//! a request for a look, not a verdict. The difference between interference and +//! an uncharted-but-legitimate stack is a question evidence answers, and this +//! makes that question askable instead of pre-answering it wrongly. +//! +//! # Explaining requires evidence, not a label +//! +//! [`OperationalCause`] never explains a deviation on its own. Each cause +//! carries a content-id for the reading that supports it — a thermal sample, a +//! co-tenancy attestation, a cache-state record. A cause asserted without +//! evidence leaves the window `Unexplained`, because otherwise "explained" +//! becomes the laundering channel: anything inconvenient gets labelled +//! `ThermalThrottle` and disappears. +//! +//! # Absence of detection is not absence +//! +//! A window is a sampling interval, and a phenomenon shorter than one window is +//! invisible to it. That case is [`Deviation::Unresolvable`], never +//! `WithinBand`. Reporting "nothing found" at a resolution that could not have +//! found it is the quietest way to be wrong, and the one an auditor is least +//! able to catch. + +use serde::{Deserialize, Serialize}; + +use jcp_schema::{ContentId, Coverage, Provenance}; + +/// One time-resolved energy sample. +/// +/// Provenance and coverage are per-window on purpose. Counters do not have +/// uniform resolution: a host may expose a package counter continuously and an +/// accelerator counter only while a queue is active, so the same trace can be +/// `HwShunt`/`Whole` in one window and `Estimator`/`Partial` in the next. A +/// single grade for the series would be a grade for its best window. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnergyWindow { + /// Monotonic start, nanoseconds. + pub started_ns: u64, + /// Window length, nanoseconds. This is the trace's resolution floor. + pub duration_ns: u64, + /// Energy attributed to this window. + pub joules_uj: u64, + /// How this window's figure was acquired. + pub provenance: Provenance, + /// How much of the window's work the figure accounts for. + pub coverage: Coverage, + /// The operation this window is declared to cover, when one is named. + pub declared_work: Option, +} + +/// Where a baseline was measured, so a reader can tell whether it applies. +/// +/// The same discipline as `mesh-router`'s `Calibration`: an expectation is a +/// fit, and a fit is only a fit somewhere. A baseline taken on different +/// silicon, at a different occupancy, or before a toolchain change is not +/// wrong — it is *inapplicable*, and the two must not be confused, because +/// comparing against an inapplicable baseline manufactures deviations that say +/// nothing about the workload. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Baseline { + /// The expected energy for one window of this shape, in µJ. + pub expected_uj: u64, + /// Half-width of the accepted band, in µJ. Deviation is measured against + /// the band, not the point: a point expectation would flag every window. + pub band_uj: u64, + /// Where the expectation came from — a harness run id, a receipt + /// content-id, a document. Names the measurement; does not substitute for + /// it. + pub source: String, + /// The window length the baseline was fit at. A baseline fit at a coarser + /// resolution than the trace cannot speak to the trace's windows. + pub fit_window_ns: u64, + /// Whether the operator has declared this baseline applicable to the host + /// and workload being traced. Defaults to false at construction: an + /// undeclared baseline is treated as inapplicable, so forgetting + /// under-claims rather than manufacturing findings. + pub declared_applicable: bool, +} + +impl Baseline { + /// A baseline that does not yet claim to apply anywhere. + pub fn new(expected_uj: u64, band_uj: u64, source: impl Into, fit_window_ns: u64) -> Self { + Baseline { + expected_uj, + band_uj, + source: source.into(), + fit_window_ns, + declared_applicable: false, + } + } + + /// Declare this baseline applicable to the host and workload being traced. + /// An explicit act, because it is the step that turns a number from + /// somewhere else into a yardstick here. + pub fn declared_applicable(mut self) -> Self { + self.declared_applicable = true; + self + } +} + +/// A named, evidenced reason a window's energy departed from expectation. +/// +/// Every variant carries the content-id of a reading that supports it. The +/// evidence is the whole point: without it a cause is a label, and labels +/// explain anything. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum OperationalCause { + /// The part was thermally limited. Evidence: a thermal sample covering the + /// window. + ThermalThrottle { reading: ContentId }, + /// The host was shared with work this trace does not account for. + /// Evidence: a co-tenancy or utilisation record. + ContendedHost { reading: ContentId }, + /// First execution: cold caches, JIT or kernel compilation. Evidence: a + /// record showing this was a first run. + ColdStart { reading: ContentId }, + /// The core ran at a different operating point than the baseline's. + /// Evidence: a frequency or power-state sample. + FrequencyScaled { reading: ContentId }, + /// The meter does not cover part of this window's work, so the figure is + /// structurally low. Evidence: the coverage record. + MeterCoverageGap { reading: ContentId }, +} + +impl OperationalCause { + /// The reading that supports this cause. + pub fn evidence(&self) -> ContentId { + match self { + OperationalCause::ThermalThrottle { reading } + | OperationalCause::ContendedHost { reading } + | OperationalCause::ColdStart { reading } + | OperationalCause::FrequencyScaled { reading } + | OperationalCause::MeterCoverageGap { reading } => *reading, + } + } + + /// The §-style token for this cause, for a receipt's claim vocabulary. + pub fn token(&self) -> &'static str { + match self { + OperationalCause::ThermalThrottle { .. } => "thermal_throttle", + OperationalCause::ContendedHost { .. } => "contended_host", + OperationalCause::ColdStart { .. } => "cold_start", + OperationalCause::FrequencyScaled { .. } => "frequency_scaled", + OperationalCause::MeterCoverageGap { .. } => "meter_coverage_gap", + } + } +} + +/// What one window's energy establishes against its baseline. +/// +/// **There is deliberately no `Malicious` variant.** See the module docs: a +/// deviation cannot carry intent, and a type that offered that arm would have it +/// used. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Deviation { + /// Inside the baseline's band. Says the window is unremarkable *at this + /// resolution and against this baseline* — nothing more. + WithinBand, + /// Outside the band, and a named cause with evidence accounts for it. + Explained { cause: OperationalCause, excess_uj: i128 }, + /// Outside the band with no evidenced cause. **This is not a finding of + /// wrongdoing.** It is the set a human should look at, and on an uncharted + /// stack it will often be the largest set — which is a statement about the + /// stack's charting, not about the workload's honesty. + Unexplained { excess_uj: i128 }, + /// The baseline does not apply here — undeclared, or fit at a resolution + /// coarser than this window. No comparison is made, because comparing + /// against an inapplicable baseline manufactures deviations. + BaselineInapplicable { reason: &'static str }, + /// The window is longer than the phenomenon being asked about, so this + /// window could not have shown it. Distinct from `WithinBand`: one is + /// "looked and saw nothing", the other is "could not have seen it". + Unresolvable { window_ns: u64, asked_ns: u64 }, +} + +impl EnergyWindow { + /// Classify this window against a baseline. + pub fn classify(&self, baseline: &Baseline) -> Deviation { + if !baseline.declared_applicable { + return Deviation::BaselineInapplicable { + reason: "baseline not declared applicable to this host and workload", + }; + } + if baseline.fit_window_ns > self.duration_ns { + return Deviation::BaselineInapplicable { + reason: "baseline fit at a coarser window than this trace samples", + }; + } + let lo = baseline.expected_uj.saturating_sub(baseline.band_uj); + let hi = baseline.expected_uj.saturating_add(baseline.band_uj); + if self.joules_uj >= lo && self.joules_uj <= hi { + return Deviation::WithinBand; + } + Deviation::Unexplained { + excess_uj: i128::from(self.joules_uj) - i128::from(baseline.expected_uj), + } + } + + /// Classify, offering a cause. The cause is accepted only because it + /// carries evidence; `None` leaves the window unexplained rather than + /// quietly excused. + pub fn classify_with( + &self, + baseline: &Baseline, + cause: Option, + ) -> Deviation { + match (self.classify(baseline), cause) { + (Deviation::Unexplained { excess_uj }, Some(cause)) => { + Deviation::Explained { cause, excess_uj } + } + (other, _) => other, + } + } + + /// Whether this window could resolve a phenomenon of `asked_ns`. + pub fn resolves(&self, asked_ns: u64) -> Deviation { + if asked_ns < self.duration_ns { + Deviation::Unresolvable { window_ns: self.duration_ns, asked_ns } + } else { + Deviation::WithinBand + } + } +} + +/// The partition over a window series. Counts, never a score. +/// +/// There is no single number here, and that is deliberate. A "risk score" would +/// collapse `unexplained` and `inapplicable` and `unresolvable` into one figure +/// that reads as confidence, when the three mean "look at this", "we have no +/// yardstick", and "we could not have seen it" — which call for completely +/// different responses. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Partition { + pub within_band: usize, + pub explained: usize, + pub unexplained: usize, + pub baseline_inapplicable: usize, + pub unresolvable: usize, + /// Σ excess over the unexplained windows, signed. + pub unexplained_excess_uj: i128, + /// The weakest provenance across every window classified. `None` until a + /// window has been folded in — an empty partition has no grade to report, + /// and inventing one would be a claim about nothing. + pub provenance_floor: Option, +} + +impl Partition { + /// Fold one classified window in. + pub fn record(&mut self, window: &EnergyWindow, d: &Deviation) { + match d { + Deviation::WithinBand => self.within_band += 1, + Deviation::Explained { .. } => self.explained += 1, + Deviation::Unexplained { excess_uj } => { + self.unexplained += 1; + self.unexplained_excess_uj += *excess_uj; + } + Deviation::BaselineInapplicable { .. } => self.baseline_inapplicable += 1, + Deviation::Unresolvable { .. } => self.unresolvable += 1, + } + self.provenance_floor = Some(match self.provenance_floor { + None => window.provenance, + Some(have) => weakest(have, window.provenance), + }); + } + + /// How many windows were actually compared against an applicable baseline. + /// + /// The denominator that matters. A partition reporting zero unexplained + /// windows out of a hundred means nothing if ninety-nine of them had no + /// applicable baseline — and that is the shape most "nothing anomalous + /// found" reports take on an uncharted stack. + pub fn compared(&self) -> usize { + self.within_band + self.explained + self.unexplained + } + + /// Whether this partition supports saying anything at all about the series. + /// + /// `false` when nothing was compared. A report built on an empty comparison + /// is not a quiet report, it is a vacuous one. + pub fn is_substantive(&self) -> bool { + self.compared() > 0 + } +} + +fn weakest(a: Provenance, b: Provenance) -> Provenance { + fn rank(p: Provenance) -> u8 { + match p { + Provenance::HwShunt => 2, + Provenance::ModelBased => 1, + Provenance::Estimator => 0, + } + } + if rank(b) < rank(a) { b } else { a } +} + +/// A granted energy budget against what the windows actually account for. +/// +/// # Why fractions of a percent are detectable here and nowhere else +/// +/// Everything in this family carries energy as **integer microjoules**, and the +/// sums are exact. That makes the detection floor a property of arithmetic +/// rather than of statistics: the smallest representable discrepancy is 1 µJ, +/// so against a budget of `N` µJ the floor is `1_000_000 / N` parts per +/// million. On a one-joule budget that is **1 ppm — one ten-thousandth of a +/// percent**. A drift of a fraction of a percent sits three orders of magnitude +/// above the floor, which is why it is not a subtle signal but an obvious one. +/// +/// This is the part that does not survive the usual construction. Almost +/// everywhere in computing, an energy figure is *derived from a performance +/// proxy* — utilisation times a thermal design figure, tokens times a +/// coefficient, instructions times picojoules-per-op — and never measured. The +/// model error in such a figure is tens of percent. A fractional-percent drift +/// is then invisible by construction: it is far inside the error bar of the +/// estimator that produced the number, and no amount of resolution recovers it. +/// Deriving energy from performance does not merely lose accuracy; it destroys +/// the ability to notice that the books do not balance. +/// +/// So the instrument is conservation, not anomaly detection. A band comparison +/// asks "is this unusual against a model of normal", which an uncharted stack +/// cannot answer. Conservation asks "do these joules fit anywhere", which needs +/// no model of normal — only exact arithmetic and a grant that said how many +/// were allowed. [`Provenance`] remains load-bearing throughout: a budget +/// reconciled against `Estimator` windows is reconciled against a model's +/// opinion, and the floor below is then fiction. The floor is real only to the +/// extent the windows were measured. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Budget { + /// Microjoules the grant authorised. + pub granted_uj: u64, + /// Σ of the windows' energy. + pub accounted_uj: u128, + /// The weakest provenance across the windows that produced + /// [`Self::accounted_uj`]. Carried because the detection floor is only as + /// real as the measurement under it. + pub provenance_floor: Option, +} + +impl Budget { + /// Reconcile a window series against a grant. + pub fn reconcile(granted_uj: u64, windows: &[EnergyWindow]) -> Self { + let accounted_uj: u128 = windows.iter().map(|w| u128::from(w.joules_uj)).sum(); + let provenance_floor = windows + .iter() + .map(|w| w.provenance) + .reduce(weakest); + Budget { granted_uj, accounted_uj, provenance_floor } + } + + /// `accounted - granted`, signed. Positive is overspend; negative is energy + /// the grant allowed that nothing claimed. Never clamped — both directions + /// are findings, and the negative one is how unattributed work hides. + pub fn drift_uj(&self) -> i128 { + self.accounted_uj as i128 - i128::from(self.granted_uj) + } + + /// The drift in parts per million of the grant. `None` for a zero grant: a + /// ratio against nothing is not a figure. + pub fn drift_ppm(&self) -> Option { + if self.granted_uj == 0 { + return None; + } + Some(self.drift_uj() * 1_000_000 / i128::from(self.granted_uj)) + } + + /// The smallest drift this budget can represent, in ppm — one microjoule + /// expressed against the grant. + /// + /// Exact, not estimated. This is the number that says whether a given + /// tolerance is even askable: a 0.1 % tolerance on a 1 mJ grant is 1 ppm of + /// nothing useful, while on a 1 J grant it is a thousand times the floor. + pub fn detectable_floor_ppm(&self) -> Option { + if self.granted_uj == 0 { + return None; + } + Some((1_000_000 / self.granted_uj).max(1)) + } + + /// Whether the observed drift is above this budget's representational floor. + /// + /// `false` means the books balance to the limit of what integers can say at + /// this grant size — which is a real and bounded statement, unlike "no + /// anomaly detected". + pub fn drift_is_resolvable(&self) -> bool { + match self.detectable_floor_ppm() { + None => false, + Some(floor) => self.drift_ppm().is_some_and(|d| d.unsigned_abs() >= u128::from(floor)), + } + } + + /// Whether the floor is a claim about measurement or about a model. + /// + /// A budget whose windows are `Estimator` has a floor that is arithmetically + /// exact and physically meaningless: the estimator's own error dwarfs it. + /// Reporting a 1 ppm floor over modelled windows would be the most precise + /// way to mislead available here. + pub fn floor_is_physical(&self) -> bool { + matches!(self.provenance_floor, Some(Provenance::HwShunt)) + } +} + +/// The physical envelope a part cannot leave, used to **refute** energy claims. +/// +/// # Energy cannot be faked; the measurement of it can +/// +/// The joules were spent. The heat left the die. That quantity is not +/// negotiable — but the *report* of it is exactly as corruptible as any other +/// report. A counter can be lied about, a figure can be fabricated, and a +/// `HwShunt` grade can be stamped on a number that came from nowhere. A meter +/// reading a frozen register while claiming hardware measurement is not a +/// hypothetical: it is a defect that ships. +/// +/// So energy buys no immunity. What it buys is something no synthetic unit has: +/// **an external referent**. A token count, an API-call count, a +/// request-per-second figure can be any number whatsoever — nothing outside the +/// accounting system constrains them, so a fabricated one is indistinguishable +/// from a true one. A joule count is bounded by physics. The part has a power +/// envelope; the window has a duration; the product of the two is a ceiling no +/// honest figure can exceed, and the idle draw is a floor no powered part can +/// fall below while doing work. +/// +/// That makes a faked energy figure *falsifiable*, which is a strictly weaker +/// and strictly more honest property than unfakeable. +/// +/// # Refutes, never verifies +/// +/// [`PhysicalVerdict`] has no `Verified` arm, for the same reason +/// `sandbox-attest`'s locality bound has none: passing a physical check means +/// the figure is *not impossible*, which is not the same as true. A figure +/// inside the envelope may still be fabricated, and the envelope will never +/// say so. +/// +/// The bounds are deliberately **permissive** — peak burst rating for the +/// ceiling, not typical; absolute idle for the floor. A refutation from a +/// generous bound is unassailable: nobody can argue the part was merely running +/// hot. The same reason the locality check uses the speed of light in vacuum +/// rather than in fibre. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PowerEnvelope { + /// Absolute peak the part can draw, milliwatts. Use the burst rating, not + /// the sustained or typical figure. + pub peak_mw: u64, + /// Floor the part cannot fall below while powered and executing, + /// milliwatts. Zero disables the floor check for parts with no credible + /// idle figure — honest, and it gives up the ability to catch a dead + /// counter. + pub idle_floor_mw: u64, + /// Where these bounds come from — a datasheet, a measured sweep, a receipt. + /// Names the source; does not substitute for it. + pub source: String, +} + +/// What a physical bound establishes about an energy figure. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PhysicalVerdict { + /// The figure is inside the envelope. **Not** a finding that it is correct: + /// a fabricated figure inside the envelope passes this check, and must. + NotRefuted { headroom_uj: u64 }, + /// The figure exceeds what the part could draw for the window's duration. + /// Physically impossible, whatever the counter said. + RefutedAbove { claimed_uj: u64, ceiling_uj: u64 }, + /// The figure is below what a powered, executing part must draw. This is + /// the shape a dead or frozen counter takes: a busy window reporting + /// implausibly little. + RefutedBelow { claimed_uj: u64, floor_uj: u64 }, + /// The window has no duration, so no bound can be computed. Not a pass. + Unboundable, +} + +impl PowerEnvelope { + /// The most the part could have spent over `duration_ns`, in µJ. + /// + /// mW × ns = 1e-3 W × 1e-9 s = 1e-12 J = 1e-6 µJ, so the product divides by + /// 1e6 to land in microjoules. Computed in `u128` and saturating: a + /// ceiling that overflowed to a small number would refute honest figures. + pub fn ceiling_uj(&self, duration_ns: u64) -> u128 { + u128::from(self.peak_mw).saturating_mul(u128::from(duration_ns)) / 1_000_000 + } + + /// The least a powered, executing part must have spent over `duration_ns`. + pub fn floor_uj(&self, duration_ns: u64) -> u128 { + u128::from(self.idle_floor_mw).saturating_mul(u128::from(duration_ns)) / 1_000_000 + } + + /// Try to refute a window's energy figure against physics. + pub fn check(&self, window: &EnergyWindow) -> PhysicalVerdict { + if window.duration_ns == 0 { + return PhysicalVerdict::Unboundable; + } + let claimed = u128::from(window.joules_uj); + let ceiling = self.ceiling_uj(window.duration_ns); + if claimed > ceiling { + return PhysicalVerdict::RefutedAbove { + claimed_uj: window.joules_uj, + ceiling_uj: ceiling.min(u128::from(u64::MAX)) as u64, + }; + } + let floor = self.floor_uj(window.duration_ns); + if floor > 0 && claimed < floor { + return PhysicalVerdict::RefutedBelow { + claimed_uj: window.joules_uj, + floor_uj: floor.min(u128::from(u64::MAX)) as u64, + }; + } + PhysicalVerdict::NotRefuted { + headroom_uj: (ceiling - claimed).min(u128::from(u64::MAX)) as u64, + } + } +} + +/// The character of a draw over time — the one thing here that cannot be faked. +/// +/// # Why shape, and not the total +/// +/// The quantity is physical and the measurement is forgeable, but **how** the +/// energy was consumed is the signature of what the silicon actually did. You +/// can report any number you like; you cannot make a part draw the profile of a +/// sustained matmul while it executes a memory-bound scan. The shape is a +/// property of the computation. +/// +/// This closes a hole conservation alone leaves open. A total can be balanced +/// by padding — invent an operation, attribute the surplus joules to it, and the +/// books add up. The profile does not cooperate: a fictional operation +/// contributes no draw, so the observed character stops matching the declared +/// work mix. +/// +/// # Why it survives an uncharted stack +/// +/// This is the part that answers the hard question. On unfamiliar silicon the +/// *magnitudes* move — a different process node, a different power envelope, a +/// different memory hierarchy all shift the absolute joules, which is exactly +/// why a band baseline fails there. The *character* does not: a compute-bound +/// steady state is flat on any part, a cold start ramps on any part, a +/// queue-driven workload alternates on any part. So a declaration can be +/// refuted by shape on hardware nobody has ever profiled, without any model of +/// what normal looks like on it. +/// +/// # Refutes a declaration, not a person +/// +/// An inconsistency says the declared work did not produce this draw. It does +/// not say what did, and it never says intent. The declaration has to be made +/// **in advance** — classifying the shape first and then deciding what it must +/// have been is post-hoc pattern matching, which is the anomaly-detection trap +/// this module exists to avoid. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DrawShape { + /// Flat within tolerance. Compute-bound steady state. + Sustained, + /// Rising then settling. Cold caches, kernel or JIT compilation, page-in. + Ramping, + /// Alternating high and low. Queue-driven, memory-bound, or I/O-gated. + Bursty, + /// At or near the floor throughout. The part was not doing the declared + /// work — or the counter is dead, which looks identical from here and is + /// why [`PowerEnvelope::check`] is the companion test. + Idle, +} + +/// What comparing a declared shape against an observed one establishes. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShapeVerdict { + /// Observed character matches what was declared. Not a finding of honesty: + /// a different workload of the same character also matches. + Consistent { shape: DrawShape }, + /// The declared work did not produce this draw. A refutation of the + /// declaration — not an attribution of cause, and never of intent. + Inconsistent { declared: DrawShape, observed: DrawShape }, + /// Too few windows to speak to character. Three is the minimum at which + /// flat, rising and alternating are distinguishable at all; fewer is + /// `Unresolvable`, never `Consistent`. + Unresolvable { windows: usize }, +} + +/// Classify the character of a window series. +/// +/// Integer arithmetic throughout, in µJ. `idle_floor_uj` is the per-window +/// floor from the [`PowerEnvelope`]; a series at or under it is `Idle` +/// regardless of its variation. +pub fn observe_shape(windows: &[EnergyWindow], idle_floor_uj: u64) -> Option { + if windows.len() < 3 { + return None; + } + let v: Vec = windows.iter().map(|w| w.joules_uj).collect(); + let n = v.len() as u128; + let sum: u128 = v.iter().map(|&x| u128::from(x)).sum(); + let mean = sum / n; + + if mean <= u128::from(idle_floor_uj) { + return Some(DrawShape::Idle); + } + + // Mean absolute deviation as a permille of the mean — integer, no floats, + // so the classification is reproducible rather than platform-dependent. + let mad: u128 = v + .iter() + .map(|&x| { + let x = u128::from(x); + if x > mean { x - mean } else { mean - x } + }) + .sum::() + / n; + let spread_permille = if mean == 0 { 0 } else { mad * 1000 / mean }; + + // Flat enough to be one steady state. + if spread_permille <= 100 { + return Some(DrawShape::Sustained); + } + + // Rising: the second half's mean clearly exceeds the first half's, and the + // series does not swing back and forth. + let half = v.len() / 2; + let first: u128 = v[..half].iter().map(|&x| u128::from(x)).sum::() / half as u128; + let last: u128 = + v[v.len() - half..].iter().map(|&x| u128::from(x)).sum::() / half as u128; + let direction_changes = v + .windows(3) + .filter(|w| (w[1] > w[0]) != (w[2] > w[1])) + .count(); + if last > first + first / 4 && direction_changes * 4 < v.len() { + return Some(DrawShape::Ramping); + } + + Some(DrawShape::Bursty) +} + +/// Compare a shape declared in advance against the series actually observed. +pub fn check_shape( + declared: DrawShape, + windows: &[EnergyWindow], + idle_floor_uj: u64, +) -> ShapeVerdict { + match observe_shape(windows, idle_floor_uj) { + None => ShapeVerdict::Unresolvable { windows: windows.len() }, + Some(observed) if observed == declared => ShapeVerdict::Consistent { shape: observed }, + Some(observed) => ShapeVerdict::Inconsistent { declared, observed }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn win(started_ns: u64, joules_uj: u64) -> EnergyWindow { + EnergyWindow { + started_ns, + duration_ns: 1_000_000, // 1 ms + joules_uj, + provenance: Provenance::HwShunt, + coverage: Coverage::Whole, + declared_work: None, + } + } + + fn series(vals: &[u64]) -> Vec { + vals.iter().enumerate().map(|(i, &v)| win(i as u64 * 1_000_000, v)).collect() + } + + /// The claim this whole module rests on, stated as a test: fractions of a + /// percent are detectable because the arithmetic is exact integers. + #[test] + fn a_fractional_percent_drift_is_three_orders_above_the_floor() { + // A 1 J grant, overspent by 0.5 % — 5 mJ. + let granted = 1_000_000u64; // 1 J in µJ + let b = Budget { + granted_uj: granted, + accounted_uj: u128::from(granted) + 5_000, + provenance_floor: Some(Provenance::HwShunt), + }; + assert_eq!(b.drift_ppm(), Some(5_000), "0.5 % is 5000 ppm"); + assert_eq!(b.detectable_floor_ppm(), Some(1), "1 µJ of 1 J is 1 ppm"); + assert!(b.drift_is_resolvable()); + assert!(b.floor_is_physical(), "and the floor means something: measured windows"); + } + + /// The floor is arithmetic, not physics, when the windows are modelled. + /// Reporting 1 ppm over an estimator's output would be the most precise way + /// to mislead available here. + #[test] + fn a_modelled_budget_has_an_exact_but_meaningless_floor() { + let b = Budget { + granted_uj: 1_000_000, + accounted_uj: 1_000_005, + provenance_floor: Some(Provenance::Estimator), + }; + assert_eq!(b.detectable_floor_ppm(), Some(1), "arithmetically exact"); + assert!(!b.floor_is_physical(), "and physically meaningless — say so"); + } + + /// Underspend is a finding too: energy the grant allowed that nothing + /// claimed is how unattributed work hides. + #[test] + fn drift_is_signed_in_both_directions() { + let under = Budget::reconcile(1_000_000, &series(&[100_000, 100_000, 100_000])); + assert!(under.drift_uj() < 0, "300 mJ claimed against a 1 J grant"); + assert_eq!(under.drift_ppm(), Some(-700_000)); + assert_eq!(Budget::reconcile(0, &series(&[1, 2, 3])).drift_ppm(), None); + } + + /// The physical floor refutes a dead counter. This is not hypothetical — + /// it is the shape of a real defect: a busy window whose counter is frozen + /// reports implausibly little while the meter still claims hardware. + #[test] + fn the_physical_floor_refutes_a_frozen_counter() { + let env = PowerEnvelope { + peak_mw: 60_000, // 60 W burst — deliberately generous + idle_floor_mw: 1_000, // 1 W floor while executing + source: "datasheet burst rating".into(), + }; + // A 400 ms busy window reporting zero. + let dead = EnergyWindow { duration_ns: 400_000_000, joules_uj: 0, ..win(0, 0) }; + match env.check(&dead) { + PhysicalVerdict::RefutedBelow { claimed_uj, floor_uj } => { + assert_eq!(claimed_uj, 0); + assert_eq!(floor_uj, 400_000, "1 W for 400 ms is 400 mJ"); + } + other => panic!("a frozen counter must be refutable: {other:?}"), + } + } + + /// And the ceiling refutes a fabricated figure no part could have drawn. + #[test] + fn the_ceiling_refutes_a_physically_impossible_figure() { + let env = PowerEnvelope { + peak_mw: 60_000, + idle_floor_mw: 0, + source: "burst rating".into(), + }; + let absurd = EnergyWindow { duration_ns: 1_000_000, joules_uj: 10_000_000, ..win(0, 0) }; + assert!(matches!(env.check(&absurd), PhysicalVerdict::RefutedAbove { .. })); + + // A plausible figure is NOT refuted — and that is not a finding of + // correctness. There is no `Verified` arm, deliberately. + let ok = EnergyWindow { duration_ns: 1_000_000, joules_uj: 30_000, ..win(0, 0) }; + assert!(matches!(env.check(&ok), PhysicalVerdict::NotRefuted { .. })); + } + + /// A zero-duration window yields no bound. Not a pass. + #[test] + fn an_instant_window_is_unboundable_not_clean() { + let env = PowerEnvelope { peak_mw: 1, idle_floor_mw: 1, source: "x".into() }; + let instant = EnergyWindow { duration_ns: 0, joules_uj: 999, ..win(0, 0) }; + assert_eq!(env.check(&instant), PhysicalVerdict::Unboundable); + } + + /// The hole conservation leaves open: a total balanced by padding. The + /// shape does not cooperate — a fictional operation contributes no draw, so + /// the declared character stops matching. + #[test] + fn shape_catches_a_total_balanced_by_padding() { + // Declared: a sustained compute-bound run. + // Observed: a short burst and then nothing, padded to the same total. + let padded = series(&[300_000, 10, 10, 10, 10, 10]); + match check_shape(DrawShape::Sustained, &padded, 100) { + ShapeVerdict::Inconsistent { declared, observed } => { + assert_eq!(declared, DrawShape::Sustained); + assert_ne!(observed, DrawShape::Sustained); + } + other => panic!("padding must not read as sustained: {other:?}"), + } + } + + /// Character survives a change of scale, which is why this works on silicon + /// nobody has profiled: the magnitudes move, the shape does not. + #[test] + fn character_is_scale_invariant() { + let small = series(&[1_000, 1_020, 980, 1_010, 990, 1_000]); + let large: Vec = [1_000u64, 1_020, 980, 1_010, 990, 1_000] + .iter() + .map(|v| v * 37) + .collect(); + assert_eq!(observe_shape(&small, 10), Some(DrawShape::Sustained)); + assert_eq!(observe_shape(&series(&large), 10), Some(DrawShape::Sustained)); + } + + /// Ramping is distinguishable from alternating, and both from flat. + #[test] + fn the_three_characters_are_distinguishable() { + assert_eq!( + observe_shape(&series(&[100, 400, 700, 1_000, 1_100, 1_120]), 10), + Some(DrawShape::Ramping) + ); + assert_eq!( + observe_shape(&series(&[100, 2_000, 100, 2_000, 100, 2_000]), 10), + Some(DrawShape::Bursty) + ); + assert_eq!( + observe_shape(&series(&[1_000, 1_000, 1_000, 1_000]), 10), + Some(DrawShape::Sustained) + ); + } + + /// Below three windows, character is unresolvable — never "consistent". + /// Reporting agreement at a resolution that could not disagree is the + /// quietest way to be wrong. + #[test] + fn two_windows_cannot_speak_to_character() { + assert_eq!( + check_shape(DrawShape::Sustained, &series(&[1_000, 1_000]), 10), + ShapeVerdict::Unresolvable { windows: 2 } + ); + assert_eq!(observe_shape(&series(&[1, 2]), 0), None); + } + + /// An undeclared baseline makes no comparison. Forgetting to declare must + /// not manufacture findings. + #[test] + fn an_undeclared_baseline_refuses_to_compare() { + let w = win(0, 5_000); + let b = Baseline::new(1_000, 100, "harness:run-1", 1_000_000); + assert!(matches!(w.classify(&b), Deviation::BaselineInapplicable { .. })); + // Declared, and now the window is outside the band. + let b = b.declared_applicable(); + assert!(matches!(w.classify(&b), Deviation::Unexplained { .. })); + } + + /// A baseline fit at a coarser window than the trace samples cannot speak + /// to the trace's windows. + #[test] + fn a_coarser_baseline_does_not_apply_to_finer_windows() { + let w = win(0, 5_000); // 1 ms window + let b = Baseline::new(1_000, 100, "harness", 10_000_000).declared_applicable(); + assert!(matches!(w.classify(&b), Deviation::BaselineInapplicable { .. })); + } + + /// A cause explains a deviation only because it carries evidence. Without + /// one the window stays unexplained rather than quietly excused. + #[test] + fn a_cause_without_evidence_does_not_explain() { + let w = win(0, 5_000); + let b = Baseline::new(1_000, 100, "harness", 1_000).declared_applicable(); + assert!(matches!(w.classify_with(&b, None), Deviation::Unexplained { .. })); + + let cause = OperationalCause::ThermalThrottle { reading: ContentId::of_bytes(b"thermal") }; + match w.classify_with(&b, Some(cause.clone())) { + Deviation::Explained { cause: got, .. } => { + assert_eq!(got.token(), "thermal_throttle"); + assert_eq!(got.evidence(), ContentId::of_bytes(b"thermal")); + } + other => panic!("an evidenced cause explains: {other:?}"), + } + } + + /// The denominator that matters: zero unexplained windows means nothing if + /// nothing was compared. + #[test] + fn a_partition_that_compared_nothing_is_vacuous_not_clean() { + let mut p = Partition::default(); + let w = win(0, 5_000); + let undeclared = Baseline::new(1_000, 100, "harness", 1_000); + for _ in 0..99 { + p.record(&w, &w.classify(&undeclared)); + } + assert_eq!(p.unexplained, 0, "nothing was flagged"); + assert_eq!(p.compared(), 0, "because nothing was compared"); + assert!(!p.is_substantive(), "so the partition supports no claim"); + assert_eq!(p.baseline_inapplicable, 99); + assert_eq!(p.provenance_floor, Some(Provenance::HwShunt)); + } + + /// There is no `Malicious` verdict, and that is the load-bearing absence. + /// Unexplained is a request for a look. + #[test] + fn unexplained_is_not_a_verdict_about_intent() { + let w = win(0, 50_000); + let b = Baseline::new(1_000, 100, "harness", 1_000).declared_applicable(); + let d = w.classify(&b); + match d { + Deviation::Unexplained { excess_uj } => assert_eq!(excess_uj, 49_000), + other => panic!("expected unexplained: {other:?}"), + } + // The partition counts it; it produces no score, and there is no arm + // anywhere in this module that names intent. + let mut p = Partition::default(); + p.record(&w, &d); + assert_eq!(p.unexplained, 1); + assert_eq!(p.unexplained_excess_uj, 49_000); + } +} diff --git a/jcp/jcp-rs/crates/jcp-efficiency/src/lib.rs b/jcp/jcp-rs/crates/jcp-efficiency/src/lib.rs index 0d656ed08..dbe47cc84 100644 --- a/jcp/jcp-rs/crates/jcp-efficiency/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-efficiency/src/lib.rs @@ -35,6 +35,10 @@ use jcp_receipt::jcr1; use jcp_schema::{ContentId, Energy}; pub mod avoided; +/// Energy traceability across operation, activity and outcome. +pub mod trace; +/// Time-resolved energy and the limits of what a deviation establishes. +pub mod dynamics; pub mod training; use serde_json::{json, Value}; diff --git a/jcp/jcp-rs/crates/jcp-efficiency/src/trace.rs b/jcp/jcp-rs/crates/jcp-efficiency/src/trace.rs new file mode 100644 index 000000000..8f0b10bc0 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-efficiency/src/trace.rs @@ -0,0 +1,736 @@ +//! Energy traceability across the three levels an AI system is accountable at: +//! **operation**, **activity**, and **outcome**. +//! +//! # The levels, and where the family already measures +//! +//! A deployed system is answerable at three different grains, and they are not +//! interchangeable: +//! +//! - **Operation** — one unit of work. `jcp-receipt`'s JCR-1 work receipt +//! carries `joules_uj` with a [`Provenance`] grade. This level is measured. +//! - **Activity** — one *authorised* thing done: a grant checked, an +//! environment attested, work performed, an effect committed, bytes egressed. +//! `jcp-action-receipt`'s AR-1 binds all five by content-id. +//! - **Outcome** — whether any of it *bought* anything. `efficiency/1` anchors +//! on `completed` and totals the energy the unit spent. +//! +//! # The two seams this module closes +//! +//! **An outcome's energy was unbound.** [`crate::Efficiency::task`] is a +//! `String`. An outcome could claim any total it liked and nothing tied that +//! figure to the activities that produced it — the one level where a number is +//! most load-bearing was the one level identified by free text. Here an outcome +//! names its activities by [`ContentId`], so a verifier can resolve each one +//! and add the figures up itself. +//! +//! **An activity had no energy of its own.** AR-1 carries exactly one joules +//! figure and it belongs to *binding 3: work*. The cost of the authorisation +//! check, the attestation, the verification, and of pushing bytes off the +//! device is real and was attributed nowhere. `Egress` counts bytes and no +//! joules. +//! +//! So the sum of an outcome's operations is **not** the outcome's energy, and +//! the difference is not noise — it is the cost of being accountable. This +//! module names that difference instead of letting it vanish. +//! +//! # Conservation, with the residual named +//! +//! Reconciliation is exact integer arithmetic in µJ, summed in `u128`: +//! +//! ```text +//! residual_uj = total_uj - Σ activity.joules_uj +//! ``` +//! +//! The residual is **signed** and never clamped. Negative means the bound +//! activities claim more energy than the outcome admits — double-counted work, +//! or a wrong binding — and that is a finding, not a rounding error. Clamping +//! it to zero would hide exactly the case an auditor needs. +//! +//! This mirrors `eoc-carbon`'s allocation discipline, where +//! `Σ shares + residual == total` holds exactly and the residual is a named +//! field rather than a silent remainder. +//! +//! # A chain is only as graded as its weakest link +//! +//! The composite [`Provenance`] is the **floor** across the parts, never an +//! average and never the best: one `Estimator` activity makes the whole trace +//! estimated, because the trace cannot be more measured than the thing in it +//! that was not measured. Coverage takes the floor the same way — one `Partial` +//! part makes the composite `Partial`, since the missing share does not become +//! accounted for by sitting next to accounted shares. +//! +//! This is the same rule as `Vmm::attained_tier` returning the floor and +//! `Backing` defaulting to `Unstated`: aggregation must not be a laundering +//! step. + +use serde::{Deserialize, Serialize}; + +use jcp_schema::{ContentId, Coverage, Energy, Provenance}; + +/// One activity bound into an outcome, as a verifier reads it. +/// +/// The fields are copied from the AR-1 receipt named by [`Self::receipt`] so a +/// reconciliation can be checked without resolving every receipt — but +/// [`Self::receipt`] is what makes the claim falsifiable: resolve it and the +/// copied figures must match. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActivityRef { + /// The AR-1 action receipt. The binding, and the only thing here that + /// cannot be fabricated without also fabricating a signed receipt. + pub receipt: ContentId, + /// The work's energy, from AR-1 `joules_uj`. + pub joules_uj: u64, + /// How that figure was acquired, from AR-1 `energy_provenance`. Carried + /// verbatim; never upgraded here. + pub provenance: Provenance, + /// How much of the activity's work the figure accounts for. + pub coverage: Coverage, +} + +impl ActivityRef { + /// Bind one activity. + pub fn new( + receipt: ContentId, + joules_uj: u64, + provenance: Provenance, + coverage: Coverage, + ) -> Self { + ActivityRef { receipt, joules_uj, provenance, coverage } + } +} + +/// Why a trace cannot be presented as traceable. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TraceError { + /// The outcome names no activities. Its energy total is then an assertion + /// about nothing a verifier can resolve — the free-text case this module + /// exists to retire. + Unbound, + /// The bound activities claim more energy than the outcome admits. + /// Double-counted work, or an activity bound to the wrong outcome. + OverAccounted { accounted_uj: u128, total_uj: u64 }, + /// The same activity receipt appears twice. Summing it twice inflates the + /// accounted figure and shrinks the residual, which flatters the trace in + /// exactly the direction that matters. + DuplicateActivity { receipt: ContentId }, +} + +impl core::fmt::Display for TraceError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + TraceError::Unbound => write!( + f, + "outcome names no activities: its energy total is not traceable \ + to anything a verifier can resolve" + ), + TraceError::OverAccounted { accounted_uj, total_uj } => write!( + f, + "bound activities account for {accounted_uj} uJ but the outcome \ + admits only {total_uj} uJ; the work is double-counted or an \ + activity is bound to the wrong outcome" + ), + TraceError::DuplicateActivity { receipt } => write!( + f, + "activity {} is bound twice; summing it twice inflates the \ + accounted figure and shrinks the residual", + receipt.to_hex() + ), + } + } +} + +impl std::error::Error for TraceError {} + +/// What reconciling an outcome against its activities establishes. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Reconciliation { + /// Σ of the bound activities' work energy. + pub accounted_uj: u128, + /// `total - accounted`. **Signed and never clamped.** Positive is the cost + /// of being accountable — authorisation, attestation, verification, egress + /// — which no operation receipt claims. Zero means the outcome admits + /// exactly its operations and nothing else, which for a gated, attested, + /// receipt-emitting system is itself worth a second look. + pub residual_uj: i128, + /// The weakest provenance across the outcome and every bound activity. + pub provenance_floor: Provenance, + /// `Partial` if the outcome or any activity is partial. + pub coverage_floor: Coverage, + /// How many activities were reconciled. + pub activities: usize, +} + +impl Reconciliation { + /// The share of the outcome's energy that no operation receipt claims, in + /// parts per million. + /// + /// This is the number the three-level question actually turns on: how much + /// of what an outcome cost is attributable to a measured operation, and how + /// much is the overhead of accountability itself. `None` when the total is + /// zero — a ratio against nothing is not a figure. + pub fn unattributed_ppm(&self, total_uj: u64) -> Option { + if total_uj == 0 { + return None; + } + Some(self.residual_uj * 1_000_000 / i128::from(total_uj)) + } +} + +/// An outcome, the activities that produced it, and the arithmetic between them. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OutcomeTrace { + /// The outcome's own energy figure, as `efficiency/1` records it. + pub total: Energy, + /// Whether the outcome was reached. Carried because energy spent on an + /// outcome that did not complete is a different claim from energy spent on + /// one that did, and a trace that drops it loses the anchor. + pub completed: bool, + /// The activities this outcome is traced to. + pub activities: Vec, +} + +impl OutcomeTrace { + /// Trace an outcome to its activities. + pub fn new(total: Energy, completed: bool, activities: Vec) -> Self { + OutcomeTrace { total, completed, activities } + } + + /// The outcome's energy in µJ, preferring the measured figure. + /// + /// `measured_uj` when present, `estimated_uj` otherwise — and the choice is + /// already graded by [`Energy::provenance`], so this does not re-decide it. + pub fn total_uj(&self) -> u64 { + self.total.measured_uj.unwrap_or(self.total.estimated_uj) + } + + /// Reconcile the outcome against its activities, or refuse to. + /// + /// Refuses an unbound outcome, a duplicated activity, and an over-accounted + /// total. Everything else reconciles, including a large positive residual — + /// that is a legitimate and interesting result, not an error. + pub fn reconcile(&self) -> Result { + if self.activities.is_empty() { + return Err(TraceError::Unbound); + } + + // Duplicate detection before summing: the whole point of the residual + // is that it is exact, and a double-counted activity corrupts it + // silently in the flattering direction. + for (i, a) in self.activities.iter().enumerate() { + if self.activities[..i].iter().any(|b| b.receipt == a.receipt) { + return Err(TraceError::DuplicateActivity { receipt: a.receipt }); + } + } + + // u128 so a long trace cannot overflow into a small number — a wrapped + // sum would under-account and inflate the residual. + let accounted_uj: u128 = + self.activities.iter().map(|a| u128::from(a.joules_uj)).sum(); + let total_uj = self.total_uj(); + if accounted_uj > u128::from(total_uj) { + return Err(TraceError::OverAccounted { accounted_uj, total_uj }); + } + + let residual_uj = i128::from(total_uj) - accounted_uj as i128; + + let provenance_floor = self + .activities + .iter() + .map(|a| a.provenance) + .fold(self.total.provenance, weakest_provenance); + let coverage_floor = self + .activities + .iter() + .map(|a| a.coverage) + .fold(self.total.coverage, weakest_coverage); + + Ok(Reconciliation { + accounted_uj, + residual_uj, + provenance_floor, + coverage_floor, + activities: self.activities.len(), + }) + } +} + +/// The weaker of two acquisition classes: `HwShunt` > `ModelBased` > `Estimator`. +/// +/// A composite is never more measured than its least-measured part. Averaging +/// would let one hardware reading launder a pile of estimates. +fn weakest_provenance(a: Provenance, b: Provenance) -> Provenance { + fn rank(p: Provenance) -> u8 { + match p { + Provenance::HwShunt => 2, + Provenance::ModelBased => 1, + Provenance::Estimator => 0, + } + } + if rank(b) < rank(a) { + b + } else { + a + } +} + +/// `Partial` if either is partial. An unaccounted share does not become +/// accounted for by being summed with accounted ones. +fn weakest_coverage(a: Coverage, b: Coverage) -> Coverage { + match (a, b) { + (Coverage::Whole, Coverage::Whole) => Coverage::Whole, + _ => Coverage::Partial, + } +} + + +#[cfg(test)] +mod seal_gate_tests { + use super::*; + use crate::dynamics::{EnergyWindow, PowerEnvelope}; + + fn energy(uj: u64, p: Provenance, c: Coverage) -> Energy { + Energy { measured_uj: Some(uj), estimated_uj: uj, provenance: p, coverage: c } + } + fn act(tag: &[u8], uj: u64, p: Provenance, c: Coverage) -> ActivityRef { + ActivityRef::new(ContentId::of_bytes(tag), uj, p, c) + } + fn win(uj: u64, dur_ns: u64) -> EnergyWindow { + EnergyWindow { + started_ns: 0, + duration_ns: dur_ns, + joules_uj: uj, + provenance: Provenance::HwShunt, + coverage: Coverage::Whole, + declared_work: None, + } + } + fn env() -> PowerEnvelope { + PowerEnvelope { peak_mw: 60_000, idle_floor_mw: 1_000, source: "burst".into() } + } + fn sound() -> OutcomeTrace { + OutcomeTrace::new( + energy(1_000_000, Provenance::HwShunt, Coverage::Whole), + true, + vec![act(b"a1", 400_000, Provenance::HwShunt, Coverage::Whole), + act(b"a2", 350_000, Provenance::HwShunt, Coverage::Whole)], + ) + } + + /// The doctrine, as a test: traceability is a precondition. An untraceable + /// outcome cannot be admitted for sealing at all — a signature over an + /// unchecked claim is the most durable way to publish it. + #[test] + fn an_untraceable_outcome_cannot_be_sealed() { + let t = OutcomeTrace::new( + energy(1_000, Provenance::HwShunt, Coverage::Whole), true, vec![], + ); + assert_eq!( + t.admit(&[], None), + Err(SealRefusal::NotTraceable(TraceError::Unbound)) + ); + } + + /// A frozen counter cannot be sealed as anything. This is the live defect + /// shape: a busy window reporting nothing while the meter claims hardware. + #[test] + fn a_frozen_counter_cannot_be_sealed() { + let t = sound(); + let dead = win(0, 400_000_000); // 400 ms claiming zero + match t.admit(&[dead], Some(&env())) { + Err(SealRefusal::PhysicallyRefuted { window_index, detail }) => { + assert_eq!(window_index, 0); + assert!(detail.contains("frozen counter"), "{detail}"); + } + other => panic!("a dead counter must not seal: {other:?}"), + } + } + + /// Nor can a figure no part could have drawn. + #[test] + fn a_physically_impossible_figure_cannot_be_sealed() { + let t = sound(); + let absurd = win(10_000_000, 1_000_000); + assert!(matches!( + t.admit(&[absurd], Some(&env())), + Err(SealRefusal::PhysicallyRefuted { .. }) + )); + } + + /// The grade is derived, never supplied. Claiming hardware over modelled + /// evidence is refused rather than quietly downgraded: the caller has a + /// bug, and writing the weaker grade silently would hide it. + #[test] + fn a_grade_the_evidence_does_not_support_is_refused() { + let t = OutcomeTrace::new( + energy(1_000, Provenance::HwShunt, Coverage::Whole), + true, + vec![act(b"a1", 300, Provenance::Estimator, Coverage::Whole)], + ); + assert_eq!( + t.admit(&[], None), + Err(SealRefusal::GradeNotEarned { + claimed: Provenance::HwShunt, + floor: Provenance::Estimator + }) + ); + } + + /// A sound trace is admitted, and what comes back carries the grade the + /// evidence earned — so the sealer writes that instead of the claim. + #[test] + fn an_admitted_trace_carries_the_earned_grade_not_the_claimed_one() { + let t = OutcomeTrace::new( + energy(1_000, Provenance::ModelBased, Coverage::Whole), + true, + vec![act(b"a1", 300, Provenance::ModelBased, Coverage::Partial)], + ); + let a = t.admit(&[], None).expect("admits"); + assert_eq!(a.earned_provenance(), Provenance::ModelBased); + assert_eq!( + a.earned_coverage(), Coverage::Partial, + "one partial part makes the receipt partial" + ); + assert_eq!(a.reconciliation().residual_uj, 700); + } + + /// Claiming LESS than the evidence supports is allowed — under-claiming is + /// always permitted, and the receipt still records the floor. + #[test] + fn under_claiming_is_permitted() { + let t = OutcomeTrace::new( + energy(1_000, Provenance::Estimator, Coverage::Whole), + true, + vec![act(b"a1", 300, Provenance::HwShunt, Coverage::Whole)], + ); + let a = t.admit(&[], None).expect("under-claiming admits"); + assert_eq!(a.earned_provenance(), Provenance::Estimator, "the floor is the claim"); + } + + /// With no envelope supplied no physical check runs, and that is not a + /// pass — the trace is admitted on its arithmetic alone, which is exactly + /// what the absence of an envelope means. + #[test] + fn no_envelope_means_no_physical_claim_was_made() { + let t = sound(); + let implausible = win(0, 400_000_000); + assert!(t.admit(&[implausible], None).is_ok(), "arithmetic closes; physics unchecked"); + assert!(t.admit(&[win(0, 400_000_000)], Some(&env())).is_err(), "with an envelope it does not"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn energy(uj: u64, p: Provenance, c: Coverage) -> Energy { + Energy { measured_uj: Some(uj), estimated_uj: uj, provenance: p, coverage: c } + } + + fn act(tag: &[u8], uj: u64, p: Provenance, c: Coverage) -> ActivityRef { + ActivityRef::new(ContentId::of_bytes(tag), uj, p, c) + } + + /// The seam this module closes: an outcome whose energy is traceable to + /// nothing is refused, rather than published as a number. + #[test] + fn an_unbound_outcome_is_refused() { + let t = OutcomeTrace::new( + energy(1_000_000, Provenance::HwShunt, Coverage::Whole), + true, + vec![], + ); + assert_eq!(t.reconcile(), Err(TraceError::Unbound)); + } + + /// The residual is the cost of being accountable — authorisation, + /// attestation, verification, egress — which no operation receipt claims. + /// It is named, not absorbed. + #[test] + fn the_residual_names_what_no_operation_claims() { + let t = OutcomeTrace::new( + energy(1_000_000, Provenance::HwShunt, Coverage::Whole), + true, + vec![ + act(b"a1", 400_000, Provenance::HwShunt, Coverage::Whole), + act(b"a2", 350_000, Provenance::HwShunt, Coverage::Whole), + ], + ); + let r = t.reconcile().expect("reconciles"); + assert_eq!(r.accounted_uj, 750_000); + assert_eq!(r.residual_uj, 250_000, "a quarter of the outcome is overhead"); + assert_eq!(r.activities, 2); + // A quarter of a joule unattributed, expressed against the whole. + assert_eq!(r.unattributed_ppm(t.total_uj()), Some(250_000)); + } + + /// Over-accounting is a finding, not a rounding error: the work is + /// double-counted or an activity is bound to the wrong outcome. + #[test] + fn activities_claiming_more_than_the_outcome_are_refused() { + let t = OutcomeTrace::new( + energy(100, Provenance::HwShunt, Coverage::Whole), + true, + vec![act(b"a1", 90, Provenance::HwShunt, Coverage::Whole), + act(b"a2", 50, Provenance::HwShunt, Coverage::Whole)], + ); + assert_eq!( + t.reconcile(), + Err(TraceError::OverAccounted { accounted_uj: 140, total_uj: 100 }) + ); + } + + /// A duplicated activity inflates the accounted figure and shrinks the + /// residual — flattering in exactly the direction that matters, so it is + /// caught before the sum. + #[test] + fn a_duplicated_activity_is_refused_before_it_can_flatter() { + let dup = act(b"same", 100, Provenance::HwShunt, Coverage::Whole); + let t = OutcomeTrace::new( + energy(1_000, Provenance::HwShunt, Coverage::Whole), + true, + vec![dup.clone(), dup.clone()], + ); + match t.reconcile() { + Err(TraceError::DuplicateActivity { receipt }) => { + assert_eq!(receipt, ContentId::of_bytes(b"same")); + } + other => panic!("a double-bound activity must be refused: {other:?}"), + } + } + + /// A chain is only as graded as its weakest link. One estimate makes the + /// whole trace estimated — averaging would let a hardware reading launder + /// a pile of models. + #[test] + fn provenance_takes_the_floor_never_the_average() { + let t = OutcomeTrace::new( + energy(1_000, Provenance::HwShunt, Coverage::Whole), + true, + vec![ + act(b"a1", 300, Provenance::HwShunt, Coverage::Whole), + act(b"a2", 300, Provenance::Estimator, Coverage::Whole), + ], + ); + let r = t.reconcile().unwrap(); + assert_eq!(r.provenance_floor, Provenance::Estimator); + } + + /// An unaccounted share does not become accounted for by sitting next to + /// accounted ones. + #[test] + fn coverage_takes_the_floor_too() { + let t = OutcomeTrace::new( + energy(1_000, Provenance::HwShunt, Coverage::Whole), + true, + vec![ + act(b"a1", 300, Provenance::HwShunt, Coverage::Whole), + act(b"a2", 300, Provenance::HwShunt, Coverage::Partial), + ], + ); + assert_eq!(t.reconcile().unwrap().coverage_floor, Coverage::Partial); + } + + /// A trace whose operations exactly equal its outcome admits no overhead at + /// all — for a gated, attested, receipt-emitting system that is itself + /// worth a second look, so the residual reports zero rather than hiding it. + #[test] + fn a_zero_residual_is_reported_not_hidden() { + let t = OutcomeTrace::new( + energy(500, Provenance::HwShunt, Coverage::Whole), + true, + vec![act(b"a1", 500, Provenance::HwShunt, Coverage::Whole)], + ); + let r = t.reconcile().unwrap(); + assert_eq!(r.residual_uj, 0); + assert_eq!(r.unattributed_ppm(500), Some(0)); + } + + /// Energy spent on an outcome that did not complete is a different claim + /// from energy spent on one that did; the anchor is carried, not dropped. + #[test] + fn the_outcome_anchor_survives_into_the_trace() { + let t = OutcomeTrace::new( + energy(900, Provenance::HwShunt, Coverage::Whole), + false, + vec![act(b"a1", 900, Provenance::HwShunt, Coverage::Whole)], + ); + assert!(!t.completed, "the joules bought nothing, and the trace says so"); + assert!(t.reconcile().is_ok()); + } + + /// A ratio against nothing is not a figure. + #[test] + fn no_ratio_against_a_zero_total() { + let t = OutcomeTrace::new( + energy(0, Provenance::Estimator, Coverage::Whole), + true, + vec![act(b"a1", 0, Provenance::Estimator, Coverage::Whole)], + ); + assert_eq!(t.reconcile().unwrap().unattributed_ppm(0), None); + } +} + +// --------------------------------------------------------------------------- +// Enforcement: a precondition, not a report +// --------------------------------------------------------------------------- + +/// Why an outcome receipt must not be sealed. +/// +/// §5 of the spec says JCP "makes it a precondition, not a report". Until this +/// existed, traceability was a report: [`crate::Efficiency::seal`] serialises +/// and signs whatever it is handed, so an untraceable outcome, a +/// double-counted activity or a physically impossible figure could all be +/// sealed, signed, and indistinguishable afterwards from a sound one. A +/// signature over an unchecked claim is the most durable way to publish it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SealRefusal { + /// The trace does not reconcile. Carries the reason. + NotTraceable(TraceError), + /// A window's figure is impossible for the part and duration — above the + /// envelope, or below the floor a powered executing part must draw. The + /// second is the shape a frozen counter takes, and §5's liveness rule + /// exists because of it. + PhysicallyRefuted { window_index: usize, detail: String }, + /// The receipt claims a hardware grade the evidence under it does not + /// support. Refused rather than silently downgraded **in this direction**, + /// because a caller asking to seal `HwShunt` over modelled windows has a + /// bug, not a preference. + GradeNotEarned { claimed: Provenance, floor: Provenance }, +} + +impl core::fmt::Display for SealRefusal { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + SealRefusal::NotTraceable(e) => write!(f, "not traceable: {e}"), + SealRefusal::PhysicallyRefuted { window_index, detail } => { + write!(f, "window {window_index} is physically refuted: {detail}") + } + SealRefusal::GradeNotEarned { claimed, floor } => write!( + f, + "receipt claims {claimed:?} but the evidence under it supports only \ + {floor:?}; a signature would make the stronger claim permanent" + ), + } + } +} + +impl std::error::Error for SealRefusal {} + +/// What a trace establishes once every precondition has held. +/// +/// Construction is the proof: there is no way to build one except through +/// [`OutcomeTrace::admit`], so a function taking an `AdmittedTrace` knows the +/// checks ran rather than hoping the caller remembered. The same reason +/// `sandbox-attest` hands back a sealed run rather than a boolean. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AdmittedTrace { + reconciliation: Reconciliation, + /// The grade the evidence actually supports — **derived**, never the one + /// the caller supplied. Written into the receipt in place of the claim, so + /// aggregation cannot be the step at which a grade improves. + earned_provenance: Provenance, + earned_coverage: Coverage, +} + +impl AdmittedTrace { + pub fn reconciliation(&self) -> &Reconciliation { + &self.reconciliation + } + /// The provenance the receipt is entitled to carry. + pub fn earned_provenance(&self) -> Provenance { + self.earned_provenance + } + /// The coverage the receipt is entitled to carry. + pub fn earned_coverage(&self) -> Coverage { + self.earned_coverage + } +} + +impl OutcomeTrace { + /// Admit this trace for sealing, or refuse it. + /// + /// Three gates, in the order a verifier would apply them: + /// + /// 1. **It reconciles.** An unbound, duplicated or over-accounted trace is + /// refused — the arithmetic has to close before anything else matters. + /// 2. **No window is physically impossible.** Checked against the part's + /// envelope when one is supplied. A figure above the burst ceiling or + /// below the idle floor is refused regardless of what the counter said, + /// because the counter is the thing in question. + /// 3. **The grade is earned, not asserted.** The receipt may carry no + /// stronger provenance than the floor across its parts. A caller asking + /// for more is refused rather than quietly downgraded: asking to seal + /// `HwShunt` over `Estimator` evidence is a bug in the caller, and + /// silently writing the weaker grade would hide it. + /// + /// The returned [`AdmittedTrace`] carries the *derived* grade, so the + /// sealer writes what the evidence supports instead of what it was handed. + pub fn admit( + &self, + windows: &[crate::dynamics::EnergyWindow], + envelope: Option<&crate::dynamics::PowerEnvelope>, + ) -> Result { + use crate::dynamics::PhysicalVerdict; + + let reconciliation = self.reconcile().map_err(SealRefusal::NotTraceable)?; + + if let Some(env) = envelope { + for (i, w) in windows.iter().enumerate() { + match env.check(w) { + PhysicalVerdict::RefutedAbove { claimed_uj, ceiling_uj } => { + return Err(SealRefusal::PhysicallyRefuted { + window_index: i, + detail: format!( + "{claimed_uj} uJ exceeds the {ceiling_uj} uJ the part \ + could draw in {} ns", + w.duration_ns + ), + }) + } + PhysicalVerdict::RefutedBelow { claimed_uj, floor_uj } => { + return Err(SealRefusal::PhysicallyRefuted { + window_index: i, + detail: format!( + "{claimed_uj} uJ is below the {floor_uj} uJ a powered, \ + executing part must draw in {} ns — the shape a frozen \ + counter takes", + w.duration_ns + ), + }) + } + // Inside the envelope, or unboundable. Neither is a + // verification; both are permitted to seal, because the + // envelope only ever refutes. + _ => {} + } + } + } + + if self.total.provenance != reconciliation.provenance_floor + && stronger(self.total.provenance, reconciliation.provenance_floor) + { + return Err(SealRefusal::GradeNotEarned { + claimed: self.total.provenance, + floor: reconciliation.provenance_floor, + }); + } + + Ok(AdmittedTrace { + earned_provenance: reconciliation.provenance_floor, + earned_coverage: reconciliation.coverage_floor, + reconciliation, + }) + } +} + +/// Whether `a` claims a stronger acquisition class than `b`. +fn stronger(a: Provenance, b: Provenance) -> bool { + fn rank(p: Provenance) -> u8 { + match p { + Provenance::HwShunt => 2, + Provenance::ModelBased => 1, + Provenance::Estimator => 0, + } + } + rank(a) > rank(b) +} diff --git a/jcp/jcp-rs/crates/jcp-efficiency/tests/energy_trace_pack.rs b/jcp/jcp-rs/crates/jcp-efficiency/tests/energy_trace_pack.rs new file mode 100644 index 000000000..c094f6e80 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-efficiency/tests/energy_trace_pack.rs @@ -0,0 +1,184 @@ +//! Replay the published `energy_trace/1` pack from disk. +//! +//! SPEC §5.3–5.6 states rules, and `gen_trace_vectors` emits the vectors. This +//! reads those bytes back — the same bytes a third party would — and checks the +//! reference implementation reproduces every recorded verdict. A pack only its +//! own generator can read is not a conformance pack. +//! +//! The refusals matter more than the acceptances. An implementation that admits +//! an unbound outcome, a double-counted activity or a frozen counter has failed +//! in the direction that flatters, which is the direction nobody notices. + +use jcp_efficiency::dynamics::{ + DrawShape, EnergyWindow, PhysicalVerdict, PowerEnvelope, +}; +use jcp_efficiency::trace::{OutcomeTrace, TraceError}; +use serde_json::Value; + +fn pack() -> Value { + let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../conformance/v1/energy_trace.json"); + // Assert, not skip. A `return` here would make this test pass green in the + // one case it exists to catch — the pack going missing or moving. + assert!( + p.exists(), + "conformance pack missing at {} — it is what this test replays", + p.display() + ); + let bytes = std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())); + serde_json::from_slice(&bytes).expect("pack parses") +} + +#[test] +fn the_pack_declares_its_profile_and_spec() { + let v = pack(); + assert_eq!(v["profile"], "energy_trace/1"); + assert!(v["spec"].as_str().unwrap().contains("5.3-5.6")); +} + +/// Every positive conservation vector reproduces its recorded reconciliation. +#[test] +fn conservation_positives_reproduce_their_recorded_verdicts() { + let v = pack(); + let cases = v["conservation"]["positive"].as_array().expect("positives"); + assert!(cases.len() >= 2, "expected the published positive cases"); + for c in cases { + let note = c["note"].as_str().unwrap_or("?"); + let t: OutcomeTrace = + serde_json::from_value(c["input"].clone()).unwrap_or_else(|e| panic!("{note}: {e}")); + let r = t.reconcile().unwrap_or_else(|e| panic!("{note}: must reconcile: {e}")); + let exp = &c["expected"]; + assert_eq!( + serde_json::to_value(r.accounted_uj).unwrap(), exp["accounted_uj"], + "{note}: accounted" + ); + assert_eq!( + serde_json::to_value(r.residual_uj).unwrap(), exp["residual_uj"], + "{note}: the residual is the whole point" + ); + assert_eq!( + serde_json::to_value(r.provenance_floor).unwrap(), exp["provenance_floor"], + "{note}: provenance takes the floor" + ); + assert_eq!( + serde_json::to_value(r.coverage_floor).unwrap(), exp["coverage_floor"], + "{note}: coverage takes the floor" + ); + } +} + +/// Every refusal is actually refused, and for the recorded reason. An +/// implementation that accepted these would be wrong in the flattering +/// direction. +#[test] +fn conservation_negatives_are_refused_for_the_recorded_reason() { + let v = pack(); + let cases = v["conservation"]["negative"].as_array().expect("negatives"); + assert!(cases.len() >= 3, "expected the three published refusals"); + for c in cases { + let note = c["note"].as_str().unwrap_or("?"); + let t: OutcomeTrace = + serde_json::from_value(c["input"].clone()).unwrap_or_else(|e| panic!("{note}: {e}")); + let err = t.reconcile().expect_err(&format!("{note}: must be refused")); + let want = c["expected"]["error"].as_str().unwrap(); + let got = match err { + TraceError::Unbound => "Unbound", + TraceError::DuplicateActivity { .. } => "DuplicateActivity", + TraceError::OverAccounted { .. } => "OverAccounted", + }; + assert_eq!(got, want, "{note}: refused, but for the wrong reason"); + } +} + +/// The physical bound refutes above the envelope and below the floor — the +/// second being the shape a frozen counter takes. +#[test] +fn physical_bounds_reproduce_their_recorded_verdicts() { + let v = pack(); + let env: PowerEnvelope = + serde_json::from_value(v["physical"]["envelope"].clone()).expect("envelope"); + let cases = v["physical"]["cases"].as_array().expect("cases"); + assert!(cases.len() >= 4); + let mut saw_below = false; + for c in cases { + let note = c["note"].as_str().unwrap_or("?"); + let w: EnergyWindow = + serde_json::from_value(c["input"].clone()).unwrap_or_else(|e| panic!("{note}: {e}")); + let got = match env.check(&w) { + PhysicalVerdict::NotRefuted { .. } => "NotRefuted", + PhysicalVerdict::RefutedAbove { .. } => "RefutedAbove", + PhysicalVerdict::RefutedBelow { claimed_uj, floor_uj } => { + saw_below = true; + if let Some(f) = c["expected"]["floor_uj"].as_u64() { + assert_eq!(floor_uj, f, "{note}: floor"); + assert_eq!(claimed_uj, 0, "{note}: a frozen counter claims nothing"); + } + "RefutedBelow" + } + PhysicalVerdict::Unboundable => "Unboundable", + }; + assert_eq!(got, c["expected"]["verdict"].as_str().unwrap(), "{note}"); + } + assert!(saw_below, "the frozen-counter refutation must be in the pack"); +} + +/// Character survives a change of scale, and a padded total does not read as +/// sustained. These two together are what the shape check is for. +#[test] +fn draw_character_reproduces_its_recorded_verdicts() { + let v = pack(); + let floor = v["shape"]["idle_floor_uj"].as_u64().expect("idle floor"); + let cases = v["shape"]["cases"].as_array().expect("cases"); + assert!(cases.len() >= 6); + let mut saw_inconsistent = false; + for c in cases { + let note = c["note"].as_str().unwrap_or("?"); + let declared: DrawShape = + serde_json::from_value(c["input"]["declared"].clone()).expect("declared"); + let windows: Vec = + serde_json::from_value(c["input"]["windows"].clone()).expect("windows"); + let got = jcp_efficiency::dynamics::check_shape(declared, &windows, floor); + let want = c["expected"]["verdict"].as_str().unwrap(); + let name = match &got { + jcp_efficiency::dynamics::ShapeVerdict::Consistent { shape } => { + if let Some(s) = c["expected"]["shape"].as_str() { + assert_eq!( + serde_json::to_value(shape).unwrap().as_str().unwrap(), s, + "{note}: shape" + ); + } + "Consistent" + } + jcp_efficiency::dynamics::ShapeVerdict::Inconsistent { .. } => { + saw_inconsistent = true; + "Inconsistent" + } + jcp_efficiency::dynamics::ShapeVerdict::Unresolvable { windows } => { + if let Some(n) = c["expected"]["windows"].as_u64() { + assert_eq!(*windows as u64, n, "{note}: window count"); + } + "Unresolvable" + } + }; + assert_eq!(name, want, "{note}"); + } + assert!(saw_inconsistent, "the padding refutation must be in the pack"); +} + +/// Anti-vacuity across the whole pack: a wrong path, a renamed key or an empty +/// array would otherwise let every test above pass having checked nothing. +#[test] +fn the_pack_carries_every_section_it_claims() { + let v = pack(); + for section in ["conservation", "budget", "physical", "shape", "dynamism"] { + assert!(!v[section].is_null(), "section {section} missing from the pack"); + } + let total: usize = v["conservation"]["positive"].as_array().map_or(0, Vec::len) + + v["conservation"]["negative"].as_array().map_or(0, Vec::len) + + v["physical"]["cases"].as_array().map_or(0, Vec::len) + + v["shape"]["cases"].as_array().map_or(0, Vec::len) + + v["dynamism"]["cases"].as_array().map_or(0, Vec::len) + + v["budget"]["positive"].as_array().map_or(0, Vec::len) + + v["budget"]["negative"].as_array().map_or(0, Vec::len); + assert!(total >= 20, "expected the full published pack, replayed {total} cases"); +}