Skip to content

Mode-3 case schema + UK battery: UKMOD/EUROMOD lane groundwork - #49

Open
vahid-ahmadi wants to merge 6 commits into
mainfrom
uk/ukmod-cases-schema
Open

Mode-3 case schema + UK battery: UKMOD/EUROMOD lane groundwork#49
vahid-ahmadi wants to merge 6 commits into
mainfrom
uk/ukmod-cases-schema

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

Part of #41 — the schema/battery half of the UKMOD/EUROMOD mode-3 lane. Designed per #5 as the single case-level schema shared with the TAXSIM lane: country on the case and oracle (ukmod | taxsim) on the result row keep it engine-agnostic, so mode 3 gets one schema, not two.

What's here

  • sources/ukmod-cases/SCHEMA.md — the case-diff contract. A case = {case_id, description, policy_year, country, household, expected_focus, rationale} with an engine-neutral, closed household input vocabulary (people with ages/incomes/disability + carer flags, benefit-unit structure, region/tenure/rent) that each lane's connector maps to its engines' variables. A result row = {case_id, variable, pe_value, oracle_value, oracle, engine_version, oracle_version, computed_at, abs_diff, classification}. Classification is a fail-loud closed set — match_exact / match_within_tolerance / pe_gap / oracle_difference / policy_scope_mismatch / rounding / unclassified — with oracle_difference and rounding reachable only by adjudication with a writeup. Tolerances per variable class: currency £0.01/week (0.52/year annual comparisons), booleans exact.
  • sources/ukmod-cases/battery/cases.json — 14 curated UK hypothetical households, inputs + expected focus only (no fabricated expected values; both sides of every comparison come from engine runs later). Coverage: single unemployed on UC; lone parent + 2 children with LHA rent; two-child limit binding with a pre-April-2017 protected child; London benefit cap; 55% taper + work allowance; minimum income floor; Pension Credit single + couple; mixed-age couple routed to UC; Scottish bands + Scottish Child Payment; HICBC at the £70k mid-taper; NI threshold edges (£12,570 / £50,270) with salary sacrifice; carer CA + UC carer element; £100k personal-allowance taper. Round, auditable inputs with a rationale per case.
  • scorecard_db/case_diffs.pyCaseSpec / CaseResult dataclasses in the models.py fail-loud style (closed enums raise on unknown values; benefit units must partition people; owner-occupiers carry no rent; abs_diff must reconcile), the battery loader, and classify() — the first-pass classifier whose above-tolerance default is unclassified, never a flattering bucket.
  • tests/test_case_schema.py — battery loads/validates, unique country-prefixed ids, inputs-only invariant, focus coverage, classifier and result-row edge cases (28 tests).
  • data/lanes.json (+ app/public/data mirror) — ukmod-cases advanced registered → cataloged.

