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

180 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.0`s 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`) |