Skip to content

pint units (GEP 10): foundation — framework core, mandatory+edge checks, currency knob, mettsim annotation#137

Closed
MImmesberger wants to merge 25 commits into
mainfrom
gep10-foundation
Closed

pint units (GEP 10): foundation — framework core, mandatory+edge checks, currency knob, mettsim annotation#137
MImmesberger wants to merge 25 commits into
mainfrom
gep10-foundation

Conversation

@MImmesberger

Copy link
Copy Markdown
Member

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 latest main yet — 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):

🤖 Generated with Claude Code

MImmesberger and others added 25 commits June 27, 2026 14:04
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>
@MImmesberger

Copy link
Copy Markdown
Member Author

Superseded by the re-split GEP-10 stack: infra is now three logically-distinct PRs (#138 dimensional core → #139 currency & boundary → #140 compositional + T8) with the mettsim worked example as a single green PR on top (#141). Branch retained.

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