Files
Aura/docs/specs/0008-sum-combinators.md
T
Brummel 288e75e7fd 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
2026-06-04 18:01:09 +02:00

11 KiB

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:

// before: a hand-authored project-local node
struct Add2 { out: Vec<Scalar> }
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):

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)

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)

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<f64>,
    out: [Scalar; 1],
}

impl LinComb {
    /// Build a `LinComb` with one weight per input (at least one required).
    pub fn new(weights: Vec<f64>) -> 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)

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_addLinComb::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.