Skip to content

Refuse products that are not a usual rate times weeks, and tie annual flows to period year - #1134

Open
MaxGhenis wants to merge 5 commits into
mainfrom
product-rate-times-flow
Open

MaxGhenis wants to merge 5 commits into
mainfrom
product-rate-times-flow

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #1128. That PR left Product outside its annual-flow rule, and its merge note named the two rules below as follow-ups. This PR adds both.

What this does

A product reads a usual rate times weeks. A product binding now reads exactly two concepts, in either order: one usual rate held as a float, and one annual flow counted in weeks (unit weeks, an int or a float). Before, the only check counted two concepts, so these all validated:

  • Product(liquid_financial_assets, weeks_worked), although the first is a stock;
  • Product(take_up_seed, weeks_worked), a persistent draw;
  • Product(usual_weekly_hours, employment_income), hours a week times a year's income;
  • Product(employment_income, weeks_worked), two annual flows.

Construction now refuses each one, with a message that names the concepts at fault:

Binding for 'x': a product reads a usual rate and an annual flow, and fact:person.liquid_financial_assets has temporal basis reference_state.
Binding for 'x': a product multiplies a usual rate by a number of weeks, and fact:person.employment_income has unit base_currency and dtype float.

Of the four committed mappings, only policyengine-uk's binds a product: hours_worked, usual weekly hours times weeks worked. It still validates.

The weeks rule goes one step past #1128's note. #1128 described the follow-up as a usual rate times an annual flow. This PR also requires the annual flow to count weeks. The TemporalBasis.USUAL_RATE docstring calls a usual rate "a usual intensity during the reference year's weeks of activity", so only a number of weeks multiplies it to a year's total. The product branch of _apply multiplies the two columns as float64 and tracks no unit, so nothing downstream would catch a mismatch. Of the 14 committed annual flows, 13 hold currency amounts. Without the weeks check, 28 ordered pairs of committed concepts would validate (usual weekly hours times each annual flow, in either order), usual_weekly_hours x employment_income among them; with it, 2 do. The rule leaves the rate's own unit free, since a usual weekly amount times weeks also gives a year's total.

An annual flow has period year. Concept.__post_init__ refuses temporal_basis=annual_flow with any other period. All 14 committed annual flows already have period year. concept_schema_sha256 hashes the declarations, and no declaration changes, so the schema digest does not move. The tie runs one way: other bases take any period, and usual_weekly_hours, a usual rate, has period year.

Invariants

  1. A product is a usual rate times weeks. For every binding whose transform is a product, it reads exactly two concepts: one usual rate with dtype float and one annual flow with unit weeks and dtype int or float, in either order.
    • Enforcement: _check_product, which _check_transform_arity calls from InputBinding.__post_init__. These construction paths run it: bind(), direct construction, InputBinding.from_dict, ConceptMapping.from_dict and dataclasses.replace.
    • Order-free: a product validates in one order exactly when it validates in the other. Refusal messages can differ by order when both operands break the rule.
  2. An annual flow's period is year. For every Concept, temporal_basis is ANNUAL_FLOW implies period == "year". Period year does not imply an annual flow: usual_weekly_hours, a usual rate, has period year.
    • Enforcement: Concept.__post_init__, so it covers direct construction and dataclasses.replace.
  3. No committed data changes. Every committed mapping and concept already meets both rules. No binding, coverage report, golden or pinned hash moves.

ADR invariant 11 (docs/concept-schema-transport-adr.md) states both rules.

Tests

In TestArithmeticReadsAnnualFlows (engine_free/shared/test_concept_mapping.py):

  • Hypothesis, binding level. The property starts from the committed hours product. It may swap each operand for any committed concept and redeclare its basis, and, for a numeric quantity that is neither an amount nor a pointer, its dtype and unit. In both orders, the binding validates exactly when a test-side statement of the rule (_faults) says it should. A refusal names only operands at fault.
  • Exhaustive, committed pairs. Of all 870 ordered pairs of distinct committed concepts (30 concepts), exactly (usual_weekly_hours, weeks_worked) and its reverse validate. Construction refuses every other pair with a product message that names only concepts at fault.
  • Each way to break the rule, refused by name. Thirteen cases cover the stock times weeks in both orders, the seed, age, two annual flows, two usual rates, hours times income, an integer rate, a text rate, weeks in hours per week, in years and in unit interval, and weeks as text. Each matches the whole message, and each validates once the test redeclares the field at fault to fit. In TestConstructionRules, construction also refuses a product of one concept and of three, and the test matches the whole message.
  • Exhaustive, the committed product. Redeclaring either operand of every committed product under every other basis refuses it, and the refusal names that operand. Redeclaring it under its own basis keeps it valid.
  • Exhaustive, policyengine-uk's JSON form. ConceptMapping.from_dict refuses hours_worked with any of the other 21 person concepts in either operand's place, and the refusal names the newcomer. A second test matches the stock times weeks worked, the case that prompted the rule, in full, and shows that it validates once the test declares the stock a usual rate.
  • The complement test changes as intended. It excludes products, since redeclaring either operand of the hours product under another basis now refuses it. It also now redeclares every read of every other committed binding under all four bases. Before, it covered the three that are not annual flows.
  • _redeclared now sets period year whenever it redeclares a concept as an annual flow. Without that, Refuse scale, sum, share and fraction on concepts that are not annual flows #1128's tests that redeclare the stock (period point) as an annual flow would trip the new period tie.

