diff --git a/docs/specs/fieldtest-0008-sum-combinators.md b/docs/specs/fieldtest-0008-sum-combinators.md new file mode 100644 index 0000000..bf93b1b --- /dev/null +++ b/docs/specs/fieldtest-0008-sum-combinators.md @@ -0,0 +1,179 @@ +# 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 ` (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 1–4 (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`) | diff --git a/fieldtests/cycle-0008-sum-combinators/Cargo.lock b/fieldtests/cycle-0008-sum-combinators/Cargo.lock new file mode 100644 index 0000000..cd8a368 --- /dev/null +++ b/fieldtests/cycle-0008-sum-combinators/Cargo.lock @@ -0,0 +1,30 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aura-core" +version = "0.1.0" + +[[package]] +name = "aura-engine" +version = "0.1.0" +dependencies = [ + "aura-core", +] + +[[package]] +name = "aura-std" +version = "0.1.0" +dependencies = [ + "aura-core", +] + +[[package]] +name = "c0008-fieldtest" +version = "0.0.0" +dependencies = [ + "aura-core", + "aura-engine", + "aura-std", +] diff --git a/fieldtests/cycle-0008-sum-combinators/Cargo.toml b/fieldtests/cycle-0008-sum-combinators/Cargo.toml new file mode 100644 index 0000000..ece68e2 --- /dev/null +++ b/fieldtests/cycle-0008-sum-combinators/Cargo.toml @@ -0,0 +1,41 @@ +# Standalone downstream-consumer crate for the cycle-0008 fieldtest (sum combinators). +# +# Like the cycle-0007 fixture, this is NOT a member of the aura workspace — it +# path-depends on the engine crates exactly as a real research project (C16) +# would, and is built via +# `cargo run --manifest-path fieldtests/cycle-0008-sum-combinators/Cargo.toml --bin ` +# so HEAD source is always what runs. +# Empty [workspace] table: marks this fixture crate as its OWN workspace root +# (documented in docs/project-layout.md as the nested-project onboarding fix). +[workspace] + +[package] +name = "c0008-fieldtest" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +aura-core = { path = "../../crates/aura-core" } +aura-engine = { path = "../../crates/aura-engine" } +aura-std = { path = "../../crates/aura-std" } + +[[bin]] +name = "c0008_1_combine_with_add" +path = "c0008_1_combine_with_add.rs" + +[[bin]] +name = "c0008_2_lincomb_weighted" +path = "c0008_2_lincomb_weighted.rs" + +[[bin]] +name = "c0008_3_lincomb_three_legs" +path = "c0008_3_lincomb_three_legs.rs" + +[[bin]] +name = "c0008_4_lincomb_edges" +path = "c0008_4_lincomb_edges.rs" + +[[bin]] +name = "c0008_5_lincomb_two_sources" +path = "c0008_5_lincomb_two_sources.rs" diff --git a/fieldtests/cycle-0008-sum-combinators/c0008_1_combine_with_add.rs b/fieldtests/cycle-0008-sum-combinators/c0008_1_combine_with_add.rs new file mode 100644 index 0000000..077ce0f --- /dev/null +++ b/fieldtests/cycle-0008-sum-combinators/c0008_1_combine_with_add.rs @@ -0,0 +1,149 @@ +//! Example 1 — the north-star "combine one signal with another" move (C10), +//! expressed with the SHIPPED `Add` node (no hand-authored combinator). +//! +//! This is the demonstration that the cycle-0007 fieldtest boilerplate (a +//! project-local `Add2`) is retired: aura-std now ships `Add`, so two MA-cross +//! spreads can be summed into one exposure from shipped blocks alone. +//! +//! price ─┬─> Sma(2) ─┐ +//! ├─> Sma(4) ─┴─> Sub (fast spread) ─┐ +//! ├─> Sma(3) ─┐ ├─> Add ─> Exposure ─> SimBroker ─> Recorder +//! ├─> Sma(6) ─┴─> Sub (slow spread) ─┘ ^ +//! └────────────────────────────────────────────────────────────┘ (price) + +mod recorder; + +use std::cell::RefCell; +use std::rc::Rc; + +use aura_core::{Scalar, Timestamp}; +use aura_engine::{Edge, Harness, SourceSpec, Target}; +use aura_core::ScalarKind; +use aura_std::{Add, Exposure, SimBroker, Sma, Sub}; + +use recorder::{Recorder, Tape}; + +fn main() { + // Node indices. + const SMA2: usize = 0; + const SMA4: usize = 1; + const SMA3: usize = 2; + const SMA6: usize = 3; + const SUB_FAST: usize = 4; + const SUB_SLOW: usize = 5; + const ADD: usize = 6; + const EXPOSURE: usize = 7; + const BROKER: usize = 8; + const REC: usize = 9; + + let tape: Tape = Rc::new(RefCell::new(Vec::new())); + + let nodes: Vec> = vec![ + Box::new(Sma::new(2)), + Box::new(Sma::new(4)), + Box::new(Sma::new(3)), + Box::new(Sma::new(6)), + Box::new(Sub::new()), + Box::new(Sub::new()), + Box::new(Add::new()), + Box::new(Exposure::new(0.5)), + Box::new(SimBroker::new(1.0)), + Box::new(Recorder::new(tape.clone())), + ]; + + // One price source, fed into all four SMAs (slot 0) and the broker price (slot 1). + let sources = vec![SourceSpec { + kind: ScalarKind::F64, + targets: vec![ + Target { node: SMA2, slot: 0 }, + Target { node: SMA4, slot: 0 }, + Target { node: SMA3, slot: 0 }, + Target { node: SMA6, slot: 0 }, + Target { node: BROKER, slot: 1 }, + ], + }]; + + let edges = vec![ + // fast spread = Sma(2) - Sma(4) + Edge { from: SMA2, to: SUB_FAST, slot: 0, from_field: 0 }, + Edge { from: SMA4, to: SUB_FAST, slot: 1, from_field: 0 }, + // slow spread = Sma(3) - Sma(6) + Edge { from: SMA3, to: SUB_SLOW, slot: 0, from_field: 0 }, + Edge { from: SMA6, to: SUB_SLOW, slot: 1, from_field: 0 }, + // combine the two spreads with the SHIPPED Add + Edge { from: SUB_FAST, to: ADD, slot: 0, from_field: 0 }, + Edge { from: SUB_SLOW, to: ADD, slot: 1, from_field: 0 }, + // sum -> exposure -> broker exposure slot (0) + Edge { from: ADD, to: EXPOSURE, slot: 0, from_field: 0 }, + Edge { from: EXPOSURE, to: BROKER, slot: 0, from_field: 0 }, + // broker equity -> recorder + Edge { from: BROKER, to: REC, slot: 0, from_field: 0 }, + ]; + + let mut harness = Harness::bootstrap(nodes, sources, edges) + .expect("bootstrap should succeed for an acyclic, kind-consistent graph"); + + // A simple rising-then-falling price path, 1 tick per ns. + let prices: &[f64] = &[ + 100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, // rising + 106.0, 104.0, 102.0, 100.0, 98.0, 96.0, 94.0, 92.0, // falling + ]; + let stream: Vec<(Timestamp, Scalar)> = prices + .iter() + .enumerate() + .map(|(i, &p)| (Timestamp(i as i64), Scalar::F64(p))) + .collect(); + + harness.run(vec![stream]); + + let tape = tape.borrow(); + println!("recorded {} pip-equity rows (one per price cycle)", tape.len()); + for (ts, eq) in tape.iter() { + println!(" t={:>3} equity={:+.4}", ts.0, eq); + } + + // Determinism check (C1): same input -> identical run. + let tape2: Tape = Rc::new(RefCell::new(Vec::new())); + let nodes2: Vec> = vec![ + Box::new(Sma::new(2)), + Box::new(Sma::new(4)), + Box::new(Sma::new(3)), + Box::new(Sma::new(6)), + Box::new(Sub::new()), + Box::new(Sub::new()), + Box::new(Add::new()), + Box::new(Exposure::new(0.5)), + Box::new(SimBroker::new(1.0)), + Box::new(Recorder::new(tape2.clone())), + ]; + let sources2 = vec![SourceSpec { + kind: ScalarKind::F64, + targets: vec![ + Target { node: SMA2, slot: 0 }, + Target { node: SMA4, slot: 0 }, + Target { node: SMA3, slot: 0 }, + Target { node: SMA6, slot: 0 }, + Target { node: BROKER, slot: 1 }, + ], + }]; + let edges2 = vec![ + Edge { from: SMA2, to: SUB_FAST, slot: 0, from_field: 0 }, + Edge { from: SMA4, to: SUB_FAST, slot: 1, from_field: 0 }, + Edge { from: SMA3, to: SUB_SLOW, slot: 0, from_field: 0 }, + Edge { from: SMA6, to: SUB_SLOW, slot: 1, from_field: 0 }, + Edge { from: SUB_FAST, to: ADD, slot: 0, from_field: 0 }, + Edge { from: SUB_SLOW, to: ADD, slot: 1, from_field: 0 }, + Edge { from: ADD, to: EXPOSURE, slot: 0, from_field: 0 }, + Edge { from: EXPOSURE, to: BROKER, slot: 0, from_field: 0 }, + Edge { from: BROKER, to: REC, slot: 0, from_field: 0 }, + ]; + let stream2: Vec<(Timestamp, Scalar)> = prices + .iter() + .enumerate() + .map(|(i, &p)| (Timestamp(i as i64), Scalar::F64(p))) + .collect(); + let mut harness2 = Harness::bootstrap(nodes2, sources2, edges2).unwrap(); + harness2.run(vec![stream2]); + assert_eq!(*tape, *tape2.borrow(), "C1: two runs must be bit-identical"); + println!("determinism: two runs bit-identical = OK"); +} diff --git a/fieldtests/cycle-0008-sum-combinators/c0008_2_lincomb_weighted.rs b/fieldtests/cycle-0008-sum-combinators/c0008_2_lincomb_weighted.rs new file mode 100644 index 0000000..1b73623 --- /dev/null +++ b/fieldtests/cycle-0008-sum-combinators/c0008_2_lincomb_weighted.rs @@ -0,0 +1,91 @@ +//! Example 2 — weighted combine via `LinComb` with non-trivial weights. +//! +//! A consumer combines two moving averages with weights [0.7, 0.3] and wants to +//! confirm the *weighted* sum (not a plain sum) is what reaches the exposure +//! node. We tap all three streams (Sma(2), Sma(4), and the LinComb output) with +//! recorders and assert `combined == 0.7*sma2 + 0.3*sma4` on every fired cycle. +//! +//! price ─┬─> Sma(2) ─┬────────────> Recorder (a) +//! └─> Sma(4) ─┼┬───────────> Recorder (b) +//! ││ +//! LinComb([0.7,0.3]) ─> Recorder (c) + +mod recorder; + +use std::cell::RefCell; +use std::rc::Rc; + +use aura_core::ScalarKind; +use aura_core::{Scalar, Timestamp}; +use aura_engine::{Edge, Harness, SourceSpec, Target}; +use aura_std::{LinComb, Sma}; + +use recorder::{Recorder, Tape}; + +fn main() { + const SMA2: usize = 0; + const SMA4: usize = 1; + const LINCOMB: usize = 2; + const REC_A: usize = 3; + const REC_B: usize = 4; + const REC_C: usize = 5; + + let tape_a: Tape = Rc::new(RefCell::new(Vec::new())); + let tape_b: Tape = Rc::new(RefCell::new(Vec::new())); + let tape_c: Tape = Rc::new(RefCell::new(Vec::new())); + + let nodes: Vec> = vec![ + Box::new(Sma::new(2)), + Box::new(Sma::new(4)), + Box::new(LinComb::new(vec![0.7, 0.3])), + Box::new(Recorder::new(tape_a.clone())), + Box::new(Recorder::new(tape_b.clone())), + Box::new(Recorder::new(tape_c.clone())), + ]; + + let sources = vec![SourceSpec { + kind: ScalarKind::F64, + targets: vec![ + Target { node: SMA2, slot: 0 }, + Target { node: SMA4, slot: 0 }, + ], + }]; + + let edges = vec![ + Edge { from: SMA2, to: LINCOMB, slot: 0, from_field: 0 }, + Edge { from: SMA4, to: LINCOMB, slot: 1, from_field: 0 }, + Edge { from: SMA2, to: REC_A, slot: 0, from_field: 0 }, + Edge { from: SMA4, to: REC_B, slot: 0, from_field: 0 }, + Edge { from: LINCOMB, to: REC_C, slot: 0, from_field: 0 }, + ]; + + let mut harness = Harness::bootstrap(nodes, sources, edges).expect("bootstrap"); + + let prices: &[f64] = &[10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0]; + let stream: Vec<(Timestamp, Scalar)> = prices + .iter() + .enumerate() + .map(|(i, &p)| (Timestamp(i as i64), Scalar::F64(p))) + .collect(); + + harness.run(vec![stream]); + + let (a, b, c) = (tape_a.borrow(), tape_b.borrow(), tape_c.borrow()); + println!("sma2 rows={}, sma4 rows={}, lincomb rows={}", a.len(), b.len(), c.len()); + + // LinComb withholds output until BOTH inputs are present, so it fires only + // from the cycle where the slower Sma(4) has warmed up. + println!("\n t sma2 sma4 lincomb expected(0.7*sma2+0.3*sma4)"); + for &(ts, combined) in c.iter() { + let s2 = a.iter().find(|&&(t, _)| t == ts).map(|&(_, v)| v).unwrap(); + let s4 = b.iter().find(|&&(t, _)| t == ts).map(|&(_, v)| v).unwrap(); + let expected = 0.7 * s2 + 0.3 * s4; + let ok = (combined - expected).abs() < 1e-12; + println!( + " {:>2} {:>7.3} {:>7.3} {:>8.4} {:>8.4} {}", + ts.0, s2, s4, combined, expected, if ok { "OK" } else { "MISMATCH" } + ); + assert!(ok, "weighted sum mismatch at t={}", ts.0); + } + println!("\nall weighted sums match 0.7*sma2 + 0.3*sma4 = OK"); +} diff --git a/fieldtests/cycle-0008-sum-combinators/c0008_3_lincomb_three_legs.rs b/fieldtests/cycle-0008-sum-combinators/c0008_3_lincomb_three_legs.rs new file mode 100644 index 0000000..bd074ac --- /dev/null +++ b/fieldtests/cycle-0008-sum-combinators/c0008_3_lincomb_three_legs.rs @@ -0,0 +1,102 @@ +//! Example 3 — the variadic-arity case: `LinComb` over N>2 signal legs. +//! +//! Three moving averages combined with weights [0.5, 0.3, 0.2]. Verifies the +//! arity is fixed by `weights.len()` (3 input slots), and that the recorded +//! output equals the 3-leg weighted sum on every fired cycle. Also confirms +//! the firing barrier: LinComb withholds output until ALL THREE legs warm up +//! (i.e. until the slowest, Sma(4), produces a value). + +mod recorder; + +use std::cell::RefCell; +use std::rc::Rc; + +use aura_core::ScalarKind; +use aura_core::{Scalar, Timestamp}; +use aura_engine::{Edge, Harness, SourceSpec, Target}; +use aura_std::{LinComb, Sma}; + +use recorder::{Recorder, Tape}; + +fn main() { + const SMA2: usize = 0; + const SMA3: usize = 1; + const SMA4: usize = 2; + const LINCOMB: usize = 3; + const REC2: usize = 4; + const REC3: usize = 5; + const REC4: usize = 6; + const REC_OUT: usize = 7; + + let t2: Tape = Rc::new(RefCell::new(Vec::new())); + let t3: Tape = Rc::new(RefCell::new(Vec::new())); + let t4: Tape = Rc::new(RefCell::new(Vec::new())); + let tout: Tape = Rc::new(RefCell::new(Vec::new())); + + let weights = vec![0.5, 0.3, 0.2]; + + let nodes: Vec> = vec![ + Box::new(Sma::new(2)), + Box::new(Sma::new(3)), + Box::new(Sma::new(4)), + Box::new(LinComb::new(weights.clone())), + Box::new(Recorder::new(t2.clone())), + Box::new(Recorder::new(t3.clone())), + Box::new(Recorder::new(t4.clone())), + Box::new(Recorder::new(tout.clone())), + ]; + + let sources = vec![SourceSpec { + kind: ScalarKind::F64, + targets: vec![ + Target { node: SMA2, slot: 0 }, + Target { node: SMA3, slot: 0 }, + Target { node: SMA4, slot: 0 }, + ], + }]; + + let edges = vec![ + Edge { from: SMA2, to: LINCOMB, slot: 0, from_field: 0 }, + Edge { from: SMA3, to: LINCOMB, slot: 1, from_field: 0 }, + Edge { from: SMA4, to: LINCOMB, slot: 2, from_field: 0 }, + Edge { from: SMA2, to: REC2, slot: 0, from_field: 0 }, + Edge { from: SMA3, to: REC3, slot: 0, from_field: 0 }, + Edge { from: SMA4, to: REC4, slot: 0, from_field: 0 }, + Edge { from: LINCOMB, to: REC_OUT, slot: 0, from_field: 0 }, + ]; + + let mut harness = Harness::bootstrap(nodes, sources, edges).expect("bootstrap"); + + let prices: &[f64] = &[10.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0]; + let stream: Vec<(Timestamp, Scalar)> = prices + .iter() + .enumerate() + .map(|(i, &p)| (Timestamp(i as i64), Scalar::F64(p))) + .collect(); + + harness.run(vec![stream]); + + let (a, b, c, out) = (t2.borrow(), t3.borrow(), t4.borrow(), tout.borrow()); + println!("sma2 rows={}, sma3 rows={}, sma4 rows={}, lincomb rows={}", a.len(), b.len(), c.len(), out.len()); + + // Sma(4) warms up last (needs 4 samples -> first value at t=3), so the 3-leg + // barrier means LinComb's first output is at t=3. + let first_out_ts = out.first().map(|&(t, _)| t.0); + println!("LinComb first fires at t={:?} (expect t=3, the slowest leg's warm-up)", first_out_ts); + assert_eq!(first_out_ts, Some(3), "3-leg warm-up barrier"); + + println!("\n t sma2 sma3 sma4 lincomb expected(0.5,0.3,0.2)"); + for &(ts, combined) in out.iter() { + let s2 = a.iter().find(|&&(t, _)| t == ts).map(|&(_, v)| v).unwrap(); + let s3 = b.iter().find(|&&(t, _)| t == ts).map(|&(_, v)| v).unwrap(); + let s4 = c.iter().find(|&&(t, _)| t == ts).map(|&(_, v)| v).unwrap(); + let expected = 0.5 * s2 + 0.3 * s3 + 0.2 * s4; + let ok = (combined - expected).abs() < 1e-12; + println!( + " {:>2} {:>7.3} {:>7.3} {:>7.3} {:>8.4} {:>8.4} {}", + ts.0, s2, s3, s4, combined, expected, if ok { "OK" } else { "MISMATCH" } + ); + assert!(ok, "3-leg weighted sum mismatch at t={}", ts.0); + } + println!("\nall 3-leg weighted sums match = OK"); +} diff --git a/fieldtests/cycle-0008-sum-combinators/c0008_4_lincomb_edges.rs b/fieldtests/cycle-0008-sum-combinators/c0008_4_lincomb_edges.rs new file mode 100644 index 0000000..58f6d44 --- /dev/null +++ b/fieldtests/cycle-0008-sum-combinators/c0008_4_lincomb_edges.rs @@ -0,0 +1,98 @@ +//! Example 4 — edge behaviour of the new combinators. +//! +//! (a) Warm-up barrier: `LinComb` withholds output (`None`) until EVERY leg has +//! a value. With two legs that warm up at different times (Sma(2) at t=1, +//! Sma(5) at t=4), the combined output must first appear at t=4, never +//! folding a cold leg in as 0.0. +//! +//! (b) The empty-weights build error: `LinComb::new(vec![])` panics (a build-time +//! param error, documented on the public surface), caught here and reported. + +mod recorder; + +use std::cell::RefCell; +use std::panic; +use std::rc::Rc; + +use aura_core::ScalarKind; +use aura_core::{Scalar, Timestamp}; +use aura_engine::{Edge, Harness, SourceSpec, Target}; +use aura_std::{LinComb, Sma}; + +use recorder::{Recorder, Tape}; + +fn warm_up_barrier() { + const SMA2: usize = 0; + const SMA5: usize = 1; + const LINCOMB: usize = 2; + const REC: usize = 3; + + let tape: Tape = Rc::new(RefCell::new(Vec::new())); + + let nodes: Vec> = vec![ + Box::new(Sma::new(2)), + Box::new(Sma::new(5)), + Box::new(LinComb::new(vec![1.0, 1.0])), + Box::new(Recorder::new(tape.clone())), + ]; + + let sources = vec![SourceSpec { + kind: ScalarKind::F64, + targets: vec![ + Target { node: SMA2, slot: 0 }, + Target { node: SMA5, slot: 0 }, + ], + }]; + + let edges = vec![ + Edge { from: SMA2, to: LINCOMB, slot: 0, from_field: 0 }, + Edge { from: SMA5, to: LINCOMB, slot: 1, from_field: 0 }, + Edge { from: LINCOMB, to: REC, slot: 0, from_field: 0 }, + ]; + + let mut harness = Harness::bootstrap(nodes, sources, edges).expect("bootstrap"); + + let prices: &[f64] = &[10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0]; + let stream: Vec<(Timestamp, Scalar)> = prices + .iter() + .enumerate() + .map(|(i, &p)| (Timestamp(i as i64), Scalar::F64(p))) + .collect(); + + harness.run(vec![stream]); + + let tape = tape.borrow(); + let first = tape.first().map(|&(t, _)| t.0); + println!("(a) warm-up: LinComb first fires at t={:?} (expect t=4, when Sma(5) warms up)", first); + for (ts, v) in tape.iter() { + println!(" t={:>2} combined={:.4}", ts.0, v); + } + assert_eq!(first, Some(4), "barrier: no output until the slower leg (Sma(5)) is present"); + // No leading rows that would betray a cold leg silently read as 0.0. + assert!( + tape.iter().all(|&(t, _)| t.0 >= 4), + "no row before both legs are warm (no implicit cold-leg 0.0)" + ); + println!(" -> no cold-leg-as-0.0 rows; barrier honoured = OK"); +} + +fn empty_weights_panic() { + print!("(b) empty weights: LinComb::new(vec![]) ... "); + let prev = panic::take_hook(); + panic::set_hook(Box::new(|_| {})); // silence the default panic print + let result = panic::catch_unwind(|| { + let _ = LinComb::new(vec![]); + }); + panic::set_hook(prev); + match result { + Ok(_) => println!("NO PANIC (surface says it should panic!) -- unexpected"), + Err(_) => println!("panicked as documented = OK"), + } + assert!(result.is_err(), "LinComb::new(vec![]) must panic per the public surface"); +} + +fn main() { + warm_up_barrier(); + println!(); + empty_weights_panic(); +} diff --git a/fieldtests/cycle-0008-sum-combinators/c0008_5_lincomb_two_sources.rs b/fieldtests/cycle-0008-sum-combinators/c0008_5_lincomb_two_sources.rs new file mode 100644 index 0000000..64d6ebf --- /dev/null +++ b/fieldtests/cycle-0008-sum-combinators/c0008_5_lincomb_two_sources.rs @@ -0,0 +1,76 @@ +//! Example 5 (firing-policy probe) — combine two signals driven by two DIFFERENT +//! sources that update on different cycles. +//! +//! This is the genuinely-heterogeneous combine the north-star reaches for: a +//! fast price tick × a slower second feed, summed by LinComb. The interesting +//! question — invisible from the public surface — is LinComb's per-input firing +//! policy (C6): does a fired-this-cycle leg get combined with the OTHER leg's +//! *held* value (mode A, as-of join), or does LinComb only fire when both legs +//! are fresh in the same cycle (mode B barrier)? +//! +//! The rustdoc says only "Emits None until all inputs have a value" — it does +//! not state the firing policy. We feed two sources at interleaved timestamps +//! and RECORD what LinComb emits, to see which reading the shipped node took. +//! +//! source A (f64, fast) ─> LinComb slot 0 ┐ +//! source B (f64, slow) ─> LinComb slot 1 ┴─> Recorder + +mod recorder; + +use std::cell::RefCell; +use std::rc::Rc; + +use aura_core::ScalarKind; +use aura_core::{Scalar, Timestamp}; +use aura_engine::{Edge, Harness, SourceSpec, Target}; +use aura_std::LinComb; + +use recorder::{Recorder, Tape}; + +fn main() { + const LINCOMB: usize = 0; + const REC: usize = 1; + + let tape: Tape = Rc::new(RefCell::new(Vec::new())); + + let nodes: Vec> = vec![ + Box::new(LinComb::new(vec![1.0, 1.0])), + Box::new(Recorder::new(tape.clone())), + ]; + + // Two independent sources, both f64, each feeding one LinComb slot. + let sources = vec![ + SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: LINCOMB, slot: 0 }] }, + SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: LINCOMB, slot: 1 }] }, + ]; + + let edges = vec![Edge { from: LINCOMB, to: REC, slot: 0, from_field: 0 }]; + + let mut harness = Harness::bootstrap(nodes, sources, edges).expect("bootstrap"); + + // Source A fires at t = 0,1,2,3,4,5; source B fires only at t = 1 and t = 4. + // (Distinct timestamps => distinct cycles, C4.) + let stream_a: Vec<(Timestamp, Scalar)> = (0..6) + .map(|i| (Timestamp(i), Scalar::F64(100.0 + i as f64))) + .collect(); + let stream_b: Vec<(Timestamp, Scalar)> = vec![ + (Timestamp(1), Scalar::F64(10.0)), + (Timestamp(4), Scalar::F64(20.0)), + ]; + + harness.run(vec![stream_a, stream_b]); + + let tape = tape.borrow(); + println!("LinComb emitted {} rows from two interleaved sources:", tape.len()); + println!(" (source A: a=100+t at t=0..5; source B: b=10 at t=1, b=20 at t=4)"); + for (ts, v) in tape.iter() { + println!(" t={:>2} combined={:.4}", ts.0, v); + } + println!(); + println!("Interpretation (public surface is SILENT on the firing policy):"); + println!(" - mode A (as-of/hold): once both legs seen, every A-fresh cycle re-fires"); + println!(" combining a's fresh value with b's HELD value."); + println!(" - mode B (barrier): LinComb fires only on cycles where BOTH legs are"); + println!(" fresh at the same timestamp -> here only t=1 and t=4 would emit."); + println!(" The recorded rows above reveal which reading shipped."); +} diff --git a/fieldtests/cycle-0008-sum-combinators/recorder.rs b/fieldtests/cycle-0008-sum-combinators/recorder.rs new file mode 100644 index 0000000..eb544b4 --- /dev/null +++ b/fieldtests/cycle-0008-sum-combinators/recorder.rs @@ -0,0 +1,45 @@ +//! A minimal recording sink a downstream consumer would author (C8 sink role, +//! C22: displayable = exactly what a sink recorded). +//! +//! It declares a single `f64` input with `Firing::Any`, an empty `output` +//! (the pure-consumer declaration), and in `eval` pushes `(now, value)` to a +//! destination it holds as a field — an out-of-graph side effect. Returns +//! `None` (records nothing into the graph). + +use std::cell::RefCell; +use std::rc::Rc; + +use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind, Timestamp}; + +/// Shared, out-of-graph destination the recorder writes into. +pub type Tape = Rc>>; + +pub struct Recorder { + tape: Tape, +} + +impl Recorder { + pub fn new(tape: Tape) -> Self { + Self { tape } + } +} + +impl Node for Recorder { + fn schema(&self) -> NodeSchema { + NodeSchema { + inputs: vec![InputSpec { + kind: ScalarKind::F64, + lookback: 1, + firing: Firing::Any, + }], + output: vec![], // pure consumer (sink) + } + } + + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { + if let Some(v) = ctx.f64_in(0).get(0) { + self.tape.borrow_mut().push((ctx.now(), v)); + } + None + } +}