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.
This commit is contained in:
2026-06-04 18:21:57 +02:00
parent 45920faa8f
commit 90e298d3b4
9 changed files with 811 additions and 0 deletions
@@ -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 <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`) |
+30
View File
@@ -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",
]
@@ -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 <name>`
# 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"
@@ -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<Box<dyn aura_core::Node>> = 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<Box<dyn aura_core::Node>> = 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");
}
@@ -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<Box<dyn aura_core::Node>> = 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");
}
@@ -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<Box<dyn aura_core::Node>> = 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");
}
@@ -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<Box<dyn aura_core::Node>> = 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();
}
@@ -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<Box<dyn aura_core::Node>> = 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.");
}
@@ -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<RefCell<Vec<(Timestamp, f64)>>>;
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
}
}