Files
Aura/docs/specs/fieldtest-0008-sum-combinators.md
T
Brummel 90e298d3b4 fieldtest: cycle-0008 — 5 examples, 6 findings
First fieldtest of the sum combinators. A standalone downstream-consumer crate
(fieldtests/cycle-0008-sum-combinators/) path-depends on the engine crates and
exercises the post-0008 surface from the public interface only (rustdoc + ledger
+ glossary + project-layout, never crates/*/src).

Primary axis empirically met: the north-star two-signal combine move now uses the
shipped Add (Add::new() dropped in exactly where the 0007 fixture hand-authored
an Add2) — the 0007 boilerplate is retired, end to end through SimBroker to a
deterministic recorded pip curve.

Findings: 0 bugs, 1 friction, 2 spec_gaps, 3 working.
  - working ×3: Add2 retired; LinComb weighted/variadic sums exact (<1e-12);
    warm-up barrier + empty-weights panic as documented.
  - spec_gap: Add/LinComb per-input firing policy (Firing::Any / mode-A as-of
    join) absent from the public surface — same class as the 0007 SimBroker gap.
  - spec_gap: a fired combinator emits one row per *cycle*, so a heterogeneous
    same-timestamp multi-source trace can carry >1 row per timestamp (correct per
    C4/C5, undocumented at this surface).
  - friction: nested consumer crate still needs the empty [workspace] table
    (carried from 0006/0007; #9 closed the docs half; folds into aura new).

Spec feeds the next plan as reference.
2026-06-04 18:21:57 +02:00

10 KiB
Raw Blame History

Fieldtest — cycle-0008 (sum combinators) — 2026-06-04

Status: Draft — awaiting orchestrator triage Author: fieldtester (dispatched by fieldtest skill)

Scope

Cycle 0008 ships the missing sum combinator(s) in aura-std so the north-star "combine one signal with another" research move (C10) is expressible from shipped blocks alone — retiring the hand-authored Add2 the cycle-0007 fieldtest had to write:

  • Add — parameterless two-input f64 sum (input 0 + input 1), the companion to Sub. Emits None until both inputs have a value.
  • LinComb { weights } — N-input weighted sum Σ weights[i]·input[i]. The weights are construction parameters that configure the node and fix its arity (weights.len() input slots, in slot order). Emits None until all inputs have a value. LinComb::new(vec![]) panics (build-time param error). LinComb([1,1]) is Add; LinComb([1,-1]) is Sub.

No engine or manifest change this cycle (purely additive aura-std, C16).

Build exercised: all five binaries built and run from the current working tree via cargo run --manifest-path fieldtests/cycle-0008-sum-combinators/Cargo.toml --bin <name> (debug profile, HEAD source recompiled each invocation — confirmed by the Compiling aura-core/std/engine lines). The fixture is a standalone downstream-consumer crate that path-depends on the engine crates and uses only the public surface (rustdoc via cargo doc, the design ledger, the glossary, docs/project-layout.md — never crates/*/src).

Examples

