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:
@@ -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 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`) |
|
||||||
+30
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user