Skip to content

pint units (GEP 10): units & dimensionality infrastructure#138

Open
MImmesberger wants to merge 18 commits into
mainfrom
gep10-infra-1-dimensional-core
Open

pint units (GEP 10): units & dimensionality infrastructure#138
MImmesberger wants to merge 18 commits into
mainfrom
gep10-infra-1-dimensional-core

Conversation

@MImmesberger

@MImmesberger MImmesberger commented Jun 27, 2026

Copy link
Copy Markdown
Member

The complete GEP-10 unit-and-dimensionality infrastructure for TTSIM, as one final-form diff. Policy functions stay currency- and unit-agnostic; parameters, columns, and (optionally) input data carry units that the framework reads to check dimensions and grouping levels and to convert at build time.

What's in here

Compositional unit vocabulary. A unit is a base optionally divided by an area, a period, and a grouping level, in canonical order (CURRENCY_PER_SQUARE_METER_PER_MONTH_PER_BG). The flat YAML string and the fluent .py builder (Unit.CURRENCY.PER_MONTH.PER_BG) round-trip through one parser and formatter.

Dimensions. [currency], [area], [time], an isolated [hours] dimension (so hours / week is not a bare number), the [person] count, and one non-interconvertible dimension per grouping level ([hh], [bg], …). The individual person leaf is implied, never spelled; group levels are spelled and validated against the name suffix. Booleans carry their level (1 / [level]).

