Skip to content

Country-agnostic gate battery core: phased evaluation, unconditional reporting, per-country gate selection (#611) - #619

Merged
MaxGhenis merged 4 commits into
mainfrom
country-agnostic-gate-battery
Aug 5, 2026
Merged

Country-agnostic gate battery core: phased evaluation, unconditional reporting, per-country gate selection (#611)#619
MaxGhenis merged 4 commits into
mainfrom
country-agnostic-gate-battery

Conversation

@juaristi22

Copy link
Copy Markdown
Collaborator

Where this sits

The tooling half of #611 increment 1 — the country-agnostic core only, deliberately scoped to files neither #610 nor #617 touch so it can review in parallel with both. The UK migration (uk/gates.json, the terminal_gates.py rewrite, contract.py schema 4 + per-country pins) is the consumer half and follows after #610 merges and the #612 Frame carrier lands, since its content consumes both.

What this adds

country_spec.py: phases as country data. gates.json gains a phases header declaring the country's phase order (UK national will declare [preflight, terminal]; the US pool builder [assembled, transferred, simulated] — its existing POOL_CHECKPOINT_STAGE_ORDER) and every gate entry declares its phase. The vocabulary (ALLOWED_GATE_PHASES) is global so reports stay comparable across countries; the order is spec data because the unreached outcome is only computable from it. Entries also gain a reviewed not_applicable reason — a deliberately unselected gate becomes a reported receipt instead of a silence (the shape #578 item 5 will use). The gate-function allowlist widens with the names the UK battery and pool builder need, kept country-neutral (weight_ess, not uk_weight_ess).

gate_battery.py: one executor, two layers. The gate comparisons stay in populace.build.gates, untouched. The new binding layer (GateBinding / FunctionBinding) adapts declared spec parameters into gate keyword arguments and routes evidence out of a per-phase EvidenceContext (frame, named artifacts, the loaded spec). Behaviour is lifted from the UK terminal battery, which already does it correctly:

  • Batched within a phase, fail-closed at the boundary. Every entry in a phase evaluates even when an earlier one fails; a raising evaluator becomes a failed result (_evaluate_gate, verbatim from uk_runtime/terminal_gates.py), and a result under the wrong name fails rather than impersonating another gate.
  • Five-state outcome taxonomy. passed / failed / not_applicable (reviewed spec reason) / evidence_absent (no implementation, or missing evidence — named, never silent; on release candidates it blocks, because a missing frozen reference is not a passing gate, while dev builds record it and continue) / unreached (an upstream phase blocked first). Conflating unreached with not_applicable is the failure mode the battery exists to prevent, so phases_evaluated and blocked_at_phase are report fields and the attestation covers both.
  • Write-then-block by construction. run_phase persists the full report — later phases as unreached placeholders — before returning; enforce is a separate call that can only run afterwards, with the two blocking modes the three existing call sites need: blocks_artifact (national releases — the raise precedes the H5 write) and marks_artifact (the pool builder — the intermediate H5 is written and the manifest records the verdict).
  • Policy and evidence digests. policy_sha256 is the canonical hash of the resolved entries — ids, gates, phases, criticalities, parameters, exclusion reasons — so a threshold outside the hash is not attested, with no hand-threaded payload builder per country. Bindings may contribute per-entry evidence_sha256 lines; evidence that cannot be canonicalized turns a pass into a failure rather than shipping unattested.
  • Attestation, generalized. Same HMAC-SHA256 dance as the UK report (sign with a null signature slot, fill the slot), same env-var convention per country (POPULACE_{CC}_TERMINAL_GATE_SIGNING_KEY — the UK's existing name is the uk instance). The signature is valid over failed reports too: a signed failure is evidence, an unsigned one is a hole. An unsigned report is never shippable.

Belgium proved compatible, not asserted. build/be/gates.json gains its phase declarations (all nine entries terminal — mechanical, no build exists) and a test loads the BE spec, runs the battery, and asserts a well-formed report in which every unimplemented gate name is a named evidence_absent gap — never a crash, never a pass. The golden spec file is regenerated for the deliberate byte change.

Registry, deliberately minimal. Bindings ship for the three gates whose evidence already travels as plain data (weights_audit, input_mass_parity, tail_concentration — the #609 pair among them, exercised end-to-end in tests with declared thresholds reaching the gate). The rest of the registry arrives with the consumers; an incomplete registry cannot manufacture a pass.

What this does not do

Verification

  • test_gate_battery.py (18 tests): taxonomy exhaustiveness, report-on-disk-before-the-raise, unreached vs not_applicable under a preflight block, fail-closed evaluators, evidence-absence semantics per release_candidate, phase-order enforcement, signature validity over a failed report recomputed independently, policy-hash movement with a threshold, BE compatibility.
  • test_country_spec.py: eight new refusal tests for the phase and not_applicable validation; BE phase assertions; golden regenerated.
  • Full populace-build suite passes; ruff check . clean.

Relates

🤖 Generated with Claude Code

…reporting, per-country selection (#611)

The tooling half of #611 increment 1. populace.build.gate_battery
evaluates a country's declared gate selection phase by phase, resolves
every declared entry to exactly one of five statuses (passed / failed /
not_applicable / evidence_absent / unreached), persists the full
attested report before any blocking decision, and hashes every declared
threshold into the policy digest. gates.json gains a per-country phases
order and per-entry phase; thresholds live in entry parameters; a
reviewed not_applicable reason turns a deliberately unselected gate
into a reported receipt. The Belgian spec migrates mechanically (nine
entries, all terminal) and a compatibility test proves it runs as
declared, with unimplemented gate names surfacing as named
evidence_absent gaps rather than crashes. The UK terminal battery is
untouched; its migration onto this executor is the consumer half,
sequenced after #610 and the #612 Frame carrier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@juaristi22
juaristi22 requested a review from MaxGhenis August 5, 2026 11:36

@MaxGhenis MaxGhenis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This core is the right shape — phases as country data with a global vocabulary, unreached computed only from spec order, and the reviewed not_applicable receipt are exactly what the #578 battery consolidation needs, and the US-path neutrality holds in fixture diffs. Both blockers are in the trust boundary between the caller and the report, and they mirror what the #616 review rounds found in the US battery this same week — so there's a ready-made convergence path rather than a redesign.

1. A truncated substitute spec can claim canonical provenance and ship signed. GateBatteryRun (gate_battery.py:677) accepts a naked GatesManifest plus independently supplied spec_fingerprint and gates_manifest_sha256, verifying neither; completeness iterates only the supplied manifest (:786). Repro with the canonical nine-gate spec's digests over a one-gate substitute: reported_gate_count=1, claims_canonical_spec_fingerprint=True, signed=True, shippable=True. And gates_manifest_sha256 is optional — a signed release can ship without it. The report currently receipts a caller assertion, not the spec.

2. Post-evaluation mutation of the parameter dict attests a policy that was never evaluated. The frozen GateSelectionSpec holds a mutable dict (country_spec.py:363) and the policy digest reads it live after evaluation (gate_battery.py:807). Repro: evaluate at tolerance 0.5 (passes at 5% drift), mutate to 0.01, and the report signs hash_matches_strict=True, reported_status=passed for a policy that fails at 1%.

Both close with the same move: digests derived by the run from the objects it actually evaluated (never caller-supplied), spec/manifest content deep-frozen at construction, and the canonical spec loaded from an authoritative module path with any test substitution receipted as non-canonical. That's precisely the canonical-authority doctrine #616's fix round is landing on the US side — once it merges (expected within a day), this PR can consume the same primitives and the two battery efforts converge on one framework, which was the point of #611. The phase model and reporting core need no changes.

@MaxGhenis

Copy link
Copy Markdown
Contributor

Pushed the two review blockers as fix commits on top of your branch (María — your phase model and reporting core are untouched; these only close the trust-boundary items):

  • cdb9f5e7 — provenance digests are now derived by GateBatteryRun from the manifest it actually evaluates; the caller digest arguments are gone, gates_manifest_sha256 is required for shippability, and the review's one-gate-substitute repro is a committed test proving substitute specs can no longer claim canonical provenance.
  • d6258f77 — gate parameters are recursively frozen at construction, with the committed test proving post-evaluation mutation of the source dict changes nothing in the report, results, or policy digest.

Full workspace 5,095 passed / 0 failed; #583 guard 495 exact. The later convergence onto #616's canonical-authority primitives stays open as the follow-up we discussed — these fixes are deliberately self-contained so this PR doesn't wait on it.

@MaxGhenis MaxGhenis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review findings closed with the repros as committed regressions (receipts in the fix comment). Approving to dismiss the earlier request-changes.

@MaxGhenis
MaxGhenis merged commit 188c5d9 into main Aug 5, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants