Repository navigation
Add CenTax's costing of removing the £100,000 childcare limit - #207
Merged
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This was referenced Oct 21, 2026
Vendor the CenTax report (Advani, Flew, Pepin-Hall and Summers, September 2026) and an extract of the pages it is cited on; the adapter parses Table 4.2 (3 scenarios x 2 tax years x 6 columns) with a sum-identity, cross-table (Table 4.3) and prose QC. Add the compute script for the PolicyEngine counterpart on the certified populace-uk bundle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…BP's appendix Run pipeline/compute_uk_childcare_100k.py on the certified populace-uk bundle (policyengine-uk 2.89.2): removing the 30-hours limit costs £0.690bn in 2027-28 and £0.838bn in 2029-30 (static, the three free-hours entitlements summed); removing both limits adds Tax-Free Childcare. Nothing else moves (gov_spending's change equals the entitlements', gov_tax's is zero). Diagnostic worlds with full 30-hour use give £1.40bn and £1.72bn. Vendor the Centre for British Progress technical appendix (25 March 2026) and parse its Tables 2 and 3 (72 cells) with structure and ordering QC. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New lane uk-childcare-100k (scorecard_db/ingest_uk_childcare_100k.py, a build step after uk_ab2025_lanes): 36 held-out revenue_change claims (24 CenTax Table 4.2 cells, 12 CBP abolition cells), 22 results from the certified pair and 4 history rows holding PolicyEngine's published dashboard figure, and a pe_gap diagnosis on the central static rows linked to microcosm#1126. Each result's status is the one status-setting annotation that matches it: static against static is comparable, every net figure concept_mismatch, the published figure constructed. The published figure is older than the certified run, so the latest shows the certified run and the published miss stays in the history. Adds the scenario condition, CBP's held-out relationship (none of the certified build's 149 targets is a childcare or CBP quantity), the app's CBP label, the source README with the calibration follow-up, docs, the regenerated feeds, and tests: source fidelity, accounting and rounding properties, conservation and differential checks on the artifact, exhaustive status selection, and a DB-wide check that no reform-world claim is ever a calibration target. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The full-use diagnostic now refuses to run if anything that reads the hours-usage input was calculated before the override, and the artifact records the empty holders. The artifact also counts people with a fractional age (none on the certified pair), so the under-1s note rests on a measurement of this pair rather than on microcosm#1126's statement about another release. Rerun on the certified pair: identical costs (£0.690bn, £0.838bn). Wording: the certified pair was built on 19 June 2026, before the microcosm fix (microcosm#1299, 16 October), instead of an unverified claim about which bundles are certified; policyengine-uk#2177 is the change that closed #1930; the uncertified-release statement is attributed to the dashboard PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The independent review of 661c14d asked for eight changes. This commit makes the code and text changes; the artifact, database and feeds are regenerated by a rerun after the fleet's disk hold lifts, so the artifact-reading tests fail until then. - Runtime dataset: each world hashes the file the managed simulation reports as runtime_dataset_source (policyengine 5.0.2 sets it in managed_microsimulation), before construction and after its reads, and requires the runtime release bundle and dataset years to be the variant's. The run goes through compute_uk_event's preflight for the variant (legacy or materialised, new --variant flag). - Variant identity: verify_installed and verify_import_origins before and after the run; the artifact records package versions and RECORD hashes, Python, the packaged manifest and the release bundle, and ingest refuses an artifact whose pairing differs from its variant record (schema_version 2). - England: both offers are England's, and policyengine-uk 2.89.2 applies Tax-Free Childcare UK-wide, so every head is also summed for people in English households and the counterparts use those sums. Ingest refuses a free-hours change outside England and states the Tax-Free Childcare change outside England on each result. - Claim ids: the printed value moves from conditions to publication, so a corrected extraction keeps every id (property test). - Annotation bases: the static basis now cites the mechanism the engine uses (employment_income adds employment_income_behavioral_response, which is zero because both elasticities are zero); CBP's friction rule cites pages 7 and 9, now in the extract; the full-utilisation note rests on the take-up and usage inputs the run records. - Metadata: is_child, is_child_receiving_extended_childcare, country, the take-up inputs and childcare_expenses are recorded; a test reads every calculate() name off the source. - History: ingest keeps every certified artifact's run, newest latest, so the README's promise holds when a later variant arrives. - Adapters emit the tidy-row core (program, metric, subgroup, variant, geography); ARCHITECTURE.md states that source facets may ride beside it when the ingester names them. Rebased on main (#227); the generated feeds are main's until the rebuild. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
force-pushed
the
uk/centax-childcare-100k
branch
from
October 25, 2026 17:56
661c14d to
233c609
Compare
…ly with runs Reran pipeline/compute_uk_childcare_100k.py on the default replay variant (populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z, policyengine-uk 2.89.2): artifact schema 2, run childcare-100k-20261025. Every world's dataset digest before and after its reads equals the certified f17306cc..., read at the path the managed simulation reported. Every free-hours head in every world and year equals the 661c14d run, and England's sums equal the UK's. The CBP counterparts are England-only now and drop the Tax-Free Childcare change outside England (GBP 4m-10m a year). Ingest's expected tally was fixed at one certified run, so a second artifact would have failed the build. It now scales with the runs (expected(runs)), the history test runs the tally on two runs, and the lane note counts claims on the latest run. Rebuilt the database and re-exported lanes.json and populations.json (36 rows added, none removed), copied to app/public/data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lished figure's live window Answers the second review of #207: - Notes whose basis is the engine or a run name the certified pairs they were checked on (variants) and attach only to those pairs' results. Each pair's own statements (why its run is static, its hours draw, its pe_gap diagnosis) live in verified_pairs, and ingest refuses an artifact on a pair with no entry. - The expected tally and pe_missing follow the years each run computed. pe_missing moves into publication, outside the claim id; the feed shows it as conditions.pe_missing. - The compute script's default run id is childcare-100k-<variant>-<UTC start>, so runs on different pairs or at different times never collide. - The metadata scan resolves each calculate() argument in its own scope. The published dashboard figure (£0.434bn in 2027-28) was on policyengine.org from 12 October 2026 until policyengine-app-v2 de2b07cf took the page down on 21 October. The record now states that window. What the dashboard's own deployment has served since uk-childcare-100k- threshold#8 merged (£0.902bn on Microcosm a133f37a, in no certified pair) is stated on the row and not attached. policyengine.py#685's certified pair with the fixed draw is not yet run in this lane (#228). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the published figure's live window
…st run - Diagnoses and the expected count come from each claim's newest run that computed its year, so a newer run that skips a year leaves that year's diagnosis. - The metadata scan accepts a name only when it is bound once in its scope chain by a plain loop or comprehension over known tuples, or by one module assignment; the review's two rebinding mutants and three more are regression cases. - Every path refuses an artifact on an unverified pair, even one that computed no claim year. measured_notes reads input coverage in the run's own year. - Ingest refuses two artifacts with one computed_at, or a non-UTC one, so each claim's newest run is never a tie. - The run-id prose says what the id guarantees. The lane's property tests set deadline=None, as the repo's others do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… use - check_history, called by every stage (load, results, certified_result, diagnoses, expected), refuses duplicate run ids, run ids with the comparisons view's reserved platform-grid- prefix, and computed_at times that are not distinct UTC times in isoformat's form, so text order is time order here and in the database. The compute CLI refuses the prefix too, and the published row's time is checked the same way. - full_use_cost states the full-use cost only when both diagnostic worlds record the override (variable, value 30, the year set, before any dependent), read it back at 30, and run the main world's reform in force that year; notes and diagnoses share it. - The metadata contract refuses assignment expressions, global, nonlocal, match, dynamic name access, calculate reached other than by a direct call and attribute writes to module constants, and a recording simulation of the functions that calculate must see exactly the names the scan resolves. The round-4 survivors are regression cases. - A real ingest into an isolated database checks that the comparisons view's latest result for each claim is the run _covering picks. - The README says each claim's latest is its newest covering run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ted names - Ingest and the compute CLI refuse a run id the comparisons view's LIKE 'platform-grid-%' matches, in any ASCII case; a Hypothesis differential checks both against SQLite evaluating the view's own test. - full_use_cost now checks the reform in force on 30 April of the year (dates parsed, start and stop included, as Reform.from_dict reads them), the NO_LIMIT sentinel, the executed limits read back against the main world's, and the exact holders set_full_use checks. It keeps per-year windows, overlapping periods that agree and a world with no recipients. - The metadata scan refuses any import, builtin or attribute name not vetted, and the stand-in run is now main() end to end on stand-in simulations: every world, year and hours branch. Each layer alone catches the round-5 review's thirteen survivors, kept as regressions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rose - The metadata scan holds compute_uk_ab2025._resolve_parameter, a getattr loop, to direct calls whose path resolves to gov. parameter paths that never name calculate; aliasing it is refused like aliasing calculate. - The stand-in run keeps the real path helper, and every stub and stand-in engine method refuses an argument whose type the script could have defined, so a callback cannot hide in what a stub drops. The test loader registers each module as an import does. - The round-6 callback and five neighbours are regressions, and one test pins what the stand-in run alone makes of all 34 mutants. - Ingest states that no family receives the extended hours, rather than formatting a missing mean, and refuses a mean that disagrees with the receiving record count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The round-7 review hid an unlisted calculation in a __float__ method on a class the compute script defined, reached through a literal getattr(ab, "_resolve_parameter") and an object array passed to set_input; the engine's NaN check (holder.py:278) would call it. Patching each delivery route one at a time had not converged, so the scan now refuses the carrier itself: - _refuse_classes: no class statement and no type() with more than one argument. The script defines none; a method on one is the script's code that engine or library code can call implicitly, where the stand-in run does not follow. - A string naming calculate or a guarded getter is refused, so a literal getattr cannot reach _resolve_parameter past the path check. - The stand-in's _array refuses any object dtype, structured ones too. Each guard alone refuses the reviewer's exact carrier, kept as the object_array_callback mutant with three neighbours (38 mutants). The review's producer boundary: records receiving the offer with zero total weight gave year_block 0/0 = nan, printed as "nan". year_block now writes no mean when no weight receives the offer, and ingest keys the mean on the weighted count: a finite mean with positive weight, or no mean with zero weight; anything else, NaN, inf or a bool included, is refused. The guard no longer calls receiving.any(), so "any" leaves the vetted attributes (81). The committed run's numbers do not change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds CenTax's published costing of removing the £100,000 childcare limit as an external reform score, with PolicyEngine's static counterpart on the certified UK pair. Also adds the Centre for British Progress estimate that CenTax reviews (lane
uk-childcare-100k).Status: approved in round 8
Seven independent reviews (GPT-6.1 Sol) asked for changes, and the eighth approved:
Round 1, at 661c14d: 8 findings, answered in 233c609.
Round 2, at 12b94b0: all 8 round-1 findings confirmed fixed, plus 5 new findings. These are answered in 371f1cf.
Round 3, at 371f1cf: round-2 items 2 to 4 and the three body points confirmed fixed, and item 1 fixed for normal ingestion. It found 2 minor issues, 1 nit and 2 body nits, answered in e5736b1.
Round 4, at e5736b1: every round-3 item confirmed fixed. Ordering was fixed only in part, and so was the metadata scan, which still let 8 mutants through. It found 4 minor issues and 1 nit, all future-artifact or edge cases; the committed numbers are unaffected. Answered in 39d0027.
Round 5, at 39d0027: every round-4 item confirmed fixed for the reported cases, and both direct-call limits closed. It found 3 minor issues and 1 nit, all about future artifacts or future edits. The committed numbers are unaffected: two fresh builds matched all 174 committed feed files. Answered in 491bb2e.
Round 6, at 491bb2e: every round-5 item confirmed fixed (the reserved ids agree with SQLite in 32,160 comparisons). It found 1 minor issue and 1 nit, both in the metadata contract's tests; the committed numbers are unaffected: two fresh builds matched all 174 committed feed files apart from today's date stamps. Answered in 0311c08.
Round 7, at 0311c08: the round-6 nit and no-recipient note confirmed fixed, and the round-6 callback fixed for the reported example (all 34 mutants and the 51-case matrix independently re-run). It found 1 minor issue, a neighbouring callback route in the metadata contract's tests, and noted a nonblocking producer boundary; the committed numbers are unaffected: two fresh builds hash
e5497fd5…and reproduce all 174 feed files apart from the date stamps. Answered in 49e782b.Round 8, at 49e782b: APPROVE, no new findings. Both round-7 items confirmed fixed; all 38 mutant outcomes, the 51 earlier cases and the 20 mutation counts reproduced; two fresh builds hash
e5497fd5…and reproduce all 174 feed files apart from the date stamps. It noted one boundary outside this PR's cases: a population whose total weight is zero would still give NaN take-up shares; no supported pair reaches it, and the guard goes with Run the £100,000 childcare lane on policyengine.py's certified Microcosm pair (6cba5d71, policyengine-uk 2.132.6) #228, which reruns the compute script.Each response is listed under "Review response".
While answering round 2, I found that the published dashboard figure is no longer on policyengine.org:
The comparison (£bn, negative = cost to the Exchequer)
The certified pair is the default replay variant:
populace-uk-2023-dd68c73-4aa4b14-20260619T023711Zwith policyengine-uk 2.89.2 (data/uk/certified_bundle.json). Against CenTax's central static cost, PolicyEngine is 14% below in both years (ratios 0.863 and 0.855).The certified pair has the pre-fix hours draw. Families receiving the extended hours use a measured weighted mean of 15.3 of the 30 weekly hours. microcosm#1126 puts DfE's mean at about 28.5. The pair was built on 19 June 2026, before the fix merged in microcosm on 16 October (microcosm#1299). A diagnostic run on the same pair with every family at the full 30 hours gives £1.40bn and £1.72bn. It is not attached to any claim.
policyengine.py#685 certified national cut
microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71with policyengine-uk 2.132.6. It is the first cut with the fixed draw, and policyengine.py 6.11.0 (25 October) still pins it. This repository records it only as an experiment population, not as a replay variant. Until #228 runs it, the latest result is the 19 June pair's, and the row shows that pair's miss.The dashboard's current figure runs on Microcosm's 23 October release (a133f37a, built from microcosm 829c1bc26, which contains #1299) with policyengine-uk 2.147.2. That release is in no certified pair. The published row records the figure and does not attach it. When policyengine.org serves the page again, its figure becomes a new published row.
What changed
sources/centax-childcare-100k/: the report PDF and an extract of the twelve pages it is cited on. The adapter parses Table 4.2 (3 scenarios × 2 tax years × 6 columns) and cross-checks it three ways:annotations.jsongives each difference with its page or engine-file basis.pe/published_dashboard.jsonholds the published figure, pinned touk-childcare-100k-threshold@f25f0dband its results file's SHA-256. Two fields record what has happened since:live: when policyengine.org served the page, with the deployment and takedown commits.after: what the dashboard has served since Scorecard database: reform-keyed claims, populace-targets shape #8 merged, pinned to 1b83dd46 and its results file's SHA-256.The README notes the calibration follow-up.
sources/cbp-rates-and-wrongs/: CBP's own technical appendix, which carries the numbers. Tables 2 and 3 have 72 cells; the 12 "Abolish Cliff" cells become claims. CenTax cites the main report at a URL that returns "Article Not Found" (checked 2026-10-21), andsource.jsonsays so.pipeline/compute_uk_childcare_100k.py: the checks run in this order.check_variant_environmentvalidates the record and runsverify_installed.compute_uk_event.preflightthen:certified_bundle.jsonand the record's release bundle;runtime_dataset_source, before construction and after its reads.A variant materialised with
--variantgoes throughvariant_preflightinstead, which runs the same checks.The script runs three worlds: current law, the 30-hours limit removed, and both limits removed. Each runs years 2026 to 2030 (2026-27 to 2030-31), and two hours diagnostics run years 2027 and 2029. Every head is summed UK-wide and for people in English households. The artifact records the take-up and usage inputs the dataset supplied, and is written to
results/uk/childcare_100k/<variant>.json. The default run id ischildcare-100k-<variant>-<UTC start second>.scorecard_db/ingest_uk_childcare_100k.py(build step afteruk_ab2025_lanes):revenue_changeand held out;pe_gapdiagnoses. Each claim's diagnosis comes from the newest run that computed that claim's year, so a newer run that skips a year leaves that year's diagnosis in place.Drops are tallied: CenTax's 12 bunching counts and CBP's 60 taper cells, both still in
data/externals.Each result's status is the one status-setting annotation that matches it; anything else raises.
Every certified artifact in
results/uk/childcare_100k/is checked against its own variant record and keeps its run in the history. Each claim's latest result is the newest run that computed its year. Counterparts are England-only.A note whose basis is the engine or a run names the pairs it was checked on (
variants) and attaches only to their results. Each pair's own statements are inverified_pairsinresults/uk/childcare_100k/construction_annotations.json: why its run is static, its hours draw, and itspe_gapdiagnosis. Ingest refuses an artifact on a pair with no entry.models.STANDARD_CONDITIONSgainsscenario.relationships.uk_relationshipgains CBP: none of the certified build's 149 targets is a childcare or CBP quantity. The app gains the CBP source label.export_populationsshows a claim'spublication.pe_missingasconditions.pe_missing. Docs are updated, and the regenerated feeds are included.Statuses
CenTax static against PE static:
comparable(all three scenarios).CenTax net, and every CBP row (net of behaviour, includes Tax-Free Childcare):
concept_mismatch.The published dashboard figure:
constructed. The dashboard applied six local corrections to policyengine-uk 2.102.3. Itscomputed_at(2026-10-09) is older than the certified run, so the comparisons view and the app's latest show the certified run, and the published miss stays in the history beside it.Some claims have no PE value:
They are published without a result.
publication.pe_missingsays why, read off the years each run computed; the feed shows it asconditions.pe_missing.Annotations (each cites its basis)
The engine and run notes name the pairs they were checked on (all the default pair):
utils/parameters.py);Never a calibration target
Both sources are held out.
never_calibrate()is keyed on metric and covers only the poverty family, so it does not by itself cover reform estimates. A new built-DB test enforces the rule for every source: any claim scoring a non-baseline world isheld_out. It holds on a fresh build at 371f1cf: all 6,812consumed_as_targetclaims are baseline-world.Invariants (load-bearing logic) and their tests
Source fidelity:
Accounting identity: static + intensive + extensive = net within the rounding bound. The bound is derived, and a Hypothesis property checks it for any three real components.
Round trip: a published money cell parses back to its value (Hypothesis).
Conservation: in every reform world and year,
gov_spendingmoves by the four childcare heads to within £29 (measured; the test and ingest allow £1m), andgov_taxmoves by £0. Removing the 30-hours limit moves Tax-Free Childcare by £0.Geography: no free-hours head moves outside England (ingest raises otherwise; a mutation test checks the raise). England's Tax-Free Childcare is between 0 and the UK's.
Provenance: each world's dataset digest before and after equals the certified one, read from the file the simulation reported. An artifact is refused when any of these differ from its variant record: package versions, RECORD hashes, Python, packaged manifest, release bundle, dataset or data years. A parametrised test covers 14 mutations.
Pair scoping: a note on the engine or a run reaches only results on the pairs it names. An artifact on a pair with no
verified_pairsentry is refused at stage, results, diagnoses and the expected tally, whether or not it computed a claim year. A two-pair test checks that a note not verified on the second pair, the default pair's hours draw and its diagnosis never reach the second pair's 22 results._specrefuses an engine or run note withoutvariants, and avariantslist on a note that is neither.Coverage: the expected tally and every
pe_missingfollow the years each run computed. Tested on three histories: one artifact, two artifacts, and two artifacts where the newer one also computes 2031. Certified results are 22, 44 and 46; published 4; claim ids are identical across all three.Diagnoses follow the claim's own latest run: a test adds a newer verified run that computes only 2031. Both diagnoses stay, with the default pair's text, and the tally expects 2.
Identity: a claim id ignores the value, its printed form and
pe_missing(Hypothesis).Run ids and order: the default id differs across variants and across start seconds. One history check runs on every public path (load, results,
certified_result, diagnoses, the expected tally); the private helpers_coveringand_pe_missingare reached only through them. It refuses:LIKE 'platform-grid-%'matches, in any ASCII case (the view prefers those runs within a data bundle). The compute CLI refuses the same ids, and a Hypothesis differential checks both tests against SQLite evaluating the view's ownLIKE;computed_at;computed_atthat is not a UTC time in the formdatetime.isoformat()writes. In that form text order is time order, and a Hypothesis property checks it for any two UTC times.The published row's time is checked the same way.
Differential, database against ingest: a real ingest into an isolated database runs on three runs: two pairs share a data bundle, and the newest run computes only 2027. For every claim, the comparisons view's latest result is the run
_coveringpicks, and its diagnosis is that run's pair's.History: every certified run stays on each claim it computed; the published figure must be older than every certified run. The
afterfigure is stated on the published row and never stored as a value.Differential: the free-hours heads agree between the 30-hours-only world and the both-limits world.
Bounds and monotonicity:
Metadata (vetted scan and stand-in run): each
calculate()argument must resolve to names the artifact's metadata lists. Two layers check this. The scan alone refuses all 38 mutants kept as regressions. With the scan set aside, the stand-in run alone sees an unlisted name in 30 of them. Of the other eight, two (tuple_target,module_constant_bound_twice) calculate only names their ownMETADATAlists, one (match_mapping_rest) crashes before it writes an artifact, and in five a stand-in refuses the object or object array the mutant passes it. A test pins that outcome for each mutant.The scan reads the compute script's own code. A name resolves only if it is bound exactly once in its scope chain, as one of:
foror comprehension target over a tuple display or a module tuple;Any other binding raises. So does any form that could bind a name or reach
calculateout of the scan's sight:global,nonlocalandmatch;getattrwithout a literal name;calculateused other than as a direct call, or named by a string;compute_uk_ab2025._resolve_parameter, which isgetattrin a loop, used other than as a direct call, named by a string, or on a path that does not resolve (as acalculatename does) togov.parameter paths with nocalculatesegment;classstatement or bytype()with more than one argument. The script defines none. A method on one (resolve,__fspath__,__float__, …) is the script's code that library or engine code can call implicitly, inside a stub or an array conversion, where the stand-in run does not follow;Then every import, builtin name and attribute name the script uses must be on a vetted list: 16 imports, 19 names and 81 attributes, which a test keeps equal to what the script uses. I read each for a route to
calculateother than a direct call. The one I found is_resolve_parameter, which walks any dotted path; that is why the scan holds its paths. A new name is refused until someone reads it and adds it.The stand-in run runs the script's
main()end to end on stand-in simulations. That covers all three worlds in every default year and both full-use worlds, with the hours input drawn below 30 in the ordinary worlds and set to 30 in the full-use ones. Only code outside the script that needs the engine, the data or the variant registry is stubbed: the variant record and preflight, the dataset hash, the bundle checks, the import-origin check andbuild_sim. The path helper runs as it is. Each stub, and each stand-in engine method, refuses any argument whose type is not exactly a builtin value, a path or an array, and any array of an object dtype, structured ones included. So no object can carry a callback that a stub would drop or that only the engine's own conversion would make. A stand-in offers only what the script reads, so any other engine call raises. The test loader registers each module as an import does. The names it records must equal the names the scan resolves (a differential test), and the artifact it writes must carry metadata for exactlyMETADATA.Limits. The scan covers the compute script, not the modules it imports. An AST check of the thirteen pipeline functions the script calls finds
getattron a string the caller passes only in_resolve_parameter; it does not follow those functions' own callees. The stand-in run sees a call only on a path it takes, and its values are fixed, so a branch that turns on other data values may not run (the review's probe that tests an age below 120 is one). The scan reads every branch, so it covers those.The 38 mutants are round 3's two, round 4's eight survivors and its mapping capture, four more, round 5's thirteen survivors, round 6's callback and its five neighbours, and round 7's object-array carrier and its three neighbours. Each of round 5's thirteen is caught by either layer alone, and so is round 7's carrier. The round-5 review's whole 51-case matrix, re-applied to this head, is refused by the scan.
Evidence for full use: a note or diagnosis says every family is at the full 30 hours only when both full-use worlds show it. Each must record:
set_full_usechecks; a differential test reads them off a stand-in run of it;year_blockwrites it;NO_LIMITsentinel. Eachstart.stopperiod is read asReform.from_dictapplies it, with both days in force, and overlapping periods must agree;NO_LIMITexactly when the reform removes it.A parametrised test breaks each of 23 conditions. Another keeps four true record shapes: per-year windows from
reform_dict, a one-day window on 30 April, overlapping periods that agree, and no recipients.Determinism: two builds hash the same, as in CI.
Exhaustive status decision: every attached facet has exactly one stated status.
Checks run
ruff format --check be nz scorecard_db tests pipelineclean;ruff checkon the three lane files clean;e5497fd5…, unchanged since 371f1cf, so the feeds are unchanged; the re-export differs only in the date stamps, which are not committed;pytest tests/ -n 4): 2,806 passed, 18 skipped; the lane's tests: 238 passed;'_resolve_parameter' named by a string), refused by the stand-in run alone (an object array), refused by the contract. Each scan rule alone refuses it too: the class rule with the getter rules off, and the string rule with the class rule off; with both off the scan accepts it. The round-6 callback diff: refused by the scan and the contract, seen by the stand-in run alone. The round-5 51-case matrix: all 51 refused by the scan, and the stand-in run alone sees an unlisted name in 45;str(1); stubs taking any argument (2);get_holdertaking any argument (1); the loader not registering modules (1); vetting off (12); the stand-in run for 2027 only (11); hours never drawn (1);year_blockdividing by zero receiving weight (2); the receiving mean not checked (12); checked against the record count instead of the weight (6); non-finite numbers accepted (2); bools accepted (2); the note formatting a missing mean (2); the diagnosis formatting one (1).ruff format --check be nz scorecard_db tests pipelineclean;ruff checkon the three lane files clean;e5497fd5…, unchanged since 371f1cf, so the feeds are unchanged; the re-export differs only in the date stamps, which are not committed;pytest tests/ -n 4): 2,784 passed, 18 skipped; the lane's tests: 216 passed;str(1); stubs taking any argument (2);get_holdertaking any argument (1); the loader not registering modules (1); vetting off (13); the stand-in run for 2027 only (11); hours never drawn (1); the receiving mean not checked against the records (3); the note or the diagnosis formatting a missing mean (1 each).ruff format --check be nz scorecard_db tests pipelineclean;ruff checkon the three changed Python files clean;e5497fd5…, the same as every build since 371f1cf, so the feeds are unchanged;app/public/data; the only diff is the export's date stamp (built,updated: today's date), which is not committed;pytest tests/ -n 4): 2,746 passed, 18 skipped; the lane's tests: 178 passed;tuple_targetcalculates only listed names);reserved_run_idcase-sensitive in ingest (5 tests fail) and in the compute script (3);_in_forcereading year prefixes only (2);_same_reformnot checking values (7); no executed-limit check (2); any dependents dict accepted (2); no zero-recipient allowance (1); no sentinel check (1);_refuse_unvettedswitched off (13); the stand-in run for 2027 only (11); hours never drawn (1).ruff format --check be nz scorecard_db tests pipelineandruff checkclean;e5497fd5…, the same as the e5736b1 and 371f1cf builds, so the feeds are unchanged;app/public/data;git diff --exit-codeclean;pytest tests/ -n 4): 2,667 passed, 18 skipped; the lane's tests: 99 passed;_coveringreverted to the newest run overall: 2 tests fail, one of them the database differential;_utcwithout the canonical-form check: 3 tests fail;full_use_costignoring the read-back: 1 test fails;ruff format --check be nz scorecard_db tests pipelineandruff checkclean;e5497fd5…, the same as the 371f1cf build, so the feeds are unchanged;pytest tests/ -n 4): 2,638 passed, 18 skipped; the lane's tests: 70 passed;_coveringreverted to the newest run overall, the new diagnosis test fails (0 diagnoses);ruff format --check be nz scorecard_db tests pipeline(CI's scope) andruff checkclean;e5497fd5…);app/public/data;pytest tests/ -n 4): 2,630 passed, 18 skipped; the lane's tests: 62 passed;bun test src(65 pass),bun run lint(warnings only, in a file this PR does not touch) andbun run build;childcare-100k-20261025, 528 s, 26 GB max RSS).country == ENGLAND.Review response, round 7 (GPT-6.1 Sol at 0311c08)
An object array carried a callback past both layers (minor). The review's carrier was a class defined in the script. Its
__float__reachedcalculatethrough a literalgetattr(ab, "_resolve_parameter"), and the script handed it toset_inputin an object array. policyengine-core 3.27.1 converts such an array withastype(float)(holder.py:278), which calls__float__; a stand-in stores it without converting. Earlier answers closed one delivery route at a time, and that had not converged, so this answer refuses the carrier. Three guards:classstatement, and notype()with more than one argument. The script defines none.calculateor_resolve_parameter, so a literalgetattrcannot reach the getter past its path check.Each guard alone refuses the review's carrier, and with both scan rules off the scan accepts it, which reproduces the hole. The carrier is kept as
object_array_callback, with three neighbours: the getter named by a string with no class, a class whose__float__calculates, and the same class built withtype()."any"leaves the vetted attributes (now 81), because item 2's guard no longer callsreceiving.any().Zero receiving weight wrote NaN (the review's nonblocking producer boundary).
year_blocktestedreceiving.any()over records. Records that receive the offer but weigh nothing in total therefore gave 0/0, which the note printed asnan.year_blocknow writes no mean when the weighted count is zero. Ingest keys the mean on that weighted count, not on the record count: a finite mean with positive weight, or no mean with zero weight. It refuses anything else, including NaN, infinity and bools. The new test builds the block withyear_blockon a stand-in whose receiving units weigh nothing, and the note then says no family receives the hours. On the committed run every receiving weight is positive, so its numbers and the build hash are unchanged.Review response, round 6 (GPT-6.1 Sol at 491bb2e)
_resolve_parameterreaches any attribute, so a class defined in the script could callcalculatefrom itsresolve()without a direct call. Because the stand-in run stubbedrelative_to_rootwithstr, thatresolve()never ran. My claim that the vetted names leave only direct calls was wrong. Three changes:_resolve_parameterto direct calls on paths it resolves togov.parameter paths, and refuses it aliased.employment_income, and the contract refuses it.test_what_the_stand_in_run_alone_makes_of_each_mutantnow pins each mutant's outcome. Writing that test showed that the test loader did not register modules as an import does. Somodule_attribute_writecrashed with aKeyErrorin the harness, where a real run rebinds the name. The loader now registers them.year_blockitself, on a stand-in.Unchanged, as the stated scope says:
_in_forcerefuses overlapping windows that disagree, and the privatepublished_resultis reached only through the guarded_published.Review response, round 5 (GPT-6.1 Sol at 39d0027)
The reserved prefix was case-sensitive (minor). The comparisons view's
LIKEignores ASCII case, andstartswithdid not. Ingest (the history and the published row) and the compute CLI now refuse any id the view'sLIKEmatches. A Hypothesis differential checks both tests against SQLite evaluating theLIKEread fromdb.py, including cased prefixes and non-ASCII look-alikes. The history, stage, published-row and CLI tests carry mixed-case ids.Full-use evidence accepted records that do not show it (minor).
full_use_costnow reads the reform in force on 30 April of the year. It parses the dates and treats start and stop as both in force, as policyengine-core 3.27.1'sReform.from_dictandParameter.updateapply them. It also checks the sentinel, the executed limits against the main world's, and the exact holdersset_full_usechecks.reform_dict, and a world with no recipients, are now kept.The metadata recorder skipped a helper and branches (minor). Two changes:
calculatein the script is a direct call, which the scan resolves.main()end to end, through every world, year and hours branch, and only code outside the script is stubbed.Each layer alone refuses all thirteen survivors: with either layer switched off, the other still catches them. The review's 51-case matrix, re-applied to this head, is all refused. The invariant above now states what the two layers cover and their limits.
"Exactly" overstated conservation (nit). The body now says within £29 (measured), against the £1m tolerance, and
gov_taxat £0.The review also noted that "every path" needed scope: the invariant now names the public paths and says the private helpers are reached only through them.
Review response, round 4 (GPT-6.1 Sol at e5736b1)
The diagnosis selector could differ from the database's latest result (minor). This happened when an older run took the
platform-grid-prefix, which the comparisons view prefers within a bundle. Ingest now refuses that prefix on every path, and so does the compute CLI. A real-ingest differential test checks that the view and_coveringpick the same run for every claim on a same-bundle history.UTC validation did not fix the order (minor).
computed_atmust now be exactly whatdatetime.isoformat()writes for a UTC time, soZ,.000000and other spellings are refused. Duplicates are checked on that one form, and a Hypothesis property checks that text order is time order.The checks live in one
check_history, called by load, results,certified_result, diagnoses and expected. This also closes the direct-call tie and duplicate-run-id bypasses noted under "Checks completed and limits". A test calls each path.Metadata enforcement incomplete (minor). The scan now refuses each form the eight survivors used, plus
match(the mapping capture), dynamic name access and attribute writes to module constants. A recording simulation of the functions that calculate must see exactly the names the scan resolves. All 15 mutants are regression cases, each refused for its own reason.Diagnostic prose could contradict the recorded override (minor).
full_use_costis shared by the notes and the diagnoses. It refuses unless both full-use worlds record the override and read it back at 30, on the main world's reform in force that year. Ten broken records are regression cases.Global-newest wording (nit). The lane README,
docs/ARCHITECTURE.mdand this body now say that each claim's latest is the newest run that computed its year. The round-2 response no longer says "three-run test".Review response, round 3 (GPT-6.1 Sol at 371f1cf)
_covering), not from the newest run overall. New test: a newer verified run that computes only 2031 leaves both 2027 and 2029 diagnoses and the tally of 2.v;v =inside theHEADSloop) are now regression cases, with three more.From "Completed checks and limits":
certified_resultchecks the pair before the year check, andresultsandexpectedcheck every artifact. An unverified pair is now refused even when it computed no claim year; the unverified-pair test checks results, diagnoses and the tally, including an artifact with no years.measured_notesreads whether the dataset supplies an input in the run's own year (dataset_inputsperiods), not whether it supplies it in any year. A missing input is now described as the engine's default, and no longer as 30 hours.computed_at, and acomputed_atthat is not UTC (runs are ordered by the ISO string). The docstrings that said the newest artifact is every claim's latest now say it is the newest run that computed the claim's year.deadline=None, as the repo's other property tests do. Round 3 saw 200 ms deadline failures under host load; the values were unchanged.PR-body nits: the coverage test is described as three histories (one, two and two artifacts), and the years are split into the main worlds' years (2026–2030) and the diagnostic years (2027, 2029).
Review response, round 2 (GPT-6.1 Sol at 12b94b0)
variants) and attaches only to their results.verified_pairs: why its run is static, its hours draw, and thepe_gapdiagnosis, or none.expected()now derives the tally from the claims, each run's computed years and the published file, not from a fixed per-run count. The coverage test covers a history where a run computes 2031.pe_missingmoves from conditions (part of the id) into publication, so a new run never changes an id.conditions.pe_missing.childcare-100k-<variant>-<UTC start>, not a fixed string.PR-body points:
Review response, round 1 (GPT-6.1 Sol at 661c14d)
pe-england-only), and the CBP note says so too.value_verbatim: moved to publication, with an id-stability property test.employment_incomeaddsemployment_income_behavioral_response, which is zero because both elasticities are zero; the old "only throughapply_dynamics" wording was wrong. CBP's friction rule now cites pages 7 and 9, which are added to the extract. The full-utilisation note now rests on the inputs the run records.calculate()name in the source.ARCHITECTURE.mdnow states that source facets may sit beside them when the ingester names them.Follow-ups (not in this PR)
verified_pairsentry. The new run becomes the latest, and the 19 June pair's run and the published figure stay in the history.axiom: n/a: scorecard data and a microsimulation comparison; no PolicyEngine country-model change.
🤖 Generated with Claude Code