Checks (build-time only). Mandatory units on every active node; edge-consistency (a node's declared unit vs. its consumers) and aggregation-consistency (T8 — an aggregation resolves to its target level). A symbolic dry-run evaluates each function body to infer and check its unit, reusing the array vectorizer's and/or rewrite to combine leveled booleans. pint runs only while the environment is built; the numeric runtime stays pure arrays and JAX-safe.

Currency. register_currency, a currency knob on main(), the Layer-2 input/output boundary, and per-axis build-time conversion (DM→EUR and the like). Per-date unit: declarations forward-fill, so a unit that holds across many dates is spelled once and restated only where it changes.

Stack

Collapses the former three-way infra split (#138/#139/#140) into a single PR, so each line reaches its final form exactly once. The worked example — mettsim annotated end-to-end — is #141, stacked on top of this.

Red by design: units become mandatory here, and the worked example that satisfies them is annotated in #141.

🤖 Generated with Claude Code

@MImmesberger MImmesberger force-pushed the gep10-infra-1-dimensional-core branch 5 times, most recently from 7c344d0 to 3c0f7f2 Compare June 28, 2026 14:07
@MImmesberger MImmesberger changed the title pint units (GEP 10) · infra 1/3: dimensional core pint units (GEP 10): units & dimensionality (infra) Jun 28, 2026
@MImmesberger MImmesberger force-pushed the gep10-infra-1-dimensional-core branch from 3c0f7f2 to e691a78 Compare June 28, 2026 14:17
@MImmesberger MImmesberger changed the title pint units (GEP 10): units & dimensionality (infra) pint units (GEP 10): units & dimensionality infrastructure Jun 28, 2026
@MImmesberger MImmesberger force-pushed the gep10-infra-1-dimensional-core branch 3 times, most recently from 80ee22c to e511391 Compare July 1, 2026 22:18
@MImmesberger MImmesberger force-pushed the gep10-infra-1-dimensional-core branch 2 times, most recently from 0cab324 to 6042a01 Compare July 7, 2026 18:07
MImmesberger and others added 4 commits July 7, 2026 20:24
…dary, checks

Introduces the GEP-10 unit system in its final, compositional form (no
intermediate token model): grouping-level dimensions plus the [person] count
and the isolated [hours] dimension; mandatory units with edge- and
aggregation-consistency checks and leveled booleans; the currency knob with the
Layer-2 input/output boundary and per-axis build-time conversion; forward-fill
of per-date unit declarations. The build-time dry-run reuses the array
vectorizer's and/or rewrite to check logical combinations of leveled booleans.
Quality-only changes, no behavior change:

- vectorization: reuse the already-parsed AST in
  `recompile_with_logical_ops_as_calls` instead of re-parsing the source
  to recover argument names (the BoolOp rewrite leaves the arg list intact).
- unit_checks: drop the never-called `_DryRunQuantity._controlled_bool`;
  compute `node_is_boolean` once in `_resolve_agg_by_group_unit`.
- policy_environment: call the existing `_strip_unit_overrides` helper from
  `_clean_one_param_spec`; extract `_axis_factors` for the per-axis currency
  factors shared by the piecewise and lookup-table branches of `_get_one_param`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework the unit-annotated input/output trees so every leaf is a
`UnitAnnotatedColumn(values=, unit=Unit....)` — the same shape in and out,
with `unit` built off the `Unit` vocabulary:

- inject each concrete currency onto the `Unit` namespace in
  `register_currency` (so `Unit.EUR.PER_MONTH` can tag input data);
- reconstruct each result leaf's compositional unit in the run currency via
  `composite_from_resolved_unit` (inverse of `resolve_compositional_unit`);
- require concrete currency on input and screen the spelled grouping level
  against the column's declared level, as inline interface fail functions;
- consolidate the generic grouping-level pint helpers in `tt/units.py`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A quantity carries a group level iff it is a property of the group as a
whole; a person's property carries none, even when constant within the
group. The level is declared, not read off the suffix: a spelled group
level must equal the name's aggregation suffix, but may be omitted at any
suffix (implied person leaf for a level-carrying base or boolean, bare for
an intensive one). Consequences:

- SUM/MIN/MAX aggregations take the target level whatever the source's
  base (a level-less age min'd to _fg becomes MONTHS/[fg]); MEAN is the
  exception and resolves to the individual level (MEAN = SUM/COUNT).
- Levels attach and strip via pint *unit* arithmetic, not quantity
  arithmetic, so calendar points can carry levels (CALENDAR_YEAR_PER_HH).
- The body check's level axis compares the inferred level against the
  declared unit's level, not the name suffix; a terminal cross-level
  division residue ([kin]/[fam]) is now caught and takes an explicit
  verify_units=False. Rename the input-boundary fail node to
  input_levels_disagree_with_declaration accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MImmesberger MImmesberger force-pushed the gep10-infra-1-dimensional-core branch from 6042a01 to 0ab7894 Compare July 7, 2026 18:25
The single opt-out grain so far was the whole body (verify_units=False).
cast_unit(value, unit) re-tags one expression instead: the identity at run
time (like typing.cast), a wholesale re-tag of the stand-in in the dry-run.
With the narrow escape in place, two lenient rules become strict:

- the body/declaration level match is exact, by full level signature —
  a level-less inference no longer silently claims a declared group level,
  and a squared level (share_fam * total_fam) no longer slips through;
- an area denominator suppresses the implied person leaf (a rent cap is a
  price, owned by nobody), so cap * area cancels to a person-level amount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MImmesberger MImmesberger force-pushed the gep10-infra-1-dimensional-core branch from 2ca91b3 to ad4e0ef Compare July 7, 2026 21:21
MImmesberger and others added 6 commits July 8, 2026 17:57
… at the pluck

A require_converter may now declare a per-leaf `unit:` mapping (as a dict
param does), so a heterogeneous blob converts each currency leaf by its own
token instead of being split into homogeneous parameters. The
leaf-scaled-schedule guard covers the mapping form too.

A @param_function building a structured value declares `unit=UNSET_UNIT`
explicitly (the decorator requires the argument, so the sentinel is never an
omission). The mandatory-units gate exempts it; its consumers receive an
opaque stand-in that lets plucks through and demands a `cast_unit` where a
number leaves the structure, so their bodies stay checked instead of opting
out wholesale. A too-coarse cast fails loudly on the next deeper pluck, and a
`piecewise_polynomial` call on a converter-built schedule stays opaque for
the caller to cast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tten in

A rounding spec's base and to_add_after_rounding are statutory numbers
written in a concrete currency, exactly like a parameter's values. They
now declare it (RoundingSpec.unit, the full composite) and are restated
in the run currency when the policy environment is assembled, so
rounding down to multiples of 54 DM stays 54 DM in a EUR run instead of
silently becoming 54 EUR.

The unit checks enforce the declaration: mandatory on a currency-valued
function, forbidden elsewhere, and the composite must equal the
function's declared unit with the agnostic base swapped for the
concrete currency.

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

Three review fixes to the body check:

- The delegate-to-pint dispensation for calendar points stays with `+`/`-`
  (the only screened ops that run a forward pint operation); an ordering
  comparison or an `xnp.where` involving a point is screened by equivalence
  like any other unit, deciding points by identity. Before, any ordering
  with a point on either side passed unscreened (`geburtsjahr >= income_m`).
- `policy_month`/`policy_day` (and their evaluation twins) carry a
  month-of-year/day-of-month: cyclic ordinals that wrap, hence DIMENSIONLESS
  by GEP 10's own rule — not calendar points, whose affine algebra would
  bless a shift that wraps at run time.
- A failing run reports its branch combination in the body's own terms
  ("on the branch where `befreit` is False"), argument names propagated
  through comparisons and logical ops; exploration continues past a failure
  so the error can say whether all other branch combinations match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A process may host more than one downstream package (two test suites in one
pytest run), so a single process-wide base currency cannot work. Each
registered currency records the base its definition chains to — its
*family* — and:

- a second `register_currency(..., base=True)` starts a new family instead
  of raising;
- conversion (parameters at build time, pint-tagged input columns at the
  boundary) is only possible within a family: both bases sit at factor 1
  against the abstract CURRENCY reference, so pint would silently relate
  them 1:1 — now a loud error;
- the default run currency follows the policy objects in play: the
  `currency` node reads the concrete currencies off the parameters' unit
  declarations and resolves their family's base, falling back to the
  registry-wide base only when no policy objects are given. With bases from
  several families and no policy objects, `currency=` must be passed
  explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The second-family tests register through `isolated_currency_registration`,
so the process-global bookkeeping is restored for every other test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The exploration-with-accumulation loop had grown past the complexity
budget; one run of the body and the collapsing of branch failures into a
single message are their own concerns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MImmesberger and others added 3 commits July 9, 2026 09:21
`InputData.tree_with_unit_annotations` described pint-Quantity leaves from
an earlier design; the contract is `UnitAnnotatedColumn` with a concrete
currency. `UNSET_UNIT` claimed to be unreachable through the decorators,
contradicting the documented `@param_function(unit=UNSET_UNIT)` statement
of a structured output — it has two readings, told apart by the node type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two seams where the checking chain used to stop at a trusted cast:

A require_converter declaring input_unit:/output_unit: axes now promises
a schedule: every consuming param function must be annotated as returning
one of the two schedule types and declare unit=UNSET_UNIT, and only one
axes-declaring blob may feed a converter — the runtime conversion would
have rescaled its output once per blob, silently, and a non-schedule
consumer only failed in cross-currency runs. In return, the dry-run hands
consumers a schedule stand-in carrying the declared axes (the assumption
the per-axis currency conversion has always made), so call sites screen
exactly like a parameter-declared schedule, with no cast at the call.

A parameter dataclass states each scalar field's unit in its Annotated
type: plucks resolve from the field annotations, nested dataclasses walk,
everything else keeps the cast at the pluck. Where a per-leaf unit:
mapping leaf's path coincides with a field's path, the two declarations
are cross-checked — the mapping converts the number, the annotation
checks its uses, so a drift between them no longer stays silent.

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

Framework changes addressing the GEP-10 review.

Correctness fixes to the dry-run and the boundary:
- SUM over a boolean is a head count everywhere: one shared
  `head_count_from_boolean_sum` drives both the auto-assigned token and the
  resolved unit, so an auto-generated boolean group aggregate no longer fails
  the build against the framework's own assignment.
- Cross-axis calendar `point - point` is rejected (pint would silently /12).
- The input boundary compares currency presence, so a currency tag on a
  dimensionless column (or the converse) no longer silently rescales.
- Int-keyed dict params, scalar `not`, and int->float coercion of unconverted
  parameters are all handled; a calendar `DimensionalityError` reports as a
  calendar misuse, not as an un-checkable body.

Design changes:
- `@agg_by_group_function(verify_units=False)` lets an aggregation declare a
  level the derivation cannot express (a MEAN stated `PER_KIN`).
- The run currency defaults to the registered base only, never inferred from
  the policy objects; a multi-family process must pass `currency=`.
- The input level and measurement checks merge into one
  `input_units_are_inconsistent`; the separate `input_levels_...` node is gone.
- The `unit_converters` time helpers are modelled by the dry-run as period
  rebases, so a body using them is checked against its declaration.

Simplification: delete the dead `infer_function_unit`, dedupe the period
tables, the structured-value dunders, and the `resolve_compositional_*`
wrappers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@MImmesberger MImmesberger force-pushed the gep10-infra-1-dimensional-core branch from c133ba9 to aa5068e Compare July 12, 2026 11:42
MImmesberger and others added 4 commits July 12, 2026 17:32
Merge the two currency interface nodes into one; move all build-time
parameter currency conversion into param_currency_conversion.py; split
units.py into currencies.py (registration + conversion) and
grouping_levels.py (level registration); require an explicit unit= on
@group_creation_function; trim the tt facade to the user-facing surface;
reject a currency lookup-table input axis at read-in; plus the review's
renames, jargon removal, readable error messages, and docstring
simplifications.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold the two near-identical unit-mapping flatteners into one
`_flatten_to_paths`, and replace the bind-in-a-one-element-list idiom in
the two declaration-error helpers with an assignment expression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every run is denominated in a single concrete currency. base_currency()
returns str and raises when the registry is empty; a downstream package's base
currency is the default, so users need not pass currency= themselves.

Currency families are gone: a second base=True registration is rejected and a
definition must reference an already-registered currency. All 'run_currency is
None' branches are removed and the currency chain is str end-to-end. The
templates/plotting and inline-policy-environment meta-paths now carry a concrete
run currency (the currency node is wired into their partial DAGs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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