pint units (GEP 10): foundation — framework core, mandatory+edge checks, currency knob, mettsim annotation#137
Closed
MImmesberger wants to merge 25 commits into
Closed
pint units (GEP 10): foundation — framework core, mandatory+edge checks, currency knob, mettsim annotation#137MImmesberger wants to merge 25 commits into
MImmesberger wants to merge 25 commits into
Conversation
This was referenced Jun 27, 2026
register_currency tolerated re-registration as long as the existing unit was currency-dimensioned, checking only the dimension and silently keeping the original factor. Re-registering `castar / 4` as `castar / 5`, or a derived currency as `base=True`, was swallowed and invalidated every later conversion. Compare the existing factor against CURRENCY with the requested one and reject any mismatch (GEP 10). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A unit-algebra primitive for the Layer-2 input check (GEP 10): divide out the currency component (converted at the boundary) and the flow period (screened against the name suffix), leaving the numerator scale that must match the declared unit exactly. Composes the existing currency/flow-period component helpers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the level-aware primitives to the dimension model. Each grouping level (the individual 'person', and one per *_id group discovered per build) becomes its own non-convertible pint base dimension; a leveled quantity carries its level as a denominator, read off the aggregation suffix. 'person' doubles as the [person] count dimension, so head counts ([person]/[group]) and per-person amounts cancel. New: register_grouping_levels (idempotent, always registers person), divide_by_grouping_level, grouping_level_count_unit, unit_token_carries_level (extensive/intensive default: currency/area/count carry a level; durations, calendar points, dimensionless, hours, per-area rates do not), reference_level on resolve_param_unit/resolve_scalar_param_unit, and resolved_unit_for_aggregation (SUM swaps source->target level, COUNT mints [person]/[target], MIN/MAX/MEAN preserve the source verbatim, ANY/ALL dimensionless). Orchestration (discovering levels from *_id, threading reference_level and the aggregation target/source levels, the suffix-vs-unit-level check) lands in the mandatory+edge-check PR. Validated by standalone tests preserved on wip-level-dims, run at the top of the cascade. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ount Standalone core-mechanics tests for the level primitives: levels as non-convertible base dimensions, level-as-denominator resolution, the [person] count bridge (division and multiplication cancel), cross-level rejection, reference_level on parameters, and the level-aware aggregation (SUM swaps source->target, COUNT mints [person]/[target], MIN/MAX/MEAN preserve the source verbatim, ANY/ALL dimensionless). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add `Unit.HEADCOUNT`, a declarable head count `[person] / [level]`: base is the [person] leaf level (the count dimension), level-carrying, so a reference level enters as the denominator exactly as for currency/area. This lets a parameter, hand-written function, or input column *declare* a head count, which only aggregations could produce before. The reference level is mandatory but PERSON_LEVEL is allowed: a head count per individual is `[person]/[person]` = dimensionless, which is exactly right. A HEADCOUNT parameter must set `reference_level` (person or group); a column takes it from the name position. `unit_for_aggregation` now mints HEADCOUNT for COUNT (group and PID alike): for a group node the resolved unit is recomputed level-aware regardless, and for a PID node the token resolves through the column path to `[person]/[person]` = dimensionless — a head count per individual. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aggregate, check) Wire the level-aware primitives from `tt/units.py` into the build: - `resolve_environment_units` registers the discovered grouping levels, then divides each level-carrying column/function unit by its aggregation-suffix level (unsuffixed = `person`); level-less tokens stay level-less regardless of an index-only suffix (`MIN`-of-age at `_fg`). - auto-aggregation nodes resolve through `resolved_unit_for_aggregation` (mint/swap/preserve), threading the source level (from the source token + suffix) and the target level (the agg's own suffix). - `reference_level` is threaded from the param spec into `resolve_param_unit` / `resolve_scalar_param_unit` for scalar, dict, raw, per-leaf-mapping, and schedule params. - the body dry-run now applies the index-vs-unit-level rule: an inferred level-denominator must equal the name's suffix level; a level-less inferred unit is exempt. The physical residual (currency, period, area) is compared with grouping levels stripped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Currency/area nodes now carry a grouping-level denominator read off the name suffix (an unsuffixed name is the individual [person] level), so the resolution assertions expect the leveled units.
A head count is COUNT *or* a SUM over a boolean (counting the persons the indicator is true for) — both must mint [person]/[target], not DIMENSIONLESS. The orchestration previously hardcoded SUM and reconstructed the source by stripping the name suffix, so COUNT never minted and hand-written counts (number_of_adults_fam sums 'adult', not 'number_of_adults') resolved wrong; the [person] count bridge was inert end-to-end. Store agg_type on AggByGroupFunction and branch on it in _resolve_agg_by_group_unit: COUNT and SUM-over-boolean mint [person]/[target]; SUM over an extensive value swaps the level; MEAN/MIN/MAX preserve; ANY/ALL dimensionless. The value source is read off the function signature, so both auto- and hand-written aggregations resolve. Per-capita divisions (rent_m_hh / number_of_individuals_hh -> CURRENCY/[person]) now type-check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add HEADCOUNT to the params-schema unitToken enum, and update the COUNT aggregation decorator test: a COUNT now auto-assigns the HEADCOUNT token (its level-aware resolved unit is unchanged, [person]/[target]). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Body-level regression tests for the grouping-level dry-run catch that previously existed only at the units_are_equivalent primitive level: group-vs-group and person-vs-group subtraction and an across-level ordering comparison all raise UnitConsistencyError, plus a same-group-level positive control. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An aggregation derives its unit from its source + agg_type, but the declared token was never checked against that derivation — so a SUM over a boolean (a head count, [person]/[target]) could declare DIMENSIONLESS undetected, and a SUM of currency could declare anything. Two coupled fixes in unit_checks.py: - Source detection in `_resolve_agg_by_group_unit` now excludes person pointers (`p_id_*`), not just group ids. An `agg_by_p_id` aggregation becomes an `AggByGroupFunction` once tree logic is removed, carrying its foreign-key pointer as a plain argument; previously that gave two "sources" and the node silently failed to resolve. Now pid aggregations derive from their source too. - `fail_if_environment_units_are_inconsistent` now validates every aggregation: the declared token's physical kind (period-free, level-free — the period and grouping level are the framework's to derive, and a group suffix may be a mere index level for MAX/MIN/MEAN) must equal what the aggregation derives, else UnitConsistencyError. This is the declared-vs-produced contract that hand-written bodies already had, extended to the derived node types. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A require_converter declaring input_unit:/output_unit: is left raw at build time; its converter's typed (piecewise / lookup) output is converted per axis in with_processed_params_and_scalars, so a polynomial coefficient of order j scales by f_out / f_in**j instead of by a single uniform factor (GEP-10 audit finding S3). A homogeneous require_converter keeps its single unit: and uniform scaling; one that carries a currency unit: yet produces a schedule is rejected, pointing the author at the per-axis declaration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fail_if_input_units_are_inconsistent compared only the dimensionality of a tagged input column against its declared unit, so a HECTARES column tagged in m² (a 10,000-fold level error) or a YEARS age tagged in months passed the check and was then mis-stripped at the boundary — only the currency is converted there, area and intrinsic time are left untouched. Compare the residual after factoring out the currency (converted at the boundary) and the flow period (screened against the name suffix by the dedicated period guard); the remaining numerator scale must match exactly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The schema let a scalar parameter declare `reference_period` (which the implementation forbids — a scalar takes its period from a time suffix on its name) and accepted a nested `unit:` mapping on a scalar (only a dict parameter may declare per-leaf units; construction later mishandles it). Condition both on `type: scalar`. Only the ttsim copy changes; the GEP copy stays on the pre-pint form and migrates wholesale later. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Plumb the reference_level facet end to end: the ParamObject field, the spec->object parsing, and the JSON schema. A per-person/per-group parameter names the grouping level it is denominated per by its *_id stem (reference_level: person / hh / ...), orthogonal to reference_period and - unlike reference_period - admissible on a scalar parameter. The resolution (resolve_param_unit) and the orchestration already consume it; this lets it flow from YAML. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-axis conversion Adds the king's levy: a require_converter whose converter computes a quadratic schedule (a Progressionsfaktor of units 1/currency) and declares input_unit: / output_unit: axes. Running the same household in both currencies, the silver-penny levy equals four times the castar levy only because the quadratic term scales by 1/f_in (x4), not by a uniform factor (x64) — the worked example behind GEP-10 finding S3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Declare Middle-Earth's grouping levels so the leveled dimension checks pass with head counts minting [person]/[group]. Per-person / per-group parameters gain reference_level: the wealth-tax exemption thresholds (person/fam/kin), the payroll-tax wealth threshold (fam) and lump-sum deduction (person), the property-tax acre cap (person), and the housing-benefits subsistence level (person — multiplied by a head count to a per-family total). The unsuffixed person-level input columns are tagged in the end-to-end test. number_of_family_members_considered_fam caps a head count with a scalar; a hand-written function cannot declare a [person]/[group] head-count unit (only aggregations mint it), so it and its single consumer opt out of body inference (verify_units=False) while keeping the cap test. Worked example for the grouping-level feature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
max_number_of_family_members becomes a HEADCOUNT / reference_level: fam, and number_of_family_members_considered_fam declares unit=HEADCOUNT. The whole min(count, cap) chain now type-checks, so the two verify_units=False opt-outs in eligibility.py are removed. The unrelated opt-outs in amount.py are untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A SUM over the boolean adult is a head count ([person]/[fam]); with aggregation units now validated against what they derive, its stale DIMENSIONLESS declaration is rejected. Declare HEADCOUNT. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
39e1204 to
70d9386
Compare
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Member
Author
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.
GEP-10 foundation, combined into a single PR to stop the deep stacked-PR cascade.
This is the same content previously split across the stack #122 → #124 → #125 → #126, now presented as one PR onto
main. The 25 commits are kept intact (linear, no rebase onto latestmainyet — handled separately if/when needed). The compositional-units redesign + T8 continues to stack on top as #136 (now based on this branch).Folds in (closed in favour of this PR — their review threads remain visible on the closed PRs):
pint-117-framework-core— framework core + time-as-a-dimension modelpint-119-mandatory-and-edge-check— mandatory units + edge-consistency checkspint-120-currency-knob— per-axis currency conversion + Layer-2 boundary assertionspint-121-annotate-mettsim— mettsim worked example + grouping-level annotations🤖 Generated with Claude Code