Blueprint constants: bind a node param as a structural constant outside param_space #55

Closed
opened 2026-06-10 22:47:42 +02:00 by Brummel · 2 comments
Owner

Part of the param-space line (refs #31). Surfaced in design discussion after #33.

Problem

#31 made blueprints value-empty: a tunable lives only in the injected param vector, declared as a param_space knob (Sma::builder() forces length into param_space, read from p[0] at build). Correct for tuning params — values the run supplies, the sweep varies.

But some values are structural constants, not tuning params: a value whose variation does not yield another valid point of the same strategy but deforms it into a different (or nonsensical) one. Example: an "SMA2-entry" strategy — "close of the last candle > high of the prior, SMA(2) as bias". The 2 is bound to the two-candle construction; sweeping length to 5 does not give "the same strategy at another point", it breaks it. "SMA2-entry" is a different strategy from a length-generic "SMA-entry", not a point in its sweep space.

For such a value, leaving the knob open and fixing it in the vector is wrong, not just verbose: param_space would declare a degree of freedom that does not exist, and a sweep over it would enumerate deformed strategies as valid family members.

The discriminator:

  • variation -> another valid point -> tuning param -> param_space / vector (#31, exists)
  • variation -> deformed strategy -> structural constant -> bound in the blueprint, NOT in param_space (this issue)

The pattern already exists

SimBroker::builder(0.0001) already captures a construction value that is not in param_space (SimBroker::builder(0.0001).schema().params.is_empty() is asserted in aura-std). So "a value in the blueprint, outside param_space" is already legitimate — but only for nodes that offer no sweep mode at all. The gap: param-bearing nodes (SMA, Exposure) have no constant path.

Direction: a binding overlay, the twin of ParamAlias (C23)

NOT a second builder_const(2) per node — that is per-node boilerplate, explodes combinatorially for multi-param nodes (which subset is constant?), and is not dynamic.

Instead: a ParamBinding { node, slot, value } overlay, structurally the twin of the existing ParamAlias { name, node, slot } (C23):

  • ParamAlias relabels slot (node,slot) in param_space — collect_params pushes with the new name.
  • ParamBinding removes slot (node,slot) from param_space + feeds value at compile — collect_params skips the push; lower_items/compile_with_params assembles the build vector from bound slots + the injected (shrunk) vector.

Docks at the same sites as aliases (blueprint.rs collect_params for the param_space side; lower_items/compile_with_params for the value side). The build-closure signature (Fn(&[Scalar])) is unchanged; param_space() is the self-reflecting surface that reports only the still-open knobs:

sma_entry  : param_space = [length]      // open -> sweepable
            .bind(length -> 2)
sma2_entry : param_space = []            // length gone -> structural constant

bind is Blueprint -> Blueprint with a smaller param_space. builder_const would be at most sugar over bind, never the primitive. The overlay form is uniform (one bind for every node), dynamic (decided per knob at construction time), and reflected in param_space — the properties a future blueprint-as-values collection needs.

Invariant boundary (C9)

A future "blueprint registry" must stay a Rust module of blueprint values (cargo-native reuse, C9-ok) — NOT a by-name runtime node resolution ("SMA" -> constructor), which is the node-registry C9 forbids and the RustAst/DSL trap C10 forbids. Blueprints are already first-class values (Composite / param_space / compile_with_params); "create a node from a blueprint dynamically" IS compile_with_params. This issue adds only the bind overlay — no registry layer.

Notes

  • additive: takes nothing away from #31; the injected vector stays. New third category alongside (topology factory-arg, tuning param).
  • needs its own brainstorm/spec when picked up (design fork: overlay data placement, bind ergonomics, how a named frozen strategy is exported).

depends on: nothing blocking
context: structural-constant binding, distinct from #35 (which is named-binding on the vector side only)


Update (2026-06-11) — the ParamAlias framing is superseded

The "twin of the existing ParamAlias (C23)" direction above is stale. This
issue was filed before cycles 0031 (node-instance naming) and 0032 (param_space()
injectivity) landed. There is no longer a ParamAlias { name, node, slot } overlay
anywhere in the workspace — node-instance naming replaced it: each node carries its
own instance name (.named(...), or the lowercased type label by default), and
path-qualification is folded directly into collect_params (blueprint.rs), which
builds each ParamSpec.name as <prefix>.<node>.<param> from the node's own name.
No separate relabel table sits beside param_space().

What this changes for the design (the substance — bind overlay, the
tuning-param/structural-constant discriminator, the additive stance — still holds):

  • The bind overlay is not the twin of a ParamAlias struct (that struct is
    gone). It is the twin of collect_params' push: where collect_params today
    pushes a slot under its path-qualified name, .bind() would skip the push and
    feed the value at compile from the bound slot, assembling the build vector from
    bound slots + the injected (shrunk) vector. param_space() reports only the
    still-open knobs, exactly as before.
  • "Docks at the same sites as aliases" now reads: docks at collect_params (the
    param_space side — skip the push) and lower_items / compile_with_params (the
    value side — inject at the bound slot). There is no alias site to share any more.
  • The three open forks are unchanged and still need their own brainstorm:
    overlay-data placement (now with no ParamAlias neighbour to sit beside),
    .bind() ergonomics, and how a named frozen strategy is exported.

The C9 invariant boundary (a registry stays a Rust module of blueprint values,
never a by-name runtime node lookup) is unaffected.

Part of the param-space line (refs #31). Surfaced in design discussion after #33. ## Problem #31 made blueprints value-empty: a tunable lives only in the injected param vector, declared as a param_space knob (`Sma::builder()` forces `length` into param_space, read from `p[0]` at build). Correct for **tuning params** — values the run supplies, the sweep varies. But some values are **structural constants**, not tuning params: a value whose variation does not yield another valid point of the same strategy but **deforms** it into a different (or nonsensical) one. Example: an "SMA2-entry" strategy — "close of the last candle > high of the prior, SMA(2) as bias". The `2` is bound to the two-candle construction; sweeping `length` to 5 does not give "the same strategy at another point", it breaks it. "SMA2-entry" is a *different strategy* from a length-generic "SMA-entry", not a point in its sweep space. For such a value, leaving the knob open and fixing it in the vector is **wrong**, not just verbose: param_space would declare a degree of freedom that does not exist, and a sweep over it would enumerate deformed strategies as valid family members. The discriminator: - variation -> another valid point -> tuning param -> param_space / vector (#31, exists) - variation -> deformed strategy -> structural constant -> bound in the blueprint, NOT in param_space (this issue) ## The pattern already exists `SimBroker::builder(0.0001)` already captures a construction value that is **not** in param_space (`SimBroker::builder(0.0001).schema().params.is_empty()` is asserted in aura-std). So "a value in the blueprint, outside param_space" is already legitimate — but only for nodes that offer no sweep mode at all. The gap: param-bearing nodes (SMA, Exposure) have no constant path. ## Direction: a binding overlay, the twin of ParamAlias (C23) NOT a second `builder_const(2)` per node — that is per-node boilerplate, explodes combinatorially for multi-param nodes (which subset is constant?), and is not dynamic. Instead: a `ParamBinding { node, slot, value }` overlay, structurally the twin of the existing `ParamAlias { name, node, slot }` (C23): - `ParamAlias` relabels slot (node,slot) in param_space — `collect_params` pushes with the new name. - `ParamBinding` removes slot (node,slot) from param_space + feeds `value` at compile — `collect_params` skips the push; `lower_items`/`compile_with_params` assembles the build vector from bound slots + the injected (shrunk) vector. Docks at the same sites as aliases (blueprint.rs `collect_params` for the param_space side; `lower_items`/`compile_with_params` for the value side). The build-closure signature (`Fn(&[Scalar])`) is unchanged; `param_space()` is the self-reflecting surface that reports only the still-open knobs: sma_entry : param_space = [length] // open -> sweepable .bind(length -> 2) sma2_entry : param_space = [] // length gone -> structural constant `bind` is Blueprint -> Blueprint with a smaller param_space. `builder_const` would be at most sugar over `bind`, never the primitive. The overlay form is uniform (one bind for every node), dynamic (decided per knob at construction time), and reflected in param_space — the properties a future blueprint-as-values collection needs. ## Invariant boundary (C9) A future "blueprint registry" must stay a Rust module of blueprint **values** (cargo-native reuse, C9-ok) — NOT a by-name runtime node resolution (`"SMA"` -> constructor), which is the node-registry C9 forbids and the RustAst/DSL trap C10 forbids. Blueprints are already first-class values (`Composite` / `param_space` / `compile_with_params`); "create a node from a blueprint dynamically" IS `compile_with_params`. This issue adds only the `bind` overlay — no registry layer. ## Notes - additive: takes nothing away from #31; the injected vector stays. New third category alongside (topology factory-arg, tuning param). - needs its own brainstorm/spec when picked up (design fork: overlay data placement, bind ergonomics, how a named frozen strategy is exported). depends on: nothing blocking context: structural-constant binding, distinct from #35 (which is named-binding on the vector side only) --- ## Update (2026-06-11) — the `ParamAlias` framing is superseded The "twin of the existing `ParamAlias` (C23)" direction above is **stale**. This issue was filed before cycles 0031 (node-instance naming) and 0032 (`param_space()` injectivity) landed. There is no longer a `ParamAlias { name, node, slot }` overlay anywhere in the workspace — node-instance naming replaced it: each node carries its own instance name (`.named(...)`, or the lowercased type label by default), and path-qualification is folded directly into `collect_params` (`blueprint.rs`), which builds each `ParamSpec.name` as `<prefix>.<node>.<param>` from the node's own name. No separate relabel table sits beside `param_space()`. What this changes for the design (the substance — bind overlay, the tuning-param/structural-constant discriminator, the additive stance — still holds): - The bind overlay is **not** the twin of a `ParamAlias` struct (that struct is gone). It is the twin of `collect_params`' *push*: where `collect_params` today pushes a slot under its path-qualified name, `.bind()` would **skip the push** and feed the value at compile from the bound slot, assembling the build vector from bound slots + the injected (shrunk) vector. `param_space()` reports only the still-open knobs, exactly as before. - "Docks at the same sites as aliases" now reads: docks at `collect_params` (the param_space side — skip the push) and `lower_items` / `compile_with_params` (the value side — inject at the bound slot). There is no alias site to share any more. - The three open forks are unchanged and still need their own brainstorm: overlay-data placement (now with *no* `ParamAlias` neighbour to sit beside), `.bind()` ergonomics, and how a named frozen strategy is exported. The C9 invariant boundary (a registry stays a Rust module of blueprint *values*, never a by-name runtime node lookup) is unaffected.
Brummel added the feature label 2026-06-10 22:47:42 +02:00
Author
Owner

Acceptance evidence from the milestone fieldtest (mw_3,
docs/specs/fieldtest-milestone-the-world-param-sweep.md, fieldtests/milestone-the-world-param-sweep/mw_3_structural_constant.rs).

The fieldtest drove the SMA2-entry case end-to-end as a downstream consumer and
confirmed both avenues are wrong in a recordable way:

  • (a) leave the length as an open param_space() knob fixed to 2: the knob
    sma2_entry.bias : I64 is then indistinguishable from a genuinely-tunable
    knob, and .axis("sma2_entry.bias", [2,5,10]) would enumerate deformed
    strategies as valid family members. param_space() declares a degree of
    freedom the strategy's definition forbids.
  • (b) imitate SimBroker::builder(1e-4): verbatim compile wall —
    Sma::builder() takes no length arg (length is always an injected param), and
    Sma::new(2) returns a built Sma, not a PrimitiveBuilder / BlueprintNode;
    the only conversion is From<PrimitiveBuilder> for BlueprintNode (no From<Sma>).
    A blueprint holds value-empty recipes, never built nodes. The construction-constant
    escape hatch exists for SimBroker only because its builder(pip_size) takes
    the constant; there is no Sma::builder_with(2). So the escape hatch is
    structurally unreachable for a stock indicator node.

The wished-for consumer code (recorded verbatim in mw_3) is the shape #55 should
make work: a Sma::builder().bind("length", 2) that removes the knob from
param_space, or an Sma::builder_with(2) recipe constructor. The .bind() name
is already reserved for exactly this in spec 0030.

**Acceptance evidence from the milestone fieldtest** (mw_3, docs/specs/fieldtest-milestone-the-world-param-sweep.md, fieldtests/milestone-the-world-param-sweep/mw_3_structural_constant.rs). The fieldtest drove the SMA2-entry case end-to-end as a downstream consumer and confirmed both avenues are wrong in a recordable way: - **(a) leave the length as an open `param_space()` knob fixed to 2:** the knob `sma2_entry.bias : I64` is then **indistinguishable** from a genuinely-tunable knob, and `.axis("sma2_entry.bias", [2,5,10])` would enumerate **deformed** strategies as valid family members. `param_space()` declares a degree of freedom the strategy's definition forbids. - **(b) imitate `SimBroker::builder(1e-4)`:** verbatim compile wall — `Sma::builder()` takes no length arg (length is always an injected param), and `Sma::new(2)` returns a **built** `Sma`, not a `PrimitiveBuilder` / `BlueprintNode`; the only conversion is `From<PrimitiveBuilder> for BlueprintNode` (no `From<Sma>`). A blueprint holds value-empty recipes, never built nodes. The construction-constant escape hatch exists for `SimBroker` only because its `builder(pip_size)` *takes* the constant; there is no `Sma::builder_with(2)`. So the escape hatch is **structurally unreachable** for a stock indicator node. The wished-for consumer code (recorded verbatim in mw_3) is the shape #55 should make work: a `Sma::builder().bind("length", 2)` that removes the knob from `param_space`, or an `Sma::builder_with(2)` recipe constructor. The `.bind()` name is already reserved for exactly this in spec 0030.
Author
Owner

Design reconciliation (specify, in-context entry)

Spec: 0034-blueprint-constant-bind. The forks below are still listed open in
this issue body (which predates the 2026-06-12 session); their status is updated
here so the record matches the design as settled.

  • Fork: overlay-data placementOption B, builder-level bind. .bind()
    is a method on PrimitiveBuilder that shrinks the node's declared param surface
    and wraps its build closure; it is not an overlay struct on Composite. The
    two sites the issue update names as "dock sites" (collect_params,
    lower_items) are touched by zero lines — both already key off
    builder.params(), so the shrink propagates transparently. This inverts the
    issue update's framing (those sites are read-only beneficiaries, not edit
    sites).
    Provenance: user decision, 2026-06-12 session — verbatim "Ursprünglich war B
    geplant" ("B was originally planned"), followed by an explicit green-light to
    proceed with B as the settled direction.

  • Fork: .bind() ergonomics (slot addressing)by param name.
    .bind(slot: &str, value: Scalar) matches the slot against the node's declared
    ParamSpec.name. This is not a fresh pick: it follows the established C23
    authoring-address-space convention as amended in cycle 0032
    (check_param_namespace_injective) — the by-name authoring address space is
    injective, while the compilat stays wired by raw index. Name at the authoring
    surface, index in the compilat; .bind() sits on the authoring side.
    Provenance: C23 contract + cycle-0032 amendment (design ledger), not a new
    orchestrator decision.

  • Fork: how a named frozen strategy is exportedNOT resolved; deferred,
    out of scope for this cycle.
    This cycle ships the .bind() overlay only. The
    issue itself frames export as a "future blueprint-as-values collection"; it
    keeps its own brainstorm when picked up. The C9 boundary (a blueprint registry
    stays a Rust module of blueprint values, never a by-name runtime node lookup)
    is unaffected and needs no work here.

## Design reconciliation (specify, in-context entry) Spec: `0034-blueprint-constant-bind`. The forks below are still listed open in this issue body (which predates the 2026-06-12 session); their status is updated here so the record matches the design as settled. - **Fork: overlay-data placement** → **Option B, builder-level bind.** `.bind()` is a method on `PrimitiveBuilder` that shrinks the node's declared param surface and wraps its build closure; it is *not* an overlay struct on `Composite`. The two sites the issue update names as "dock sites" (`collect_params`, `lower_items`) are touched by zero lines — both already key off `builder.params()`, so the shrink propagates transparently. This inverts the issue update's framing (those sites are read-only beneficiaries, not edit sites). Provenance: user decision, 2026-06-12 session — verbatim "Ursprünglich war B geplant" ("B was originally planned"), followed by an explicit green-light to proceed with B as the settled direction. - **Fork: `.bind()` ergonomics (slot addressing)** → **by param name.** `.bind(slot: &str, value: Scalar)` matches the slot against the node's declared `ParamSpec.name`. This is not a fresh pick: it follows the established C23 authoring-address-space convention as amended in cycle 0032 (`check_param_namespace_injective`) — the by-name *authoring* address space is injective, while the *compilat* stays wired by raw index. Name at the authoring surface, index in the compilat; `.bind()` sits on the authoring side. Provenance: C23 contract + cycle-0032 amendment (design ledger), not a new orchestrator decision. - **Fork: how a named frozen strategy is exported** → **NOT resolved; deferred, out of scope for this cycle.** This cycle ships the `.bind()` overlay only. The issue itself frames export as a "future blueprint-as-values collection"; it keeps its own brainstorm when picked up. The C9 boundary (a blueprint registry stays a Rust module of blueprint *values*, never a by-name runtime node lookup) is unaffected and needs no work here.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Brummel/Aura#55