Out of scope (remaining for #41)

The JRC connector run itself (needs the UKMOD environment): vocabulary→UKMOD input mapping, executing the battery on both engines, appending CaseResult rows, and publishing the miss table.

Verification

  • PYTHONPATH=. uv run --with pytest pytest tests/ -q — 156 passed, 4 skipped
  • uvx ruff format --check scorecard_db tests pipeline — clean
  • data/lanes.json regenerated with the feed's exact serialization; app mirror byte-identical

🤖 Generated with Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor

Dual-gate review: NEEDS-FIXES. Blockers: (1) arbitrary misses can be blessed — match_within_tolerance/rounding/oracle_difference validate with empty annotations and no tolerance or adjudication writeup, contradicting the module contract and SCHEMA.md; (2) closed-schema gaps — benefit-unit adults/children and expected_focus aren't required to be lists, so one-character strings validate; (3) the two-child-limit case can't exercise its rationale — policy year 2026 begins 6 April, the day the limit ended, and exempting the eldest (already within the first two children) never tests a third-child exemption. Verified: no dependency on #48.

@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

All three blockers fixed in 838cc45.

# Blocker Fix
1 match_within_tolerance/rounding/oracle_difference validated with empty annotations and no tolerance or writeup CaseResult grows a tolerance field, required on (and only on) match_within_tolerance rows with 0 < abs_diff <= tolerance enforced — stored per row so a tolerance-table change can never silently re-bless history. oracle_difference/rounding are now ADJUDICATED_ONLY: they raise without a non-empty annotations writeup. annotations itself must be a list of non-empty strings. SCHEMA.md documents all of it
2 Benefit-unit adults/children and expected_focus accepted strings (iterate as characters) Both now require actual lists — person-id strings for unit members, variable names for expected_focus; a bare string raises. Pinned by tests either way
3 The 2026 two-child-limit case could not exercise its rationale (limit ended 6 April 2026; the "exempt" eldest was already within the first two elements) Replaced with an honest pair: uk-uc-two-child-limit-multiple-birth at policy year 2025 (limit in force), all three children post-April-2017 and the multiple-birth exception sitting on the third child, so the exemption strictly changes entitlement (3 elements vs 2); and uk-uc-two-child-limit-abolished — the same family in 2026, where an engine still applying the limit under-pays exactly one element. test_two_child_limit_pair pins the year split, post-2017 DOBs, and the shared-DOB construction

Tests: test_case_schema.py 25 → 37 (empty-annotation rows fail for each of the three statuses; string adults/children/expected_focus fail; tolerance bounds at both edges; zero-diff can never be match_within_tolerance). Full suite 164 passed / 4 skipped; ruff format --check clean.

Note: the battery now spans policy years {2025, 2026} — test_uk_battery_shape says why. Case ids are safe to rename since no CaseResult rows reference the old id (the connector run is still pending the UKMOD environment).

@DTrim99

DTrim99 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Review — Mode-3 case schema + UK battery

Disciplined contract PR. The closed vocabularies are enforced in code, not just SCHEMA.md prose: unknown oracle / classification / country / person-key / household-key / case-key all raise (case_diffs.py:223-291, 346-402, 447-471) and are tested (test_case_schema.py:1119-1179, 1250-1256). The "single schema shared with the TAXSIM lane" claim holds — there's no divergent taxsim-cases schema in the tree, so this is the one definition both lanes will share. Battery cases are inputs-only, each with a rationale. No blocking issues.

Should address

  • classify()CaseResult tolerance seam is untested end-to-end (case_diffs.py:405-430 vs 336-397). classify returns MATCH_WITHIN_TOLERANCE but not the tolerance it used; the caller must re-derive DEFAULT_TOLERANCES[variable_class] and thread it into CaseResult.tolerance, which re-validates. Nothing wires classify → CaseResult, so a caller passing the wrong tolerance/variable_class is uncaught. Either add one integration test or have classify also return the applied tolerance — worth doing before Official-calculator oracles (mode 3): closed-set extension, dated-reading provenance, calculator work list #64's connector lane relies on it.
  • variable_class never persisted on CaseResult (case_diffs.py:329-344) — a stored row can't be re-classified/audited for which tolerance rule applied except via the free-text variable name.

Suggestions

Reviewed with Claude Code assistance.

@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

All four review items addressed in a1d57d7:

Item Fix
classify → CaseResult seam untested CaseResult.from_classification() is now the one wiring path: it derives abs_diff, threads the exact tolerance classify judged against (only onto match_within_tolerance rows), and persists the variable class — no caller re-derives DEFAULT_TOLERANCES[...] by hand. Six end-to-end seam tests, including a custom-table case proving the stored tolerance is the one actually applied.
variable_class not persisted Added to CaseResult (closed vocabulary, validated), set by the constructor path, documented in SCHEMA.md.
No schema_version SCHEMA_VERSION = 1 on the battery file (missing/wrong version raises in load_battery) and on every result row (foreign version raises) — #64's connector lane stacks on an explicit contract version.
date_of_birth shape-only Now parsed with date.fromisoformat; 2026-13-40, 0000-00-00, and 2025-02-30 all fail, 1996-02-29 passes.

Suite: 176 passed / 4 skipped; ruff clean.

@DTrim99

DTrim99 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Re-review — all addressed ✅

Went back through my prior notes against the new commits; everything's resolved:

  • classify→CaseResult tolerance seamCaseResult.from_classification(...) is now the single wiring path (threads the exact tolerance classify judged against), covered end-to-end by TestClassifyToResultSeam.
  • variable_class persisted on CaseResult and round-tripped by the seam.
  • schema_version on the battery + every result (loader rejects mismatch), and date_of_birth now real-parsed via date.fromisoformat so 2026-13-40/2025-02-30 fail.

Nice — no remaining concerns from my side.

@DTrim99 DTrim99 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed — all prior findings are addressed with tests (the classify→CaseResult from_classification seam, variable_class persisted, schema_version, and real-date date_of_birth validation). Approving.

vahid-ahmadi and others added 3 commits August 20, 2026 12:35
Groundwork for #41, designed as the single case-level schema mode 3
shares with the TAXSIM lane (#5) — country on the case and oracle on the
result row keep it engine-agnostic.

- sources/ukmod-cases/SCHEMA.md: the case-diff contract — engine-neutral
  household input vocabulary, result-row shape, the closed fail-loud
  classification set (match_exact / match_within_tolerance / pe_gap /
  oracle_difference / policy_scope_mismatch / rounding / unclassified),
  and per-variable-class tolerances (currency GBP 0.01/week = 0.52/year;
  booleans exact).
- sources/ukmod-cases/battery/cases.json: 14 curated UK hypothetical
  households, inputs + expected focus only (no fabricated expected
  values): UC standard/housing, lone parent + LHA, two-child limit with
  the pre-April-2017 protection, London benefit cap, taper + work
  allowance, minimum income floor, Pension Credit single/couple,
  mixed-age couple routed to UC, Scottish bands + Scottish Child
  Payment, HICBC mid-taper, NI threshold edges with salary sacrifice,
  carer CA/UC interaction, and the GBP 100k personal-allowance taper.
- scorecard_db/case_diffs.py: CaseSpec/CaseResult dataclasses (closed
  enums raise on unknown values, models.py doctrine), battery loader,
  and the classify() first-pass classifier (above tolerance defaults to
  unclassified — adjudication, never a flattering bucket).
- tests/test_case_schema.py: battery validation, unique ids, inputs-only
  invariant, classifier and result-row edge cases.
- data/lanes.json (+ app mirror): ukmod-cases registered -> cataloged;
  connector run pending the UKMOD environment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hild-limit cases

Three review blockers from the dual-gate pass:

1. Adjudicated misses need their paperwork: CaseResult now requires the
   judged-against tolerance on match_within_tolerance rows
   (0 < abs_diff <= tolerance, tolerance stored so table changes never
   re-bless old rows) and a non-empty annotations writeup on the
   adjudicated-only classes oracle_difference / rounding (ADJUDICATED_ONLY);
   tolerance is forbidden elsewhere and annotations must be a list of
   non-empty strings.

2. Closed-schema list types: benefit-unit adults/children must be lists of
   person-id strings and expected_focus must be a list — bare strings no
   longer validate by iterating as characters.

3. The two-child-limit case is split into an honest pair: a policy-year-2025
   multiple-birth case where the exemption sits on the third child and
   strictly changes entitlement (the limit was abolished 6 April 2026, so
   only a pre-abolition year can exercise it), plus a 2026 abolition
   counterpart of the same family where an engine still applying the limit
   under-pays one element.

SCHEMA.md documents the tightened contract; tests extended to 37 in
test_case_schema.py (suite 164 passed / 4 skipped).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ma_version, real-date DOB validation

Review items from the 2026-08-18 pass:
- CaseResult.from_classification() is the one wiring path from
  classify to a stored row: derives abs_diff, threads the exact
  tolerance classify judged against, persists variable_class. Six
  end-to-end seam tests.
- variable_class persisted on CaseResult (closed vocabulary) so stored
  rows are auditable without inferring the rule from the variable name.
- SCHEMA_VERSION = 1 on the battery file and every result row; a
  mismatched or missing version raises, so breaking changes migrate
  explicitly.
- date_of_birth must be a real calendar date (2026-13-40 now fails).

Suite: 176 passed, 4 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vahid-ahmadi
vahid-ahmadi force-pushed the uk/ukmod-cases-schema branch from a1d57d7 to 8adbc93 Compare August 20, 2026 11:35
@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

Rebased onto main (post #70#73) — it had gone conflicting on three trivial spots: the lanes.json updated date (both copies kept identical) and two __all__ orderings in scorecard_db/__init__.py, all resolved as unions with no semantic change. Suite 290 passed / 6 skipped, format clean.

`updated` is derived, not authored: sync_lane_feed() sets it from the
max of the lane rows' own updated_at (2026-08-19) and rewrites the file.
The rebase conflict resolution hand-set it to 2026-08-20, so every
ingest reverts it — invisible while the DB was committed, but #74 builds
the DB during collection, so the drift now fails the no-drift gate and
test_app_data_copies_match_committed_data (the build rewrites data/ but
not the app/public/ copy).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

Post-#74 fix (9eff590). Verified the whole stack against the new DB-built-in-CI main and this branch was the one breakage — my own, from the earlier rebase: I hand-set lanes.json's updated to 2026-08-20, but that field is derivedsync_lane_feed() sets it from the max of the lane rows' updated_at (2026-08-19) and rewrites the file.

Invisible while the DB was committed (ingest never ran during the suite); with #74 building the DB at collection it reverts every run, so it failed both new no-drift gates and test_app_data_copies_match_committed_data (the build rewrites data/ but not the app/public/ copy, so the committed copies diverge at runtime).

Reverted to the derived value in both copies. Merged against current main locally: 295 passed / 2 skipped, no-drift clean before and after the suite. Same class of latent breakage as the PyYAML finding on #56 — old green CI was luck.

@MaxGhenis

Copy link
Copy Markdown
Contributor

Re-gate round 2 — closure fixed; the seam and the epistemic wiring aren't yet

Cleared this round: unknown JSON keys and unknown enum strings now raise (the narrow closure blocker), the synthetic merge with post-#74 main is conflict-free with no committed-DB dependency, and an in-memory replay of all feed synchronizers reproduced lanes.json byte-identically — the branch is safe against the new no-drift gates as it stands.

Six findings, four blocking (each was probed, not read):

  1. The classify → CaseResult seam is still bypassable. CaseResult(...) accepts a caller-supplied classification and the validator never recomputes classify() — the factory is a convention, not an enforcement. Probes accepted: 100 == 100 persisted as unclassified or pe_gap (classifier says match_exact); numeric mismatches persisted as pe_gap/policy_scope_mismatch without annotations; boolean 1 != 0 persisted as match_within_tolerance with a caller-provided tolerance. Only oracle_difference and rounding require writeups, so the adjudication claim doesn't hold either. Fix shape: the validator recomputes and compares, or construction is only possible through the classifying factory.

  2. variable_class isn't actually required — still Optional[...] = None, and the shipped valid-row test itself omits it. Same class: an omitted result schema_version silently defaults to 1 rather than raising.

  3. Mode-3 results bypass the repo's epistemic discipline. CaseResult carries no calibration_relationship (UKMOD is already deliberately held_out in relationships.py — case rows never record it), no executed baseline key, no case/battery digest, no bundle/run pin, no JRC connector revision, and no citable adjudication link; blank engine versions and a nonexistent case_id were accepted. There's also no case table, writer, export, or descriptive route yet, and the enum collapses raw comparison status with normative diagnosis into one field outside the action-link gate — so nothing structurally prevents match_* reading as a win or an unsupported pe_gap reading as blame. Suggest splitting status (descriptive) from diagnosis (gated, citable) exactly as external_scores/diagnoses do.

  4. The two-child-limit pair doesn't test its claimed mechanism. Both the 2025 and 2026 households keep the same twins — an engine that wrongly retains the limit but rightly applies the multiple-birth exception still pays three elements, so the case can't catch the failure its rationale names; and the year-on-year delta moves ages, rates, and policy year together, so it can't isolate abolition. The registered pre_ab2025 world is the right instrument for a same-year counterfactual — cases/results just need a way to reference a registered baseline world.

  5. Identity closure is local, not integrated. Probes accepted region="MARS", invented expected_focus values, NaN incomes, infinite rent, boolean-class values outside {0,1}, and an arbitrary battery schema — none of these route through uk_aliases or any case registry. The private-rent cases also don't pin one LHA world: the schema routes LHA by ITL-1 region while forbidding brma, and the Yorkshire rationale concedes the rent clears the cap only in "most" BRMAs — the two engines aren't guaranteed the same world.

  6. Accuracy note on 9eff590: the current bytes are right, but sync_lane_feed() doesn't derive the updated date — it copies a caller-supplied literal, and today's literals happen to reproduce 2026-08-19. Worth either truly deriving it or documenting the literal contract, so the next caller doesn't reintroduce the drift you fixed.

47 focused tests pass and the worktree stays clean — the schema-closure work is real progress; the remaining arc is making the invariants structural rather than conventional, and wiring mode-3 into the same descriptive/gated discipline as the rest of the DB. Happy to pair on #3's table design — it's the piece with repo-wide surface.

🤖 Generated with Claude Code

1. The classify -> CaseResult seam is enforced, not conventional.
   __post_init__ now RECOMPUTES classify() and compares. A stored
   classification is valid only if it equals the classifier's, or if it
   adjudicates a row the classifier left `unclassified` — drawn from
   ADJUDICATABLE and carrying a writeup. Every round-2 probe now raises:
   100 vs 100 as unclassified/pe_gap, a numeric mismatch as
   policy_scope_mismatch with nothing said, and a boolean 1 vs 0 as
   match_within_tolerance with a caller-supplied tolerance of 100.
   pe_gap and policy_scope_mismatch are two-sided: mechanical on a null
   side, adjudicated (and explained) on a numeric-vs-numeric row —
   previously only oracle_difference and rounding demanded a writeup.
   Tolerances now come from DEFAULT_TOLERANCES alone; from_classification
   no longer takes a table, and a row carrying a different tolerance is
   rejected. It DOES now take annotations, which is also what made every
   #64 calculator call raise.

2. variable_class is required (the validator cannot check a row without
   it, and the shipped valid-row test omitted it) and schema_version is
   required rather than defaulting to the current contract. Blank engine
   or oracle versions are rejected.

4. The two-child-limit family isolates one mechanism per pair. Three
   2026 cases: `binding` (three SEPARATE births, so no exception can
   stand in for the limit) and `multiple-birth` (identical household,
   one date of birth apart) both in the registered `pre_ab2025` world,
   and `abolished` — byte-identical household, same year, same rates —
   under current law. binding/abolished attributes the abolition;
   binding/multiple-birth attributes the exception. The old pair used
   twins on both sides, so an engine that wrongly kept the limit but
   rightly applied the exception still paid three elements and passed,
   and its year-on-year delta moved ages, rates and policy year at once.
   Cases can now reference a registered baseline world, which is the
   instrument that made the same-year pair possible.

5. Identity closure reaches the edges. Regions are a closed per-country
   registry (region="MARS" raises), expected_focus is closed per country
   and may not repeat, NaN and infinity are rejected everywhere an
   amount is read, a boolean-class comparison may only hold 0 or 1, the
   battery's `schema` path must name a contract this repo defines, and a
   case baseline must already be registered in baselines.py. UK
   private-rent cases must now pin a BRMA — LHA is set per Broad Rental
   Market Area, so "the rent clears the cap in most Yorkshire BRMAs" was
   not a pinned world — and a BRMA outside its own region raises.

6. sync_lane_feed's `updated` is documented as the caller-supplied
   LITERAL it is, with the constraint that every caller in a build must
   pass the same constant, so the next caller does not reintroduce the
   drift.

Deliberately NOT in this commit: finding 3, the case/result table,
writer, exporter and build_db step, plus the epistemic columns and the
status/diagnosis split that ride on them. That is the piece with
repo-wide surface that Max offered to pair on, and guessing at it
unilaterally is how it ends up re-litigated. SCHEMA.md and the module
docstring now state the open shape explicitly rather than leaving it
implied.

Suite 306 passed / 4 skipped, ruff format clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
vahid-ahmadi pushed a commit that referenced this pull request Aug 21, 2026
Carries the repaired #49 schema, which resolves the first blocker on its
own: from_classification now accepts annotations, so calculator rows can
use the required seam instead of raising, and #49's bypasses (optional
variable_class, caller-supplied classifications and tolerances) are gone.

3. Dated-reading provenance is structured, not syntactic. A calculator
   row now carries a CalculatorReading: the reading date as a real
   calendar date, the exact https page, the tax year the SERVICE
   computed, the archive location, and sha256 digests of both the
   archived bytes and the canonical input vector. The oracle_version
   date must be the reading's own; a bare "archive:" annotation is
   rejected and the citation must name the reading's own archive_ref; a
   reading may not postdate its row; and a model oracle may not carry a
   reading at all. CALCULATOR_POLICY_YEARS declares what each service
   actually computes — gov.uk's income-tax estimator is 2026-27 only —
   and both the work list and every row are checked against it, which is
   the concrete consequence the review named. The three cases pinned to
   2025 for verified 2025-26 thresholds move to 2026 (those thresholds
   are frozen at the same nominal values), so the assignment is honest
   rather than merely permitted. validate_results() checks a run against
   its battery: an unknown case_id and an invented variable were both
   accepted before.

4. Benchmark class and calibration relationship are assigned per oracle
   with publisher evidence, and land on the row. All seven oracles are
   different_model + held_out: GOV.UK describes its own tools' outputs as
   estimates and explicitly calls Entitledto, Turn2us and Policy in
   Practice "independent" calculators, so none is an authority PE is
   fitted to. A caller cannot relabel a calculator as authoritative.

Also: the two-child multiple-birth case leaves the calculator work list.
After #49 it is evaluated in the registered pre_ab2025 world, and a
production calculator computes current law only — now enforced, so the
abolished case is the only member of that family a calculator can answer.

2. Mode-3 persistence is still absent and is deliberately not guessed at
   here: it is the same table/writer/export/builder design #49 defers to
   the pairing Max offered, and the epistemic columns added here are
   written to travel on the row so they are ready for it.

Suite 340 passed / 4 skipped, ruff format clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

Five of six addressed in 7684f64; finding 3 is deliberately left for the pairing you offered — see the bottom.

  1. The seam is enforced. __post_init__ now recomputes classify() and compares. A stored classification is valid only if it equals the classifier's, or if it adjudicates a row the classifier left unclassified — drawn from ADJUDICATABLE and carrying a writeup. Every round-2 probe raises now: 100 vs 100 as unclassified/pe_gap, a numeric mismatch as policy_scope_mismatch with nothing said, and a boolean 1 vs 0 as match_within_tolerance with a caller-supplied tolerance of 100. You were right that the adjudication claim did not hold either: pe_gap and policy_scope_mismatch are now two-sided — mechanical on a null side, adjudicated and explained on a numeric-vs-numeric row. Tolerances come from DEFAULT_TOLERANCES alone; from_classification no longer takes a table, and it DOES now take annotations, which is also what made every Official-calculator oracles (mode 3): closed-set extension, dated-reading provenance, calculator work list #64 calculator call raise.

  2. variable_class and schema_version are required. The validator cannot check a row without the first, and an omitted second silently reinterpreted a stored row. Blank engine/oracle versions are rejected.

  3. The two-child family. Rebuilt as two same-year pairs. binding uses three SEPARATE births — you were right that twins on both sides meant an engine wrongly keeping the limit but rightly applying the exception still paid three elements and passed. abolished is a byte-identical household in the same year under current law, so binding/abolished attributes the abolition; multiple-birth differs from binding by exactly one date of birth, so it attributes the exception. Cases can now reference a registered baseline world, which is the instrument that made the same-year pair possible — thank you for pointing at pre_ab2025.

  4. Identity closure. Regions are a closed per-country registry (region="MARS" raises), expected_focus is closed per country and may not repeat, NaN and infinity are rejected wherever an amount is read, a boolean-class comparison may only hold 0 or 1, the battery's schema must name a contract this repo defines, and a case baseline must already be registered. On LHA: agreed the region does not pin a world — UK private-rent cases must now pin a BRMA, and a BRMA outside its own region raises.

  5. sync_lane_feed. Correct, it copies a caller-supplied literal. Documented as exactly that, with the constraint that every caller in a build must pass the same constant, so the next caller does not reintroduce the drift.

Finding 3 — taking you up on the offer to pair. The case/result table, writer, exporter and build_db step, plus the epistemic columns and the status/diagnosis split that ride on them, are the piece with repo-wide surface, and guessing at it unilaterally is how it ends up re-litigated. SCHEMA.md and the module docstring now state the open shape explicitly rather than leaving it implied. Happy whenever suits you.

Suite 306 passed / 4 skipped, ruff format clean.

Found in an integration review pass: uk/ukmod-cases-schema forked before
#74 (the database leaving git), so it carried NO scorecard_db/build_db.py
and still tracked data/scorecard.db as a committed binary. Its CI was
therefore the PRE-#74 workflow — the determinism check and the no-drift
gate had never run against this branch at all, so the green tick was a
weaker check than it looked.

Merging current main brings it under the current gates: it builds from
scratch, two builds agree on content_hash, the tree is clean afterwards,
and the suite is 325 passed (up from 287, because main's own tests come
with it).

No conflicts — main's deletion of the committed database wins over an
untouched file on this side, so nothing resurrects it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
vahid-ahmadi pushed a commit that referenced this pull request Aug 25, 2026
Same integration finding as uk/ukmod-cases-schema, which this branch
stacks on: uk/calculator-oracles forked before #74, so it had no
scorecard_db/build_db.py and still tracked the committed database. Its
CI was the PRE-#74 workflow, so the determinism check and the no-drift
gate had never run here either.

Now under the current gates: builds from scratch, two builds agree on
content_hash, clean tree afterwards, suite 359 passed (up from 321).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

Integration review finding — this branch had never met the post-#74 gates.

It forked before #74 (the database leaving git), so it carried no scorecard_db/build_db.py and still tracked data/scorecard.db as a committed binary. CI config is per-branch, so this PR was running the pre-#74 workflow: the determinism check and the no-drift gate had never executed against it at all. The green tick was a weaker check than it looked — worth knowing before reading the approval as covering them.

Current main is merged in now. The branch builds from scratch, two builds agree on content_hash, the tree is clean afterwards, and the suite goes from 287 to 325 passed because main's own tests come with it. No conflicts — main's deletion of the committed database wins over an untouched file on this side, so nothing resurrects.

Nothing in the branch's own code changed.

vahid-ahmadi pushed a commit that referenced this pull request Aug 25, 2026
GitHub runs a pull_request workflow from the PULL REQUEST'S OWN branch,
not from the base. So a branch that forked before a gate was added keeps
running the workflow WITHOUT it — and its green tick looks identical to
a full one while meaning strictly less.

That is not hypothetical. #74 made the database a derived artifact and
added a determinism check and a no-drift gate. Branches forked before it
kept the pre-#74 workflow, so neither gate had ever run against them,
and they were reviewed and approved on the understanding that both had.
An audit of every open PR found four in that state; two were mine (#49,
#64, since fixed) and two are still open.

gate-freshness.yml runs from the BASE via pull_request_target, so a
stale head cannot skip it: the check is defined by main and applies to
every PR regardless of what its own .github looks like.

The gate set is DERIVED FROM THE BASE rather than hardcoded — it reads
main's ci.yml and requires every determinism/no-drift line it finds to
be present in the head's — so a gate added later is enforced on every
open PR without anyone remembering to update the guard. It also refuses
a branch that still tracks data/scorecard.db, whose build cannot have
been from-scratch.

Security: pull_request_target runs in the base repo's context, so this
job NEVER checks out or executes pull-request code. It reads git
metadata only and holds contents:read. A test asserts that, and asserts
the guard cannot quietly become a pull_request trigger.

Verified by running the exact logic against all 16 open PRs: 14 pass and
exactly the two known-stale branches fail, with the reasons named.

Suite 270 passed, ruff format clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
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.

3 participants