pint units (GEP 10): units & dimensionality infrastructure#138
Open
MImmesberger wants to merge 18 commits into
Open
pint units (GEP 10): units & dimensionality infrastructure#138MImmesberger wants to merge 18 commits into
MImmesberger wants to merge 18 commits into
Conversation
7c344d0 to
3c0f7f2
Compare
This was referenced Jun 28, 2026
3c0f7f2 to
e691a78
Compare
80ee22c to
e511391
Compare
0cab324 to
6042a01
Compare
…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>
6042a01 to
0ab7894
Compare
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>
2ca91b3 to
ad4e0ef
Compare
… 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>
`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>
c133ba9 to
aa5068e
Compare
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>
for more information, see https://pre-commit.ci
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>
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.
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.pybuilder (Unit.CURRENCY.PER_MONTH.PER_BG) round-trip through one parser and formatter.Dimensions.
[currency],[area],[time], an isolated[hours]dimension (sohours / weekis 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/orrewrite to combine leveled booleans. pint runs only while the environment is built; the numeric runtime stays pure arrays and JAX-safe.Currency.
register_currency, acurrencyknob onmain(), the Layer-2 input/output boundary, and per-axis build-time conversion (DM→EUR and the like). Per-dateunit: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