In TestConceptValidation (engine_free/shared/test_concepts.py):

  • Exhaustive, the period tie. For every committed concept, every basis and every period (30 x 4 x 3 = 360 declarations), construction refuses exactly the annual-flow, non-year declarations, and the test matches the whole message. Every other declaration constructs.
  • One more bad declaration. test_bad_declarations_are_refused adds age redeclared as an annual flow, which keeps its period point.

Hashes

  • No pinned hash moves. These stay unchanged:
    • concept_schema_sha256 (5a55669d…, the value the coverage goldens and the NZ build files commit) and docs/graph-interface.lock, which hashes decl.py and kernel.py;
    • the three graph parity pins: implementation hash and every platform's node key. source_hash covers each kernel's defining module, and no parity kernel lives in frame/concepts.py or frame/concept_mapping.py;
    • each committed mapping's JSON, byte for byte. coverage_report reads only the mapping, the engine's input surface and the concept registry and its digest, and none of them changes, so no coverage golden can move;
    • the spec-engine seed pins (seed_protocol, seed_map, US spec_sha256, loader golden). This PR edits none of their files, and no spec-engine module names frame/concepts.py or frame/concept_mapping.py.
  • No citation drifts. This PR adds 7 lines to concepts.py. Cite NZ spec sources by symbol or stable record, never by a drifting line #1282 replaced the NZ benefit-unit rule's line-number citations of concepts.py with symbol citations, so no committed line citation moves.

Mutation testing

At 38c313157, before the merge, a pytest plugin swapped _check_product or Concept.__post_init__ for 25 mutants at run time, with no tracked file edited and the Hypothesis database off. The two shared test files killed 24. The merge leaves _check_product byte-identical and the period tie unchanged.

  • Product rule, killed:
    • the whole rule removed;
    • any basis, persistent draws or stocks allowed;
    • two operands of one basis allowed;
    • no float check on the rate, or an int rate allowed;
    • no weeks check on the flow, or the weeks operand allowed any dtype;
    • order enforced (rate first);
    • rate and weeks swapped;
    • only the first operand's basis scanned;
    • the arity check removed;
    • pairing decided by unit instead of basis;
    • four mutants that name the wrong concept in a message.
  • Period tie, killed:
    • no tie;
    • a tie both ways;
    • every basis tied to year;
    • month allowed for annual flows;
    • a tie on usual rates instead;
    • a message that names the wrong period.
  • Survived, as intended: == instead of is on enum members. It behaves identically.

At 91de7f67a, an independent reviewer removed or weakened the two rules in nine ways in a scratch copy, and a test failed for each.

Tests run

  • Targeted engine-free files, at 91de7f67a: one pytest process with -p no:cacheprovider and a fresh Hypothesis directory. Exit 0, 474 passed in 105 s. At the head, the two shared files pass again (440 tests).
    • engine_free/shared/test_concept_mapping.py
    • engine_free/shared/test_concepts.py
    • engine_free/shared/test_axiom_concept_mapping.py
    • engine_free/us/test_concept_mapping.py
    • engine_free/uk/test_concept_mapping.py
  • Hypothesis statistics for the product property: 100 examples. Of 103 generated cases in one run, 3 validated, the rule refused 97, and Hypothesis discarded 3 as invalid.
  • Not run locally: the rest of the suite, including the NZ spec-package and graph parity-pin tests in other packages. CI runs them.
  • Static checks: ruff check and ruff format --check (ruff 0.15.16) pass on the four touched Python files.

Merge with main

