From 288e75e7fd850c988c834db82b542d15e08bd3f1 Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 4 Jun 2026 18:01:09 +0200 Subject: [PATCH] spec: 0008 sum combinators (Add + LinComb) Ships the missing sum combinator(s) in aura-std so the north-star combine move (C10) is expressible from shipped blocks. Two nodes: Add (parameterless two-input f64 sum, companion to Sub) and LinComb { weights } (N-input weighted sum, weights as tunable params + arity, C8/C12). Both withhold output until all inputs are present (no implicit cold-leg 0.0). Resolves the cycle-0007 fieldtest [friction]: the consumer had to hand-author a project-local Add2 to combine two signals. refs #11 --- docs/specs/0008-sum-combinators.md | 287 +++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 docs/specs/0008-sum-combinators.md diff --git a/docs/specs/0008-sum-combinators.md b/docs/specs/0008-sum-combinators.md new file mode 100644 index 0000000..3ce6c2b --- /dev/null +++ b/docs/specs/0008-sum-combinators.md @@ -0,0 +1,287 @@ +# Sum Combinators (`Add` + `LinComb`) — Design Spec + +**Date:** 2026-06-04 +**Status:** Draft — awaiting user spec review +**Authors:** orchestrator + Claude + +## Goal + +Ship the missing sum combinator(s) in `aura-std` so the project's stated +north-star research move — "backtest one signal, combine it with another, +backtest the combination" (C10) — is expressible from shipped blocks alone. + +The cycle-0007 fieldtest (`docs/specs/fieldtest-0007-signal-quality.md`, +finding `[friction]`) surfaced the gap: `aura-std` ships `Sma`, `Sub`, +`Exposure`, `SimBroker`, but no sum. To combine two signal streams into one the +fieldtester had to hand-author a project-local `Add2` node (two f64 inputs, one +f64 output summing the newest values) — legitimate per C16, but boilerplate +every consumer attempting the headline move must rewrite. `Sub` (difference) +ships; its companion sum does not. + +This cycle ships **two** nodes: + +- **`Add`** — the parameterless two-input companion to `Sub` (`a + b`), the + readable symmetric pair to the existing difference node. +- **`LinComb { weights }`** — the general weighted form: `N` f64 inputs → + `Σ wᵢ·xᵢ`, with the weights carried as the node's **tunable parameters** + (C8/C12). This is the form the north-star "combine A and B *with weights*" + reaches for, and the natural home of a meta-signal's combination tuning + params that a sweep optimizes over (C12). + +`Add` is the convenience companion to `Sub`; `LinComb` is the general primitive +that also subsumes both (`Add` = `LinComb([1,1])`, `Sub` = `LinComb([1,-1])`). +Both ship because each is independently reached-for: `Add` for readability +symmetry with `Sub`, `LinComb` for the weighted/tunable combination. This +mirrors the project's already-shipped choice to keep `Sub` as a named node +rather than only a general form. + +## Architecture + +Two new leaf nodes in `aura-std`, each in its own file (`add.rs`, `lincomb.rs`), +following the established one-node-per-file + hand-driven-unit-test pattern of +`sub.rs` / `sma.rs`. Both implement the `aura_core::Node` contract (C8): +`schema()` declares typed f64 inputs (lookback 1, `Firing::Any`) and a single +f64 output column; `eval(ctx)` reads the newest value of each input window and +returns a borrowed one-row output, or `None` until warmed up. + +No engine change. No new dependency. No change to any existing node. The cycle +is a pure additive extension of the standard node library (C16, top tier). + +### Warm-up discipline (both nodes) + +Both nodes emit `None` until **all** their inputs are present, then emit the +full (weighted) sum. This is consistent with `Sub` and the hand-authored `Add2`, +and is the causally clean choice: a cold input leg is **never** silently treated +as `0.0` and folded into the sum. (Contrast the `SimBroker` cold-leg-as-`0.0` +behaviour the 0007 fieldtest flagged as a surprise — these combinators +deliberately do *not* do that; they withhold output until every leg is warm.) + +## Concrete code shapes + +### User-facing program (the Step-4 empirical evidence) + +The cycle-0007 fieldtest's two-signal combine example +(`fieldtests/cycle-0007-signal-quality/c0007_4_combine_two_signals.rs`) defines +a project-local `Add2` and wires it as node 6. After this cycle that hand-authored +node is dropped in favour of a shipped one — both forms below are valid drop-ins: + +```rust +// before: a hand-authored project-local node +struct Add2 { out: Vec } +impl Node for Add2 { /* two f64 inputs, eval = a[0] + b[0], None until both present */ } +// ... +Box::new(Add2::new()), // node 6: fast + slow + +// after, option 1 — the readable companion to Sub: +use aura_std::Add; +Box::new(Add::new()), // node 6: fast + slow + +// after, option 2 — the general weighted form (equal weights == the old Add2): +use aura_std::LinComb; +Box::new(LinComb::new(vec![1.0, 1.0])), // node 6: 1·fast + 1·slow +``` + +The north-star "combine A and B *with weights*" move — the form `Add` cannot +express — and where the combination weights live as tunable params (C12): + +```rust +use aura_std::LinComb; +// 0.7·signalA + 0.3·signalB; the weights are the combine tuning params a sweep +// optimizes over — change them and re-bootstrap, no recompile (C12). +Box::new(LinComb::new(vec![0.7, 0.3])), +``` + +### Implementation shape — `Add` (secondary; mirrors `sub.rs`) + +```rust +use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind}; + +/// Two-input f64 sum: input 0 plus input 1. The companion to `Sub`. Emits +/// `None` until both inputs have a value. +pub struct Add { + out: [Scalar; 1], +} + +impl Add { + /// Build an `Add` node. + pub fn new() -> Self { + Self { out: [Scalar::F64(0.0)] } + } +} + +impl Default for Add { + fn default() -> Self { + Self::new() + } +} + +impl Node for Add { + fn schema(&self) -> NodeSchema { + NodeSchema { + inputs: vec![ + InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, + InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, + ], + output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], + } + } + + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { + let a = ctx.f64_in(0); + let b = ctx.f64_in(1); + if a.is_empty() || b.is_empty() { + return None; + } + self.out[0] = Scalar::F64(a[0] + b[0]); + Some(&self.out) + } +} +``` + +### Implementation shape — `LinComb` (secondary; param-carrying, variadic schema) + +```rust +use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind}; + +/// Weighted sum of `N` f64 inputs: `Σ weights[i] · input[i]`. The general +/// combinator — `LinComb([1.0, 1.0])` is `Add`, `LinComb([1.0, -1.0])` is `Sub`. +/// The `weights` are the node's tunable parameters (C8/C12) and fix its arity +/// (`weights.len()` inputs). Emits `None` until *all* inputs have a value. +pub struct LinComb { + weights: Vec, + out: [Scalar; 1], +} + +impl LinComb { + /// Build a `LinComb` with one weight per input (at least one required). + pub fn new(weights: Vec) -> Self { + assert!(!weights.is_empty(), "LinComb needs at least one weight"); + Self { weights, out: [Scalar::F64(0.0)] } + } +} + +impl Node for LinComb { + fn schema(&self) -> NodeSchema { + NodeSchema { + inputs: self + .weights + .iter() + .map(|_| InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }) + .collect(), + output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], + } + } + + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { + let mut acc = 0.0; + for (i, &w) in self.weights.iter().enumerate() { + let w_in = ctx.f64_in(i); + if w_in.is_empty() { + return None; // not yet warmed up — withhold until every leg is present + } + acc += w * w_in[0]; + } + self.out[0] = Scalar::F64(acc); + Some(&self.out) + } +} +``` + +### `lib.rs` export shape (secondary) + +```rust +mod add; +mod exposure; +mod lincomb; +mod sim_broker; +mod sma; +mod sub; +pub use add::Add; +pub use exposure::Exposure; +pub use lincomb::LinComb; +pub use sim_broker::SimBroker; +pub use sma::Sma; +pub use sub::Sub; +``` + +## Components + +| Component | File | Role | +|---|---|---| +| `Add` | `crates/aura-std/src/add.rs` | Two-input f64 sum; parameterless companion to `Sub`. | +| `LinComb` | `crates/aura-std/src/lincomb.rs` | `N`-input weighted sum; weights = tunable params + arity. | +| exports | `crates/aura-std/src/lib.rs` | `pub use add::Add; pub use lincomb::LinComb;` | + +`aura-std`'s lib.rs doc comment already lists "standard combinators" as an +intended category; these are the first two. + +## Data flow + +Each node sits mid-graph as an ordinary transformer (C8/C9): + +``` + signalA ──┐ + ├──▶ Add / LinComb ──▶ value ──▶ (Exposure ──▶ SimBroker ──▶ sink) + signalB ──┘ +``` + +For `LinComb`, the producer edges bind to slots `0..weights.len()` field-wise +(`Edge::from_field`, C8). Inputs are read newest-first (index 0 = newest), one +value per input (lookback 1). One `eval` → at most one output row (C8). No +look-ahead: each node sees only the newest committed value of each input window +(C2). + +## Error handling + +- **Empty weights (`LinComb`):** `LinComb::new(vec![])` panics at construction + (`assert!`), matching `Sma::new`'s `length >= 1` discipline — a topology/param + error caught at build, not a silent runtime degenerate. +- **Cold input leg:** any input window empty → `eval` returns `None` (filter / + not-yet-warmed-up per C8). No partial sum, no implicit `0.0` substitution. +- **Arity mismatch wiring:** if a consumer wires fewer/more edges than the + node's input count, that is caught by the engine's existing bootstrap edge + resolution (out of scope for these nodes — same as every other multi-input + node, e.g. `Sub`). Note (carried, not fixed here): like `Sub`/`SimBroker`, + `LinComb`'s slots are role-distinct but all f64, so a *swapped* wiring of + two equal-weight legs is not kind-caught — for a symmetric weighted sum the + result is order-independent anyway; for asymmetric weights the consumer owns + slot order (documented on the rustdoc). + +## Testing strategy + +Hand-driven unit tests in the established `aura-std` style (drive `eval` by hand +with `AnyColumn` input windows + `Ctx::new`, exactly as `sub.rs` / `sma.rs` do), +co-located in each node's `#[cfg(test)] mod tests`: + +**`Add`:** +- `add_is_sum_once_both_inputs_present` — only input 0 present → `None`; both + present → `a + b` (mirrors `sub_is_difference_once_both_inputs_present`). + +**`LinComb`:** +- `lincomb_weighted_sum_once_all_present` — two weighted inputs `[0.5, 2.0]`: + `None` until both present, then `0.5·x₀ + 2.0·x₁`. +- `lincomb_unit_weights_equal_add` — `LinComb::new(vec![1.0, 1.0])` reproduces + `Add` on the same inputs (the documented "`Add` = `LinComb([1,1])`" identity). +- `lincomb_three_inputs_warm_up` — three weights; output withheld until the + third leg is present (variadic warm-up, the N>2 case). +- `lincomb_empty_weights_panics` — `#[should_panic]` on `LinComb::new(vec![])`. + +`cargo test -p aura-std`, `cargo clippy -p aura-std --all-targets -D warnings`, +and `RUSTDOCFLAGS="-D warnings" cargo doc -p aura-std --no-deps` must all pass. + +## Acceptance criteria + +- [ ] `aura-std` ships `Add` (two f64 inputs → sum) and `LinComb { weights }` + (`N` f64 inputs → weighted sum, weights as params + arity), each in its + own file, exported from `lib.rs`. +- [ ] Both emit `None` until all inputs are present, then the full (weighted) + sum — no implicit cold-leg `0.0`. +- [ ] `LinComb::new(vec![])` panics at construction. +- [ ] The cycle-0007 fieldtest's two-signal combine example + (`c0007_4_combine_two_signals.rs`) can drop its hand-authored `Add2` in + favour of either shipped node (`Add::new()` or `LinComb::new(vec![1.0, 1.0])`). +- [ ] Hand-driven unit tests in the established `aura-std` style cover both + nodes incl. the `Add` == `LinComb([1,1])` identity and the empty-weights + panic. +- [ ] `cargo test -p aura-std`, `clippy -D warnings`, and `cargo doc` (warnings + denied) all clean.