Typed construction args for vocabulary nodes — reach non-scalar structural config (Session timezone) from data-only blueprints #271

Closed
opened 2026-07-14 10:36:23 +02:00 by claude · 3 comments
Collaborator

Motivation

Roster factories are zero-arg by construction: the std_vocabulary_roster! macro expands every entry to <$ty>::builder() (crates/aura-std/src/vocabulary.rs), and the only post-construction channel is .bind() over the four scalar param kinds. Any node carrying non-scalar structural config is therefore unreachable from a data-only blueprint and must be baked in Rust.

The first real-world case is Session: its timezone and open time are structural (chrono_tz::Tz is not a scalar), so the closed vocabulary can reach it only through per-city baked presets (SessionFrankfurt, #261). A research project wanting the New York session for a US index today has to author a node crate — a pure data variation (same logic, different timezone) is forced through the logic channel (role 2). That inverts the C25 principle: closed, typed vocabularies as serialized data are the proven pattern; new logic escalates to Rust, new data must not.

Provenance: the user ruled the status quo unacceptable for real-world projects and requested this issue — 2026-07-14.

Sketch (shape only — the concrete form is deliberately undecided)

A second, separate channel on the construction op: typed construction args, declared per roster entry, consumed once at bootstrap.

// roster entry declares construction args in addition to knobs
"Session" => schema {
    args:   [ tz: TzId, open: TimeOfDay ],   // closed kinds, load-time-validated
    params: [ period_minutes: I64 ],         // scalar knobs, unchanged
}

// blueprint / op-script side: data, no Rust
Add {
    type_id: "Session",
    args: [ ("tz", "America/New_York"), ("open", "09:30") ],
    bind: [ ("period_minutes", i64 15) ],
}

Guardrails any implementation must keep

  • Four-scalar discipline untouched. Construction args are bootstrap metadata, never streamed, never bound, never swept — the invariant already parks non-scalars as metadata beside the hot path, and this stays exactly there.
  • No DSL hole. Arg values come from closed, load-time-validated tables (an IANA timezone is a dropdown in Blockly-litmus terms, not freetext) — no open logic enters the artifact.
  • Args are structural. They enter the blueprint content-id (C18) and are not a sweep axis (a different tz is a different harness); they are, for the same reason, legitimate experiment-matrix axes.
  • The node-crate valve (#241) stays. Genuinely new logic still escalates to Rust; this issue narrows only the case where an existing node carries non-scalar configuration.

Performance note

PrimitiveBuilder creation and roster resolution happen at bootstrap ("construction is a bootstrap phase"), once per harness member — never inside the per-tick eval loop. Arg parsing and validation are therefore not performance-critical; the hot path is unaffected by design.

Open (to be decided before implementation)

  • Representation of the arg schema (where it lives on PrimitiveBuilder/NodeSchema; which closed arg kinds exist beyond TzId/TimeOfDay).
  • Serialization in blueprints/op-scripts and content-id integration (C18).
  • Whether SessionFrankfurt remains as sugar or is retired once Session itself is reachable.
  • Whether structural args surface as experiment-matrix axes in campaign documents.
## Motivation Roster factories are zero-arg **by construction**: the `std_vocabulary_roster!` macro expands every entry to `<$ty>::builder()` (`crates/aura-std/src/vocabulary.rs`), and the only post-construction channel is `.bind()` over the four scalar param kinds. Any node carrying **non-scalar structural config** is therefore unreachable from a data-only blueprint and must be baked in Rust. The first real-world case is `Session`: its timezone and open time are structural (`chrono_tz::Tz` is not a scalar), so the closed vocabulary can reach it only through per-city baked presets (`SessionFrankfurt`, #261). A research project wanting the New York session for a US index today has to author a node crate — a pure **data** variation (same logic, different timezone) is forced through the **logic** channel (role 2). That inverts the C25 principle: closed, typed vocabularies as serialized data are the proven pattern; new *logic* escalates to Rust, new *data* must not. Provenance: the user ruled the status quo unacceptable for real-world projects and requested this issue — 2026-07-14. ## Sketch (shape only — the concrete form is deliberately undecided) A second, separate channel on the construction op: typed construction args, declared per roster entry, consumed once at bootstrap. ```text // roster entry declares construction args in addition to knobs "Session" => schema { args: [ tz: TzId, open: TimeOfDay ], // closed kinds, load-time-validated params: [ period_minutes: I64 ], // scalar knobs, unchanged } // blueprint / op-script side: data, no Rust Add { type_id: "Session", args: [ ("tz", "America/New_York"), ("open", "09:30") ], bind: [ ("period_minutes", i64 15) ], } ``` ## Guardrails any implementation must keep - **Four-scalar discipline untouched.** Construction args are bootstrap metadata, never streamed, never bound, never swept — the invariant already parks non-scalars as metadata beside the hot path, and this stays exactly there. - **No DSL hole.** Arg values come from closed, load-time-validated tables (an IANA timezone is a dropdown in Blockly-litmus terms, not freetext) — no open logic enters the artifact. - **Args are structural.** They enter the blueprint content-id (C18) and are not a sweep axis (a different `tz` is a different harness); they are, for the same reason, legitimate experiment-*matrix* axes. - **The node-crate valve (#241) stays.** Genuinely new logic still escalates to Rust; this issue narrows only the case where an existing node carries non-scalar *configuration*. ## Performance note `PrimitiveBuilder` creation and roster resolution happen at **bootstrap** ("construction is a bootstrap phase"), once per harness member — never inside the per-tick eval loop. Arg parsing and validation are therefore not performance-critical; the hot path is unaffected by design. ## Open (to be decided before implementation) - Representation of the arg schema (where it lives on `PrimitiveBuilder`/`NodeSchema`; which closed arg kinds exist beyond `TzId`/`TimeOfDay`). - Serialization in blueprints/op-scripts and content-id integration (C18). - Whether `SessionFrankfurt` remains as sugar or is retired once `Session` itself is reachable. - Whether structural args surface as experiment-matrix axes in campaign documents.
claude added the feature label 2026-07-14 10:36:23 +02:00
Author
Collaborator

Re-anchoring note: the file paths cited in this issue and in its closed predecessor #261 have moved and no longer resolve.

  • The zero-arg roster macro (std_vocabulary_roster!) and its scope-limitation doc comment, previously at crates/aura-std/src/vocabulary.rs, now live at crates/aura-vocabulary/src/lib.rs (moved in commit b39fd63, "refactor: split the aura-std roster into C28 layer crates", 2026-07-19, part of the unrelated C28 internal-stratification work, not the #295 shell-boundary cycle).
  • The Session node implementation, previously implied under aura-std, is at crates/aura-market/src/session.rs; the SessionFrankfurt baked preset is exported from crates/aura-market/src/lib.rs.

The substance of the issue is unchanged: the roster's doc comment at the new location (crates/aura-vocabulary/src/lib.rs, lines 10-17) still names Session(..) among the builders excluded from the zero-arg vocabulary because their construction config is structural, not scalar, and still forward-references the args extension as a later addition. No work since this issue was filed has touched typed construction args; SessionFrankfurt remains the sole workaround.

This issue's construction-time concern (non-scalar node config surfaced as blueprint data) is a distinct layer from the run-time CLI flag migration decided in #300 (--axis, --from/--to, --seeds, etc. moving into document vocabulary), but shares its underlying direction — text artifacts as the canonical layer, control surfaces as projections over them (C25). No relevance change to the open questions in this issue; only the path citations need updating for anyone picking it up.

Re-anchoring note: the file paths cited in this issue and in its closed predecessor #261 have moved and no longer resolve. - The zero-arg roster macro (`std_vocabulary_roster!`) and its scope-limitation doc comment, previously at `crates/aura-std/src/vocabulary.rs`, now live at `crates/aura-vocabulary/src/lib.rs` (moved in commit b39fd63, "refactor: split the aura-std roster into C28 layer crates", 2026-07-19, part of the unrelated C28 internal-stratification work, not the #295 shell-boundary cycle). - The Session node implementation, previously implied under `aura-std`, is at `crates/aura-market/src/session.rs`; the SessionFrankfurt baked preset is exported from `crates/aura-market/src/lib.rs`. The substance of the issue is unchanged: the roster's doc comment at the new location (`crates/aura-vocabulary/src/lib.rs`, lines 10-17) still names `Session(..)` among the builders excluded from the zero-arg vocabulary because their construction config is structural, not scalar, and still forward-references the args extension as a later addition. No work since this issue was filed has touched typed construction args; SessionFrankfurt remains the sole workaround. This issue's construction-time concern (non-scalar node config surfaced as blueprint data) is a distinct layer from the run-time CLI flag migration decided in #300 (--axis, --from/--to, --seeds, etc. moving into document vocabulary), but shares its underlying direction — text artifacts as the canonical layer, control surfaces as projections over them (C25). No relevance change to the open questions in this issue; only the path citations need updating for anyone picking it up.
claude added this to the Data-authorability boundary — folds, composites, construction args milestone 2026-07-23 13:42:57 +02:00
Author
Collaborator

Design reconciliation (specify)

Spec: construction-args. The issue's four open questions (plus one recon-surfaced) are still listed open; this records their resolution — all derived orchestrator decisions.

  • Fork: representation locus → construction args ride in Op::Add's own payload and serialize as a new PrimitiveData field mirroring bound; they are consumed via a new args seam on PrimitiveBuilder. The vocabulary resolver keeps its Fn(&str) -> Option<PrimitiveBuilder> shape, including the C-ABI ProjectDescriptor fn pointer.
    Basis: derived — the resolver is a per-type lookup while args are per-instance data (exactly like bound); this keeps descriptor_version stable, leaves the C29 load gate untouched, and makes reconstruction symmetric (resolve → apply args → apply bound).
  • Fork: arg-kind closedness → a new, separate closed ArgKind table beside ScalarKind (initially Tz, TimeOfDay, Count); values serialize as plain strings in documents and are load-time-validated (IANA table via chrono-tz, strict HH:MM, positive count).
    Basis: derived — ScalarKind is domain invariant 4's streamed set; construction args are never streamed, so extending it would conflate two different closedness axes.
  • Fork: entry scope → Session, LinComb, and CostSum — the three builders the roster's own scope doc excludes — become roster-reachable via args this cycle (roster count 33 → 36, both count pins). SimBroker::builder(pip_size: f64) is a different, scalar-typed gap (should be an ordinary ParamSpec) and stays out of scope.
    Basis: derived — proving the mechanism on two arg families (Tz/TimeOfDay and Count with an arity-dependent schema) shows it is generic, not a Session special case; the roster doc already names exactly these three.
  • Fork: SessionFrankfurt → stays this cycle; the authoring guide presents Session+args as the canonical path and the preset as baked sugar. Retirement is routed to the sugar-retirement milestone as its own decision.
    Basis: derived — registered blueprints resolve nodes by type_id at load; retiring the entry would orphan loadable registered artifacts, and C29's discipline is that registered artifacts are never retroactively invalidated.
  • Fork: experiment-matrix surfacing → out of scope. Construction args are a bootstrap channel only; whether they additionally surface as campaign-document matrix axes (RiskRegime precedent) is a separate campaign-vocabulary decision.
    Basis: derived — the campaign axis vocabulary (aura-research Axis/StrategyEntry) is a different serialization surface with its own closed-vocabulary discipline; coupling the two would widen this cycle past its boundary.
  • Recon-surfaced: format version → the writer emits the lowest sufficient version (1 for args-free documents, 2 when args are present, via a skip-if-empty serde field); the loader accepts both.
    Basis: derived — a global bump would change every canonical JSON and drift every existing content id (C18 breach), while args are genuinely must-understand for old loaders (C24 tier-2); the data-driven version is the only form satisfying both.
## Design reconciliation (specify) Spec: construction-args. The issue's four open questions (plus one recon-surfaced) are still listed open; this records their resolution — all derived orchestrator decisions. - **Fork: representation locus** → construction args ride in `Op::Add`'s own payload and serialize as a new `PrimitiveData` field mirroring `bound`; they are consumed via a new args seam on `PrimitiveBuilder`. The vocabulary resolver keeps its `Fn(&str) -> Option<PrimitiveBuilder>` shape, including the C-ABI `ProjectDescriptor` fn pointer. Basis: derived — the resolver is a per-type lookup while args are per-instance data (exactly like `bound`); this keeps `descriptor_version` stable, leaves the C29 load gate untouched, and makes reconstruction symmetric (resolve → apply args → apply bound). - **Fork: arg-kind closedness** → a new, separate closed `ArgKind` table beside `ScalarKind` (initially Tz, TimeOfDay, Count); values serialize as plain strings in documents and are load-time-validated (IANA table via chrono-tz, strict HH:MM, positive count). Basis: derived — `ScalarKind` is domain invariant 4's streamed set; construction args are never streamed, so extending it would conflate two different closedness axes. - **Fork: entry scope** → Session, LinComb, and CostSum — the three builders the roster's own scope doc excludes — become roster-reachable via args this cycle (roster count 33 → 36, both count pins). `SimBroker::builder(pip_size: f64)` is a different, scalar-typed gap (should be an ordinary ParamSpec) and stays out of scope. Basis: derived — proving the mechanism on two arg families (Tz/TimeOfDay and Count with an arity-dependent schema) shows it is generic, not a Session special case; the roster doc already names exactly these three. - **Fork: SessionFrankfurt** → stays this cycle; the authoring guide presents Session+args as the canonical path and the preset as baked sugar. Retirement is routed to the sugar-retirement milestone as its own decision. Basis: derived — registered blueprints resolve nodes by `type_id` at load; retiring the entry would orphan loadable registered artifacts, and C29's discipline is that registered artifacts are never retroactively invalidated. - **Fork: experiment-matrix surfacing** → out of scope. Construction args are a bootstrap channel only; whether they additionally surface as campaign-document matrix axes (RiskRegime precedent) is a separate campaign-vocabulary decision. Basis: derived — the campaign axis vocabulary (`aura-research` `Axis`/`StrategyEntry`) is a different serialization surface with its own closed-vocabulary discipline; coupling the two would widen this cycle past its boundary. - **Recon-surfaced: format version** → the writer emits the lowest sufficient version (1 for args-free documents, 2 when args are present, via a skip-if-empty serde field); the loader accepts both. Basis: derived — a global bump would change every canonical JSON and drift every existing content id (C18 breach), while args are genuinely must-understand for old loaders (C24 tier-2); the data-driven version is the only form satisfying both.
Author
Collaborator

Mid-cycle minute (implement batch 2)

The spec's worked op-script example was unbuildable as written: it wired Session.bars_since_open (I64) into LinComb.term[0] (F64) — a kind mismatch the existing connect gate correctly refuses (a spec prose/example defect, no behaviour question). The implementation's e2e test graph_build_accepts_the_session_args_example pins the corrected form (LinComb blends two same-kind SMAs; bars_since_open is exposed as a second output field). The spec example was aligned to the tested form after the fact.

Derived decision: no grounding-check re-run for this post-PASS spec edit. Basis: the edit removes a false assumption (that an I64→F64 wire would build) rather than introducing a new one, changes no design decision, and the corrected example's behaviour is pinned by a green test written this cycle. Recorded here for audit/veto.

## Mid-cycle minute (implement batch 2) The spec's worked op-script example was unbuildable as written: it wired `Session.bars_since_open` (I64) into `LinComb.term[0]` (F64) — a kind mismatch the existing connect gate correctly refuses (a spec prose/example defect, no behaviour question). The implementation's e2e test `graph_build_accepts_the_session_args_example` pins the corrected form (LinComb blends two same-kind SMAs; `bars_since_open` is exposed as a second output field). The spec example was aligned to the tested form after the fact. Derived decision: **no grounding-check re-run** for this post-PASS spec edit. Basis: the edit removes a false assumption (that an I64→F64 wire would build) rather than introducing a new one, changes no design decision, and the corrected example's behaviour is pinned by a green test written this cycle. Recorded here for audit/veto.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Brummel/Aura#271