fieldtests/cycle-0008-sum-combinators/c0008_1_combine_with_add.rs — north-star combine with shipped Add

  • Wires price → {Sma(2),Sma(4)} → Sub (fast spread) and price → {Sma(3),Sma(6)} → Sub (slow spread), sums the two spreads with the shipped Add, then Exposure → SimBroker → Recorder — the full signal-quality loop, the exact shape the 0007 fieldtest built with a hand-authored Add2.
  • Fits the cycle's primary axis: it is THE demonstration that the 0007 boilerplate is retired — Add drops straight in where Add2 was.
  • Outcome: built, ran, matched expected. 16 pip-equity rows (one per price cycle, leading 0.0s during warm-up exactly as SimBroker's rustdoc describes); final equity +10.6667; two runs bit-identical (C1).

fieldtests/cycle-0008-sum-combinators/c0008_2_lincomb_weighted.rs — weighted combine via LinComb

  • price → {Sma(2),Sma(4)} → LinComb([0.7,0.3]), tapping Sma(2), Sma(4), and the LinComb output with three recorders, asserting combined == 0.7·sma2 + 0.3·sma4 on every fired cycle.
  • Fits the weighted-combine axis: confirms the weighted sum (not a plain sum) is what reaches exposure.
  • Outcome: built, ran, matched expected. 5 LinComb rows (warm-up gated by the slower Sma(4)); every row matched the weighted sum to < 1e-12.

fieldtests/cycle-0008-sum-combinators/c0008_3_lincomb_three_legs.rs — variadic N>2 arity

  • price → {Sma(2),Sma(3),Sma(4)} → LinComb([0.5,0.3,0.2]), three legs, output asserted against the 3-leg weighted sum each cycle; first fire asserted at t=3 (the slowest leg's warm-up).
  • Fits the variadic-arity axis: arity is fixed by weights.len() = 3 input slots.
  • Outcome: built, ran, matched expected. 4 rows, first at t=3, all weighted sums matched.

fieldtests/cycle-0008-sum-combinators/c0008_4_lincomb_edges.rs — warm-up barrier + empty-weights panic

  • (a) Two legs warming up at different times (Sma(2) at t=1, Sma(5) at t=4): asserts LinComb's first output is at t=4 and no row exists before both legs are warm (no cold leg silently folded in as 0.0).
  • (b) LinComb::new(vec![]) wrapped in catch_unwind: asserts it panics, as the rustdoc documents.
  • Fits the edge-behaviour axis (warm-up + empty-weights panic).
  • Outcome: built, ran, matched expected. First fire at t=4, no leading rows; the empty-weights call panicked as documented.

fieldtests/cycle-0008-sum-combinators/c0008_5_lincomb_two_sources.rs — firing-policy probe (two heterogeneous sources)

  • LinComb([1,1]) fed by two independent f64 sources updating on different cycles (source A at t=0..5, source B only at t=1 and t=4). Records what LinComb emits to reveal its per-input firing policy (C6), which the public surface does not state.
  • Fits the variadic/combine axis at its genuinely-heterogeneous corner — the north-star "combine A with a different-rate B" the docs motivate.
  • Outcome: built, ran. Emitted 6 rows: once both legs are seen (t≥1), LinComb re-fires on every fresh cycle combining the fresh leg with the held value of the other (mode A / as-of join), and at t=4 — where both sources fire as two distinct same-timestamp cycles (C4) — it emits two rows (114.0 then 124.0). This is internally consistent (C4/C5) but is not predictable from the rustdoc; see the spec_gap below.

Findings

[working] North-star combine expressed with shipped Add — Add2 retired

  • Example 1.
  • Two MA-cross spreads summed by aura_std::Add into one exposure, end to end through SimBroker to a recorded pip curve; bit-identical across two runs. Add::new() dropped in exactly where the 0007 fixture had a hand-authored Add2 — same wiring shape, one fewer node definition to author.
  • Why working: the cycle's primary promise (retire the 0007 boilerplate) is met empirically — the combinator exists, composes, and the curve is deterministic.
  • Recommended action: carry-on.

[working] LinComb weighted & variadic sums are arithmetically exact

  • Examples 2 and 3.
  • LinComb([0.7,0.3]) and LinComb([0.5,0.3,0.2]) produced outputs matching the closed-form weighted sum to < 1e-12 on every fired cycle; arity followed weights.len(); warm-up gated correctly by the slowest leg.
  • Why working: the weighted/variadic behaviour the rustdoc promises holds, on first try, with no surprises in the same-source case.
  • Recommended action: carry-on.

[working] Warm-up barrier and empty-weights panic behave as documented

  • Example 4.
  • LinComb withheld output until every leg had a value (no cold-leg-as-0.0); LinComb::new(vec![]) panicked as the # Panics rustdoc states.
  • Why working: the two documented edge behaviours are observable and correct.
  • Recommended action: carry-on.

[spec_gap] Add / LinComb per-input firing policy is absent from the public surface

  • Example 5 (surfaced); latent in 14 (all same-source, so co-fresh by construction, hiding the question).
  • What happened: combining two different-rate sources, LinComb re-fires on every cycle in which any leg is fresh (once both have been seen), pairing the fresh leg with the held value of the other — i.e. mode A / as-of join (Firing::Any). The rustdoc says only "Emits None until all inputs have a value"; it does not say whether a combine is an as-of join (mode A) or an all-fresh barrier (mode B). A downstream consumer combining, say, an M5 signal with a daily-bias leg cannot predict from the public surface whether the bias is held-and-combined every M5 cycle (mode A) or only combined on the rare cycles where both are fresh (mode B). The two readings give materially different equity curves.
  • Why spec_gap: the surface is silent; I picked the natural reading (mode A, the as-of join — the C6 default for fire-on-fresh combination) and it matched what shipped, but mode B is an equally plausible reading of "combine two signals," and the choice is invisible. This is the same class of gap the cycle-0007 fieldtest raised for SimBroker (firing policy + warm-up shape not on the surface), which was then patched into SimBroker's rustdoc — Add/LinComb ship without the equivalent note.
  • Recommended action: tighten the design ledger / rustdoc — document on the Add and LinComb struct docs that both inputs are Firing::Any (mode A, as-of join: a fresh leg combines with the held value of the others), mirroring the note already added to SimBroker.

[spec_gap] Multiple combined rows at one timestamp (same-ts multi-source) is undocumented

  • Example 5.
  • What happened: at t=4 LinComb emitted two rows (114.0 then 124.0) because source A and source B both carry t=4 and are therefore two distinct cycles (C4 tie-break by source order), and a mode-A node fires once per fresh cycle. A consumer reading "one combined value per cycle" naturally expects one row per timestamp and is surprised to find two.
  • Why spec_gap: this is correct and forced by C4/C5 (same timestamp from two sources = two cycles), but nothing on the public combinator surface tells a consumer that a recorded combined stream can contain multiple rows sharing one event_ts. The reading "one row per timestamp" is plausible and wrong; the shipped reading "one row per cycle" is right but undocumented at this surface.
  • Recommended action: ratify (the behaviour is correct per C4) and tighten the rustdoc — a one-line note on the combinator (or in project-layout's recorded- trace description) that with heterogeneous same-timestamp sources a fired node emits one row per cycle, so a trace may carry >1 row per timestamp.

[friction] Standalone consumer crate still needs the empty [workspace] table

  • All examples (the fixture Cargo.toml).
  • What happened: a nested consumer crate under the engine repo must carry an empty [workspace] table or cargo refuses to build it (walks up to the engine workspace). I added it from the documented hint (docs/project-layout.md), so it cost only the one line — but it is still a hand-step every nested consumer pays until aura new exists.
  • Why friction: the task completed, but the surface still forces a boilerplate line the docs now predict (cycle-0006/0007 raised this; #9 closed the docs half). Recording it keeps the residual cost visible until the scaffolder lands.
  • Recommended action: carry-on / plan — fold the emitted [workspace] line into the future aura new scaffolder (already tracked); no new action this cycle.

Recommendation summary

Finding Class Action
North-star combine with shipped Add — Add2 retired working carry-on
LinComb weighted & variadic sums exact working carry-on
Warm-up barrier + empty-weights panic as documented working carry-on
Add/LinComb firing policy absent from public surface spec_gap tighten ledger/rustdoc (mirror SimBroker note)
Multiple combined rows at one timestamp undocumented spec_gap ratify + tighten rustdoc
Consumer crate still needs empty [workspace] friction carry-on (folds into aura new)