diff --git a/crates/aura-composites/src/lib.rs b/crates/aura-composites/src/lib.rs index a9f95b1..c0edf08 100644 --- a/crates/aura-composites/src/lib.rs +++ b/crates/aura-composites/src/lib.rs @@ -52,8 +52,8 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite { let sqrt = g.add(Sqrt::builder()); // → σ (price units) // k·σ: weights[0] bound (Some) or open and named `stop_k` (None). let scale = g.add(match knobs { - Some((_, k)) => LinComb::builder(1).bind("weights[0]", Scalar::f64(k)), - None => LinComb::builder(1).named("stop_k"), + Some((_, k)) => LinComb::configured(1).bind("weights[0]", Scalar::f64(k)), + None => LinComb::configured(1).named("stop_k"), }); g.feed(price, [delay.input("series"), sub.input("lhs")]); g.connect(delay.output("value"), sub.input("rhs")); @@ -174,7 +174,7 @@ pub fn cost_graph(cost_nodes: Vec) -> Composite { let entry = g.input_role("entry_price"); let stop = g.input_role("stop_price"); - let agg = g.add(CostSum::builder(n)); + let agg = g.add(CostSum::configured(n)); // Per-role geometry fan targets, collected across all nodes, fed once per role. let mut closed_t = Vec::with_capacity(n); diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index c6a5b72..d4513ec 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -1821,7 +1821,7 @@ mod tests { Composite::new( "sig3", vec![ - LinComb::builder(2).into(), + LinComb::configured(2).into(), Bias::builder().into(), Sma::builder().named("c").into(), ], @@ -1860,7 +1860,7 @@ mod tests { Composite::new( "sig3", vec![ - LinComb::builder(2).into(), + LinComb::configured(2).into(), Bias::builder().into(), Sma::builder().named("c").into(), ], @@ -3233,7 +3233,7 @@ mod tests { // outer composite "strategy": the inner composite + a LinComb([1,-1]) let strategy = Composite::new( "strategy", - vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()], + vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()], vec![], vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }], vec![OutField { node: 0, field: 0, name: "out".into() }], @@ -3441,7 +3441,7 @@ mod tests { // outer composite "strategy": the inner composite + a LinComb([1,-1]) let strategy = Composite::new( "strategy", - vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()], + vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()], // fan fast_slow's output into both LinComb terms so every interior slot // is wired (the totality check, cycle 0040); param order is unaffected. vec![ @@ -3501,7 +3501,7 @@ mod tests { use aura_std::{LinComb, Sma}; let bp = Composite::new( "root", - vec![Sma::builder().into(), LinComb::builder(2).into()], + vec![Sma::builder().into(), LinComb::configured(2).into()], vec![], vec![], vec![], // output diff --git a/crates/aura-engine/tests/construction_e2e.rs b/crates/aura-engine/tests/construction_e2e.rs index 73d7208..968412a 100644 --- a/crates/aura-engine/tests/construction_e2e.rs +++ b/crates/aura-engine/tests/construction_e2e.rs @@ -34,10 +34,10 @@ fn params() -> [Scalar; 3] { fn signal_ops() -> Vec { vec![ Op::Source { role: "price".into(), kind: ScalarKind::F64 }, - Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, - Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }, - Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, - Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }, + Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }, + Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] }, + Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }, + Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] }, Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }, Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() }, @@ -96,10 +96,10 @@ fn replayed_construction_compiles_identically_to_graphbuilder() { #[test] fn replay_attributes_eager_fault_to_its_op_index() { let ops = vec![ - Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 0 ok - Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, // 1 ok - Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }, // 2 fails - Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }, // 3 never runs + Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }, // 0 ok + Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }, // 1 ok + Op::Add { type_id: "Nope".into(), as_name: None, args: vec![], bind: vec![] }, // 2 fails + Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] }, // 3 never runs ]; // `.err().unwrap()` (not `unwrap_err`): the Ok arm `Composite` holds build // closures and is not `Debug`, which `unwrap_err`'s bound would require. @@ -120,9 +120,9 @@ fn replay_attributes_eager_fault_to_its_op_index() { fn replay_attributes_holistic_fault_to_finalize_step() { let ops = vec![ Op::Source { role: "price".into(), kind: ScalarKind::F64 }, // 0 - Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 1 - Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }, // 2 - Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, // 3 + Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }, // 1 + Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] }, // 2 + Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }, // 3 Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }, // 4 Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, // 5 // sub.rhs deliberately left unwired -> only finalize can decide totality. diff --git a/crates/aura-engine/tests/ger40_breakout.rs b/crates/aura-engine/tests/ger40_breakout.rs index 6a0bf5d..478d823 100644 --- a/crates/aura-engine/tests/ger40_breakout.rs +++ b/crates/aura-engine/tests/ger40_breakout.rs @@ -110,7 +110,7 @@ fn build_harness( Resample::builder().schema().clone(), // 0 Delay::builder().schema().clone(), // 1 Gt::builder().schema().clone(), // 2 - Session::builder(9, 0, Berlin, 15).schema().clone(), // 3 + Session::configured(9, 0, Berlin, 15).schema().clone(), // 3 EqConst::builder().schema().clone(), // 4 EqConst::builder().schema().clone(), // 5 And::builder().schema().clone(), // 6 diff --git a/crates/aura-engine/tests/r_meanrev_e2e.rs b/crates/aura-engine/tests/r_meanrev_e2e.rs index 046d8cc..113a19c 100644 --- a/crates/aura-engine/tests/r_meanrev_e2e.rs +++ b/crates/aura-engine/tests/r_meanrev_e2e.rs @@ -21,7 +21,7 @@ fn run_meanrev_bias(closes: &[f64], n: i64, k: f64) -> Vec { let sq = g.add(Mul::builder()); // dev * dev let var = g.add(Ema::builder().bind("length", Scalar::i64(n))); // EWMA variance let sigma = g.add(Sqrt::builder()); - let band = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k))); // k*sigma + let band = g.add(LinComb::configured(1).bind("weights[0]", Scalar::f64(k))); // k*sigma let upper = g.add(Add::builder()); // mean + k*sigma let lower = g.add(Sub::builder()); // mean - k*sigma let gt_hi = g.add(Gt::builder()); // price > upper diff --git a/crates/aura-ingest/examples/shared/breakout_real.rs b/crates/aura-ingest/examples/shared/breakout_real.rs index 373b710..065e75a 100644 --- a/crates/aura-ingest/examples/shared/breakout_real.rs +++ b/crates/aura-ingest/examples/shared/breakout_real.rs @@ -112,7 +112,7 @@ pub fn build_harness() -> (Harness, Taps) { Resample::builder().schema().clone(), // 0 Delay::builder().schema().clone(), // 1 Gt::builder().schema().clone(), // 2 - Session::builder(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES) + Session::configured(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES) .schema() .clone(), // 3 EqConst::builder().schema().clone(), // 4 @@ -248,7 +248,7 @@ pub fn ger40_breakout_blueprint( ); let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1))); let gt = g.add(Gt::builder()); - let session = g.add(Session::builder(open_hour, open_minute, tz, bar_period_minutes)); + let session = g.add(Session::configured(open_hour, open_minute, tz, bar_period_minutes)); // The ONLY two params: the EqConst targets, named so the space reads // `entry_bar.target` / `exit_bar.target`. Their `target` is left UNBOUND. let entry_bar = g.add(EqConst::builder().named("entry_bar")); diff --git a/crates/aura-market/src/session.rs b/crates/aura-market/src/session.rs index 6941d83..3c71aa3 100644 --- a/crates/aura-market/src/session.rs +++ b/crates/aura-market/src/session.rs @@ -33,10 +33,17 @@ //! **Cold guard.** If the trigger column is empty (not yet fired) → `None`. use aura_core::{ - Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind, + ArgKind, ArgSpec, ArgValue, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, + PrimitiveBuilder, Scalar, ScalarKind, }; use chrono::{TimeZone, Timelike}; +/// The declared construction args of a `Session` recipe (spec §Concrete code +/// shapes): the timezone and local open time — structural, non-scalar +/// configuration, never a swept param. +const SESSION_ARGS: &[ArgSpec] = + &[ArgSpec { name: "tz", kind: ArgKind::Tz }, ArgSpec { name: "open", kind: ArgKind::TimeOfDay }]; + /// Frankfurt-session bar indexer. Holds the open offset (minutes past local /// midnight), the IANA timezone, and the bar period — baked at construction, /// never streamed and never a swept param (a timezone is not a scalar). @@ -60,23 +67,57 @@ impl Session { } } - /// The param-generic recipe for a blueprint primitive. The open time, - /// timezone, and period are **structural** config baked into the closure - /// (like `SimBroker::builder` bakes `pip_size`), NOT scalar params — the - /// declared param list is empty and the `build_fn` ignores its slice (like - /// `Gt` / `Latch`). - pub fn builder(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder { + /// Roster factory: zero-arg, arg-bearing (spec §Concrete code shapes) — the + /// timezone and open time come through the `args` channel (`try_args`), + /// declared by [`SESSION_ARGS`]. `period_minutes` becomes an ordinary + /// `ParamSpec` once `make` runs (a real scalar knob, unlike tz/open). + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::pending( + "Session", + "bars elapsed since the session open, from the configured open time and timezone", + SESSION_ARGS, + Self::make, + ) + } + + /// Turn a validated `(tz, open)` value pair into the real signature: one + /// `trigger` input, one `bars_since_open` output, and `period_minutes` as + /// the sole (now scalar) `ParamSpec` — read from the param slice at build. + fn make(values: &[(String, ArgValue)]) -> PrimitiveBuilder { + let tz = values + .iter() + .find_map(|(n, v)| match (n.as_str(), v) { + ("tz", ArgValue::Tz(tz)) => Some(*tz), + _ => None, + }) + .expect("try_args validated `tz` as ArgKind::Tz before calling make"); + let (open_hour, open_minute) = values + .iter() + .find_map(|(n, v)| match (n.as_str(), v) { + ("open", ArgValue::TimeOfDay { hour, minute }) => Some((*hour, *minute)), + _ => None, + }) + .expect("try_args validated `open` as ArgKind::TimeOfDay before calling make"); PrimitiveBuilder::new( "Session", NodeSchema { inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }], output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }], - params: vec![], + params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }], doc: "bars elapsed since the session open, from the configured open time and timezone", }, - move |_| Box::new(Session::new(open_hour, open_minute, tz, period_minutes)), + move |p| Box::new(Session::new(open_hour, open_minute, tz, p[0].i64())), ) } + + /// Rust-path convenience — the same recipe as the data path (twin + /// identity): `builder().try_args([tz, open]) + .bind("period_minutes", …)`. + pub fn configured(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder { + Self::builder() + .try_args(&[("tz".to_string(), tz.name().to_string()), ("open".to_string(), format!("{open_hour:02}:{open_minute:02}"))]) + .expect("configured: a valid chrono_tz::Tz and in-range hour/minute always parse") + .bind("period_minutes", Scalar::i64(period_minutes)) + } } /// The zero-arg `SessionFrankfurt` roster preset (#261): the Frankfurt open @@ -244,4 +285,36 @@ mod tests { let inputs = trigger_input(); assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None); } + + /// Twin identity (spec §aura-market): `configured` is exactly + /// `builder().try_args([tz, open]).bind("period_minutes", …)` — same + /// accepted args, same declared signature, whichever path authored it. + #[test] + fn session_configured_twin_equals_builder_try_args() { + let via_configured = Session::configured(9, 30, Berlin, 15); + let via_try_args = Session::builder() + .try_args(&[("tz".into(), "Europe/Berlin".into()), ("open".into(), "09:30".into())]) + .expect("valid tz/open configure") + .bind("period_minutes", Scalar::i64(15)); + assert_eq!(via_configured.construction_args(), via_try_args.construction_args()); + assert_eq!(via_configured.schema(), via_try_args.schema()); + } + + /// Behaviour re-anchor (#271 Task 2): the DST-aware headline property + /// (local 09:45 reads bar 3 in both CEST summer and CET winter) holds + /// identically when the node is built through `configured` instead of + /// `Session::new` directly. + #[test] + fn session_configured_behaviour_unchanged() { + let mut node = Session::configured(9, 0, Berlin, 15).build(&[]); + let mut inputs = trigger_input(); + + let summer = Berlin.with_ymd_and_hms(2024, 7, 1, 9, 45, 0).unwrap().timestamp_nanos_opt().unwrap(); + inputs[0].push(Scalar::f64(0.0)).unwrap(); + assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(summer))).map(|r| r[0].i64()), Some(3)); + + let winter = Berlin.with_ymd_and_hms(2024, 1, 2, 9, 45, 0).unwrap().timestamp_nanos_opt().unwrap(); + inputs[0].push(Scalar::f64(0.0)).unwrap(); + assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(winter))).map(|r| r[0].i64()), Some(3)); + } } diff --git a/crates/aura-runner/src/member.rs b/crates/aura-runner/src/member.rs index 2c77f44..cd7eee5 100644 --- a/crates/aura-runner/src/member.rs +++ b/crates/aura-runner/src/member.rs @@ -327,7 +327,7 @@ pub fn wrap_r( if !reduce { // r_equity = cum_realized_r + unrealized_r — one tapped series for charting. let r_equity = g.add( - LinComb::builder(2) + LinComb::configured(2) .bind("weights[0]", Scalar::f64(1.0)) .bind("weights[1]", Scalar::f64(1.0)), ); @@ -411,7 +411,7 @@ pub fn wrap_r( // net_r_equity = cum_realized_r + unrealized_r − Σcum_cost_in_r // − Σopen_cost_in_r (the #221-deleted LinComb(4), weights 1,1,-1,-1). let net_eq = g.add( - LinComb::builder(4) + LinComb::configured(4) .bind("weights[0]", Scalar::f64(1.0)) .bind("weights[1]", Scalar::f64(1.0)) .bind("weights[2]", Scalar::f64(-1.0)) diff --git a/crates/aura-std/Cargo.toml b/crates/aura-std/Cargo.toml index a0c8658..c5345b5 100644 --- a/crates/aura-std/Cargo.toml +++ b/crates/aura-std/Cargo.toml @@ -7,13 +7,6 @@ publish.workspace = true [dependencies] aura-core = { path = "../aura-core" } -# DST-correct wall-clock math for the `Session` node. A vetted -# standard crate for timezone/DST — never hand-rolled (C16 per-case policy). -# `chrono` is already a transitive workspace dep (0.4 via aura-ingest's -# data-server); aligned to that major. `chrono-tz` brings the IANA `Europe/Berlin` -# zone + DST transitions. -chrono = { version = "0.4", default-features = false, features = ["clock"] } -chrono-tz = { version = "0.10", default-features = false } [dev-dependencies] aura-strategy = { path = "../aura-strategy" } diff --git a/crates/aura-std/src/lincomb.rs b/crates/aura-std/src/lincomb.rs index b02c899..beb7c3f 100644 --- a/crates/aura-std/src/lincomb.rs +++ b/crates/aura-std/src/lincomb.rs @@ -7,10 +7,15 @@ //! indexed `F64` knobs that `Composite::param_space` aggregates (C8/C12/C19). use aura_core::{ - Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, - ScalarKind, + ArgKind, ArgSpec, ArgValue, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, + PrimitiveBuilder, ScalarKind, }; +/// The declared construction args of a `LinComb` recipe: `arity` fixes the +/// node's input/param count (topology, C19), taken through the `args` +/// channel instead of a Rust-side builder parameter. +const LINCOMB_ARGS: &[ArgSpec] = &[ArgSpec { name: "arity", kind: ArgKind::Count }]; + /// Weighted sum of `N` f64 inputs: `Σ weights[i] · input[i]`. The `weights` are /// construction parameters that configure the node and fix its arity /// (`weights.len()` inputs, in slot order). Emits `None` until *all* inputs @@ -40,10 +45,29 @@ impl LinComb { Self { weights, out: [Cell::from_f64(0.0)] } } - /// The param-generic recipe for a blueprint primitive. The `arity` is topology - /// (fixed per blueprint, C19), taken as a builder arg; only the weight *values* - /// are injected, slot by slot, through `LinComb::new` (the single sizing gate). - pub fn builder(arity: usize) -> PrimitiveBuilder { + /// Roster factory: zero-arg, arg-bearing. `arity` is topology (fixed per + /// blueprint, C19), now taken through the `args` channel instead of a + /// Rust-side parameter. + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::pending( + "LinComb", + "linear combination of its inputs with constant weights", + LINCOMB_ARGS, + Self::make, + ) + } + + /// Turn a validated `arity` into the real, arity-sized signature; only + /// the weight *values* are injected, slot by slot, through `LinComb::new` + /// (the single sizing gate) once params are bound/built. + fn make(values: &[(String, ArgValue)]) -> PrimitiveBuilder { + let arity = values + .iter() + .find_map(|(n, v)| match (n.as_str(), v) { + ("arity", ArgValue::Count(n)) => Some(*n), + _ => None, + }) + .expect("try_args validated `arity` as ArgKind::Count before calling make"); let inputs = (0..arity) .map(|i| PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: format!("term[{i}]") }) .collect(); @@ -63,6 +87,13 @@ impl LinComb { )), ) } + + /// Rust-path convenience — same recipe as the data path (twin identity). + pub fn configured(arity: usize) -> PrimitiveBuilder { + Self::builder() + .try_args(&[("arity".to_string(), arity.to_string())]) + .expect("configured: a positive arity is always the canonical strict-form Count string") + } } impl Node for LinComb { @@ -149,7 +180,7 @@ mod tests { #[test] fn input_slots_are_named_term_index() { - let lc = LinComb::builder(3); + let lc = LinComb::configured(3); let names: Vec = lc.schema().inputs.iter().map(|p| p.name.clone()).collect(); assert_eq!(names, ["term[0]", "term[1]", "term[2]"]); } @@ -157,7 +188,7 @@ mod tests { #[test] fn chained_bind_reconstructs_positional_vector() { // bind BOTH weights, in reverse slot order, to DISTINCT values; build empty. - let builder = LinComb::builder(2) + let builder = LinComb::configured(2) .bind("weights[1]", Scalar::f64(2.0)) .bind("weights[0]", Scalar::f64(0.5)); assert!(builder.params().is_empty()); @@ -173,7 +204,7 @@ mod tests { assert_eq!(lc.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(11.0)].as_slice())); // partial: bind weights[0], leave weights[1] open → inject it at build - let partial = LinComb::builder(2).bind("weights[0]", Scalar::f64(0.5)); + let partial = LinComb::configured(2).bind("weights[0]", Scalar::f64(0.5)); assert_eq!( partial.params().iter().map(|p| p.name.as_str()).collect::>(), ["weights[1]"], @@ -187,4 +218,14 @@ mod tests { inputs2[1].push(Scalar::f64(3.0)).unwrap(); assert_eq!(lc2.eval(Ctx::new(&inputs2, Timestamp(0))), Some([Cell::from_f64(11.0)].as_slice())); } + + /// Twin identity (spec §aura-std): `configured(arity)` is exactly + /// `builder().try_args([("arity", arity)])`. + #[test] + fn lincomb_configured_twin_equals_builder_try_args() { + let via_configured = LinComb::configured(3); + let via_try_args = LinComb::builder().try_args(&[("arity".into(), "3".into())]).expect("valid arity configures"); + assert_eq!(via_configured.construction_args(), via_try_args.construction_args()); + assert_eq!(via_configured.schema(), via_try_args.schema()); + } } diff --git a/crates/aura-std/src/sma.rs b/crates/aura-std/src/sma.rs index d30c743..2a430a9 100644 --- a/crates/aura-std/src/sma.rs +++ b/crates/aura-std/src/sma.rs @@ -238,7 +238,7 @@ mod tests { vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }], ); // vector knob expands flat to N indexed F64 entries - let lc = LinComb::builder(2).schema().params.clone(); + let lc = LinComb::configured(2).schema().params.clone(); assert_eq!(lc.len(), 2); assert_eq!(lc[0].name, "weights[0]"); assert_eq!(lc[1].name, "weights[1]"); diff --git a/crates/aura-strategy/src/cost.rs b/crates/aura-strategy/src/cost.rs index afabe62..115b69b 100644 --- a/crates/aura-strategy/src/cost.rs +++ b/crates/aura-strategy/src/cost.rs @@ -555,10 +555,10 @@ mod tests { ConstantCost::builder().schema().output.iter().map(|f| f.name.clone()).collect(); assert_eq!(prod, COST_FIELD_NAMES.to_vec()); let agg_out: Vec = - CostSum::builder(1).schema().output.iter().map(|f| f.name.clone()).collect(); + CostSum::configured(1).schema().output.iter().map(|f| f.name.clone()).collect(); assert_eq!(agg_out, COST_FIELD_NAMES.to_vec()); let agg_in: Vec = - CostSum::builder(1).schema().inputs.iter().map(|p| p.name.clone()).collect(); + CostSum::configured(1).schema().inputs.iter().map(|p| p.name.clone()).collect(); let expected: Vec = COST_FIELD_NAMES.iter().map(|f| format!("cost[0].{f}")).collect(); assert_eq!(agg_in, expected); diff --git a/crates/aura-strategy/src/cost_sum.rs b/crates/aura-strategy/src/cost_sum.rs index 12fa5a4..427ada7 100644 --- a/crates/aura-strategy/src/cost_sum.rs +++ b/crates/aura-strategy/src/cost_sum.rs @@ -7,9 +7,15 @@ //! the cost path is uniform whether one or several cost nodes are wired. use aura_core::{ - Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind, + ArgKind, ArgSpec, ArgValue, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, + PrimitiveBuilder, ScalarKind, }; +/// The declared construction args of a `CostSum` recipe: `n_costs` fixes the +/// node's input count (topology, C19), taken through the `args` channel +/// instead of a Rust-side builder parameter. +const COST_SUM_ARGS: &[ArgSpec] = &[ArgSpec { name: "n_costs", kind: ArgKind::Count }]; + /// The cost-record field triple and its width come from the shared cost contract /// (`crate::cost::{COST_FIELD_NAMES, COST_WIDTH}`) — one source of truth, read by /// both the producer side (`cost_node_builder`) and this aggregator. The input-name @@ -32,10 +38,29 @@ impl CostSum { Self { n_costs, out: [Cell::from_f64(0.0); COST_WIDTH] } } - /// The param-generic recipe. `n_costs` is topology (fixed per blueprint, C19), - /// captured by the build closure (no per-build params). The input names are a - /// lockstep contract with the connect side (`cost[k].`). - pub fn builder(n_costs: usize) -> PrimitiveBuilder { + /// Roster factory: zero-arg, arg-bearing. `n_costs` is topology (fixed per + /// blueprint, C19), now taken through the `args` channel instead of a + /// Rust-side parameter. + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::pending( + "CostSum", + "sums cost-model contributions into one cost-in-R stream", + COST_SUM_ARGS, + Self::make, + ) + } + + /// Turn a validated `n_costs` into the real, arity-sized signature. The + /// input names are a lockstep contract with the connect side + /// (`cost[k].`). + fn make(values: &[(String, ArgValue)]) -> PrimitiveBuilder { + let n_costs = values + .iter() + .find_map(|(n, v)| match (n.as_str(), v) { + ("n_costs", ArgValue::Count(n)) => Some(*n), + _ => None, + }) + .expect("try_args validated `n_costs` as ArgKind::Count before calling make"); let mut inputs = Vec::with_capacity(n_costs * COST_WIDTH); for k in 0..n_costs { for field in COST_FIELD_NAMES { @@ -63,6 +88,13 @@ impl CostSum { move |_| Box::new(CostSum::new(n_costs)), ) } + + /// Rust-path convenience — same recipe as the data path (twin identity). + pub fn configured(n_costs: usize) -> PrimitiveBuilder { + Self::builder() + .try_args(&[("n_costs".to_string(), n_costs.to_string())]) + .expect("configured: a positive n_costs is always the canonical strict-form Count string") + } } impl Node for CostSum { @@ -140,7 +172,7 @@ mod tests { #[test] fn input_slots_are_named_cost_index_field() { - let s = CostSum::builder(2); + let s = CostSum::configured(2); let names: Vec = s.schema().inputs.iter().map(|p| p.name.clone()).collect(); assert_eq!( names, @@ -161,4 +193,15 @@ mod tests { fn new_panics_on_zero() { let _ = CostSum::new(0); } + + /// Twin identity (spec §aura-strategy): `configured(n_costs)` is exactly + /// `builder().try_args([("n_costs", n_costs)])`. + #[test] + fn cost_sum_configured_twin_equals_builder_try_args() { + let via_configured = CostSum::configured(2); + let via_try_args = + CostSum::builder().try_args(&[("n_costs".into(), "2".into())]).expect("valid n_costs configures"); + assert_eq!(via_configured.construction_args(), via_try_args.construction_args()); + assert_eq!(via_configured.schema(), via_try_args.schema()); + } }