The branch sat 2,464 commits behind main. I merged main 89660a3b6 into it as 91de7f67a. Since the branch's base, main had added the ScaledSum and SignedPart transforms and ADR invariants 12 and 13. Three files conflicted:

  • concept_mapping.py: _FLOW_TRANSFORMS keeps main's six transforms, and its comment keeps this branch's pointer to the product rule.
  • docs/concept-schema-transport-adr.md: main's wording for the stock and the scaled sum, this branch's product and annual-flow text in invariant 11, then main's invariants 12 and 13.
  • test_concept_mapping.py: imports both example and event, and the class docstring names the scaled sum beside this branch's product paragraph.

In concept_mapping.py and concepts.py, the lines git diff 89660a3b6 91de7f67a adds and removes equal the branch's own.

Two later commits change prose only. The annual-flow rule covers six transforms and its text named five, so the ADR, the test file's docstrings and comments and the _FLOW_TRANSFORMS comment now name the signed part. The ADR also gives the product and period rules their own dated amendment and says the product rule leaves the usual rate's unit unchecked.

axiom: n/a: a validation rule in Microcosm's concept layer; it encodes no policy.

🤖 Generated with Claude Code

…ws to year

A product binding now reads exactly two concepts: one usual rate held as
a float and one annual flow counted in weeks, in either order. Before,
the only check was the count of two, so these validated:
- Product(liquid_financial_assets, weeks_worked), although the first is
  a stock;
- usual weekly hours times a year's income.
policyengine-uk's hours_worked, usual weekly hours times weeks worked, is
the one committed product, and it still validates.

A concept declared an annual flow must also have period year. All 14
committed annual flows already do, so concept_schema_sha256 does not
move. The tie runs one way: usual weekly hours, a usual rate, keeps
period year.

Tests:
- Hypothesis: either operand of the hours product is swapped for any
  concept and its basis redeclared; for a numeric quantity that is
  neither an amount nor a pointer, its dtype and unit are redeclared
  too. In either order, the binding validates exactly when the rule's
  text says it should, and a refusal names only operands at fault.
- Exhaustive: of all ordered pairs of committed concepts, only usual
  weekly hours and weeks worked multiply.
- Each of 13 ways to break the rule is refused with its whole message,
  and validates once the field at fault is redeclared. A product of one
  concept or of three is refused by name.
- Exhaustive: redeclaring either operand of the committed product under
  any other basis refuses it.
- The complement test now excludes products and redeclares the reads of
  every other committed binding under all four bases.
- Exhaustive over policyengine-uk's JSON form: any other person concept
  in either operand's place is refused.
- Exhaustive over every committed concept, basis and period: exactly an
  annual flow outside period year is refused.

The _redeclared test helper now sets period year when it redeclares a
concept as an annual flow.

ADR invariant 11 states both rules. No pinned hash moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis force-pushed the product-rate-times-flow branch from ae69246 to 8713319 Compare October 7, 2026 15:41
The product property drew both operands independently and rejected
collisions with assume(), which discarded up to a quarter of the
examples. It now draws the second operand from the concepts the first
did not take, and records whether each example validates or is refused
as a Hypothesis event (the valid branch runs in about 4-8% of
examples). The ADR says "any other concept" to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 3 commits November 11, 2026 08:15
Main (89660a3) added the ScaledSum and SignedPart transforms and ADR
invariants 12 and 13 since this branch's base. Three files conflicted:

- concept_mapping.py: _FLOW_TRANSFORMS keeps main's six transforms, and its
  comment keeps this branch's pointer to the product rule.
- docs/concept-schema-transport-adr.md: main's wording for the stock and the
  scaled sum, this branch's product and annual-flow text in invariant 11,
  then main's invariants 12 and 13.
- test_concept_mapping.py: imports both example and event, and the docstring
  names the scaled sum beside this branch's product paragraph.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… amendment

The annual-flow rule covers six transforms, and its prose named five: a
signed part computes from annual flows too, so "other transforms are outside
both rules" contradicted the code. The ADR's lists, the test class docstring
and the _FLOW_TRANSFORMS comment now name it.

The ADR also gives the product and period rules their own dated amendment,
says the product rule refuses a concept of any other basis, and says the rule
leaves the usual rate's unit unchecked. No code changes.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A reviewer of the previous commit found four places in
test_concept_mapping.py that still listed five of the six transforms under
the annual-flow rule: two docstrings and two comments. They now name the
signed part. One test loops over every committed arithmetic binding, the UK
mapping's two signed parts included, and its comment now says so.

Also rewraps the _FLOW_TRANSFORMS comment and gives an ADR parenthesis a
verb. No code changes: both Python files parse to the same tree as before
once docstrings are set aside.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review November 11, 2026 14:11

This branch has not been deployed

No deployments
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.

1 participant