7cd990f789
Iteration-1 plan for spec 0030 (the single-run side): two tasks. - Task 1 (aura-engine): BindError vocabulary, the shared `resolve` core (two-phase total error order), Composite::with / Binder / Binder::bootstrap, the lib.rs re-export, and the engine tests (resolve round-trip, one per single-run BindError variant, two precedence tests, the C1 named≡positional equivalence test). RED-first via a todo!() resolve stub, GREEN on impl. - Task 2 (aura-cli): convert the sample single-run test to .with(...).bootstrap() using the exact param_space() names (sma_cross.fast/sma_cross.slow/scale). The full BindError enum (incl. EmptyAxis) is defined now and re-exported, so the unconstructed sweep-only variant is reachable API (not dead_code) — EmptyAxis is constructed in iteration 2. Engine core untouched (C1/C12/C19/C23). refs #35
386 lines
15 KiB
Markdown
386 lines
15 KiB
Markdown
# Named param binding — Iteration 1 (single run) — Implementation Plan
|
|
|
|
> **Parent spec:** `docs/specs/0030-named-param-binding.md`
|
|
>
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
|
|
> this plan. Steps use `- [ ]` checkboxes for tracking.
|
|
|
|
**Goal:** Add the single-run named-binding authoring layer — `Composite::with` →
|
|
`Binder` → `Binder::bootstrap`, backed by the shared `resolve` core and the
|
|
`BindError` vocabulary — and convert the CLI sample single-run to it.
|
|
|
|
**Architecture:** A pure authoring layer in `aura-engine` over the existing
|
|
`Composite::param_space` / `bootstrap_with_params` primitives (engine core
|
|
untouched, C1/C12/C19/C23 preserved). `resolve` maps named bindings to a
|
|
positional `Vec<Scalar>` against `param_space()` using a total error order;
|
|
`Binder` is the fluent accumulator. The sweep side (`axis`/`SweepBinder`/
|
|
`resolve_axes`/`EmptyAxis` construction) is iteration 2 and out of scope here.
|
|
|
|
**Tech Stack:** Rust; `crates/aura-engine/src/blueprint.rs` (new items + tests),
|
|
`crates/aura-engine/src/lib.rs` (re-export), `crates/aura-cli/src/main.rs` (sample
|
|
conversion).
|
|
|
|
---
|
|
|
|
**Files this plan creates or modifies:**
|
|
|
|
- Modify: `crates/aura-engine/src/blueprint.rs` — add `enum BindError`, `struct
|
|
Binder`, `impl Binder`, free `fn resolve`, and `Composite::with`; add unit +
|
|
equivalence tests in the `#[cfg(test)] mod tests`.
|
|
- Modify: `crates/aura-engine/src/lib.rs:41` — add `BindError, Binder` to the
|
|
`pub use blueprint::{…}` re-export.
|
|
- Test: `crates/aura-engine/src/blueprint.rs` (test module) — `resolve` round-trip,
|
|
one assertion per single-run `BindError` variant, two precedence tests, one C1
|
|
named≡positional equivalence test.
|
|
- Modify: `crates/aura-cli/src/main.rs:514-525` — convert the
|
|
`sample_blueprint_with_sinks_bootstraps_runs_and_drains` test to `.with(…).bootstrap()`.
|
|
|
|
---
|
|
|
|
## Task 1: Engine — `BindError`, `resolve`, `Composite::with`, `Binder`
|
|
|
|
**Files:**
|
|
- Modify: `crates/aura-engine/src/blueprint.rs` (new top-level items near
|
|
`CompileError` ~271; `Composite::with` in `impl Composite` ~144-267; tests in
|
|
`#[cfg(test)] mod tests`)
|
|
- Modify: `crates/aura-engine/src/lib.rs:41`
|
|
|
|
- [ ] **Step 1: Add the skeleton (enum, builder, stubbed `resolve`) + re-export**
|
|
|
|
In `crates/aura-engine/src/blueprint.rs`, add these top-level items (place them
|
|
just above the `CompileError` definition, ~line 270; `ScalarKind`, `Scalar`,
|
|
`ParamSpec` are already imported at the top, `Harness` via `crate::harness`):
|
|
|
|
```rust
|
|
/// A fault in resolving a named binding against `param_space()` — the authoring
|
|
/// layer over `bootstrap_with_params` / `sweep`. Name-qualified: each message
|
|
/// names the offending knob, not a slot index. The total error order is documented
|
|
/// on the resolution path; the first failing check wins.
|
|
#[derive(Debug, PartialEq)]
|
|
pub enum BindError {
|
|
/// A bound name matches no `param_space()` slot's exact name.
|
|
UnknownKnob(String),
|
|
/// A `param_space()` slot was left unbound.
|
|
MissingKnob(String),
|
|
/// A bound value's kind does not equal the slot's declared kind.
|
|
KindMismatch { knob: String, expected: ScalarKind, got: ScalarKind },
|
|
/// The same name was bound twice.
|
|
DuplicateBinding(String),
|
|
/// A name matches the exact name of more than one slot.
|
|
AmbiguousKnob(String),
|
|
/// (sweep, iteration 2) An axis was given zero values.
|
|
EmptyAxis(String),
|
|
/// The resolved point passed name resolution but failed downstream bootstrap.
|
|
Compile(CompileError),
|
|
}
|
|
|
|
/// A fluent accumulator of named knob bindings for a single run, terminated by
|
|
/// [`Binder::bootstrap`]. Bindings are resolved against `param_space()` once, at
|
|
/// the terminal.
|
|
pub struct Binder {
|
|
bp: Composite,
|
|
bound: Vec<(String, Scalar)>,
|
|
}
|
|
|
|
impl Binder {
|
|
/// Bind one more knob by name; raw literals lower via `Into<Scalar>`.
|
|
pub fn with(mut self, name: &str, v: impl Into<Scalar>) -> Binder {
|
|
self.bound.push((name.to_string(), v.into()));
|
|
self
|
|
}
|
|
|
|
/// Resolve the accumulated bindings against `param_space()` and bootstrap.
|
|
pub fn bootstrap(self) -> Result<Harness, BindError> {
|
|
let space = self.bp.param_space();
|
|
let point = resolve(&space, &self.bound)?;
|
|
self.bp.bootstrap_with_params(point).map_err(BindError::Compile)
|
|
}
|
|
}
|
|
|
|
/// Resolve named bindings to a positional `Vec<Scalar>` in `param_space()` slot
|
|
/// order. (Body filled in Step 3.)
|
|
fn resolve(space: &[ParamSpec], bound: &[(String, Scalar)]) -> Result<Vec<Scalar>, BindError> {
|
|
let _ = (space, bound);
|
|
todo!("resolve")
|
|
}
|
|
```
|
|
|
|
Then add `Composite::with` inside the existing `impl Composite` block (anywhere
|
|
among its methods, e.g. just after `bootstrap` ~line 267):
|
|
|
|
```rust
|
|
/// Begin binding this blueprint's knobs **by name** for a single run (the
|
|
/// fluent alternative to a positional `bootstrap_with_params` vector). The
|
|
/// bound name is the exact `param_space()` name — path-qualified for a knob
|
|
/// inside a composite (`sma_cross.fast`), bare for a root-level knob (`scale`).
|
|
pub fn with(self, name: &str, v: impl Into<Scalar>) -> Binder {
|
|
Binder { bp: self, bound: vec![(name.to_string(), v.into())] }
|
|
}
|
|
```
|
|
|
|
In `crates/aura-engine/src/lib.rs`, line 41 currently reads:
|
|
|
|
```rust
|
|
pub use blueprint::{aliases_on, signature_of, BlueprintNode, CompileError, Composite, OutField, ParamAlias, Role};
|
|
```
|
|
|
|
Replace it with (adds `BindError, Binder`):
|
|
|
|
```rust
|
|
pub use blueprint::{aliases_on, signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField, ParamAlias, Role};
|
|
```
|
|
|
|
- [ ] **Step 2: Add all Task-1 tests, run, verify RED**
|
|
|
|
In the `#[cfg(test)] mod tests` of `crates/aura-engine/src/blueprint.rs`, add
|
|
(the module already has `use super::*;`, and `synthetic_prices` +
|
|
`composite_sma_cross_harness` fixtures):
|
|
|
|
```rust
|
|
#[test]
|
|
fn resolve_named_equals_positional_vector() {
|
|
// order-independent: shuffled bindings resolve to slot order
|
|
let space = vec![
|
|
ParamSpec { name: "sma_cross.fast".into(), kind: ScalarKind::I64 },
|
|
ParamSpec { name: "sma_cross.slow".into(), kind: ScalarKind::I64 },
|
|
ParamSpec { name: "scale".into(), kind: ScalarKind::F64 },
|
|
];
|
|
let bound = vec![
|
|
("scale".to_string(), Scalar::F64(0.5)),
|
|
("sma_cross.fast".to_string(), Scalar::I64(2)),
|
|
("sma_cross.slow".to_string(), Scalar::I64(4)),
|
|
];
|
|
assert_eq!(
|
|
resolve(&space, &bound),
|
|
Ok(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_unknown_knob() {
|
|
let space = vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }];
|
|
assert_eq!(
|
|
resolve(&space, &[("nope".to_string(), Scalar::F64(0.5))]),
|
|
Err(BindError::UnknownKnob("nope".to_string())),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_missing_knob() {
|
|
let space = vec![
|
|
ParamSpec { name: "a".into(), kind: ScalarKind::I64 },
|
|
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
|
|
];
|
|
assert_eq!(
|
|
resolve(&space, &[("a".to_string(), Scalar::I64(1))]),
|
|
Err(BindError::MissingKnob("b".to_string())),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_kind_mismatch() {
|
|
let space = vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }];
|
|
assert_eq!(
|
|
resolve(&space, &[("scale".to_string(), Scalar::I64(2))]),
|
|
Err(BindError::KindMismatch {
|
|
knob: "scale".to_string(),
|
|
expected: ScalarKind::F64,
|
|
got: ScalarKind::I64,
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_duplicate_binding() {
|
|
let space = vec![ParamSpec { name: "a".into(), kind: ScalarKind::I64 }];
|
|
assert_eq!(
|
|
resolve(
|
|
&space,
|
|
&[("a".to_string(), Scalar::I64(1)), ("a".to_string(), Scalar::I64(2))],
|
|
),
|
|
Err(BindError::DuplicateBinding("a".to_string())),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_ambiguous_knob() {
|
|
// two slots with the identical exact name
|
|
let space = vec![
|
|
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
|
|
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
|
|
];
|
|
assert_eq!(
|
|
resolve(&space, &[("dup".to_string(), Scalar::I64(1))]),
|
|
Err(BindError::AmbiguousKnob("dup".to_string())),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_precedence_unknown_before_kind_mismatch() {
|
|
// Phase-1 (unknown) wins over Phase-2 (kind mismatch)
|
|
let space = vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }];
|
|
let bound = vec![
|
|
("typo".to_string(), Scalar::I64(9)),
|
|
("scale".to_string(), Scalar::I64(2)),
|
|
];
|
|
assert_eq!(resolve(&space, &bound), Err(BindError::UnknownKnob("typo".to_string())));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_precedence_unknown_before_duplicate() {
|
|
// intra-binding: check a (unknown) precedes check d (duplicate)
|
|
let space = vec![ParamSpec { name: "a".into(), kind: ScalarKind::I64 }];
|
|
let bound = vec![
|
|
("typo".to_string(), Scalar::I64(1)),
|
|
("typo".to_string(), Scalar::I64(2)),
|
|
];
|
|
assert_eq!(resolve(&space, &bound), Err(BindError::UnknownKnob("typo".to_string())));
|
|
}
|
|
|
|
#[test]
|
|
fn named_binder_runs_bit_identical_to_positional() {
|
|
// C1 equivalence: the named builder bootstraps to a run bit-identical to
|
|
// the positional vector, over the sample composite harness.
|
|
let (bp, comp_eq, comp_ex) = composite_sma_cross_harness();
|
|
let mut named = bp
|
|
.with("sma_cross.fast", 2)
|
|
.with("sma_cross.slow", 4)
|
|
.with("scale", 0.5)
|
|
.bootstrap()
|
|
.expect("named binding resolves and bootstraps");
|
|
named.run(vec![synthetic_prices()]);
|
|
let named_eq = comp_eq.try_iter().collect::<Vec<_>>();
|
|
let named_ex = comp_ex.try_iter().collect::<Vec<_>>();
|
|
|
|
let (bp2, pos_eq, pos_ex) = composite_sma_cross_harness();
|
|
let mut positional = bp2
|
|
.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
|
|
.expect("positional bootstrap");
|
|
positional.run(vec![synthetic_prices()]);
|
|
let pos_eq_v = pos_eq.try_iter().collect::<Vec<_>>();
|
|
let pos_ex_v = pos_ex.try_iter().collect::<Vec<_>>();
|
|
|
|
assert!(!named_eq.is_empty(), "named run drained empty");
|
|
assert_eq!(named_eq, pos_eq_v, "equity stream: named must equal positional");
|
|
assert_eq!(named_ex, pos_ex_v, "exposure stream: named must equal positional");
|
|
}
|
|
```
|
|
|
|
Run: `cargo test -p aura-engine resolve_ named_binder_runs_bit_identical_to_positional 2>&1 | tail -20`
|
|
(filters match the nine new tests by their `resolve_*` prefix + the equivalence
|
|
test name; all currently call `resolve` via the `todo!()` stub).
|
|
Expected: FAIL — every new test panics with `not yet implemented: resolve` (the
|
|
`todo!("resolve")` stub).
|
|
|
|
- [ ] **Step 3: Implement `resolve` (total error order), run, verify GREEN**
|
|
|
|
In `crates/aura-engine/src/blueprint.rs`, replace the `resolve` stub body from
|
|
Step 1 with:
|
|
|
|
```rust
|
|
fn resolve(space: &[ParamSpec], bound: &[(String, Scalar)]) -> Result<Vec<Scalar>, BindError> {
|
|
// Phase 1 — per binding in input order: claim slots by exact name (a, b, d).
|
|
let mut claimed: Vec<Option<Scalar>> = vec![None; space.len()];
|
|
for (name, value) in bound {
|
|
let matches: Vec<usize> = space
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, p)| p.name == *name)
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
match matches.as_slice() {
|
|
[] => return Err(BindError::UnknownKnob(name.clone())), // a
|
|
[idx] => {
|
|
if claimed[*idx].is_some() {
|
|
return Err(BindError::DuplicateBinding(name.clone())); // d
|
|
}
|
|
claimed[*idx] = Some(*value);
|
|
}
|
|
_ => return Err(BindError::AmbiguousKnob(name.clone())), // b
|
|
}
|
|
}
|
|
// Phase 2 — slot walk in param_space() order (e, f).
|
|
let mut point = Vec::with_capacity(space.len());
|
|
for (i, p) in space.iter().enumerate() {
|
|
match claimed[i] {
|
|
None => return Err(BindError::MissingKnob(p.name.clone())), // e
|
|
Some(v) => {
|
|
if v.kind() != p.kind {
|
|
return Err(BindError::KindMismatch {
|
|
knob: p.name.clone(),
|
|
expected: p.kind,
|
|
got: v.kind(),
|
|
}); // f
|
|
}
|
|
point.push(v);
|
|
}
|
|
}
|
|
}
|
|
Ok(point)
|
|
}
|
|
```
|
|
|
|
Run: `cargo test -p aura-engine resolve_ named_binder_runs_bit_identical_to_positional 2>&1 | tail -20`
|
|
Expected: PASS — all nine new tests green.
|
|
|
|
- [ ] **Step 4: Full workspace gate**
|
|
|
|
Run: `cargo build --workspace 2>&1 | tail -5`
|
|
Expected: success, no errors.
|
|
|
|
Run: `cargo test --workspace 2>&1 | tail -15`
|
|
Expected: all suites PASS (the nine new + all pre-existing).
|
|
|
|
Run: `cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10`
|
|
Expected: clean — no warnings. (`resolve` is used by `Binder::bootstrap`;
|
|
`BindError`/`Binder`/`with` are `pub`-re-exported so the unconstructed `EmptyAxis`
|
|
variant is reachable API, not `dead_code`.)
|
|
|
|
## Task 2: CLI — convert the sample single-run to `.with(…).bootstrap()`
|
|
|
|
**Files:**
|
|
- Modify: `crates/aura-cli/src/main.rs:514-525`
|
|
(test `sample_blueprint_with_sinks_bootstraps_runs_and_drains`)
|
|
|
|
- [ ] **Step 1: Convert the bootstrap call**
|
|
|
|
In `crates/aura-cli/src/main.rs`, in the test
|
|
`sample_blueprint_with_sinks_bootstraps_runs_and_drains`, replace:
|
|
|
|
```rust
|
|
let mut h = bp
|
|
.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
|
|
.expect("sample blueprint compiles under a valid point");
|
|
```
|
|
|
|
with (the exact `param_space()` names: composite-interior knobs path-qualified,
|
|
the root `scale` bare):
|
|
|
|
```rust
|
|
let mut h = bp
|
|
.with("sma_cross.fast", 2)
|
|
.with("sma_cross.slow", 4)
|
|
.with("scale", 0.5)
|
|
.bootstrap()
|
|
.expect("sample blueprint compiles under a valid point");
|
|
```
|
|
|
|
(`with`/`bootstrap` are methods on the already-imported `Composite` / the
|
|
re-exported `Binder`; no new `use` is required. If the compiler reports `Scalar`
|
|
is now unused in this test, leave it — it is still used elsewhere in the test
|
|
module; do not remove the import.)
|
|
|
|
- [ ] **Step 2: Run the converted test, verify GREEN (behaviour-preserving)**
|
|
|
|
Run: `cargo test -p aura-cli sample_blueprint_with_sinks_bootstraps_runs_and_drains 2>&1 | tail -10`
|
|
Expected: PASS — `1 passed` (the named form bootstraps and drains both sinks
|
|
exactly as the positional form did).
|
|
|
|
- [ ] **Step 3: Full workspace gate**
|
|
|
|
Run: `cargo test --workspace 2>&1 | tail -15`
|
|
Expected: all suites PASS.
|
|
|
|
Run: `cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10`
|
|
Expected: clean.
|