From 40e962db899a92a0fd10d4ecf1e9abc32581fc5a Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 17 Jul 2026 19:49:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(std):=20Sign=20and=20Select=20cells=20?= =?UTF-8?q?=E2=80=94=20the=20Bool=E2=86=92F64=20bridge=20pair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of the #281 clock-enable set: Sign promotes the private sign0() semantics to vocabulary (strict comparisons; NaN and -0.0 fall to 0.0), Select is the stateless 2:1 mux that consumes Bool back into the F64 plane. Both zero-arg, Firing::Any, rostered (count pins 29→31); an engine E2E drives both through the real GraphBuilder→compile→run seam. refs #281 --- crates/aura-cli/tests/graph_construct.rs | 4 +- crates/aura-engine/tests/select_sign_e2e.rs | 92 ++++++++++++++ crates/aura-std/src/lib.rs | 4 + crates/aura-std/src/select.rs | 126 ++++++++++++++++++++ crates/aura-std/src/sign.rs | 113 ++++++++++++++++++ crates/aura-std/src/vocabulary.rs | 8 +- 6 files changed, 342 insertions(+), 5 deletions(-) create mode 100644 crates/aura-engine/tests/select_sign_e2e.rs create mode 100644 crates/aura-std/src/select.rs create mode 100644 crates/aura-std/src/sign.rs diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index e5844d6..76c7b4d 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -121,8 +121,8 @@ fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() { let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect(); assert_eq!( lines.len(), - 29, - "the std-only (no project) vocabulary has exactly the roster's 29 entries: {stdout}" + 31, + "the std-only (no project) vocabulary has exactly the roster's 31 entries: {stdout}" ); } diff --git a/crates/aura-engine/tests/select_sign_e2e.rs b/crates/aura-engine/tests/select_sign_e2e.rs new file mode 100644 index 0000000..577ca88 --- /dev/null +++ b/crates/aura-engine/tests/select_sign_e2e.rs @@ -0,0 +1,92 @@ +//! End-to-end coverage for the two new RTL standard cells (#281): `Select` (the +//! Bool -> F64 conditional bridge) and `Sign` (the promoted `sign0()` semantics). +//! +//! The in-module tests in `aura-std/src/select.rs` and `aura-std/src/sign.rs` +//! drive each node's `eval` directly — they never go through a `PrimitiveBuilder` +//! schema, a `GraphBuilder`-resolved wiring, or a compiled+bootstrapped +//! `FlatGraph`. That is the real seam a data-authored op-script or a Rust +//! `sma_signal`-style builder exercises (C24), and it is exactly what this file +//! proves: both cells are reachable from the public `aura_engine`/`aura_std` +//! construction surface, wire cleanly by declared port name/kind, and produce +//! the right values once actually driven cycle-by-cycle through a real +//! `Harness::run`. + +use std::sync::mpsc; + +use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; +use aura_engine::{GraphBuilder, VecSource}; +use aura_std::{Abs, Gt, Recorder, Select, Sign}; + +/// A deterministic 5-tick F64 stream, no timestamps/randomness beyond a fixed +/// literal sequence. +fn ticks(values: &[f64]) -> Vec<(Timestamp, Scalar)> { + values.iter().enumerate().map(|(i, &v)| (Timestamp(i as i64 + 1), Scalar::f64(v))).collect() +} + +/// Property: **`Select` is the real Bool -> F64 bridge, through the compiled +/// engine, not merely by direct `eval`.** One `price` source feeds both `Abs` +/// (computing `|price|`) and `Gt(|price|, price)` (true exactly when +/// `price < 0`); `Select(cond, then=price, otherwise=|price|)` must forward the +/// RAW (possibly negative) `price` on the one row where `price < 0` and the +/// (always non-negative) `|price|` on every other row. Since `|price| == price` +/// for every non-negative row, the only way to distinguish "the mux correctly +/// read `cond` and picked `then`" from "the wiring is inverted / stuck on one +/// branch" is the single negative row: a stuck-on-`otherwise` bug would emit +/// `3.0` there instead of `-3.0`. This drives the REAL `PrimitiveBuilder` +/// schemas (`Gt`'s declared `bool` output, `Select`'s declared `bool`/`f64`/`f64` +/// inputs) through `GraphBuilder::build` -> `compile_with_params` -> +/// `Harness::run`, the seam an op-script or a `sma_signal`-style Rust builder +/// actually uses — the in-module `select.rs` unit tests never leave direct +/// `eval` calls. +#[test] +fn select_bridges_a_real_gt_bool_output_into_f64_end_to_end() { + let (tx, rx) = mpsc::channel(); + let mut g = GraphBuilder::new("root"); + let price = g.source_role("price", ScalarKind::F64); + let abs = g.add(Abs::builder()); + let gt = g.add(Gt::builder()); + let select = g.add(Select::builder()); + let rec = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx)); + + g.feed(price, [abs.input("value"), gt.input("b"), select.input("then")]); + g.connect(abs.output("value"), gt.input("a")); + g.connect(abs.output("value"), select.input("otherwise")); + g.connect(gt.output("value"), select.input("cond")); + g.connect(select.output("value"), rec.input("col[0]")); + + let mut h = g.build().expect("resolves by name").bootstrap_with_params(vec![]).expect("bootstraps"); + h.run(vec![Box::new(VecSource::new(ticks(&[-3.0, 0.0, 2.0, -0.0, 5.0])))]); + let trace: Vec = rx.try_iter().map(|(_, row)| row[0].as_f64()).collect(); + + assert_eq!( + trace, + vec![-3.0, 0.0, 2.0, 0.0, 5.0], + "row 0 (price<0) must forward the raw negative `then`; every other row forwards `|price|`" + ); +} + +/// Property: **`Sign` correctly registers its `PrimitiveBuilder` schema +/// (F64 in, F64 out) and dispatches through a compiled+bootstrapped +/// `FlatGraph`, mapping the three sign regions across real engine cycles** — +/// not merely via a direct `eval` call (the in-module `sign.rs` unit tests' +/// seam). A schema/wiring regression (wrong port kind, wrong output arity) +/// would fail at `GraphBuilder::build` or `compile_with_params`, before a +/// single cycle runs; a dispatch regression would show up in the recorded +/// values below. +#[test] +fn sign_dispatches_through_a_compiled_graph_across_all_three_regions() { + let (tx, rx) = mpsc::channel(); + let mut g = GraphBuilder::new("root"); + let spread = g.source_role("spread", ScalarKind::F64); + let sign = g.add(Sign::builder()); + let rec = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx)); + + g.feed(spread, [sign.input("value")]); + g.connect(sign.output("value"), rec.input("col[0]")); + + let mut h = g.build().expect("resolves by name").bootstrap_with_params(vec![]).expect("bootstraps"); + h.run(vec![Box::new(VecSource::new(ticks(&[-3.0, 0.0, 2.5, -0.0, 4.0])))]); + let trace: Vec = rx.try_iter().map(|(_, row)| row[0].as_f64()).collect(); + + assert_eq!(trace, vec![-1.0, 0.0, 1.0, 0.0, 1.0], "all three sign regions, dispatched through the real engine"); +} diff --git a/crates/aura-std/src/lib.rs b/crates/aura-std/src/lib.rs index 46c540c..9618d50 100644 --- a/crates/aura-std/src/lib.rs +++ b/crates/aura-std/src/lib.rs @@ -44,8 +44,10 @@ mod resample; mod rolling_max; mod rolling_min; mod scale; +mod select; mod series_reducer; mod session; +mod sign; mod sim_broker; mod sizer; mod sma; @@ -88,8 +90,10 @@ pub use resample::Resample; pub use rolling_max::RollingMax; pub use rolling_min::RollingMin; pub use scale::Scale; +pub use select::Select; pub use series_reducer::SeriesReducer; pub use session::{Session, SessionFrankfurt}; +pub use sign::Sign; pub use sim_broker::SimBroker; pub use sizer::Sizer; pub use sma::Sma; diff --git a/crates/aura-std/src/select.rs b/crates/aura-std/src/select.rs new file mode 100644 index 0000000..81c7e41 --- /dev/null +++ b/crates/aura-std/src/select.rs @@ -0,0 +1,126 @@ +//! `Select` — the stateless 2:1 mux `bool × f64 × f64 -> f64`: forwards +//! `then` when `cond` is true, `otherwise` when it is false. +//! +//! The missing RTL standard cell (#281): the generic consumer of the Bool +//! plane back into F64. Until it existed, conditional values required +//! arithmetic contortions; with it, `count_true` composes as +//! `CumSum(Select(cond, 1.0, 0.0))` — no Counter primitive needed. +//! +//! Three inputs (`cond: Bool`, `then: F64`, `otherwise: F64`, each +//! `Firing::Any`), one `f64` output, no params, stateless. Emits `None` +//! until **all three** legs are warm — the mux reads both data inputs, as +//! [`Gt`](crate::Gt) requires both of its legs. + +use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind}; + +/// Stateless 2:1 mux: `out = if cond { then } else { otherwise }`, decided +/// fresh each fired cycle. Emits `None` until all three legs are warm (C8). +pub struct Select { + out: [Cell; 1], +} + +impl Select { + /// Build a `Select` node. + pub fn new() -> Self { + Self { out: [Cell::from_f64(0.0)] } + } + + /// The param-generic recipe for a blueprint primitive: paramless, builds + /// through `Select::new`. + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "Select", + NodeSchema { + inputs: vec![ + PortSpec { kind: ScalarKind::Bool, firing: Firing::Any, name: "cond".into() }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "then".into() }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "otherwise".into() }, + ], + output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], + params: vec![], + }, + |_| Box::new(Select::new()), + ) + } +} + +impl Default for Select { + fn default() -> Self { + Self::new() + } +} + +impl Node for Select { + fn lookbacks(&self) -> Vec { + vec![1, 1, 1] + } + + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + let cond = ctx.bool_in(0); + let then = ctx.f64_in(1); + let otherwise = ctx.f64_in(2); + if cond.is_empty() || then.is_empty() || otherwise.is_empty() { + return None; // all three legs required (C8 warm-up gate) + } + let c = cond.get(0).unwrap_or(false); + self.out[0] = Cell::from_f64(if c { then[0] } else { otherwise[0] }); + Some(&self.out) + } + + fn label(&self) -> String { + "Select".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{AnyColumn, Scalar, Timestamp}; + + fn mux_inputs() -> Vec { + vec![ + AnyColumn::with_capacity(ScalarKind::Bool, 1), + AnyColumn::with_capacity(ScalarKind::F64, 1), + AnyColumn::with_capacity(ScalarKind::F64, 1), + ] + } + + #[test] + fn select_muxes_per_cycle_by_cond() { + // PROPERTY: out = if cond { then } else { otherwise }, re-decided each + // cycle (stateless) — the 2:1 mux, both branches exercised plus a flip. + let mut node = Select::new(); + let mut inputs = mux_inputs(); + let cases = [((true, 1.0, 9.0), 1.0), ((false, 1.0, 9.0), 9.0), ((true, 5.0, 7.0), 5.0)]; + for ((c, t, o), want) in cases { + inputs[0].push(Scalar::bool(c)).unwrap(); + inputs[1].push(Scalar::f64(t)).unwrap(); + inputs[2].push(Scalar::f64(o)).unwrap(); + let got = node.eval(Ctx::new(&inputs, Timestamp(0))); + assert_eq!(got, Some([Cell::from_f64(want)].as_slice())); + } + } + + #[test] + fn select_is_none_until_all_three_legs_warm() { + // The mux reads BOTH data inputs: a cold otherwise-leg gates the output + // even while cond is true and then is present. + let mut node = Select::new(); + let mut inputs = mux_inputs(); + inputs[0].push(Scalar::bool(true)).unwrap(); + inputs[1].push(Scalar::f64(1.0)).unwrap(); + assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None); + inputs[2].push(Scalar::f64(9.0)).unwrap(); + assert_eq!( + node.eval(Ctx::new(&inputs, Timestamp(0))), + Some([Cell::from_f64(1.0)].as_slice()) + ); + } + + #[test] + fn input_slots_are_named_cond_then_otherwise() { + let names: Vec = + Select::builder().schema().inputs.iter().map(|p| p.name.clone()).collect(); + assert_eq!(names, ["cond", "then", "otherwise"]); + } +} diff --git a/crates/aura-std/src/sign.rs b/crates/aura-std/src/sign.rs new file mode 100644 index 0000000..ca0a816 --- /dev/null +++ b/crates/aura-std/src/sign.rs @@ -0,0 +1,113 @@ +//! `Sign` — a stateless `f64 -> f64` sign extractor emitting exactly `+1.0`, +//! `-1.0`, or `0.0` by strict comparison. +//! +//! The `sign0()` semantics already private in +//! [`PositionManagement`](crate::PositionManagement) +//! (`position_management.rs`), promoted to vocabulary (#281): `> 0 -> +1.0`, +//! `< 0 -> -1.0`, else `0.0` — so `NaN` (which compares false on both sides) +//! and `-0.0` both fall to `0.0`. The private helper itself stays untouched; +//! this cell is the roster-eligible twin, the generic bridge the Bool/F64 +//! conditional plane was missing. +//! +//! One `f64` input (`Firing::Any`), one `f64` output, no params, stateless, +//! allocation-free on the hot path (the single-cell output buffer is sized +//! once at construction, C7). + +use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind}; + +/// Stateless sign extractor: `+1.0` / `-1.0` / `0.0` by strict comparison +/// (`NaN` falls to `0.0`). Emits `None` until the input is warm (C8). +pub struct Sign { + out: [Cell; 1], +} + +impl Sign { + /// Build a `Sign` node. + pub fn new() -> Self { + Self { out: [Cell::from_f64(0.0)] } + } + + /// The param-generic recipe for a blueprint primitive: paramless, builds + /// through `Sign::new`. + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "Sign", + NodeSchema { + inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "value".into() }], + output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], + params: vec![], + }, + |_| Box::new(Sign::new()), + ) + } +} + +impl Default for Sign { + fn default() -> Self { + Self::new() + } +} + +impl Node for Sign { + fn lookbacks(&self) -> Vec { + vec![1] + } + + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + let v = ctx.f64_in(0); + if v.is_empty() { + return None; // warm-up gate (C8) + } + let x = v[0]; + // strict comparisons: NaN and -0.0 both fall to 0.0, as sign0 does + let s = if x > 0.0 { + 1.0 + } else if x < 0.0 { + -1.0 + } else { + 0.0 + }; + self.out[0] = Cell::from_f64(s); + Some(&self.out) + } + + fn label(&self) -> String { + "Sign".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{AnyColumn, Scalar, Timestamp}; + + #[test] + fn sign_maps_the_three_regions_and_nan_to_zero() { + // PROPERTY: out = +1.0 / -1.0 / 0.0 by strict comparison — the sign0() + // semantics promoted to vocabulary. NaN compares false on both sides, + // so it falls to 0.0, exactly as sign0 does; -0.0 likewise. + let mut node = Sign::new(); + let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + let cases = [(2.5, 1.0), (-0.1, -1.0), (0.0, 0.0), (f64::NAN, 0.0), (-0.0, 0.0)]; + for (x, want) in cases { + inputs[0].push(Scalar::f64(x)).unwrap(); + let got = node.eval(Ctx::new(&inputs, Timestamp(0))); + assert_eq!(got, Some([Cell::from_f64(want)].as_slice()), "sign({x})"); + } + } + + #[test] + fn sign_is_none_until_warm() { + // Cold input -> None (C8 warm-up gate), like every std cell. + let mut node = Sign::new(); + let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None); + } + + #[test] + fn input_slot_is_named_value() { + let names: Vec = + Sign::builder().schema().inputs.iter().map(|p| p.name.clone()).collect(); + assert_eq!(names, ["value"]); + } +} diff --git a/crates/aura-std/src/vocabulary.rs b/crates/aura-std/src/vocabulary.rs index 355ef0d..45f34a6 100644 --- a/crates/aura-std/src/vocabulary.rs +++ b/crates/aura-std/src/vocabulary.rs @@ -20,7 +20,7 @@ use crate::{ Abs, Add, And, Bias, CarryCost, Const, ConstantCost, Delay, Div, Ema, EqConst, FixedStop, Gt, Latch, LongOnly, Max, Min, Mul, PositionManagement, Resample, RollingMax, RollingMin, Scale, - SessionFrankfurt, Sizer, Sma, Sqrt, Sub, VolSlippageCost, + Select, SessionFrankfurt, Sign, Sizer, Sma, Sqrt, Sub, VolSlippageCost, }; use aura_core::PrimitiveBuilder; @@ -81,7 +81,9 @@ std_vocabulary_roster! { "RollingMax" => RollingMax, "RollingMin" => RollingMin, "Scale" => Scale, + "Select" => Select, "SessionFrankfurt" => SessionFrankfurt, + "Sign" => Sign, "Sizer" => Sizer, "SMA" => Sma, "Sqrt" => Sqrt, @@ -130,7 +132,7 @@ mod tests { assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node assert!(!std_vocabulary_types().contains(&"Recorder")); // sink assert!(!std_vocabulary_types().contains(&"nope")); - // count guard: pins the roster at exactly 29 entries - assert_eq!(std_vocabulary_types().len(), 29); + // count guard: pins the roster at exactly 31 entries + assert_eq!(std_vocabulary_types().len(), 31); } }