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
This commit is contained in:
@@ -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<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):
|
||||
|
||||
```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<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)
|
||||
|
||||
```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.
|
||||
Reference in New Issue
Block a user