feat(aura-market, aura-std, aura-strategy): arg-bearing builders go zero-arg
Session, LinComb, and CostSum — the three builders the roster scope doc
excludes — re-shape onto the args seam (refs #271): builder() is now
zero-arg and returns a pending recipe (Session: tz + open; LinComb:
arity; CostSum: n_costs), the full signature forms in make, and
configured(...) is the Rust-path convenience producing the identical
recipe (twin-pinned). Session's period_minutes becomes a real ParamSpec
bound by configured instead of a baked struct field — same behaviour,
now sweepable when left open. Session::new and SessionFrankfurt are
untouched.
All builder(n)-form call sites move mechanically to configured(n):
aura-runner member.rs (wrap_r), aura-composites (vol_stop/cost_graph),
aura-engine blueprint.rs + e2e tests, aura-ingest breakout example,
and the crates' own tests. aura-std drops its unused chrono/chrono-tz
deps (stale since the b39fd63 session move).
The roster is deliberately untouched here: rostering before the op
seam lands would expose unconfigured pending builders to add_node.
This commit is contained in:
@@ -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<PrimitiveBuilder>) -> 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,10 +34,10 @@ fn params() -> [Scalar; 3] {
|
||||
fn signal_ops() -> Vec<Op> {
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,7 @@ fn run_meanrev_bias(closes: &[f64], n: i64, k: f64) -> Vec<f64> {
|
||||
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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<String> = 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::<Vec<_>>(),
|
||||
["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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]");
|
||||
|
||||
@@ -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<String> =
|
||||
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<String> =
|
||||
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<String> =
|
||||
COST_FIELD_NAMES.iter().map(|f| format!("cost[0].{f}")).collect();
|
||||
assert_eq!(agg_in, expected);
|
||||
|
||||
@@ -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].<field>`).
|
||||
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].<field>`).
|
||||
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<String> = 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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user