Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fb7caf929 | |||
| 48979193b9 | |||
| a20b6f6003 | |||
| 59f547e313 | |||
| 51096a3212 | |||
| bd6ddf9efe | |||
| 7e1dd25a76 | |||
| cc683fb065 | |||
| 913d18755e | |||
| 1102d776df | |||
| 34987be389 | |||
| cfc2d9f5b1 | |||
| b04b56148d | |||
| 96e79184e0 | |||
| 2d988fdf3d | |||
| bd7585b609 | |||
| 9c7f60b269 | |||
| 7126886b81 | |||
| 593e233e66 | |||
| 750ee93180 | |||
| d6694d0641 | |||
| ab3f16879b | |||
| d8c6938027 | |||
| a32dc38d18 | |||
| df3c2f06bd | |||
| 8c19260e8d | |||
| bd0c557f16 | |||
| 6e3f394b48 | |||
| d8ef9de5c2 | |||
| 92e281e4ec | |||
| b8ba324a41 | |||
| 4ae2297a35 | |||
| 09994b83ed |
@@ -159,6 +159,16 @@ design decision, not a refactor, and belongs in the ledger.
|
||||
harness and is an execution viewer / trace explorer (structure before, live
|
||||
streams during, recorded traces after) — never a scene editor; topology is
|
||||
grown in Rust + hot-reload, runtime params are UI-tunable.
|
||||
13. **Self-description: every surface explains itself.** Every closed-
|
||||
vocabulary entry the binary exposes (nodes, metrics, tap slots, folds,
|
||||
blocks) carries a one-line meaning, enforced at its entry seam —
|
||||
compile/unit for engine-shipped entries, load for native node crates,
|
||||
register for the content-addressed store (documents: an optional gated
|
||||
`description`). The gate is deterministic string shape (`doc_gate`),
|
||||
never content judgement: the engine never evaluates description text
|
||||
(no freetext logic hole, invariant 10), descriptions never influence
|
||||
execution, determinism (C1), or identity ids, and registered artifacts
|
||||
are never retroactively invalidated. (C29)
|
||||
|
||||
## HTML surfaces
|
||||
|
||||
|
||||
@@ -119,8 +119,9 @@ aura sweep crossover.bp.json --list-axes
|
||||
```
|
||||
|
||||
The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, `tap`,
|
||||
and `gang` (`tap` declares a recorded measurement point on an interior wire —
|
||||
see the authoring guide). See
|
||||
`gang`, and `doc` (`tap` declares a recorded measurement point on an interior
|
||||
wire; `doc` declares the composite's one-line meaning, required at register —
|
||||
C29; see the authoring guide). See
|
||||
`aura graph introspect --node <T>` for a type's exact ports and the op-script
|
||||
grammar in the design ledger for the full semantics.
|
||||
|
||||
|
||||
@@ -1214,6 +1214,7 @@ mod tests {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,12 @@ impl PositionManagement {
|
||||
.collect();
|
||||
PrimitiveBuilder::new(
|
||||
"PositionManagement",
|
||||
NodeSchema { inputs, output, params: vec![] },
|
||||
NodeSchema {
|
||||
inputs,
|
||||
output,
|
||||
params: vec![],
|
||||
doc: "turns bias plus protective stop into a managed position in R",
|
||||
},
|
||||
|_| Box::new(PositionManagement::new()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ impl SimBroker {
|
||||
],
|
||||
output: vec![FieldSpec { name: "equity".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "frictionless sim-optimal broker: integrates held exposure times price return into cumulative pip equity",
|
||||
},
|
||||
move |_| Box::new(SimBroker::new(pip_size)),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"surface": "campaign_heavy",
|
||||
"metrics": {
|
||||
"cpu_percent": 2116.0,
|
||||
"peak_rss_mb": 98.25,
|
||||
"wall_s": 5.604210818
|
||||
"cpu_percent": 2152.0,
|
||||
"peak_rss_mb": 101.3828125,
|
||||
"wall_s": 5.572580056
|
||||
},
|
||||
"fingerprint": "cells=8 c0s0w17 c0s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c0s3b2883 c1s0w17 c1s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c1s3b2883 c2s0w17 c2s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c2s3b2883 c3s0w17 c3s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c3s3b2883 c4s0w17 c4s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c4s3b2883 c5s0w17 c5s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c5s3b2883 c6s0w17 c6s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c6s3b2883 c7s0w17 c7s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c7s3b2883",
|
||||
"reps": 3,
|
||||
@@ -12,6 +12,6 @@
|
||||
"nproc": 24
|
||||
},
|
||||
"profile": "release",
|
||||
"commit": "d62dede",
|
||||
"date": "2026-07-17"
|
||||
"commit": "9c7f60b",
|
||||
"date": "2026-07-23"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"surface": "campaign_sweep",
|
||||
"metrics": {
|
||||
"cpu_percent": 2170.0,
|
||||
"peak_rss_mb": 98.21875,
|
||||
"wall_s": 1.418092216
|
||||
"cpu_percent": 2190.0,
|
||||
"peak_rss_mb": 92.203125,
|
||||
"wall_s": 1.394470036
|
||||
},
|
||||
"fingerprint": "cells=8 c0s0w17 c1s0w17 c2s0w17 c3s0w17 c4s0w17 c5s0w17 c6s0w17 c7s0w17",
|
||||
"reps": 3,
|
||||
@@ -12,6 +12,6 @@
|
||||
"nproc": 24
|
||||
},
|
||||
"profile": "release",
|
||||
"commit": "d62dede",
|
||||
"date": "2026-07-17"
|
||||
"commit": "9c7f60b",
|
||||
"date": "2026-07-23"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"surface": "cli_fixed_cost",
|
||||
"metrics": {
|
||||
"help_ms": 1.5094509999999999,
|
||||
"run_ms": 3.679743
|
||||
"help_ms": 1.5046899999999999,
|
||||
"run_ms": 3.63305
|
||||
},
|
||||
"fingerprint": "run_line_fnv=792dc4434f8e3dee",
|
||||
"fingerprint": "run_line_fnv=6bb0d796f760d140",
|
||||
"reps": 3,
|
||||
"host": {
|
||||
"hostname": "Raki",
|
||||
"nproc": 24
|
||||
},
|
||||
"profile": "release",
|
||||
"commit": "13d8500",
|
||||
"date": "2026-07-17"
|
||||
"commit": "9c7f60b",
|
||||
"date": "2026-07-23"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"surface": "engine_throughput",
|
||||
"metrics": {
|
||||
"bars_per_s": 13591474.080269944,
|
||||
"wall_s": 0.735755367
|
||||
"bars_per_s": 14005588.039151624,
|
||||
"wall_s": 0.714000724
|
||||
},
|
||||
"fingerprint": "last=0.417351 max_dd=8.274577 flips=265308 n=10000000",
|
||||
"reps": 3,
|
||||
@@ -11,6 +11,6 @@
|
||||
"nproc": 24
|
||||
},
|
||||
"profile": "release",
|
||||
"commit": "d62dede",
|
||||
"date": "2026-07-17"
|
||||
"commit": "9c7f60b",
|
||||
"date": "2026-07-23"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"surface": "ingest_throughput",
|
||||
"metrics": {
|
||||
"bars_per_s": 12912289.748572402,
|
||||
"wall_s": 0.388838858
|
||||
"bars_per_s": 13000457.745164338,
|
||||
"wall_s": 0.386201786
|
||||
},
|
||||
"fingerprint": "n=251040 xor=0019885debd3bd5e",
|
||||
"reps": 3,
|
||||
@@ -11,6 +11,6 @@
|
||||
"nproc": 24
|
||||
},
|
||||
"profile": "release",
|
||||
"commit": "d62dede",
|
||||
"date": "2026-07-17"
|
||||
"commit": "9c7f60b",
|
||||
"date": "2026-07-23"
|
||||
}
|
||||
|
||||
@@ -67,8 +67,8 @@ pub const HEAVY_PROCESS_DOC_QUICK: &str = r#"{
|
||||
/// `crates/aura-cli/examples/r_sma.json` except the second variant's bound
|
||||
/// lengths (3/6 instead of 2/4), which gives it a distinct content id — two
|
||||
/// strategies, same axes surface.
|
||||
pub const BP_SMA_A: &str = r#"{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
pub const BP_SMA_B: &str = r#"{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":6}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
pub const BP_SMA_A: &str = r#"{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
pub const BP_SMA_B: &str = r#"{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":6}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
|
||||
fn run_in(bin: &Path, dir: &Path, args: &[&str]) -> Result<(String, Option<i32>), String> {
|
||||
let out = std::process::Command::new(bin)
|
||||
|
||||
@@ -830,6 +830,19 @@ fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
|
||||
.unwrap_or_else(|| "member panicked (non-string payload)".to_string())
|
||||
}
|
||||
|
||||
/// The `SilencedPanic` + `catch_unwind` + `panic_message` recipe above
|
||||
/// (this module's three call sites), exposed for reuse by a member-run caller
|
||||
/// OUTSIDE this crate that drives a bare member run without going through
|
||||
/// `execute` (#278: the synthetic blueprint sweep/walk-forward family
|
||||
/// builders in `aura-runner`, which share this exact bare-member seam but
|
||||
/// carry no #272 fault boundary of their own). Returns the panic payload's
|
||||
/// best-effort message on containment; `AssertUnwindSafe` is the same
|
||||
/// judgement documented on `panic_message` above.
|
||||
pub fn catch_member_panic<R>(f: impl FnOnce() -> R) -> Result<R, String> {
|
||||
let _silence = SilencedPanic::enter();
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(panic_message)
|
||||
}
|
||||
|
||||
/// Assemble the (CellOutcome, CellRealization) pair for a cell that failed at a
|
||||
/// stage (#272): the realized stage prefix survives in `stages`, `families`/
|
||||
/// `selections` carry whatever already-persisted stages produced (empty for a
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
|
||||
mod exec;
|
||||
pub use exec::{
|
||||
execute, member_fault_prose, CampaignOutcome, CellOutcome, StageFamily, StageSelectionOut,
|
||||
catch_member_panic, execute, member_fault_prose, CampaignOutcome, CellOutcome, StageFamily,
|
||||
StageSelectionOut,
|
||||
};
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -85,7 +85,7 @@ fn metric_vocabulary_covers_all_shipped_scalar_metrics() {
|
||||
scalar_fields.extend(selection_scalars);
|
||||
|
||||
let vocabulary: BTreeSet<String> =
|
||||
aura_research::metric_vocabulary().iter().map(|s| s.to_string()).collect();
|
||||
aura_research::metric_vocabulary().iter().map(|m| m.id.to_string()).collect();
|
||||
|
||||
let missing_from_vocabulary: Vec<_> = scalar_fields.difference(&vocabulary).collect();
|
||||
let stale_in_vocabulary: Vec<_> = vocabulary.difference(&scalar_fields).collect();
|
||||
@@ -128,7 +128,7 @@ fn rankable_roster_is_single_sourced_and_nested() {
|
||||
}
|
||||
for name in aura_campaign::PER_MEMBER_METRICS {
|
||||
assert!(
|
||||
aura_research::metric_vocabulary().contains(name),
|
||||
aura_research::metric_vocabulary().iter().any(|m| m.id == *name),
|
||||
"per-member metric '{name}' missing from the research vocabulary"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"r_breakout_signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}}
|
||||
{"format_version":1,"blueprint":{"name":"r_breakout_signal","doc":"rolling-extreme breakout latched into a long/short signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}}
|
||||
@@ -1 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"hl_channel","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}]}}
|
||||
{"format_version":1,"blueprint":{"name":"hl_channel","doc":"prior high/low channel breaks latched into a directional signal","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}]}}
|
||||
@@ -1 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band","bound":[{"pos":0,"name":"factor","kind":"F64","value":{"F64":2.0}}]}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}]}}
|
||||
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","doc":"EMA deviation against a volatility band, latched into a mean-reversion signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band","bound":[{"pos":0,"name":"factor","kind":"F64","value":{"F64":2.0}}]}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}]}}
|
||||
@@ -1 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
|
||||
{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
|
||||
@@ -315,8 +315,8 @@ fn present_campaign(
|
||||
for cell in &outcome.record.cells {
|
||||
for (stage_ix, st) in cell.stages.iter().enumerate() {
|
||||
if matches!(&st.survivor_ordinals, Some(v) if v.is_empty()) {
|
||||
eprintln!(
|
||||
"aura: cell {}/{}/[{}, {}]: gate at stage {stage_ix} left no \
|
||||
crate::diag::note!(
|
||||
"cell {}/{}/[{}, {}]: gate at stage {stage_ix} left no \
|
||||
survivors; cell realization truncated",
|
||||
cell.strategy, cell.instrument, cell.window_ms.0, cell.window_ms.1
|
||||
);
|
||||
@@ -333,8 +333,8 @@ fn present_campaign(
|
||||
if let Some(f) = &cell.fault {
|
||||
failed += 1;
|
||||
fail_labels.push(format!("{}: {}", cell.instrument, cell_fault_kind_label(f.kind)));
|
||||
eprintln!(
|
||||
"aura: cell ({}, {}, [{}, {}]) failed at stage {}: {} — recorded, campaign continues",
|
||||
crate::diag::warning!(
|
||||
"cell ({}, {}, [{}, {}]) failed at stage {}: {} — recorded, campaign continues",
|
||||
cell.strategy, cell.instrument, cell.window_ms.0, cell.window_ms.1, f.stage, f.detail
|
||||
);
|
||||
}
|
||||
@@ -388,7 +388,7 @@ fn present_campaign(
|
||||
.expect("campaign run record serializes")
|
||||
);
|
||||
eprintln!(
|
||||
"campaign run {} recorded: {} cells{}",
|
||||
"aura: campaign run {} recorded: {} cells{}",
|
||||
outcome.record.run,
|
||||
outcome.record.cells.len(),
|
||||
if failed == 0 {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Stable stderr class markers (C14): `aura: note: <text>` for benign
|
||||
//! diagnostics on a continuing run (exit code unaffected),
|
||||
//! `aura: warning: <text>` for recorded faults the run survives.
|
||||
//! Error lines that accompany a non-zero exit — and plain info lines
|
||||
//! such as the run-record summary — keep the bare `aura:` prefix; for
|
||||
//! errors, the exit-code partition is the machine contract.
|
||||
|
||||
macro_rules! note {
|
||||
($($arg:tt)*) => {
|
||||
eprintln!("aura: note: {}", format_args!($($arg)*))
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! warning {
|
||||
($($arg:tt)*) => {
|
||||
eprintln!("aura: warning: {}", format_args!($($arg)*))
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use {note, warning};
|
||||
|
||||
/// The zero-trade note's message text (#313), pluralization-aware — the
|
||||
/// milestone fieldtest tripped over "all 1 walk-forward windows"
|
||||
/// (captured: `fieldtests/milestone-stderr-honesty/captured/
|
||||
/// example2_singular.err`). Pure over the count so the wording is
|
||||
/// unit-pinned.
|
||||
fn zero_trade_note_text(n_windows: usize) -> String {
|
||||
if n_windows == 1 {
|
||||
"the single walk-forward window recorded zero trades".to_string()
|
||||
} else {
|
||||
format!("all {n_windows} walk-forward windows recorded zero trades")
|
||||
}
|
||||
}
|
||||
|
||||
/// The #313 zero-trade note, shared by both walk-forward paths (the
|
||||
/// synthetic-blueprint path in `main.rs` and the `--real` sugar path in
|
||||
/// `verb_sugar.rs`) so the wording AND the condition live in exactly one
|
||||
/// place. Takes the per-window trade counts; a no-op unless there is at
|
||||
/// least one window and every one of them traded zero times.
|
||||
pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator<Item = u64>) {
|
||||
let n_windows = window_trades.len();
|
||||
if n_windows > 0 && window_trades.all(|n| n == 0) {
|
||||
note!("{}", zero_trade_note_text(n_windows));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::zero_trade_note_text;
|
||||
|
||||
#[test]
|
||||
/// #278 fieldtest friction: the single-window form must read as
|
||||
/// grammatical English while the plural form keeps the exact phrase
|
||||
/// the e2e pins grep for.
|
||||
fn zero_trade_note_pluralizes() {
|
||||
assert_eq!(
|
||||
zero_trade_note_text(1),
|
||||
"the single walk-forward window recorded zero trades"
|
||||
);
|
||||
assert_eq!(
|
||||
zero_trade_note_text(3),
|
||||
"all 3 walk-forward windows recorded zero trades"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,9 @@ enum OpDoc {
|
||||
as_name: String,
|
||||
into: Vec<String>,
|
||||
},
|
||||
/// Declare the composite's one-line meaning (C29, #316) — the op-script
|
||||
/// twin of the builder's `.doc(...)`.
|
||||
Doc { text: String },
|
||||
}
|
||||
|
||||
impl OpDoc {
|
||||
@@ -68,6 +71,7 @@ impl OpDoc {
|
||||
OpDoc::Expose { .. } => "expose",
|
||||
OpDoc::Tap { .. } => "tap",
|
||||
OpDoc::Gang { .. } => "gang",
|
||||
OpDoc::Doc { .. } => "doc",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +89,7 @@ impl From<OpDoc> for Op {
|
||||
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
|
||||
OpDoc::Tap { from, as_name } => Op::Tap { from, as_name },
|
||||
OpDoc::Gang { as_name, into } => Op::Gang { as_name, into },
|
||||
OpDoc::Doc { text } => Op::Doc { text },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,6 +134,7 @@ fn format_op_error(e: &OpError) -> String {
|
||||
}
|
||||
OpError::GangArity { gang } => format!("gang `{gang}`: needs at least two members"),
|
||||
OpError::Incomplete(ce) => format!("{ce:?}"),
|
||||
OpError::DuplicateDoc => "a doc op may appear at most once".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! (or the `--strategy r-sma` sugar) and print canonical JSON metrics/manifests;
|
||||
//! this binary authors no built-in harness.
|
||||
|
||||
mod diag;
|
||||
mod render;
|
||||
mod graph_construct;
|
||||
mod campaign_run;
|
||||
@@ -45,6 +46,7 @@ use aura_measurement::information_coefficient;
|
||||
// (a sibling module reaching it via `crate::blueprint_axis_probe_reopened`,
|
||||
// the crate-root re-export this `use` gives it), not from this module itself.
|
||||
use aura_runner::member::{blueprint_axis_probe, blueprint_axis_probe_reopened, run_signal_r, wrapped_bound_names, RunData};
|
||||
use aura_runner::TapPlan;
|
||||
#[cfg(test)]
|
||||
use aura_runner::member::{run_blueprint_member, wrap_r, SYNTHETIC_PIP_SIZE};
|
||||
// The family builders (blueprint sweep / walk-forward / MC), the shared
|
||||
@@ -915,6 +917,12 @@ fn run_blueprint_walkforward(
|
||||
for w in &result.windows {
|
||||
println!("{}", family_member_line(&id, &w.run.oos_report));
|
||||
}
|
||||
crate::diag::note_zero_trade_windows(
|
||||
result
|
||||
.windows
|
||||
.iter()
|
||||
.map(|w| w.run.oos_report.metrics.r.as_ref().map_or(0, |m| m.n_trades)),
|
||||
);
|
||||
println!("{}", walkforward_summary_json(&result));
|
||||
}
|
||||
|
||||
@@ -1765,7 +1773,7 @@ fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
|
||||
None => Vec::new(),
|
||||
};
|
||||
let data = run_data_from(a.real.as_deref(), a.from, a.to);
|
||||
let report = run_signal_r(signal, ¶ms, data, a.seed.unwrap_or(0), env);
|
||||
let report = run_signal_r(signal, ¶ms, data, a.seed.unwrap_or(0), env, TapPlan::record_all());
|
||||
println!("{}", report.to_json());
|
||||
} else if has_tap {
|
||||
// Measurement path: wrap_r-free closed guard via the signal's own
|
||||
@@ -1786,7 +1794,7 @@ fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
|
||||
None => Vec::new(),
|
||||
};
|
||||
let data = run_data_from(a.real.as_deref(), a.from, a.to);
|
||||
let report = run_measurement(signal, ¶ms, data, a.seed.unwrap_or(0), env);
|
||||
let report = run_measurement(signal, ¶ms, data, a.seed.unwrap_or(0), env, TapPlan::record_all());
|
||||
println!("{}", report.to_json());
|
||||
} else {
|
||||
eprintln!(
|
||||
@@ -3099,6 +3107,7 @@ mod tests {
|
||||
RunData::Synthetic,
|
||||
0,
|
||||
&env,
|
||||
TapPlan::record_all(),
|
||||
);
|
||||
let member4 = &family.points[0].report; // slow=4 is the first odometer point
|
||||
assert_eq!(member4.metrics, single.metrics, "loaded sweep member == single run");
|
||||
|
||||
@@ -92,9 +92,10 @@ fn guard_one_mode(cmd: &DocIntrospectCmd, family: &str) {
|
||||
/// vocabulary, but the mode rides the shared introspect struct and answers
|
||||
/// for both families.
|
||||
fn print_metric_roster() {
|
||||
for name in aura_research::metric_vocabulary() {
|
||||
let rankable = aura_campaign::RANKABLE_METRICS.contains(name);
|
||||
let gate = aura_campaign::PER_MEMBER_METRICS.contains(name);
|
||||
for m in aura_research::metric_vocabulary() {
|
||||
let name = m.id;
|
||||
let rankable = aura_campaign::RANKABLE_METRICS.contains(&name);
|
||||
let gate = aura_campaign::PER_MEMBER_METRICS.contains(&name);
|
||||
// The generalize applicability is the registry's own R-expectancy
|
||||
// predicate — no fourth roster site (#190/#207).
|
||||
let generalize = aura_registry::check_r_metric(name).is_ok();
|
||||
@@ -181,7 +182,7 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
|
||||
DocFault::UnknownEmitKind(kind) => format!("presentation.emit: unknown kind \"{kind}\""),
|
||||
DocFault::UnknownTap { index, tap } => format!(
|
||||
"presentation.persist_taps[{index}]: unknown tap \"{tap}\" (taps: {})",
|
||||
tap_vocabulary().join(" | ")
|
||||
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
|
||||
),
|
||||
DocFault::UnknownBindingColumn { role, column } => format!(
|
||||
"data.bindings.{role}: \"{column}\" names no archive column (columns: {})",
|
||||
@@ -190,6 +191,16 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
|
||||
DocFault::ProcessRefMustBeContentId => {
|
||||
"process.ref: a process is referenced by content id in this version".into()
|
||||
}
|
||||
DocFault::BadDescription { subject, fault } => match fault {
|
||||
aura_core::DocGateFault::Empty => format!(
|
||||
"description: `{subject}`'s description is empty — omit the \
|
||||
field or write a one-line meaning (C29)"
|
||||
),
|
||||
aura_core::DocGateFault::RestatesName => format!(
|
||||
"description: `{subject}`'s description merely restates the \
|
||||
document name (C29)"
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,6 +827,35 @@ mod tests {
|
||||
assert!(!prose.contains("UnknownTap"), "Debug leak: {prose}");
|
||||
}
|
||||
|
||||
/// C29 (#316): the description-quality fault names the subject and the
|
||||
/// rule (C29) for both sub-faults — an empty description and one that
|
||||
/// merely restates the document name — never leaking the Debug variant
|
||||
/// names of either `DocFault` or the nested `DocGateFault`.
|
||||
#[test]
|
||||
fn bad_description_prose_names_subject_and_rule_for_both_faults() {
|
||||
let empty = doc_fault_prose(&DocFault::BadDescription {
|
||||
subject: "ger40-momentum".into(),
|
||||
fault: aura_core::DocGateFault::Empty,
|
||||
});
|
||||
assert!(
|
||||
empty.contains("`ger40-momentum`'s description is empty"),
|
||||
"subject and emptiness named: {empty}"
|
||||
);
|
||||
assert!(empty.contains("C29"), "rule cited: {empty}");
|
||||
assert!(!empty.contains("BadDescription") && !empty.contains("DocGateFault"), "Debug leak: {empty}");
|
||||
|
||||
let restated = doc_fault_prose(&DocFault::BadDescription {
|
||||
subject: "ger40-momentum".into(),
|
||||
fault: aura_core::DocGateFault::RestatesName,
|
||||
});
|
||||
assert!(
|
||||
restated.contains("`ger40-momentum`'s description merely restates the document name"),
|
||||
"subject and restatement named: {restated}"
|
||||
);
|
||||
assert!(restated.contains("C29"), "rule cited: {restated}");
|
||||
assert!(!restated.contains("BadDescription") && !restated.contains("DocGateFault"), "Debug leak: {restated}");
|
||||
}
|
||||
|
||||
/// #260: the instrument-map key-mismatch fault is path-addressed and names
|
||||
/// both directions — the campaign instruments the map misses AND the map
|
||||
/// keys naming no campaign instrument — in one line, no Debug leak. The
|
||||
|
||||
@@ -152,6 +152,8 @@ impl Scale {
|
||||
pub fn new(factor: f64) -> Self {
|
||||
Self { factor, out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
// Replace `doc` below when renaming this sample node — it must keep
|
||||
// describing the node's own behaviour, not this scaffolding step.
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"__NS__::Scale",
|
||||
@@ -163,6 +165,7 @@ impl Scale {
|
||||
}],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }],
|
||||
doc: "scalar gain: emits the input times the factor param",
|
||||
},
|
||||
|p| Box::new(Scale::new(p[0].f64())),
|
||||
)
|
||||
@@ -219,7 +222,7 @@ crate is attached to its project via `[nodes]` in the project's `Aura.toml`.
|
||||
|
||||
const GITIGNORE_PROJECT: &str = "/runs\n";
|
||||
|
||||
const SIGNAL_JSON_STD: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
const SIGNAL_JSON_STD: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
|
||||
const CLAUDE_MD_PROJECT: &str = r#"# __NAME__ — an aura research project (data-only)
|
||||
|
||||
@@ -280,7 +283,7 @@ pub fn scaffold_project(spec: &ProjectScaffoldSpec) -> Result<(), String> {
|
||||
Ok(o) if o.status.success() => {
|
||||
commit_all(&spec.target_dir, "aura new scaffold");
|
||||
}
|
||||
_ => eprintln!("aura: warning: git init failed (project created without a repo)"),
|
||||
_ => crate::diag::warning!("git init failed (project created without a repo)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -321,7 +324,7 @@ pub fn scaffold_node_crate(spec: &ScaffoldSpec) -> Result<(), String> {
|
||||
Ok(o) if o.status.success() => {
|
||||
commit_all(&spec.target_dir, "aura nodes new scaffold");
|
||||
}
|
||||
_ => eprintln!("aura: warning: git init failed (node crate created without a repo)"),
|
||||
_ => crate::diag::warning!("git init failed (node crate created without a repo)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -354,7 +357,7 @@ fn commit_all(dir: &std::path::Path, message: &str) {
|
||||
.current_dir(dir)
|
||||
.output();
|
||||
if !matches!(add, Ok(o) if o.status.success()) {
|
||||
eprintln!("aura: warning: git add failed (scaffold created without an initial commit)");
|
||||
crate::diag::warning!("git add failed (scaffold created without an initial commit)");
|
||||
return;
|
||||
}
|
||||
let commit = std::process::Command::new("git")
|
||||
@@ -371,8 +374,8 @@ fn commit_all(dir: &std::path::Path, message: &str) {
|
||||
.current_dir(dir)
|
||||
.output();
|
||||
if !matches!(commit, Ok(o) if o.status.success()) {
|
||||
eprintln!(
|
||||
"aura: warning: git commit failed (scaffold created without an initial commit)"
|
||||
crate::diag::warning!(
|
||||
"git commit failed (scaffold created without an initial commit)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +60,9 @@ fn persist_taps_from(trace: bool) -> Vec<String> {
|
||||
tap_vocabulary()
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
aura_runner::runner::tap_channel(t) != Some(aura_runner::runner::TapChannel::Net)
|
||||
aura_runner::runner::tap_channel(t.id) != Some(aura_runner::runner::TapChannel::Net)
|
||||
})
|
||||
.map(|t| t.to_string())
|
||||
.map(|t| t.id.to_string())
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
@@ -526,7 +526,7 @@ pub(crate) fn run_walkforward_sugar(
|
||||
)?;
|
||||
|
||||
if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) {
|
||||
eprintln!("aura: walkforward cell failed at stage {}: {}", f.stage, f.detail);
|
||||
crate::diag::warning!("walkforward cell failed at stage {}: {}", f.stage, f.detail);
|
||||
return Ok(1);
|
||||
}
|
||||
|
||||
@@ -549,6 +549,9 @@ pub(crate) fn run_walkforward_sugar(
|
||||
summary_axes.push("stop_length".to_string());
|
||||
summary_axes.push("stop_k".to_string());
|
||||
}
|
||||
crate::diag::note_zero_trade_windows(
|
||||
wf.reports.iter().map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades)),
|
||||
);
|
||||
println!("{}", crate::walkforward_summary_json_from_reports(&wf.reports, &summary_axes));
|
||||
|
||||
// Trace persistence runs LAST (mirroring `present_campaign`'s ordering,
|
||||
@@ -690,7 +693,7 @@ pub(crate) fn run_mc_sugar(
|
||||
)?;
|
||||
|
||||
if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) {
|
||||
eprintln!("aura: mc cell failed at stage {}: {}", f.stage, f.detail);
|
||||
crate::diag::warning!("mc cell failed at stage {}: {}", f.stage, f.detail);
|
||||
return Ok(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -2182,7 +2182,7 @@ const HL_RANGE_CLOSED_BLUEPRINT: &str = r#"{
|
||||
"output": [{"node":0,"field":0,"name":"bias"}]
|
||||
}
|
||||
}"#;
|
||||
const HL_SIGNAL_OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"hl_signal","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
const HL_SIGNAL_OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"hl_signal","doc":"high/low SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
|
||||
/// Property (#231 task 4 quality follow-up): `aura run` over a multi-column
|
||||
/// blueprint with NO `--real` (the synthetic default) refuses through the
|
||||
@@ -4782,6 +4782,234 @@ fn aura_walkforward_over_a_blueprint_persists_a_family() {
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// E2E (#313, Path 2 — synthetic): a walk-forward whose every window records
|
||||
/// zero trades emits exactly one `aura: note: all N walk-forward windows
|
||||
/// recorded zero trades` on stderr; exit stays 0 and the summary JSON is
|
||||
/// unchanged (a null result is a valid research result, #198). Equal
|
||||
/// fast/slow lengths make the SMA difference constantly zero, so the bias
|
||||
/// never leaves 0 and no window trades.
|
||||
#[test]
|
||||
fn aura_walkforward_all_zero_trade_windows_emit_one_note() {
|
||||
let cwd = temp_cwd("blueprint-walkforward-zero-trade-note");
|
||||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["walkforward", &bp, "--axis", "sma_signal.fast.length=8",
|
||||
"--axis", "sma_signal.slow.length=8", "--name", "wfzero"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura walkforward (degenerate)");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
assert_eq!(out.status.code(), Some(0), "exit stays 0: {stderr}");
|
||||
assert!(stdout.contains("\"walkforward\""), "summary line unchanged: {stdout}");
|
||||
assert_eq!(
|
||||
stderr.matches("walk-forward windows recorded zero trades").count(),
|
||||
1,
|
||||
"exactly one zero-trade note: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("aura: note: all "),
|
||||
"the note carries the class marker: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("aura: warning: "),
|
||||
"a benign degenerate run emits no warning lines: {stderr}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// E2E (#313 negative twin): a walk-forward with at least one traded window
|
||||
/// emits no zero-trade note.
|
||||
#[test]
|
||||
fn aura_walkforward_with_trades_emits_no_zero_trade_note() {
|
||||
let cwd = temp_cwd("blueprint-walkforward-traded-no-note");
|
||||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["walkforward", &bp, "--axis", "sma_signal.fast.length=2,3",
|
||||
"--axis", "sma_signal.slow.length=4,6", "--name", "wftrades"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura walkforward (trading)");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert_eq!(out.status.code(), Some(0), "exit 0: {stderr}");
|
||||
assert!(
|
||||
!stderr.contains("recorded zero trades"),
|
||||
"a traded run emits no zero-trade note: {stderr}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// E2E (#278 decision: same fault class, same verb, same surface => same
|
||||
/// treatment): a member fault on the SYNTHETIC walk-forward path is CONTAINED
|
||||
/// exactly like the real/campaign path contains it (#272) — it surfaces as an
|
||||
/// `aura: warning: ` class-marked stderr line naming the member fault and the
|
||||
/// process takes the deliberate failed-cells exit 3 (C14 partition,
|
||||
/// `exit_on_campaign_result` precedent). It never escapes as an uncaught Rust
|
||||
/// panic: no exit 101, no `panicked at`, no internal `crates/` source path on
|
||||
/// the consumer's stderr.
|
||||
#[test]
|
||||
fn aura_walkforward_synthetic_member_panic_is_contained() {
|
||||
let cwd = temp_cwd("blueprint-walkforward-member-panic-contained");
|
||||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
// length=0 poisons the member: `Sma::new` asserts "SMA length must be >= 1"
|
||||
// during member compile — the same fault class the real path records as
|
||||
// "a member panicked: …" and survives.
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["walkforward", &bp, "--axis", "sma_signal.fast.length=0", "--name", "wfpanic"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura walkforward (poison member)");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(
|
||||
stderr.lines().any(|l| l.contains("aura: warning: ") && l.contains("SMA length must be >= 1")),
|
||||
"the member fault surfaces as one class-marked warning naming the fault: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("panicked at"),
|
||||
"no raw panic report reaches the consumer: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("crates/"),
|
||||
"no internal source path leaks to the consumer: {stderr}"
|
||||
);
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(3),
|
||||
"a contained member fault is the deliberate failed-cells exit 3, never 101: {stderr}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// E2E (#278 decision, sweep twin): the synthetic `aura sweep` shares the
|
||||
/// same bare-member seam (`run_blueprint_member` inside the family builders'
|
||||
/// sweep closures, no #272 fault boundary), so the same poison must be
|
||||
/// contained the same way — warning + exit 3, no panic escape.
|
||||
#[test]
|
||||
fn aura_sweep_synthetic_member_panic_is_contained() {
|
||||
let cwd = temp_cwd("blueprint-sweep-member-panic-contained");
|
||||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["sweep", &bp, "--axis", "sma_signal.fast.length=0", "--name", "swpanic"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura sweep (poison member)");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(
|
||||
stderr.lines().any(|l| l.contains("aura: warning: ") && l.contains("SMA length must be >= 1")),
|
||||
"the member fault surfaces as one class-marked warning naming the fault: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("panicked at") && !stderr.contains("crates/"),
|
||||
"no raw panic report or internal source path leaks: {stderr}"
|
||||
);
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(3),
|
||||
"a contained member fault is the deliberate failed-cells exit 3, never 101: {stderr}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// E2E (#278 decision, mc twin): the synthetic `aura mc` shares the same
|
||||
/// bare-member seam as its sweep/walkforward siblings (`run_blueprint_member`
|
||||
/// inside `blueprint_mc_family`'s per-seed draw closure, no #272 fault
|
||||
/// boundary), so the same member-compile fault class must be contained the
|
||||
/// same way — one `aura: warning: ` line naming the fault, no raw panic text
|
||||
/// or internal `crates/` path on stderr, deliberate exit 3 (C14), never 101.
|
||||
/// mc takes only CLOSED blueprints, so the poison is BOUND (`length=0` on the
|
||||
/// SMA add-op) rather than a poison axis: the test builds its minimal 4-op
|
||||
/// poison blueprint inline via `aura graph build`. Two seeds so the parallel
|
||||
/// draws join before the lowest-seed fault resolves (the shipped sweep
|
||||
/// contract's thread-order-independent shape, C1).
|
||||
#[test]
|
||||
fn aura_mc_synthetic_member_panic_is_contained() {
|
||||
let cwd = temp_cwd("blueprint-mc-member-panic-contained");
|
||||
// Minimal poison: each op is load-bearing (source: the close role; add+bind:
|
||||
// the length=0 that trips `Sma::new`'s "SMA length must be >= 1" assert at
|
||||
// member compile; feed: wiring so build validation passes; expose: the bias
|
||||
// contract). Bound length keeps the blueprint CLOSED, as `aura mc` requires.
|
||||
let ops = r#"[
|
||||
{"op":"source","role":"close","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"sma","bind":{"length":{"I64":0}}},
|
||||
{"op":"feed","role":"close","into":["sma.series"]},
|
||||
{"op":"expose","from":"sma.value","as":"bias"}
|
||||
]"#;
|
||||
let ops_path = cwd.join("poison.ops.json");
|
||||
std::fs::write(&ops_path, ops).expect("write poison op-list");
|
||||
let bp_path = cwd.join("poison.bp.json");
|
||||
let build = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["graph", "build"])
|
||||
.stdin(std::fs::File::open(&ops_path).expect("open poison op-list"))
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura graph build (poison blueprint)");
|
||||
assert_eq!(
|
||||
build.status.code(),
|
||||
Some(0),
|
||||
"poison blueprint builds (the fault is a member-COMPILE fault, not a build refusal): {}",
|
||||
String::from_utf8_lossy(&build.stderr)
|
||||
);
|
||||
std::fs::write(&bp_path, &build.stdout).expect("write poison blueprint");
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["mc", bp_path.to_str().expect("utf-8 path"), "--seeds", "2"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura mc (poison member)");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(
|
||||
stderr.lines().any(|l| l.contains("aura: warning: ") && l.contains("SMA length must be >= 1")),
|
||||
"the member fault surfaces as one class-marked warning naming the fault: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("panicked at") && !stderr.contains("crates/"),
|
||||
"no raw panic report or internal source path leaks: {stderr}"
|
||||
);
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(3),
|
||||
"a contained member fault is the deliberate failed-cells exit 3, never 101: {stderr}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// E2E (#313, Path 1 — the real/sugar walk-forward): the degenerate
|
||||
/// equal-length config over the shared GER40 archive emits the same
|
||||
/// zero-trade note. Gated on the archive; skips cleanly on a data refusal.
|
||||
#[test]
|
||||
fn walkforward_real_all_zero_trade_windows_emit_the_note() {
|
||||
const FROM_MS: &str = "1735689600000";
|
||||
const TO_MS: &str = "1767225599000";
|
||||
let (cwd, _g) = fresh_project();
|
||||
let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args([
|
||||
"walkforward", &fixture, "--real", "GER40",
|
||||
"--axis", "sma_signal.fast.length=5", "--axis", "sma_signal.slow.length=5",
|
||||
"--stop-length", "14", "--stop-k", "2.0",
|
||||
"--from", FROM_MS, "--to", TO_MS,
|
||||
])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura walkforward (real degenerate)");
|
||||
if out.status.code() == Some(1) {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("no local data") || stderr.contains("no recorded geometry"),
|
||||
"exit 1 must be a data refusal, got: {stderr}"
|
||||
);
|
||||
eprintln!("skip: no local GER40 data");
|
||||
return;
|
||||
}
|
||||
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert_eq!(
|
||||
stderr.matches("walk-forward windows recorded zero trades").count(),
|
||||
1,
|
||||
"exactly one zero-trade note on the real path: {stderr}"
|
||||
);
|
||||
assert!(stderr.contains("aura: note: all "), "class marker: {stderr}");
|
||||
}
|
||||
|
||||
/// E2E (#173): a CLOSED blueprint with no --axis (nothing to re-fit) is a usage
|
||||
/// error (exit 2), naming that >= 1 --axis is required.
|
||||
#[test]
|
||||
|
||||
@@ -26,6 +26,7 @@ impl Identity {
|
||||
}],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "one-input f64 pass-through",
|
||||
},
|
||||
|_| Box::new(Identity::new()),
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ impl Identity {
|
||||
}],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "one-input f64 pass-through",
|
||||
},
|
||||
|_| Box::new(Identity::new()),
|
||||
)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"r_breakout_signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":1,"pos":0,"name":"length"},{"node":2,"pos":0,"name":"length"}]}]}}
|
||||
{"format_version":1,"blueprint":{"name":"r_breakout_signal","doc":"rolling-extreme breakout latched into a long/short signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":1,"pos":0,"name":"length"},{"node":2,"pos":0,"name":"length"}]}]}}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"hl_channel","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":2,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
|
||||
{"format_version":1,"blueprint":{"name":"hl_channel","doc":"prior high/low channel breaks latched into a directional signal","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":2,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window"}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band"}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}],"gangs":[{"name":"window","kind":"I64","members":[{"node":0,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
|
||||
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","doc":"EMA deviation against a volatility band, latched into a mean-reversion signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window"}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band"}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}],"gangs":[{"name":"window","kind":"I64","members":[{"node":0,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
|
||||
{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
|
||||
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
/runs
|
||||
@@ -0,0 +1 @@
|
||||
# restated fixture — static project context only (C17); paths only (cycle 0102).
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "restated-project"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
aura-core = { path = "../../../../aura-core" }
|
||||
|
||||
# Standalone workspace root: never a member of the engine workspace
|
||||
# (docs/project-layout.md, the empty-[workspace] note).
|
||||
[workspace]
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Fixture project for the C29 load-seam e2e (#316): one fully resolvable
|
||||
//! pass-through node under the `restated` namespace whose `NodeSchema` doc
|
||||
//! merely restates its own type id (`DocGateFault::RestatesName`, the fault
|
||||
//! arm `undescribed-project` does not exercise). The charter is satisfied
|
||||
//! (prefixed id, list/resolver in sync) so the refusal under test is exactly
|
||||
//! the doc gate's name-restatement branch.
|
||||
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder,
|
||||
ScalarKind,
|
||||
};
|
||||
|
||||
/// One-input f64 pass-through whose doc is a no-content restatement of its
|
||||
/// own name.
|
||||
pub struct Echo {
|
||||
out: [Cell; 1],
|
||||
}
|
||||
|
||||
impl Echo {
|
||||
pub fn new() -> Self {
|
||||
Self { out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"restated::Echo",
|
||||
NodeSchema {
|
||||
inputs: vec![PortSpec {
|
||||
kind: ScalarKind::F64,
|
||||
firing: Firing::Any,
|
||||
name: "value".into(),
|
||||
}],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
// The one deliberate violation this fixture exists for: this
|
||||
// norm-equals the type id "restated::Echo" once punctuation
|
||||
// and case are stripped, so it carries no meaning beyond the
|
||||
// name itself.
|
||||
doc: "Restated Echo",
|
||||
},
|
||||
|_| Box::new(Echo::new()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Echo {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for Echo {
|
||||
fn lookbacks(&self) -> Vec<usize> {
|
||||
vec![1]
|
||||
}
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
let w = ctx.f64_in(0);
|
||||
if w.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.out[0] = Cell::from_f64(w[0]);
|
||||
Some(&self.out)
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
"restated::Echo".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
|
||||
match type_id {
|
||||
"restated::Echo" => Some(Echo::builder()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn type_ids() -> &'static [&'static str] {
|
||||
&["restated::Echo"]
|
||||
}
|
||||
|
||||
aura_core::aura_project! {
|
||||
namespace: "restated",
|
||||
vocabulary: vocabulary,
|
||||
type_ids: type_ids,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
/runs
|
||||
@@ -0,0 +1 @@
|
||||
# undescribed fixture — static project context only (C17); paths only (cycle 0102).
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "undescribed-project"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
aura-core = { path = "../../../../aura-core" }
|
||||
|
||||
# Standalone workspace root: never a member of the engine workspace
|
||||
# (docs/project-layout.md, the empty-[workspace] note).
|
||||
[workspace]
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Fixture project for the C29 load-seam e2e (#316): one fully resolvable
|
||||
//! pass-through node under the `und` namespace whose `NodeSchema` carries the
|
||||
//! empty-string alibi `doc: ""` — compile-legal (the field is present), but
|
||||
//! the load gate must refuse it. The charter is satisfied (prefixed id,
|
||||
//! list/resolver in sync) so the refusal under test is exactly the doc gate.
|
||||
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder,
|
||||
ScalarKind,
|
||||
};
|
||||
|
||||
/// One-input f64 pass-through with a deliberately empty doc.
|
||||
pub struct Opaque {
|
||||
out: [Cell; 1],
|
||||
}
|
||||
|
||||
impl Opaque {
|
||||
pub fn new() -> Self {
|
||||
Self { out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"und::Opaque",
|
||||
NodeSchema {
|
||||
inputs: vec![PortSpec {
|
||||
kind: ScalarKind::F64,
|
||||
firing: Firing::Any,
|
||||
name: "value".into(),
|
||||
}],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
// The one deliberate violation this fixture exists for.
|
||||
doc: "",
|
||||
},
|
||||
|_| Box::new(Opaque::new()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Opaque {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for Opaque {
|
||||
fn lookbacks(&self) -> Vec<usize> {
|
||||
vec![1]
|
||||
}
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
let w = ctx.f64_in(0);
|
||||
if w.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.out[0] = Cell::from_f64(w[0]);
|
||||
Some(&self.out)
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
"und::Opaque".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
|
||||
match type_id {
|
||||
"und::Opaque" => Some(Opaque::builder()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn type_ids() -> &'static [&'static str] {
|
||||
&["und::Opaque"]
|
||||
}
|
||||
|
||||
aura_core::aura_project! {
|
||||
namespace: "und",
|
||||
vocabulary: vocabulary,
|
||||
type_ids: type_ids,
|
||||
}
|
||||
@@ -54,6 +54,40 @@ const SIGNAL_DOC: &str = r#"[
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
|
||||
/// SIGNAL_DOC with the C29 doc op — the register-reaching variant (a store
|
||||
/// write requires a described composite; build-only tests keep SIGNAL_DOC).
|
||||
const SIGNAL_DOC_DESCRIBED: &str = r#"[
|
||||
{"op":"doc","text":"fast/slow SMA difference clamped into a directional bias"},
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"add","type":"Bias"},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"},
|
||||
{"op":"connect","from":"slow.value","to":"sub.rhs"},
|
||||
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
|
||||
/// SIGNAL_DOC with a `doc` op whose text merely restates the op-script
|
||||
/// composite's name ("graph", the literal `replay("graph", ..)` gives every
|
||||
/// CLI-built op-script) -- the RestatesName arm of the C29 shape gate,
|
||||
/// reached via the op-script route rather than a builder-authored blueprint.
|
||||
const SIGNAL_DOC_NAME_RESTATED: &str = r#"[
|
||||
{"op":"doc","text":"Graph"},
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"add","type":"Bias"},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"},
|
||||
{"op":"connect","from":"slow.value","to":"sub.rhs"},
|
||||
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
|
||||
/// Every op below applies cleanly (all eager checks pass), but `sub.rhs` is
|
||||
/// never wired — a 0-cover input slot only the holistic finalize gate can
|
||||
/// reject. The smallest document that is op-valid yet whole-document-invalid.
|
||||
@@ -74,6 +108,21 @@ fn graph_build_emits_blueprint_for_a_valid_document() {
|
||||
assert!(stdout.contains("\"SMA\""), "carries the nodes: {stdout}");
|
||||
}
|
||||
|
||||
/// C29 (#316): the op-script `doc` op's text lands verbatim in the built
|
||||
/// blueprint's `doc` field -- `graph build` is a pure translation from ops to
|
||||
/// the canonical envelope, so a doc op is not consumed or reworded on the way
|
||||
/// through, only threaded (the shape gate is a separate, register-time check;
|
||||
/// see `graph_register_refuses_an_op_script_doc_that_restates_the_composite_name`).
|
||||
#[test]
|
||||
fn graph_build_emits_the_op_script_doc_verbatim_into_the_blueprint() {
|
||||
let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
|
||||
assert!(ok, "exit success");
|
||||
assert!(
|
||||
stdout.contains(r#""doc":"fast/slow SMA difference clamped into a directional bias""#),
|
||||
"the doc op's exact text appears in the blueprint's doc field: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The canonical blueprint artifact carries no trailing newline: `aura graph build`
|
||||
/// emits exactly the library `blueprint_to_json` bytes (the form #158 content-addresses),
|
||||
/// not a display-framed line. Byte-canonical across the CLI and library surfaces (#164).
|
||||
@@ -462,6 +511,47 @@ fn graph_register_is_idempotent() {
|
||||
assert_eq!(registered_id(&out1), registered_id(&out2), "same id twice");
|
||||
}
|
||||
|
||||
/// C29 register seam end-to-end (#316): a doc-less root composite refuses at
|
||||
/// `aura graph register` naming the composite and the rule (exit 1, C14); a
|
||||
/// doc-less named nested composite refuses naming the nested name; the
|
||||
/// described shipped example registers. The gate lives at the store write
|
||||
/// every register path shares — this pins its verb surface.
|
||||
#[test]
|
||||
fn register_refuses_undescribed_composites_end_to_end() {
|
||||
let dir = temp_cwd("register-doc-gate");
|
||||
// doc-less root: the shipped example minus its doc member
|
||||
let text = std::fs::read_to_string(example("r_sma.json")).expect("read example");
|
||||
let mut v: serde_json::Value = serde_json::from_str(&text).expect("example parses");
|
||||
v["blueprint"].as_object_mut().expect("blueprint object").remove("doc");
|
||||
let docless = dir.join("docless.json");
|
||||
std::fs::write(&docless, serde_json::to_string(&v).unwrap()).expect("write fixture");
|
||||
let (out, err, code) = run_in(&dir, &["graph", "register", docless.to_str().unwrap()]);
|
||||
assert_eq!(code, Some(1), "doc-less root refuses: {out} {err}");
|
||||
assert!(
|
||||
err.contains("composite `sma_signal` carries no doc"),
|
||||
"stderr names the composite and the rule: {err}"
|
||||
);
|
||||
assert!(err.contains("C29"), "stderr cites the contract: {err}");
|
||||
// doc-less named nested composite inside a described root
|
||||
v["blueprint"]["doc"] = serde_json::Value::String("a described root".into());
|
||||
v["blueprint"]["nodes"]
|
||||
.as_array_mut()
|
||||
.expect("nodes array")
|
||||
.push(serde_json::json!({"composite": {"name": "inner", "nodes": []}}));
|
||||
let nested = dir.join("nested.json");
|
||||
std::fs::write(&nested, serde_json::to_string(&v).unwrap()).expect("write fixture");
|
||||
let (out, err, code) = run_in(&dir, &["graph", "register", nested.to_str().unwrap()]);
|
||||
assert_eq!(code, Some(1), "doc-less nested refuses: {out} {err}");
|
||||
assert!(
|
||||
err.contains("composite `inner` carries no doc"),
|
||||
"stderr names the nested composite: {err}"
|
||||
);
|
||||
// the described shipped example registers
|
||||
let (out, err, code) = run_in(&dir, &["graph", "register", &example("r_sma.json")]);
|
||||
assert_eq!(code, Some(0), "described example registers: {out} {err}");
|
||||
assert!(out.starts_with("registered blueprint "), "prints the register line: {out}");
|
||||
}
|
||||
|
||||
/// Property (#196, the campaign-axis namespace): `--params <FILE>` prints the
|
||||
/// RAW composite param space — one `{name}:{kind:?}` line per open param, in
|
||||
/// lowering order, WITHOUT the harness-wrap prefix `aura sweep --list-axes`
|
||||
@@ -647,11 +737,12 @@ fn graph_register_rejects_an_unknown_node_type_with_prose() {
|
||||
#[test]
|
||||
fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() {
|
||||
let dir = temp_cwd("register-op-script");
|
||||
// The op-script form (a JSON array of ops), written to a file.
|
||||
// The op-script form (a JSON array of ops), written to a file. Register
|
||||
// requires a described composite (C29), so the described twin is used here.
|
||||
let op_script = dir.join("op-script.json");
|
||||
std::fs::write(&op_script, SIGNAL_DOC).expect("write op-script fixture");
|
||||
std::fs::write(&op_script, SIGNAL_DOC_DESCRIBED).expect("write op-script fixture");
|
||||
// Its `graph build` envelope (a JSON object), the shape `register` already accepts.
|
||||
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC);
|
||||
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
|
||||
assert!(built, "graph build produces the envelope");
|
||||
let envelope = dir.join("envelope.json");
|
||||
std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture");
|
||||
@@ -669,6 +760,33 @@ fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() {
|
||||
registered_id(&env_out),
|
||||
"op-script and its built envelope register to the same content id"
|
||||
);
|
||||
|
||||
// C29: the doc-less op-script form refuses at register like any other
|
||||
// doc-less composite entering the store.
|
||||
let docless_script = dir.join("docless-script.json");
|
||||
std::fs::write(&docless_script, SIGNAL_DOC).expect("write op-script fixture");
|
||||
let (dl_out, dl_err, dl_code) =
|
||||
run_in(&dir, &["graph", "register", docless_script.to_str().unwrap()]);
|
||||
assert_eq!(dl_code, Some(1), "doc-less op-script refuses: {dl_out} {dl_err}");
|
||||
assert!(dl_err.contains("carries no doc"), "stderr names the rule: {dl_err}");
|
||||
assert!(dl_err.contains("C29"), "stderr cites the contract: {dl_err}");
|
||||
}
|
||||
|
||||
/// C29: an op-script's `doc` op is not a bare presence checkbox -- its text
|
||||
/// goes through the same shape gate a builder-authored doc does. A doc that
|
||||
/// merely restates the composite's name (here "graph", every CLI op-script's
|
||||
/// literal name) refuses at register exactly like the builder-authored
|
||||
/// RestatesName case already pinned at the registry-unit layer, but reached
|
||||
/// end-to-end through the op-script CLI seam this time.
|
||||
#[test]
|
||||
fn graph_register_refuses_an_op_script_doc_that_restates_the_composite_name() {
|
||||
let dir = temp_cwd("register-op-script-restated-doc");
|
||||
let restated_script = dir.join("restated-doc-script.json");
|
||||
std::fs::write(&restated_script, SIGNAL_DOC_NAME_RESTATED).expect("write op-script fixture");
|
||||
let (out, err, code) = run_in(&dir, &["graph", "register", restated_script.to_str().unwrap()]);
|
||||
assert_eq!(code, Some(1), "name-restating doc refuses: {out} {err}");
|
||||
assert!(err.contains("merely restates"), "stderr names the rule: {err}");
|
||||
assert!(err.contains("C29"), "stderr cites the contract: {err}");
|
||||
}
|
||||
|
||||
/// Property (#202, the same on-ramp seam at the sibling verb): `aura graph
|
||||
@@ -1157,6 +1275,26 @@ fn graph_build_reports_gang_kind_mismatch_as_prose() {
|
||||
assert!(!stderr.contains("GangKindMismatch"), "does not leak the Debug variant name: {stderr}");
|
||||
}
|
||||
|
||||
/// The `doc` op's duplicate refusal (a second meaning line in one op-script)
|
||||
/// reads as prose at the binary seam, attributed to the second `doc` op's
|
||||
/// index -- never the raw `DuplicateDoc` Debug variant name.
|
||||
#[test]
|
||||
fn graph_build_reports_duplicate_doc_fault_as_prose() {
|
||||
let doc = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"doc","text":"first"},
|
||||
{"op":"doc","text":"second"}
|
||||
]"#;
|
||||
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
|
||||
assert!(!ok, "non-zero exit on a duplicate doc fault");
|
||||
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
|
||||
assert!(
|
||||
stderr.contains("op 2 (doc): a doc op may appear at most once"),
|
||||
"names the op + cause as prose: {stderr}"
|
||||
);
|
||||
assert!(!stderr.contains("DuplicateDoc"), "does not leak the Debug variant name: {stderr}");
|
||||
}
|
||||
|
||||
/// Property (corrupt-gang-blueprint, the `gang_fault_prose` seam): `aura graph
|
||||
/// register` on a hand-corrupted blueprint whose gangs section fails structural
|
||||
/// validation (a single-member gang) refuses with the `blueprint_load_prose` ->
|
||||
|
||||
@@ -19,6 +19,14 @@ fn badcharter_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/badcharter-project")
|
||||
}
|
||||
|
||||
fn undescribed_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/undescribed-project")
|
||||
}
|
||||
|
||||
fn restated_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/restated-project")
|
||||
}
|
||||
|
||||
fn aura(args: &[&str], cwd: &Path) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(args)
|
||||
@@ -164,6 +172,78 @@ fn vocabulary_charter_violation_refuses_end_to_end() {
|
||||
assert!(err.contains("lacks the project prefix"), "stderr must name the charter cause: {err}");
|
||||
}
|
||||
|
||||
/// C29 load seam end-to-end (#316): an extension node whose doc fails the
|
||||
/// shape gate (the empty-string alibi the compiler cannot catch) refuses at
|
||||
/// load with the entry and the rule named — through the real
|
||||
/// `aura-cli::project::load` path (libloading + descriptor read), not merely
|
||||
/// the pure `doc_gate` unit function. The described twin's load is pinned by
|
||||
/// `project_run_resolves_demo_node_and_is_bit_identical`.
|
||||
#[test]
|
||||
fn undescribed_vocabulary_entry_refuses_end_to_end() {
|
||||
let dir = undescribed_dir();
|
||||
let build = Command::new("cargo")
|
||||
.arg("build")
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn cargo build for the undescribed fixture");
|
||||
assert!(
|
||||
build.status.success(),
|
||||
"undescribed fixture build failed:\n{}",
|
||||
String::from_utf8_lossy(&build.stderr)
|
||||
);
|
||||
let run = aura(&["run", "x.json"], &dir);
|
||||
assert_eq!(
|
||||
run.status.code(),
|
||||
Some(1),
|
||||
"an undescribed vocabulary entry is a runtime refusal (exit 1), \
|
||||
not a usage error"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&run.stderr);
|
||||
assert!(
|
||||
err.contains("vocabulary entry `und::Opaque` has an empty doc"),
|
||||
"stderr must name the entry and the rule: {err}"
|
||||
);
|
||||
assert!(err.contains("C29"), "stderr must cite the contract: {err}");
|
||||
}
|
||||
|
||||
/// C29 load seam end-to-end, the sibling fault arm (#316): a doc that merely
|
||||
/// restates its own type id (`DocGateFault::RestatesName`) refuses at load
|
||||
/// exactly like the empty-doc case above, through the same real
|
||||
/// `aura-cli::project::load` path. `RestatesName`'s rendered prose was
|
||||
/// previously pinned only against the pure `Display` impl in
|
||||
/// `aura-runner::project`'s unit tests (never reachable at all through the
|
||||
/// CLI) — a regression that reordered `doc_gate`'s two fault arms, or that
|
||||
/// skipped the gate call for this arm specifically, would pass every
|
||||
/// existing test and still ship a self-description hole for exactly this
|
||||
/// case: an alibi doc that types-checks but says nothing.
|
||||
#[test]
|
||||
fn restated_name_vocabulary_entry_refuses_end_to_end() {
|
||||
let dir = restated_dir();
|
||||
let build = Command::new("cargo")
|
||||
.arg("build")
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn cargo build for the restated fixture");
|
||||
assert!(
|
||||
build.status.success(),
|
||||
"restated fixture build failed:\n{}",
|
||||
String::from_utf8_lossy(&build.stderr)
|
||||
);
|
||||
let run = aura(&["run", "x.json"], &dir);
|
||||
assert_eq!(
|
||||
run.status.code(),
|
||||
Some(1),
|
||||
"a name-restating vocabulary entry is a runtime refusal (exit 1), \
|
||||
not a usage error"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&run.stderr);
|
||||
assert!(
|
||||
err.contains("vocabulary entry `restated::Echo` has a doc that merely restates its name"),
|
||||
"stderr must name the entry and the rule: {err}"
|
||||
);
|
||||
assert!(err.contains("C29"), "stderr must cite the contract: {err}");
|
||||
}
|
||||
|
||||
/// Force a file's modification time to a fixed instant (explicit mtime, no
|
||||
/// sleeping — mtime granularity would make a "touch then compare" race).
|
||||
fn set_mtime(path: &Path, secs_since_epoch: u64) {
|
||||
|
||||
@@ -211,3 +211,85 @@ fn attached_crate_namespace_resolves_after_build() {
|
||||
"{}", String::from_utf8_lossy(&out.stdout)
|
||||
);
|
||||
}
|
||||
|
||||
/// E2E (#278): loading an attached node crate whose `src/` is newer than its
|
||||
/// built dylib emits exactly one `aura: warning: ` class-marked line naming
|
||||
/// the stale dylib, and still succeeds (the run proceeds with the existing
|
||||
/// dylib rather than refusing) — staleness is a continuing-run warning, never
|
||||
/// a fault. A freshly built dylib (no touched source since) emits none. Real
|
||||
/// `cargo build` + a real `libloading::Library::new` load, exercising
|
||||
/// `aura_runner::project::load_crate`'s actual call site rather than the pure
|
||||
/// `stale_warning` helper alone.
|
||||
#[test]
|
||||
fn attached_crate_load_warns_when_source_outruns_the_built_dylib() {
|
||||
let cwd = temp_cwd("stale-warn");
|
||||
assert!(aura(&["new", "lab"], &cwd).status.success());
|
||||
let proj = cwd.join("lab");
|
||||
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
|
||||
assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success());
|
||||
let crate_root = cwd.join("lab-nodes");
|
||||
let build = Command::new("cargo").arg("build").current_dir(&crate_root).output().expect("cargo build");
|
||||
assert!(build.status.success(), "{}", String::from_utf8_lossy(&build.stderr));
|
||||
|
||||
// Fresh build, untouched source: no staleness warning.
|
||||
let fresh = aura(&["graph", "introspect", "--vocabulary"], &proj);
|
||||
assert!(fresh.status.success(), "stderr: {}", String::from_utf8_lossy(&fresh.stderr));
|
||||
assert!(
|
||||
!String::from_utf8_lossy(&fresh.stderr).contains("may be stale"),
|
||||
"a freshly built dylib is not stale: {}",
|
||||
String::from_utf8_lossy(&fresh.stderr)
|
||||
);
|
||||
|
||||
// Bump `src/lib.rs`'s mtime a full minute past "now" (comfortably past the
|
||||
// dylib's just-completed build time) without rebuilding — the exact
|
||||
// "source outran the dylib, no rebuild yet" shape `stale_warning` guards.
|
||||
let lib_rs = crate_root.join("src/lib.rs");
|
||||
let f = std::fs::File::open(&lib_rs).expect("open lib.rs");
|
||||
f.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(60))
|
||||
.expect("bump lib.rs mtime past the dylib's build time");
|
||||
|
||||
let stale = aura(&["graph", "introspect", "--vocabulary"], &proj);
|
||||
assert!(
|
||||
stale.status.success(),
|
||||
"staleness is a warning, not a refusal: {}",
|
||||
String::from_utf8_lossy(&stale.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&stale.stderr);
|
||||
assert_eq!(
|
||||
stderr.matches("may be stale").count(),
|
||||
1,
|
||||
"exactly one staleness warning: {stderr}"
|
||||
);
|
||||
assert!(stderr.contains("aura: warning: "), "carries the warning class marker (#278): {stderr}");
|
||||
assert!(stderr.contains("run `cargo build` to refresh"), "names the remedy: {stderr}");
|
||||
}
|
||||
|
||||
/// Property (#316 self-description): the scaffold's own seed node ships a
|
||||
/// `doc:` line that itself passes the C29 shape gate (`aura_core::doc_gate`)
|
||||
/// — non-empty and not a bare restatement of the type name. This exact text
|
||||
/// is what a new extension author copy-pastes as the very first node of
|
||||
/// their crate; if the scaffold's own doc failed the gate it would teach the
|
||||
/// wrong pattern (an alibi doc) to every project built from it, and nothing
|
||||
/// else would catch that — `NodeSchema.doc` is required at compile time
|
||||
/// (E0063 without it) but its *shape* is never checked by the compiler.
|
||||
#[test]
|
||||
fn nodes_new_scaffold_doc_passes_the_doc_gate() {
|
||||
let cwd = temp_cwd("doc-gate");
|
||||
assert!(aura(&["new", "lab"], &cwd).status.success());
|
||||
let proj = cwd.join("lab");
|
||||
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
|
||||
assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success());
|
||||
let lib = std::fs::read_to_string(cwd.join("lab-nodes/src/lib.rs")).unwrap();
|
||||
let doc_line = lib
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("doc:"))
|
||||
.unwrap_or_else(|| panic!("scaffold must carry a doc: field: {lib}"));
|
||||
let doc_text = doc_line
|
||||
.split_once('"')
|
||||
.and_then(|(_, rest)| rest.rsplit_once('"'))
|
||||
.map(|(text, _)| text)
|
||||
.unwrap_or_else(|| panic!("doc: field must be a string literal: {doc_line}"));
|
||||
aura_core::doc_gate("lab_nodes::Scale", doc_text).unwrap_or_else(|f| {
|
||||
panic!("scaffold's own doc line fails its own gate: {f:?} — {doc_text:?}")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ const GER40_TO_MS: &str = "1727740799999";
|
||||
/// via `aura nodes new ... --namespace knob_lab`) supplies the `knob_lab`
|
||||
/// vocabulary namespace — `Scale` is the starter node every node-crate
|
||||
/// scaffold emits (#183).
|
||||
const OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"scaled_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"knob_lab::Scale","name":"gain"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}"#;
|
||||
const OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"scaled_signal","doc":"fast/slow SMA bias scaled by an extension gain node","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"knob_lab::Scale","name":"gain"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}"#;
|
||||
|
||||
/// The minimal executable process pipeline (one sweep stage) — the campaign
|
||||
/// cell's methodology reference.
|
||||
|
||||
@@ -1452,6 +1452,19 @@ const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
|
||||
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
|
||||
}"#;
|
||||
|
||||
/// A gate no realized sweep member can ever satisfy (`n_trades ge 999999`) —
|
||||
/// deterministically empties `survivor_ordinals` regardless of the fixture's
|
||||
/// actual trade counts, driving the #278 zero-survivor NOTE marker.
|
||||
const GATE_EMPTIES_PROCESS_DOC: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "gate-empties",
|
||||
"pipeline": [
|
||||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||||
{ "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 999999.0 } ] }
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// The full annotated shape for the gated e2e: sweep -> gate -> walk_forward
|
||||
/// -> monte_carlo. The gate (`n_trades ge 0`) passes every member, so
|
||||
/// walk-forward always has survivors; the mc annotator bootstraps the pooled
|
||||
@@ -1978,6 +1991,57 @@ fn campaign_run_synthetic_e2e_parallel_instruments_contains_a_real_per_cell_faul
|
||||
assert_eq!(cells[1]["instrument"].as_str(), Some("SYMB"), "doc order preserved: {line}");
|
||||
assert!(cells[0]["fault"].is_null(), "SYMA has data in January, its cell succeeds: {line}");
|
||||
assert!(!cells[1]["fault"].is_null(), "SYMB has no January data, its cell is faulted: {line}");
|
||||
assert!(
|
||||
out.contains("aura: warning: cell ("),
|
||||
"the failed-cell line carries the warning class marker (#278): {out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #278 sibling of the warning-class pin above: a gate that empties a cell's
|
||||
/// survivors (never a fault, #198 decision 8) emits the NOTE class marker,
|
||||
/// not the bare `aura:` prefix nor `warning:`. `GATE_EMPTIES_PROCESS_DOC`'s
|
||||
/// impossible threshold guarantees zero survivors regardless of the
|
||||
/// fixture's actual sweep-member trade counts. Second property pinned on the
|
||||
/// same invocation: the run's terminal "recorded" summary line — previously
|
||||
/// printed with NO `aura:` prefix at all — now carries it too, so every
|
||||
/// stderr line this command emits (per-cell class markers AND the run-level
|
||||
/// summary) shares the one `aura:`-rooted vocabulary; a regression that
|
||||
/// reverted just this one un-tagged `eprintln!` back to a bare line would
|
||||
/// not be caught by the note-marker assertion above.
|
||||
#[test]
|
||||
fn campaign_run_synthetic_e2e_gate_emptied_cell_carries_the_note_marker() {
|
||||
let (dir, _fixture) = fresh_project_with_data();
|
||||
let runs_dir = dir.join("runs");
|
||||
std::fs::remove_dir_all(&runs_dir).ok();
|
||||
let _cleanup = ScratchGuard(vec![
|
||||
ScratchPath::Dir(runs_dir.clone()),
|
||||
ScratchPath::File(dir.join("gateempty.process.json")),
|
||||
ScratchPath::File(dir.join("gateempty.campaign.json")),
|
||||
]);
|
||||
let bp_id = seed_blueprint(&dir, "campaign-run-gateempty-seed");
|
||||
let proc_id =
|
||||
register_process_doc(&dir, "gateempty.process.json", GATE_EMPTIES_PROCESS_DOC);
|
||||
let doc = campaign_doc_json_for(
|
||||
"SYMA",
|
||||
&bp_id,
|
||||
&proc_id,
|
||||
(1704844800000, 1705708800000),
|
||||
"",
|
||||
"",
|
||||
);
|
||||
write_doc(&dir, "gateempty.campaign.json", &doc);
|
||||
|
||||
let (out, code) =
|
||||
run_code_in(&dir, &["campaign", "run", "gateempty.campaign.json"]);
|
||||
assert_eq!(code, Some(0), "a zero-survivor cell is a valid result, not a fault: {out}");
|
||||
assert!(
|
||||
out.contains("aura: note: cell "),
|
||||
"the gate-emptied cell line carries the note class marker (#278): {out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("aura: campaign run "),
|
||||
"the run-level recorded summary line also carries the aura: prefix (#278): {out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The v2 boundary, campaign side: a generalize-bearing process is an
|
||||
@@ -3683,7 +3747,7 @@ fn campaign_run_real_e2e_skips_unproducible_tap_gracefully() {
|
||||
|
||||
assert!(
|
||||
out.contains(
|
||||
"aura: tap \"net_r_equity\" needs a cost model; add a cost block to the campaign document; skipped"
|
||||
"aura: note: tap \"net_r_equity\" needs a cost model; add a cost block to the campaign document; skipped"
|
||||
),
|
||||
"the unproducible tap gets a named, one-time note with the remedy: {out}"
|
||||
);
|
||||
@@ -3786,7 +3850,7 @@ fn campaign_run_real_e2e_cost_block_nets_members_and_persists_net_r_equity() {
|
||||
assert_eq!(code_gross, Some(0), "cost-less baseline run: {out_gross}");
|
||||
assert!(
|
||||
out_gross.contains(
|
||||
"aura: tap \"net_r_equity\" needs a cost model; add a cost block to the campaign document; skipped"
|
||||
"aura: note: tap \"net_r_equity\" needs a cost model; add a cost block to the campaign document; skipped"
|
||||
),
|
||||
"the cost-less run skips the net tap with the remedy: {out_gross}"
|
||||
);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! C29 coverage: every engine-shipped vocabulary entry carries a
|
||||
//! gate-passing one-line meaning. Blocks, metrics, tap slots, folds, and
|
||||
//! the std node vocabulary — the compile/unit seam of the self-description
|
||||
//! contract.
|
||||
|
||||
use aura_core::doc_gate;
|
||||
|
||||
#[test]
|
||||
fn every_shipped_vocabulary_entry_passes_the_doc_gate() {
|
||||
// process + campaign blocks (BlockSchema.doc — the pre-existing model)
|
||||
for b in aura_research::process_vocabulary()
|
||||
.iter()
|
||||
.chain(aura_research::campaign_vocabulary().iter())
|
||||
{
|
||||
doc_gate(b.id, b.doc)
|
||||
.unwrap_or_else(|f| panic!("block {}: {:?}", b.id, f));
|
||||
}
|
||||
// metrics
|
||||
for m in aura_research::metric_vocabulary() {
|
||||
doc_gate(m.id, m.doc)
|
||||
.unwrap_or_else(|f| panic!("metric {}: {:?}", m.id, f));
|
||||
}
|
||||
// tap slots
|
||||
for t in aura_research::tap_vocabulary() {
|
||||
doc_gate(t.id, t.doc)
|
||||
.unwrap_or_else(|f| panic!("tap {}: {:?}", t.id, f));
|
||||
}
|
||||
// tap folds (the #283 registry roster already carries docs)
|
||||
for (name, doc) in aura_runner::tap_plan::FoldRegistry::core().roster() {
|
||||
doc_gate(name, doc)
|
||||
.unwrap_or_else(|f| panic!("fold {}: {:?}", name, f));
|
||||
}
|
||||
// std node vocabulary: resolve each shipped type id to its schema
|
||||
let env = aura_runner::project::Env::std();
|
||||
for &type_id in aura_vocabulary::std_vocabulary_types() {
|
||||
let builder = env
|
||||
.resolve(type_id)
|
||||
.unwrap_or_else(|| panic!("std type {type_id} must resolve"));
|
||||
let schema = builder.schema();
|
||||
doc_gate(type_id, schema.doc)
|
||||
.unwrap_or_else(|f| panic!("node {type_id}: {:?}", f));
|
||||
}
|
||||
}
|
||||
@@ -150,3 +150,65 @@ fn single_run_refuses_a_duplicate_tap_name_before_persisting_anything() {
|
||||
);
|
||||
assert!(!cwd.join("runs").exists(), "a refused run must not persist a half-written trace store");
|
||||
}
|
||||
|
||||
/// The `aura: writing tap traces failed: …` register, pre-run arm: `runs`
|
||||
/// exists as a FILE, so the trace store's directory creation
|
||||
/// (`begin_run`) fails before the run — exit 1 through the register.
|
||||
/// (This register was code-present but untested before #283.)
|
||||
#[test]
|
||||
fn an_unwritable_store_root_exits_through_the_tap_trace_register() {
|
||||
let cwd = temp_cwd("store-root-is-a-file");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
std::fs::write(cwd.join("runs"), b"not a directory").expect("occupy runs as a file");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
|
||||
assert!(!out.status.success(), "an unwritable store must refuse, not succeed");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("writing tap traces failed"),
|
||||
"the tap-trace register must fire: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same register, deferred arm (#283): the run directory pre-exists
|
||||
/// read-only, so `begin_run` succeeds (create_dir_all on an existing dir)
|
||||
/// but the record consumer's deferred open in `initialize` fails; the run
|
||||
/// COMPLETES, the failure surfaces terminally through the register, and no
|
||||
/// `index.json` is written (the store's treat-as-absent crash shape).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let cwd = temp_cwd("run-dir-read-only");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
let run_dir = cwd.join("runs/traces/sma_signal");
|
||||
std::fs::create_dir_all(&run_dir).expect("pre-create run dir");
|
||||
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o555))
|
||||
.expect("make run dir read-only");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
|
||||
// restore permissions FIRST so the next run's temp_cwd cleanup works.
|
||||
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("restore run dir permissions");
|
||||
|
||||
assert!(!out.status.success(), "a failed writer must surface, not pass silently");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("writing tap traces failed"),
|
||||
"the tap-trace register must fire terminally: {stderr}"
|
||||
);
|
||||
assert!(!run_dir.join("index.json").exists(), "no index — treat-as-absent crash shape");
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ impl ConstLongBias {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }],
|
||||
output: vec![FieldSpec { name: "bias".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(ConstLongBias { out: [Cell::from_f64(0.0)] }),
|
||||
)
|
||||
|
||||
@@ -44,8 +44,8 @@ pub use column::{Column, Window};
|
||||
pub use ctx::Ctx;
|
||||
pub use error::KindMismatch;
|
||||
pub use node::{
|
||||
zip_params, BindOpError, BoundParam, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
|
||||
PrimitiveBuilder,
|
||||
doc_gate, zip_params, BindOpError, BoundParam, DocGateFault, FieldSpec, Firing, Node,
|
||||
NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
||||
};
|
||||
pub use scalar::{Scalar, ScalarKind, Timestamp};
|
||||
pub use series_fold::SeriesFold;
|
||||
|
||||
@@ -345,6 +345,36 @@ pub struct NodeSchema {
|
||||
pub inputs: Vec<PortSpec>,
|
||||
pub output: Vec<FieldSpec>,
|
||||
pub params: Vec<ParamSpec>,
|
||||
/// One-line meaning of the node — non-load-bearing metadata, shown by
|
||||
/// introspection surfaces.
|
||||
pub doc: &'static str,
|
||||
}
|
||||
|
||||
/// Shape fault of a vocabulary entry's meaning line (C29 gate).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DocGateFault {
|
||||
Empty,
|
||||
RestatesName,
|
||||
}
|
||||
|
||||
/// Deterministic shape check for a vocabulary entry's meaning line —
|
||||
/// string shape only, never content judgement (the engine must not
|
||||
/// evaluate prose).
|
||||
pub fn doc_gate(name: &str, doc: &str) -> Result<(), DocGateFault> {
|
||||
if doc.trim().is_empty() {
|
||||
return Err(DocGateFault::Empty);
|
||||
}
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
.collect()
|
||||
}
|
||||
let short_name = name.rsplit("::").next().unwrap_or(name);
|
||||
if norm(doc) == norm(name) || norm(doc) == norm(short_name) {
|
||||
return Err(DocGateFault::RestatesName);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The universal composable dataflow unit (C8): a **producer, consumer, or both**.
|
||||
@@ -379,6 +409,15 @@ pub trait Node {
|
||||
"node".to_string()
|
||||
}
|
||||
|
||||
/// Start-of-stream hook: `Harness::run` calls it once per node, in
|
||||
/// topological order, before the first source value — the mirror of
|
||||
/// [`Node::finalize`]. A consumer overrides it to acquire run resources
|
||||
/// (e.g. the file a recording sink streams into). Infallible by
|
||||
/// signature: an implementation that can fail stores its error, degrades
|
||||
/// to inert, and surfaces the failure once, terminally, at `finalize`.
|
||||
/// The default is a no-op, so existing nodes are unaffected.
|
||||
fn initialize(&mut self) {}
|
||||
|
||||
/// End-of-stream hook: `Harness::run` calls it once per node, in topological
|
||||
/// order, after the source loop drains. A folding sink overrides it to flush
|
||||
/// its accumulated summary; the default is a no-op, so existing nodes are
|
||||
@@ -437,13 +476,14 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
assert_eq!(with.label(), "SMA");
|
||||
let none = PrimitiveBuilder::new(
|
||||
"Sub",
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
assert_eq!(none.label(), "Sub");
|
||||
@@ -473,7 +513,7 @@ mod tests {
|
||||
fn node_name_strips_namespace_prefix() {
|
||||
let b = PrimitiveBuilder::new(
|
||||
"demo::Identity",
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|
||||
|_| unreachable!("never built in this test"),
|
||||
);
|
||||
assert_eq!(b.node_name(), "identity");
|
||||
@@ -505,7 +545,7 @@ mod tests {
|
||||
fn primitive_builder_build_runs_the_closure() {
|
||||
let f = PrimitiveBuilder::new(
|
||||
"Bare",
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
assert_eq!(f.params(), Vec::<ParamSpec>::new());
|
||||
@@ -520,6 +560,7 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
@@ -529,6 +570,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_carries_declared_doc() {
|
||||
let b = PrimitiveBuilder::new(
|
||||
"SMA",
|
||||
NodeSchema {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "simple moving average",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
assert_eq!(b.schema().doc, "simple moving average");
|
||||
}
|
||||
|
||||
/// A node whose label echoes the param vector its constructor received — lets a
|
||||
/// test read back the positional vector `.build()` reconstructed after binds.
|
||||
struct Probe(Vec<Cell>);
|
||||
@@ -556,6 +612,7 @@ mod tests {
|
||||
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
|
||||
ParamSpec { name: "c".into(), kind: ScalarKind::F64 },
|
||||
],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|p| Box::new(Probe(p.to_vec())),
|
||||
)
|
||||
@@ -632,6 +689,7 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
@@ -650,6 +708,7 @@ mod tests {
|
||||
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
|
||||
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
|
||||
],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
@@ -665,6 +724,7 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
@@ -716,6 +776,7 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
)
|
||||
@@ -737,6 +798,43 @@ mod tests {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_gate_refuses_empty_and_whitespace() {
|
||||
assert_eq!(doc_gate("EMA", ""), Err(DocGateFault::Empty));
|
||||
assert_eq!(doc_gate("EMA", " \t"), Err(DocGateFault::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_gate_refuses_name_restatement() {
|
||||
assert_eq!(doc_gate("EMA", "EMA"), Err(DocGateFault::RestatesName));
|
||||
assert_eq!(doc_gate("EMA", "e_m_a"), Err(DocGateFault::RestatesName));
|
||||
assert_eq!(doc_gate("rolling_max", "Rolling Max"), Err(DocGateFault::RestatesName));
|
||||
}
|
||||
|
||||
/// C29 tightening (#316 fieldtest): for a namespaced entry, the bare
|
||||
/// display segment is a restatement too — a doc that adds nothing beyond
|
||||
/// the name must not pass just because the name carries a `::` prefix.
|
||||
/// Genuine meaning lines that merely *contain* the name stay legal.
|
||||
#[test]
|
||||
fn doc_gate_refuses_namespaced_short_name_restatement() {
|
||||
// The most natural alibi at the extension-author seam: the node's
|
||||
// own display name as its doc.
|
||||
assert_eq!(doc_gate("lab316_nodes::Scale", "Scale"), Err(DocGateFault::RestatesName));
|
||||
// Guard: a real meaning line stays Ok even when it contains the
|
||||
// short name as a substring...
|
||||
assert_eq!(
|
||||
doc_gate("lab316_nodes::Scale", "scalar gain: emits the input times the factor param"),
|
||||
Ok(())
|
||||
);
|
||||
// ...and a namespaced entry with a genuine doc is untouched.
|
||||
assert_eq!(doc_gate("und::Opaque", "one-input f64 pass-through"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_gate_accepts_a_meaning_line() {
|
||||
assert_eq!(doc_gate("EMA", "exponential moving average over the input series"), Ok(()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -129,7 +129,15 @@ fn derive_signature(c: &Composite) -> NodeSchema {
|
||||
.collect();
|
||||
let mut params = Vec::new();
|
||||
collect_params(c.nodes(), c.gangs(), "", &mut params);
|
||||
NodeSchema { inputs, output, params }
|
||||
NodeSchema {
|
||||
inputs,
|
||||
output,
|
||||
params,
|
||||
// A derived composite signature is graph wiring, not a vocabulary entry:
|
||||
// no seam walks this doc. The described surface is the composite's own
|
||||
// `doc`, gated at register (C29, register seam).
|
||||
doc: "",
|
||||
}
|
||||
}
|
||||
|
||||
/// The scalar kind of the interior input slot a composite target addresses,
|
||||
@@ -1838,6 +1846,7 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![FieldSpec { name: "v".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
};
|
||||
let to = NodeSchema {
|
||||
inputs: vec![
|
||||
@@ -1846,6 +1855,7 @@ mod tests {
|
||||
],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
};
|
||||
assert!(edge_kind_check(&from, 0, &to, 0).is_ok());
|
||||
assert_eq!(
|
||||
@@ -2269,7 +2279,7 @@ mod tests {
|
||||
fn pass1() -> BlueprintNode {
|
||||
PrimitiveBuilder::new(
|
||||
"Pass1",
|
||||
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
|
||||
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![], doc: "test-only schema" },
|
||||
|_| Box::new(Pass1 { out: [Cell::from_f64(0.0)] }),
|
||||
)
|
||||
.into()
|
||||
@@ -2277,7 +2287,12 @@ mod tests {
|
||||
fn join2() -> BlueprintNode {
|
||||
PrimitiveBuilder::new(
|
||||
"Join2",
|
||||
NodeSchema { inputs: vec![f64_any(), f64_any()], output: out_v(), params: vec![] },
|
||||
NodeSchema {
|
||||
inputs: vec![f64_any(), f64_any()],
|
||||
output: out_v(),
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Join2 { out: [Cell::from_f64(0.0)] }),
|
||||
)
|
||||
.into()
|
||||
@@ -2285,7 +2300,7 @@ mod tests {
|
||||
fn sink_f64() -> BlueprintNode {
|
||||
PrimitiveBuilder::new(
|
||||
"SinkF64",
|
||||
NodeSchema { inputs: vec![f64_any()], output: vec![], params: vec![] },
|
||||
NodeSchema { inputs: vec![f64_any()], output: vec![], params: vec![], doc: "test-only schema" },
|
||||
|_| Box::new(SinkF64),
|
||||
)
|
||||
.into()
|
||||
@@ -2297,6 +2312,7 @@ mod tests {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(SinkI64),
|
||||
)
|
||||
@@ -2849,6 +2865,7 @@ mod tests {
|
||||
inputs: vec![f64_any()],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
};
|
||||
let h = Harness::bootstrap(FlatGraph {
|
||||
nodes: vec![
|
||||
@@ -3454,6 +3471,7 @@ mod tests {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }],
|
||||
output: vec![FieldSpec { name: "v".into(), kind: ScalarKind::I64 }],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| panic!("build must not run when validation fails pre-build"),
|
||||
);
|
||||
@@ -3556,7 +3574,7 @@ mod tests {
|
||||
// cannot take .named).
|
||||
let paramless_sma = PrimitiveBuilder::new(
|
||||
"Pass1",
|
||||
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
|
||||
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![], doc: "test-only schema" },
|
||||
|_| Box::new(Pass1 { out: [Cell::from_f64(0.0)] }),
|
||||
)
|
||||
.named("sma");
|
||||
|
||||
@@ -42,6 +42,10 @@ pub enum Op {
|
||||
Tap { from: String, as_name: String },
|
||||
/// Fuse two or more sibling params into one public knob (#61).
|
||||
Gang { as_name: String, into: Vec<String> },
|
||||
/// Declare the composite's one-line meaning (C29, #316) — the op-script
|
||||
/// twin of the builder's `.doc(...)`. At most one per script; a second
|
||||
/// is a fault (a declarative script carries no last-wins ambiguity).
|
||||
Doc { text: String },
|
||||
}
|
||||
|
||||
/// A per-op construction fault, by-identifier so the cause names the op.
|
||||
@@ -92,6 +96,8 @@ pub enum OpError {
|
||||
/// A holistic finalize fault (totality / injectivity / unbound root role),
|
||||
/// wrapping the unchanged engine gate's `CompileError`.
|
||||
Incomplete(CompileError),
|
||||
/// A second `doc` op — the meaning line is declared at most once.
|
||||
DuplicateDoc,
|
||||
}
|
||||
|
||||
/// A per-op-fallible blueprint accumulator. Holds the same interior data a
|
||||
@@ -113,6 +119,7 @@ pub struct GraphSession<'v> {
|
||||
tap_names: HashSet<String>,
|
||||
coverage: HashMap<(usize, usize), usize>,
|
||||
gangs: Vec<Gang>,
|
||||
doc: Option<String>,
|
||||
}
|
||||
|
||||
impl<'v> GraphSession<'v> {
|
||||
@@ -135,6 +142,7 @@ impl<'v> GraphSession<'v> {
|
||||
tap_names: HashSet::new(),
|
||||
coverage: HashMap::new(),
|
||||
gangs: Vec::new(),
|
||||
doc: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +158,13 @@ impl<'v> GraphSession<'v> {
|
||||
Op::Expose { from, as_name } => self.expose(from, as_name),
|
||||
Op::Tap { from, as_name } => self.tap(from, as_name),
|
||||
Op::Gang { as_name, into } => self.gang(as_name, into),
|
||||
Op::Doc { text } => {
|
||||
if self.doc.is_some() {
|
||||
return Err(OpError::DuplicateDoc);
|
||||
}
|
||||
self.doc = Some(text);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,13 +439,14 @@ impl<'v> GraphSession<'v> {
|
||||
.into_iter()
|
||||
.map(|(name, source, targets)| Role { name, targets, source })
|
||||
.collect();
|
||||
// No doc is attached here: the op-script vocabulary deliberately has no
|
||||
// doc-carrying surface yet (#125 scope cut) — an op-built composite
|
||||
// carries no authored rationale until that vocabulary grows a slot.
|
||||
let c = Composite::new(self.name, self.nodes, self.edges, roles, self.out)
|
||||
// The doc op (C29, #316) threads through `Composite::with_doc`.
|
||||
let mut c = Composite::new(self.name, self.nodes, self.edges, roles, self.out)
|
||||
.with_taps(self.taps)
|
||||
.with_gangs(self.gangs)
|
||||
.map_err(OpError::Incomplete)?;
|
||||
if let Some(t) = self.doc {
|
||||
c = c.with_doc(t);
|
||||
}
|
||||
check_param_namespace_injective(&c.param_space()).map_err(OpError::Incomplete)?;
|
||||
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output(), c.taps()).map_err(|e| match e {
|
||||
CompileError::UnconnectedPort { node, slot } => OpError::UnconnectedPort {
|
||||
@@ -866,6 +882,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// C29 (#316): the doc op sets the composite's meaning line — the
|
||||
/// op-script twin of the builder's .doc(...).
|
||||
#[test]
|
||||
fn doc_op_sets_the_composite_doc() {
|
||||
let mut s = scaffold();
|
||||
s.apply(Op::Doc { text: "a described op-built composite".into() }).unwrap();
|
||||
s.apply(Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] })
|
||||
.unwrap();
|
||||
s.apply(Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }).unwrap();
|
||||
s.apply(Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() }).unwrap();
|
||||
s.apply(Op::Expose { from: "sub.value".into(), as_name: "out".into() }).unwrap();
|
||||
let c = s.finish().expect("finishes");
|
||||
assert_eq!(c.doc(), Some("a described op-built composite"));
|
||||
}
|
||||
|
||||
/// A second doc op is a fault, not a silent overwrite.
|
||||
#[test]
|
||||
fn duplicate_doc_op_is_refused() {
|
||||
let mut s = scaffold();
|
||||
s.apply(Op::Doc { text: "first".into() }).unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Doc { text: "second".into() }),
|
||||
Err(OpError::DuplicateDoc)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_reports_incomplete_on_unwired_slot() {
|
||||
// a fully-named scaffold missing the sub.rhs connection -> holistic 0-arm
|
||||
|
||||
@@ -348,6 +348,7 @@ mod tests {
|
||||
],
|
||||
output: vec![FieldSpec { name: "diff".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
)
|
||||
@@ -462,6 +463,7 @@ mod tests {
|
||||
FieldSpec { name: "lo".into(), kind: ScalarKind::I64 },
|
||||
],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
)
|
||||
|
||||
@@ -519,6 +519,14 @@ impl Harness {
|
||||
let mut cycle_id: u64 = 0;
|
||||
let mut scratch: Vec<Cell> = Vec::new();
|
||||
|
||||
// start-of-stream: initialize every node once, in topological order,
|
||||
// before the first source value (the mirror of the end-of-stream
|
||||
// finalize flush below). Runs inside the deterministic sequence — no
|
||||
// within-sim concurrency (C1).
|
||||
for &nidx in topo.iter() {
|
||||
nodes[nidx].node.initialize();
|
||||
}
|
||||
|
||||
loop {
|
||||
// pick the live source head with the smallest (timestamp, source index).
|
||||
// strictly-`<` replace + source-order scan preserves C4 tie-breaking.
|
||||
@@ -870,6 +878,7 @@ mod tests {
|
||||
inputs: kinds.iter().enumerate().map(|(i, &kind)| PortSpec { kind, firing, name: format!("col[{i}]") }).collect(),
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -887,6 +896,7 @@ mod tests {
|
||||
inputs: vec![f64_port(Firing::Any), f64_port(Firing::Any)],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -916,6 +926,7 @@ mod tests {
|
||||
inputs: vec![f64_port(Firing::Barrier(0)), f64_port(Firing::Barrier(0))],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -943,6 +954,7 @@ mod tests {
|
||||
inputs: vec![f64_port(Firing::Any)],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::Bool }],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -976,6 +988,7 @@ mod tests {
|
||||
],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1014,6 +1027,7 @@ mod tests {
|
||||
FieldSpec { name: "volume".into(), kind: ScalarKind::F64 },
|
||||
],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1049,6 +1063,7 @@ mod tests {
|
||||
FieldSpec { name: "i".into(), kind: ScalarKind::I64 },
|
||||
],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1076,6 +1091,7 @@ mod tests {
|
||||
inputs: vec![f64_port(Firing::Any)],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2434,6 +2450,7 @@ mod tests {
|
||||
FieldSpec { name: "i".into(), kind: ScalarKind::I64 },
|
||||
],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2809,6 +2826,52 @@ mod tests {
|
||||
assert_eq!(fired, vec![0, 1]);
|
||||
}
|
||||
|
||||
struct InitProbe {
|
||||
id: usize,
|
||||
tx: mpsc::Sender<String>,
|
||||
evaled: bool,
|
||||
}
|
||||
impl Node for InitProbe {
|
||||
fn lookbacks(&self) -> Vec<usize> {
|
||||
vec![1]
|
||||
}
|
||||
fn initialize(&mut self) {
|
||||
let _ = self.tx.send(format!("init{}", self.id));
|
||||
}
|
||||
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
if !self.evaled {
|
||||
self.evaled = true;
|
||||
let _ = self.tx.send(format!("eval{}", self.id));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_initializes_every_node_once_before_the_stream() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
// two sink probes, both fed by the single source; no inter-node edges.
|
||||
let mut h = boot(
|
||||
vec![
|
||||
Box::new(InitProbe { id: 0, tx: tx.clone(), evaled: false }),
|
||||
Box::new(InitProbe { id: 1, tx, evaled: false }),
|
||||
],
|
||||
vec![
|
||||
recorder_sig(&[ScalarKind::F64], Firing::Any),
|
||||
recorder_sig(&[ScalarKind::F64], Firing::Any),
|
||||
],
|
||||
vec![SourceSpec::raw(ScalarKind::F64, vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }])],
|
||||
vec![],
|
||||
)
|
||||
.expect("valid");
|
||||
// nothing initialized before the run.
|
||||
assert!(rx.try_recv().is_err());
|
||||
h.run(vec![Box::new(VecSource::new(f64_stream(&[(1, 1.0), (2, 2.0)])))]);
|
||||
// both inits fire, in topo order, strictly before any eval.
|
||||
let fired: Vec<String> = rx.try_iter().collect();
|
||||
assert_eq!(fired, vec!["init0", "init1", "eval0", "eval1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_tap_attaches_a_caller_built_sink_and_records_the_tapped_series() {
|
||||
// A tap on a producer; bind_tap attaches a caller-built Recorder; the run
|
||||
|
||||
@@ -43,6 +43,7 @@ fn recorder_sig(kinds: &[ScalarKind]) -> NodeSchema {
|
||||
.collect(),
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ fn run_sample_over(prices: Vec<(Timestamp, Scalar)>) -> RunReport {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
};
|
||||
let mut h = Harness::bootstrap(FlatGraph {
|
||||
nodes: vec![
|
||||
|
||||
@@ -35,6 +35,7 @@ fn run_sample(window: (Timestamp, Timestamp), source: Box<dyn Source>) -> RunRep
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
};
|
||||
let mut h = Harness::bootstrap(FlatGraph {
|
||||
nodes: vec![
|
||||
|
||||
@@ -88,6 +88,7 @@ impl Resample {
|
||||
FieldSpec { name: "close".into(), kind: ScalarKind::F64 },
|
||||
],
|
||||
params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
|
||||
doc: "emits a completed bar once per resample boundary, never mid-bar",
|
||||
},
|
||||
|p| Box::new(Resample::new(p[0].i64())),
|
||||
)
|
||||
|
||||
@@ -72,6 +72,7 @@ impl Session {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
|
||||
output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }],
|
||||
params: vec![],
|
||||
doc: "bars elapsed since the session open, from the configured open time and timezone",
|
||||
},
|
||||
move |_| Box::new(Session::new(open_hour, open_minute, tz, period_minutes)),
|
||||
)
|
||||
@@ -99,6 +100,7 @@ impl SessionFrankfurt {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
|
||||
output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }],
|
||||
params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
|
||||
doc: "bars elapsed since the Frankfurt session open (09:00 Europe/Berlin)",
|
||||
},
|
||||
|p| Box::new(Session::new(9, 0, chrono_tz::Europe::Berlin, p[0].i64())),
|
||||
)
|
||||
|
||||
+115
-10
@@ -21,8 +21,8 @@ use aura_core::PrimitiveBuilder;
|
||||
use aura_analysis::{one_sided_p_laplace, MetricStats, MetricVocabulary, SplitMix64};
|
||||
use aura_backtest::{RunMetrics, RunReport};
|
||||
use aura_engine::{
|
||||
blueprint_from_json, blueprint_identity_json, expected_max_of_normals, FamilySelection,
|
||||
SelectionMode,
|
||||
blueprint_from_json, blueprint_identity_json, expected_max_of_normals, BlueprintDoc,
|
||||
CompositeData, FamilySelection, NodeData, SelectionMode,
|
||||
};
|
||||
use aura_research::{CampaignDoc, DocRef};
|
||||
|
||||
@@ -37,7 +37,10 @@ pub use lineage::{
|
||||
};
|
||||
|
||||
mod trace_store;
|
||||
pub use trace_store::{FamilyMember, NameKind, RunTraces, TraceStore, TraceStoreError, WriteKind};
|
||||
pub use trace_store::{
|
||||
FamilyMember, NameKind, RunTraces, TapTraceWriter, TapWriterOpener, TraceStore,
|
||||
TraceStoreError, TraceStreamer, WriteKind,
|
||||
};
|
||||
|
||||
/// An append-only run registry over a JSONL file: one serde_json line per
|
||||
/// `RunReport`.
|
||||
@@ -140,12 +143,26 @@ impl Registry {
|
||||
/// so a repeated write re-writes identical content. The registry does NOT
|
||||
/// verify `sha256(bytes) == hash` (no `sha2` dep here): the caller owns the
|
||||
/// hash, and reproduction's bit-identical metric compare is the integrity check.
|
||||
pub fn put_blueprint(&self, hash: &str, canonical_json: &str) -> Result<(), RegistryError> {
|
||||
/// Raw write — C29-gated callers go through [`Registry::put_blueprint`]; this
|
||||
/// stays for store-mechanics tests and pre-C29 store content.
|
||||
fn put_blueprint_unchecked(&self, hash: &str, canonical_json: &str) -> Result<(), RegistryError> {
|
||||
fs::create_dir_all(self.blueprints_dir())?;
|
||||
fs::write(self.blueprint_path(hash), canonical_json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write-once content-addressed put, C29-gated (#316): only a canonical
|
||||
/// blueprint whose root composite and every named nested composite carry
|
||||
/// a gate-passing doc enters the store. The gate guards the store
|
||||
/// boundary itself — verb, sugar, and any future caller alike — and the
|
||||
/// write path only: already-registered doc-less entries stay readable.
|
||||
pub fn put_blueprint(&self, hash: &str, canonical_json: &str) -> Result<(), RegistryError> {
|
||||
let doc: BlueprintDoc = serde_json::from_str(canonical_json)
|
||||
.map_err(RegistryError::MalformedBlueprint)?;
|
||||
gate_composite_docs(&doc.blueprint)?;
|
||||
self.put_blueprint_unchecked(hash, canonical_json)
|
||||
}
|
||||
|
||||
/// Read a stored blueprint by content id; `Ok(None)` if absent — the same
|
||||
/// treat-as-empty discipline `load` applies to a missing runs store.
|
||||
pub fn get_blueprint(&self, hash: &str) -> Result<Option<String>, RegistryError> {
|
||||
@@ -256,6 +273,25 @@ impl Registry {
|
||||
}
|
||||
}
|
||||
|
||||
/// C29 walk (#316): the root and every named nested composite must carry a
|
||||
/// gate-passing doc. `doc: None` refuses exactly like `Some("")` — both are
|
||||
/// the Empty fault ("carries no doc").
|
||||
fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> {
|
||||
let fault = match c.doc.as_deref() {
|
||||
None => Some(aura_core::DocGateFault::Empty),
|
||||
Some(d) => aura_core::doc_gate(&c.name, d).err(),
|
||||
};
|
||||
if let Some(fault) = fault {
|
||||
return Err(RegistryError::UndescribedComposite { name: c.name.clone(), fault });
|
||||
}
|
||||
for n in &c.nodes {
|
||||
if let NodeData::Composite(nested) = n {
|
||||
gate_composite_docs(nested)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Referential-validation findings for a campaign document. By-identifier
|
||||
/// and Display-free (the CLI phrases them).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -894,6 +930,14 @@ pub enum RegistryError {
|
||||
NonRMetric(String),
|
||||
/// A generalization was asked for with fewer than two instruments.
|
||||
TooFewInstruments(usize),
|
||||
/// C29 register seam (#316): the canonical bytes offered to the blueprint
|
||||
/// store do not parse as a canonical blueprint — the gate cannot certify
|
||||
/// what it cannot read.
|
||||
MalformedBlueprint(serde_json::Error),
|
||||
/// C29 register seam (#316): a root or named nested composite entering
|
||||
/// the store without a gate-passing doc. Registered artifacts are never
|
||||
/// retroactively invalidated — the gate guards the write path only.
|
||||
UndescribedComposite { name: String, fault: aura_core::DocGateFault },
|
||||
}
|
||||
|
||||
impl fmt::Display for RegistryError {
|
||||
@@ -917,6 +961,23 @@ impl fmt::Display for RegistryError {
|
||||
f,
|
||||
"a generalization needs >= 2 instruments, got {n}"
|
||||
),
|
||||
RegistryError::MalformedBlueprint(e) => {
|
||||
write!(f, "blueprint: not a canonical blueprint: {e}")
|
||||
}
|
||||
RegistryError::UndescribedComposite { name, fault } => match fault {
|
||||
aura_core::DocGateFault::Empty => write!(
|
||||
f,
|
||||
"blueprint: composite `{name}` carries no doc — a registered \
|
||||
composite describes itself (C29); add a doc line (builder \
|
||||
.doc(\"...\"), blueprint \"doc\" member, or op-script \
|
||||
{{\"op\":\"doc\"}}) before register"
|
||||
),
|
||||
aura_core::DocGateFault::RestatesName => write!(
|
||||
f,
|
||||
"blueprint: composite `{name}` has a doc that merely restates \
|
||||
its name — a registered composite describes itself (C29)"
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1067,13 +1128,50 @@ mod tests {
|
||||
fn blueprint_store_round_trips_by_content_id() {
|
||||
let reg = Registry::open(temp_family_dir("blueprint_store_round_trip"));
|
||||
let canonical = r#"{"format_version":1,"input_roles":[],"nodes":[],"edges":[]}"#;
|
||||
reg.put_blueprint("deadbeef", canonical).expect("put");
|
||||
reg.put_blueprint_unchecked("deadbeef", canonical).expect("put");
|
||||
// exact bytes come back, keyed by content id
|
||||
assert_eq!(reg.get_blueprint("deadbeef").expect("get"), Some(canonical.to_string()));
|
||||
// an absent id is Ok(None), not an error (treat-as-empty discipline)
|
||||
assert_eq!(reg.get_blueprint("never-written").expect("get"), None);
|
||||
}
|
||||
|
||||
/// C29 (#316): the store refuses a doc-less root (None and "" alike),
|
||||
/// a name-restating doc, and admits a described composite. The raw
|
||||
/// unchecked path still round-trips pre-C29 doc-less bytes — registered
|
||||
/// artifacts are never retroactively invalidated.
|
||||
#[test]
|
||||
fn blueprint_store_gates_docs_at_the_write_boundary() {
|
||||
let reg = Registry::open(temp_family_dir("blueprint_store_doc_gate"));
|
||||
let docless = r#"{"format_version":1,"blueprint":{"name":"s","nodes":[]}}"#;
|
||||
let e = reg.put_blueprint("h1", docless).expect_err("doc-less root must refuse");
|
||||
assert!(
|
||||
e.to_string().contains("composite `s` carries no doc"),
|
||||
"prose names the composite and the rule: {e}"
|
||||
);
|
||||
let restated = r#"{"format_version":1,"blueprint":{"name":"echo","doc":"Echo","nodes":[]}}"#;
|
||||
let e = reg.put_blueprint("h2", restated).expect_err("restated doc must refuse");
|
||||
assert!(e.to_string().contains("merely restates"), "prose names the rule: {e}");
|
||||
let described = r#"{"format_version":1,"blueprint":{"name":"s","doc":"a described fixture blueprint","nodes":[]}}"#;
|
||||
reg.put_blueprint("h3", described).expect("described root registers");
|
||||
assert_eq!(reg.get_blueprint("h3").expect("get"), Some(described.to_string()));
|
||||
// never-retroactive witness: a pre-C29 doc-less entry stays readable
|
||||
reg.put_blueprint_unchecked("h4", docless).expect("raw write");
|
||||
assert_eq!(reg.get_blueprint("h4").expect("get"), Some(docless.to_string()));
|
||||
}
|
||||
|
||||
/// A doc-less named NESTED composite refuses even when the root is
|
||||
/// described — the walk covers every named nested composite (C29).
|
||||
#[test]
|
||||
fn blueprint_store_gates_nested_composite_docs() {
|
||||
let reg = Registry::open(temp_family_dir("blueprint_store_nested_doc_gate"));
|
||||
let nested_docless = r#"{"format_version":1,"blueprint":{"name":"root","doc":"a described root","nodes":[{"composite":{"name":"inner","nodes":[]}}]}}"#;
|
||||
let e = reg.put_blueprint("h1", nested_docless).expect_err("doc-less nested must refuse");
|
||||
assert!(
|
||||
e.to_string().contains("composite `inner` carries no doc"),
|
||||
"prose names the nested composite: {e}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_stores_round_trip_by_content_id() {
|
||||
let reg = Registry::open(temp_family_dir("document_store_round_trip"));
|
||||
@@ -1807,7 +1905,8 @@ mod tests {
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
);
|
||||
)
|
||||
.with_doc("fixture composite for the registry seeder test");
|
||||
let space = composite.param_space();
|
||||
let real = space.first().expect("fixture composite has an open param");
|
||||
let real_name = real.name.clone();
|
||||
@@ -1938,6 +2037,7 @@ mod tests {
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
.with_doc("fixture composite for the registry seeder test")
|
||||
}
|
||||
|
||||
/// A second, identity-distinct fixture: same shape over the zero-arg
|
||||
@@ -1952,6 +2052,7 @@ mod tests {
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
.with_doc("fixture composite for the registry seeder test")
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2092,7 +2193,8 @@ mod tests {
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
);
|
||||
)
|
||||
.with_doc("fixture composite for the registry seeder test");
|
||||
let (_json_b, content_b, identity_twin) = seed_blueprint(®, &twin);
|
||||
assert_eq!(identity, identity_twin, "twins must share one identity id");
|
||||
assert_ne!(content_a, content_b, "twins must have distinct content ids");
|
||||
@@ -2147,7 +2249,8 @@ mod tests {
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
);
|
||||
)
|
||||
.with_doc("fixture composite for the registry seeder test");
|
||||
assert!(composite.param_space().is_empty(), "fixture is fully bound");
|
||||
let bound = composite.bound_param_space();
|
||||
let bound_param = bound.first().expect("fixture has one bound param");
|
||||
@@ -2240,7 +2343,8 @@ mod tests {
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
);
|
||||
)
|
||||
.with_doc("fixture composite for the registry seeder test");
|
||||
let space = composite.param_space();
|
||||
assert_eq!(space.len(), 2, "fixture exposes two open params");
|
||||
let covered = space[0].name.clone(); // e.g. "b1.scale"
|
||||
@@ -2313,7 +2417,8 @@ mod tests {
|
||||
source: Some(aura_core::ScalarKind::F64),
|
||||
}],
|
||||
vec![],
|
||||
);
|
||||
)
|
||||
.with_doc("fixture composite for the registry seeder test");
|
||||
let space = composite.param_space();
|
||||
let axis = space.first().expect("fixture has an open param").name.clone();
|
||||
let blueprint_json = blueprint_to_json(&composite).expect("serializes");
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, BufWriter, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use aura_core::{Cell, ScalarKind, Timestamp};
|
||||
use aura_engine::{ColumnarTrace, RunManifest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -97,24 +100,19 @@ impl TraceStore {
|
||||
let run_dir = self.dir.join(name);
|
||||
fs::create_dir_all(&run_dir)?;
|
||||
for tap in taps {
|
||||
let path = run_dir.join(format!("{}.json", tap.tap));
|
||||
let body = serde_json::to_string(tap).map_err(|source| TraceStoreError::Parse {
|
||||
file: path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
fs::write(&path, body)?;
|
||||
write_tap_file(&run_dir, tap)?;
|
||||
}
|
||||
let index = Index {
|
||||
manifest: manifest.clone(),
|
||||
taps: taps.iter().map(|t| t.tap.clone()).collect(),
|
||||
};
|
||||
let index_path = run_dir.join("index.json");
|
||||
let body = serde_json::to_string(&index).map_err(|source| TraceStoreError::Parse {
|
||||
file: index_path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
fs::write(&index_path, body)?;
|
||||
Ok(())
|
||||
let order: Vec<String> = taps.iter().map(|t| t.tap.clone()).collect();
|
||||
write_index(&run_dir, manifest, &order)
|
||||
}
|
||||
|
||||
/// Begin a streamed run write under `traces/<name>/` (directories created
|
||||
/// now; `index.json` only at [`TraceStreamer::finish`] — the crash
|
||||
/// discipline `read` already honors: no index → `NotFound`).
|
||||
pub fn begin_run(&self, name: &str) -> Result<TraceStreamer, TraceStoreError> {
|
||||
let run_dir = self.dir.join(name);
|
||||
fs::create_dir_all(&run_dir)?;
|
||||
Ok(TraceStreamer { run_dir })
|
||||
}
|
||||
|
||||
/// Read a run's index + taps back, in index order. A missing run directory (no
|
||||
@@ -239,6 +237,216 @@ impl TraceStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// One tap's canonical `<tap>.json` under `run_dir` — the single write shape
|
||||
/// both `TraceStore::write` and `TraceStreamer::write_full` share.
|
||||
fn write_tap_file(run_dir: &Path, tap: &ColumnarTrace) -> Result<(), TraceStoreError> {
|
||||
let path = run_dir.join(format!("{}.json", tap.tap));
|
||||
let body = serde_json::to_string(tap).map_err(|source| TraceStoreError::Parse {
|
||||
file: path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
fs::write(&path, body)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The run's `index.json` (manifest + tap order) — shared by `write` and
|
||||
/// `TraceStreamer::finish`. Written last by both paths (crash discipline:
|
||||
/// no `index.json` → `NotFound`, treat-as-absent).
|
||||
fn write_index(
|
||||
run_dir: &Path,
|
||||
manifest: &RunManifest,
|
||||
taps: &[String],
|
||||
) -> Result<(), TraceStoreError> {
|
||||
let index = Index { manifest: manifest.clone(), taps: taps.to_vec() };
|
||||
let index_path = run_dir.join("index.json");
|
||||
let body = serde_json::to_string(&index).map_err(|source| TraceStoreError::Parse {
|
||||
file: index_path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
fs::write(&index_path, body)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A begun streamed run write: hands out per-tap incremental writers and
|
||||
/// writes the closing `index.json`. Tap order in the index is the caller's
|
||||
/// (declared-tap order — the caller tracks it; this type keeps no order
|
||||
/// state so record-writer threads and post-run fold writes can interleave).
|
||||
pub struct TraceStreamer {
|
||||
run_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl TraceStreamer {
|
||||
/// An incremental writer for one tap, opened now. `Send`. Expressed
|
||||
/// through [`TraceStreamer::register_tap`] so there is exactly one open
|
||||
/// path and the two cannot drift.
|
||||
pub fn tap_writer(&self, tap: &str, kind: ScalarKind) -> Result<TapTraceWriter, TraceStoreError> {
|
||||
self.register_tap(tap, kind).open()
|
||||
}
|
||||
|
||||
/// The deferred twin of [`TraceStreamer::tap_writer`]: everything needed
|
||||
/// to open one tap's incremental writer later — from inside a consumer
|
||||
/// node's `initialize`, at run start — without holding the streamer.
|
||||
/// `Send`; opens no handles until [`TapWriterOpener::open`].
|
||||
pub fn register_tap(&self, tap: &str, kind: ScalarKind) -> TapWriterOpener {
|
||||
TapWriterOpener { run_dir: self.run_dir.clone(), tap: tap.to_string(), kind }
|
||||
}
|
||||
|
||||
/// Write one complete tap file at once (the fold path's one-row trace) —
|
||||
/// the same bytes `TraceStore::write` produces for it.
|
||||
pub fn write_full(&self, tap: &ColumnarTrace) -> Result<(), TraceStoreError> {
|
||||
write_tap_file(&self.run_dir, tap)
|
||||
}
|
||||
|
||||
/// Close the run: write `index.json` (manifest + `taps` in caller order).
|
||||
pub fn finish(self, manifest: &RunManifest, taps: &[String]) -> Result<(), TraceStoreError> {
|
||||
write_index(&self.run_dir, manifest, taps)
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`TraceStreamer::register_tap`].
|
||||
pub struct TapWriterOpener {
|
||||
run_dir: PathBuf,
|
||||
tap: String,
|
||||
kind: ScalarKind,
|
||||
}
|
||||
|
||||
impl TapWriterOpener {
|
||||
/// Open the two temp column streams and return the incremental writer.
|
||||
pub fn open(self) -> Result<TapTraceWriter, TraceStoreError> {
|
||||
let final_path = self.run_dir.join(format!("{}.json", self.tap));
|
||||
let ts_path = self.run_dir.join(format!("{}.ts.tmp", self.tap));
|
||||
let col_path = self.run_dir.join(format!("{}.col.tmp", self.tap));
|
||||
// Read+write handles, NOT `File::create`: `finish` seeks these same
|
||||
// handles back and `io::copy`-reads them, and a `File::create` handle
|
||||
// is O_WRONLY — reading it back fails with EBADF (os error 9).
|
||||
let ts_w = BufWriter::new(
|
||||
OpenOptions::new().read(true).write(true).create(true).truncate(true).open(&ts_path)?,
|
||||
);
|
||||
let col_w = BufWriter::new(
|
||||
OpenOptions::new().read(true).write(true).create(true).truncate(true).open(&col_path)?,
|
||||
);
|
||||
Ok(TapTraceWriter {
|
||||
tap: self.tap,
|
||||
kind: self.kind,
|
||||
final_path,
|
||||
ts_path,
|
||||
col_path,
|
||||
ts_w,
|
||||
col_w,
|
||||
any: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The four-kind tag string (the on-disk `ColumnarTrace.kinds` form). A local
|
||||
/// copy of `report.rs`'s private `kind_tag`; the streamed-vs-write
|
||||
/// byte-equality test below is the drift guard.
|
||||
fn tag(kind: ScalarKind) -> &'static str {
|
||||
match kind {
|
||||
ScalarKind::F64 => "F64",
|
||||
ScalarKind::I64 => "I64",
|
||||
ScalarKind::Bool => "Bool",
|
||||
ScalarKind::Timestamp => "Timestamp",
|
||||
}
|
||||
}
|
||||
|
||||
/// Coerce one cell to the f64 a trace column stores — the same table as
|
||||
/// `ColumnarTrace::from_rows` (`scalar_to_f64`): f64 as-is, i64 as f64,
|
||||
/// bool 1.0/0.0, timestamp epoch as f64.
|
||||
fn cell_to_f64(kind: ScalarKind, cell: Cell) -> f64 {
|
||||
match kind {
|
||||
ScalarKind::F64 => cell.f64(),
|
||||
ScalarKind::I64 => cell.i64() as f64,
|
||||
ScalarKind::Bool => {
|
||||
if cell.bool() {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
ScalarKind::Timestamp => cell.ts().0 as f64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental single-column trace writer: appends each `(ts, cell)` to two
|
||||
/// temp streams, then `finish` assembles the canonical `ColumnarTrace` JSON
|
||||
/// by streaming concatenation (constant memory) and removes the temps. The
|
||||
/// output is byte-equal to `serde_json::to_string(&ColumnarTrace)` for the
|
||||
/// same rows — pinned by test against `TraceStore::write`, which is the
|
||||
/// anchor (not a frozen byte-shape assumption).
|
||||
pub struct TapTraceWriter {
|
||||
tap: String,
|
||||
kind: ScalarKind,
|
||||
final_path: PathBuf,
|
||||
ts_path: PathBuf,
|
||||
col_path: PathBuf,
|
||||
ts_w: BufWriter<File>,
|
||||
col_w: BufWriter<File>,
|
||||
any: bool,
|
||||
}
|
||||
|
||||
impl TapTraceWriter {
|
||||
/// Append one recorded point. Number formatting is `serde_json`'s own
|
||||
/// (`to_writer` per value) — identical to the whole-struct serialize.
|
||||
pub fn append(&mut self, ts: Timestamp, cell: Cell) -> Result<(), TraceStoreError> {
|
||||
if self.any {
|
||||
self.ts_w.write_all(b",")?;
|
||||
self.col_w.write_all(b",")?;
|
||||
}
|
||||
serde_json::to_writer(&mut self.ts_w, &ts.0).map_err(|source| TraceStoreError::Parse {
|
||||
file: self.ts_path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
serde_json::to_writer(&mut self.col_w, &cell_to_f64(self.kind, cell)).map_err(
|
||||
|source| TraceStoreError::Parse {
|
||||
file: self.col_path.display().to_string(),
|
||||
source,
|
||||
},
|
||||
)?;
|
||||
self.any = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Assemble `{"tap":…,"kinds":[…],"ts":[⟨ts-tmp⟩],"columns":[[⟨col-tmp⟩]]}`
|
||||
/// and remove the temps.
|
||||
pub fn finish(self) -> Result<(), TraceStoreError> {
|
||||
let TapTraceWriter { tap, kind, final_path, ts_path, col_path, ts_w, col_w, any: _ } = self;
|
||||
let mut ts_f = ts_w.into_inner().map_err(|e| TraceStoreError::Io(e.into_error()))?;
|
||||
let mut col_f = col_w.into_inner().map_err(|e| TraceStoreError::Io(e.into_error()))?;
|
||||
ts_f.flush()?;
|
||||
col_f.flush()?;
|
||||
ts_f.seek(SeekFrom::Start(0))?;
|
||||
col_f.seek(SeekFrom::Start(0))?;
|
||||
|
||||
let mut out = BufWriter::new(File::create(&final_path)?);
|
||||
// Field order (tap, kinds, ts, columns) = ColumnarTrace declaration
|
||||
// order; string fields serialized via serde for correct escaping.
|
||||
write!(
|
||||
out,
|
||||
"{{\"tap\":{},\"kinds\":[{}],\"ts\":[",
|
||||
serde_json::to_string(&tap).map_err(|source| TraceStoreError::Parse {
|
||||
file: final_path.display().to_string(),
|
||||
source,
|
||||
})?,
|
||||
serde_json::to_string(tag(kind)).map_err(|source| TraceStoreError::Parse {
|
||||
file: final_path.display().to_string(),
|
||||
source,
|
||||
})?,
|
||||
)?;
|
||||
io::copy(&mut ts_f, &mut out)?;
|
||||
out.write_all(b"],\"columns\":[[")?;
|
||||
io::copy(&mut col_f, &mut out)?;
|
||||
out.write_all(b"]]}")?;
|
||||
out.flush()?;
|
||||
drop(out);
|
||||
drop(ts_f);
|
||||
drop(col_f);
|
||||
fs::remove_file(&ts_path)?;
|
||||
fs::remove_file(&col_path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// What can go wrong reading or writing the trace store.
|
||||
#[derive(Debug)]
|
||||
pub enum TraceStoreError {
|
||||
@@ -447,4 +655,168 @@ mod tests {
|
||||
assert!(store.ensure_name_free("fresh", WriteKind::Run).is_ok());
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The load-bearing compatibility pin: for the same rows, the streamed
|
||||
/// writer's file bytes equal `TraceStore::write`'s — the legacy path's
|
||||
/// bytes, whatever they are, define the shape. Covers all four
|
||||
/// `ScalarKind` arms (f64 fractional/negative, bool, i64, timestamp) —
|
||||
/// every arm `tag`/`cell_to_f64` dispatch on, since those two helpers are
|
||||
/// a local copy of `report.rs`'s private `kind_tag`/`scalar_to_f64` and
|
||||
/// this is the only drift guard against them — plus the empty
|
||||
/// (never-warm) tap.
|
||||
#[test]
|
||||
fn streamed_writer_is_byte_equal_to_write_for_the_same_rows() {
|
||||
use aura_core::Cell;
|
||||
|
||||
type Case<'a> = (&'a str, ScalarKind, Vec<(Timestamp, Cell)>);
|
||||
let cases: Vec<Case> = vec![
|
||||
(
|
||||
"f64_tap",
|
||||
ScalarKind::F64,
|
||||
vec![
|
||||
(Timestamp(2), Cell::from_f64(0.30000000000000004)),
|
||||
(Timestamp(3), Cell::from_f64(-1.5)),
|
||||
(Timestamp(5), Cell::from_f64(2.0)),
|
||||
],
|
||||
),
|
||||
(
|
||||
"bool_tap",
|
||||
ScalarKind::Bool,
|
||||
vec![(Timestamp(1), Cell::from_bool(true)), (Timestamp(2), Cell::from_bool(false))],
|
||||
),
|
||||
(
|
||||
"i64_tap",
|
||||
ScalarKind::I64,
|
||||
vec![(Timestamp(1), Cell::from_i64(-7)), (Timestamp(2), Cell::from_i64(42))],
|
||||
),
|
||||
(
|
||||
"timestamp_tap",
|
||||
ScalarKind::Timestamp,
|
||||
vec![
|
||||
(Timestamp(1), Cell::from_ts(Timestamp(100))),
|
||||
(Timestamp(2), Cell::from_ts(Timestamp(-3))),
|
||||
],
|
||||
),
|
||||
("empty_tap", ScalarKind::F64, vec![]),
|
||||
];
|
||||
|
||||
for (tap, kind, rows) in cases {
|
||||
// legacy path
|
||||
let legacy_root = temp_traces_root(&format!("byteeq-legacy-{tap}"));
|
||||
let legacy = TraceStore::open(&legacy_root);
|
||||
let scalar_rows: Vec<(Timestamp, Vec<Scalar>)> = rows
|
||||
.iter()
|
||||
.map(|&(t, c)| (t, vec![Scalar::from_cell(kind, c)]))
|
||||
.collect();
|
||||
let trace = ColumnarTrace::from_rows(tap, &[kind], &scalar_rows);
|
||||
legacy.write("run", &sample_manifest(), &[trace]).expect("legacy write");
|
||||
let legacy_bytes =
|
||||
fs::read(legacy_root.join(format!("traces/run/{tap}.json"))).expect("legacy bytes");
|
||||
|
||||
// streamed path
|
||||
let stream_root = temp_traces_root(&format!("byteeq-stream-{tap}"));
|
||||
let store = TraceStore::open(&stream_root);
|
||||
let streamer = store.begin_run("run").expect("begin_run");
|
||||
let mut w = streamer.tap_writer(tap, kind).expect("tap_writer");
|
||||
for (t, c) in rows {
|
||||
w.append(t, c).expect("append");
|
||||
}
|
||||
w.finish().expect("finish");
|
||||
let streamed_bytes =
|
||||
fs::read(stream_root.join(format!("traces/run/{tap}.json"))).expect("streamed bytes");
|
||||
|
||||
assert_eq!(
|
||||
streamed_bytes, legacy_bytes,
|
||||
"streamed and legacy write bytes must be identical for {tap}"
|
||||
);
|
||||
// temps removed
|
||||
assert!(!stream_root.join(format!("traces/run/{tap}.ts.tmp")).exists());
|
||||
assert!(!stream_root.join(format!("traces/run/{tap}.col.tmp")).exists());
|
||||
|
||||
let _ = fs::remove_dir_all(&legacy_root);
|
||||
let _ = fs::remove_dir_all(&stream_root);
|
||||
}
|
||||
}
|
||||
|
||||
/// A begun-but-unfinished streamed run (crash shape: tap file written or
|
||||
/// half-written, no `index.json`) reads as `NotFound` — treat-as-absent.
|
||||
#[test]
|
||||
fn begun_but_unfinished_streamed_run_reads_not_found() {
|
||||
use aura_core::Cell;
|
||||
let root = temp_traces_root("stream-crash");
|
||||
let store = TraceStore::open(&root);
|
||||
let streamer = store.begin_run("crashed").expect("begin_run");
|
||||
let mut w = streamer.tap_writer("t", ScalarKind::F64).expect("tap_writer");
|
||||
w.append(Timestamp(1), Cell::from_f64(1.0)).expect("append");
|
||||
// no w.finish(), no streamer.finish(): the crash shape.
|
||||
drop(w);
|
||||
match store.read("crashed") {
|
||||
Err(TraceStoreError::NotFound(name)) => assert_eq!(name, "crashed"),
|
||||
other => panic!("expected NotFound, got {other:?}"),
|
||||
}
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// `register_tap`/`TapWriterOpener::open` produce the exact same bytes as
|
||||
/// `tap_writer` for the same rows — the deferred opener and the
|
||||
/// open-now writer are one write path (`tap_writer` delegates to
|
||||
/// `register_tap(..).open()`), so they cannot drift.
|
||||
#[test]
|
||||
fn register_tap_open_is_the_same_write_path_as_tap_writer() {
|
||||
use aura_core::Cell;
|
||||
let root = temp_traces_root("deferred-open");
|
||||
let store = TraceStore::open(&root);
|
||||
let rows =
|
||||
[(Timestamp(2), Cell::from_f64(0.25)), (Timestamp(3), Cell::from_f64(-1.5))];
|
||||
|
||||
// run "a": open-now.
|
||||
let sa = store.begin_run("a").expect("begin a");
|
||||
let mut wa = sa.tap_writer("t", ScalarKind::F64).expect("open now");
|
||||
for &(ts, c) in &rows {
|
||||
wa.append(ts, c).expect("append a");
|
||||
}
|
||||
wa.finish().expect("finish a");
|
||||
|
||||
// run "b": deferred — the opener is Send and opens at use time.
|
||||
let sb = store.begin_run("b").expect("begin b");
|
||||
let opener = sb.register_tap("t", ScalarKind::F64);
|
||||
fn assert_send<T: Send>(_: &T) {}
|
||||
assert_send(&opener);
|
||||
let mut wb = opener.open().expect("deferred open");
|
||||
for &(ts, c) in &rows {
|
||||
wb.append(ts, c).expect("append b");
|
||||
}
|
||||
wb.finish().expect("finish b");
|
||||
|
||||
let a = fs::read(root.join("traces").join("a").join("t.json")).expect("read a");
|
||||
let b = fs::read(root.join("traces").join("b").join("t.json")).expect("read b");
|
||||
assert_eq!(a, b, "one open path, identical bytes");
|
||||
}
|
||||
|
||||
/// `TraceStreamer::finish` + `write_full` produce a run `read` returns in
|
||||
/// caller order — the fold path's one-row trace beside a streamed tap.
|
||||
#[test]
|
||||
fn streamer_finish_indexes_streamed_and_full_taps_in_caller_order() {
|
||||
use aura_core::Cell;
|
||||
let root = temp_traces_root("stream-index");
|
||||
let store = TraceStore::open(&root);
|
||||
let streamer = store.begin_run("mixed").expect("begin_run");
|
||||
let mut w = streamer.tap_writer("series", ScalarKind::F64).expect("tap_writer");
|
||||
w.append(Timestamp(1), Cell::from_f64(1.0)).expect("append");
|
||||
w.finish().expect("finish series");
|
||||
let fold_row = ColumnarTrace::from_rows(
|
||||
"agg",
|
||||
&[ScalarKind::I64],
|
||||
&[(Timestamp(1), vec![Scalar::i64(1)])],
|
||||
);
|
||||
streamer.write_full(&fold_row).expect("write_full");
|
||||
streamer
|
||||
.finish(&sample_manifest(), &["series".to_string(), "agg".to_string()])
|
||||
.expect("streamer finish");
|
||||
|
||||
let back = store.read("mixed").expect("read");
|
||||
let names: Vec<&str> = back.taps.iter().map(|t| t.tap.as_str()).collect();
|
||||
assert_eq!(names, vec!["series", "agg"], "index order = caller order");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
+114
-28
@@ -13,7 +13,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aura_core::{Scalar, ScalarKind};
|
||||
use aura_core::{doc_gate, DocGateFault, Scalar, ScalarKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The one accepted document format version.
|
||||
@@ -727,6 +727,14 @@ pub fn content_id_of(canonical: &str) -> String {
|
||||
// Intrinsic validation (store-free).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A shipped metric with its one-line meaning (C29). One carrier — the
|
||||
/// #190 guard keeps this list, the RMetrics serde fields, and the CLI
|
||||
/// count in lockstep; the doc column adds no fourth roster site.
|
||||
pub struct MetricSchema {
|
||||
pub id: &'static str,
|
||||
pub doc: &'static str,
|
||||
}
|
||||
|
||||
/// The closed declared-metric vocabulary: the scalar field names of the
|
||||
/// shipped metrics/selection types (RMetrics + RunMetrics scalars, plus the
|
||||
/// FamilySelection annotation scores). Single source for gate/stage checks
|
||||
@@ -746,25 +754,25 @@ pub fn content_id_of(canonical: &str) -> String {
|
||||
/// hand when the upstream field sets change (this list stays a hand-copy by
|
||||
/// the crate's ratified core-only isolation; #147 single-sourced the rankable
|
||||
/// roster elsewhere and the extended #190 guard pins this list's nesting).
|
||||
pub fn metric_vocabulary() -> &'static [&'static str] {
|
||||
pub fn metric_vocabulary() -> &'static [MetricSchema] {
|
||||
&[
|
||||
"expectancy_r",
|
||||
"win_rate",
|
||||
"avg_win_r",
|
||||
"avg_loss_r",
|
||||
"profit_factor",
|
||||
"max_r_drawdown",
|
||||
"sqn",
|
||||
"sqn_normalized",
|
||||
"net_expectancy_r",
|
||||
"n_trades",
|
||||
"n_open_at_end",
|
||||
"total_pips",
|
||||
"max_drawdown",
|
||||
"bias_sign_flips",
|
||||
"deflated_score",
|
||||
"overfit_probability",
|
||||
"neighbourhood_score",
|
||||
MetricSchema { id: "expectancy_r", doc: "mean realized R per closed trade — the headline E[R]" },
|
||||
MetricSchema { id: "win_rate", doc: "fraction of closed trades with positive realized R" },
|
||||
MetricSchema { id: "avg_win_r", doc: "mean realized R over winning trades" },
|
||||
MetricSchema { id: "avg_loss_r", doc: "mean realized R over losing trades" },
|
||||
MetricSchema { id: "profit_factor", doc: "gross winning R divided by absolute gross losing R" },
|
||||
MetricSchema { id: "max_r_drawdown", doc: "deepest peak-to-trough decline of the cumulative R curve" },
|
||||
MetricSchema { id: "sqn", doc: "System Quality Number: mean R over its std deviation, scaled by trade count" },
|
||||
MetricSchema { id: "sqn_normalized", doc: "SQN normalized to a 100-trade basis for n-independent comparison" },
|
||||
MetricSchema { id: "net_expectancy_r", doc: "expectancy net of the cost model's per-trade cost in R" },
|
||||
MetricSchema { id: "n_trades", doc: "count of closed trades in the run" },
|
||||
MetricSchema { id: "n_open_at_end", doc: "positions still open when the run ends" },
|
||||
MetricSchema { id: "total_pips", doc: "summed pip result over all closed trades" },
|
||||
MetricSchema { id: "max_drawdown", doc: "deepest peak-to-trough decline of the pip equity curve" },
|
||||
MetricSchema { id: "bias_sign_flips", doc: "count of sign changes in the strategy's bias stream" },
|
||||
MetricSchema { id: "deflated_score", doc: "selection score deflated for multiple testing against the bootstrap null" },
|
||||
MetricSchema { id: "overfit_probability", doc: "probability the selected edge is overfit, from the bootstrap null" },
|
||||
MetricSchema { id: "neighbourhood_score", doc: "plateau robustness: score aggregated over the param-space neighbourhood" },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -773,12 +781,23 @@ pub fn emit_vocabulary() -> &'static [&'static str] {
|
||||
&["family_table", "selection_report"]
|
||||
}
|
||||
|
||||
/// A closed-vocabulary persisted-tap slot with its one-line meaning (C29).
|
||||
pub struct TapSchema {
|
||||
pub id: &'static str,
|
||||
pub doc: &'static str,
|
||||
}
|
||||
|
||||
/// The wrap convention's persisted sink names — the closed tap vocabulary
|
||||
/// (#201 decision 1). A genuinely new observable becomes a new entry or an
|
||||
/// authored sink in the strategy blueprint (C22: the choice of sinks is part
|
||||
/// of the experiment) — never an open node-path namespace in the document.
|
||||
pub fn tap_vocabulary() -> &'static [&'static str] {
|
||||
&["equity", "exposure", "r_equity", "net_r_equity"]
|
||||
pub fn tap_vocabulary() -> &'static [TapSchema] {
|
||||
&[
|
||||
TapSchema { id: "equity", doc: "cumulative pip equity per cycle" },
|
||||
TapSchema { id: "exposure", doc: "signed position exposure per cycle" },
|
||||
TapSchema { id: "r_equity", doc: "cumulative gross R per cycle" },
|
||||
TapSchema { id: "net_r_equity", doc: "cumulative net R per cycle, after the cost model" },
|
||||
]
|
||||
}
|
||||
|
||||
/// The closed archive-column vocabulary a campaign `data.bindings` VALUE may
|
||||
@@ -821,16 +840,24 @@ pub enum DocFault {
|
||||
UnknownTap { index: usize, tap: String },
|
||||
UnknownBindingColumn { role: String, column: String },
|
||||
ProcessRefMustBeContentId,
|
||||
/// C29 (#316): a document's optional top-level `description` is present
|
||||
/// but fails the shape gate. Absent is never a fault.
|
||||
BadDescription { subject: String, fault: DocGateFault },
|
||||
}
|
||||
|
||||
fn is_known_metric(name: &str) -> bool {
|
||||
metric_vocabulary().contains(&name)
|
||||
metric_vocabulary().iter().any(|m| m.id == name)
|
||||
}
|
||||
|
||||
/// Intrinsic validation of a process document (P1 constraints included).
|
||||
/// Returns ALL findings, not the first.
|
||||
pub fn validate_process(doc: &ProcessDoc) -> Vec<DocFault> {
|
||||
let mut faults = Vec::new();
|
||||
if let Some(desc) = &doc.description
|
||||
&& let Err(fault) = doc_gate(&doc.name, desc)
|
||||
{
|
||||
faults.push(DocFault::BadDescription { subject: doc.name.clone(), fault });
|
||||
}
|
||||
if doc.pipeline.is_empty() {
|
||||
faults.push(DocFault::EmptyPipeline);
|
||||
}
|
||||
@@ -894,6 +921,11 @@ pub fn validate_process(doc: &ProcessDoc) -> Vec<DocFault> {
|
||||
/// Returns ALL findings, not the first.
|
||||
pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
|
||||
let mut faults = Vec::new();
|
||||
if let Some(desc) = &doc.description
|
||||
&& let Err(fault) = doc_gate(&doc.name, desc)
|
||||
{
|
||||
faults.push(DocFault::BadDescription { subject: doc.name.clone(), fault });
|
||||
}
|
||||
if doc.data.instruments.is_empty() {
|
||||
faults.push(DocFault::EmptyInstruments);
|
||||
}
|
||||
@@ -1000,7 +1032,7 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
|
||||
}
|
||||
}
|
||||
for (i, t) in doc.presentation.persist_taps.iter().enumerate() {
|
||||
if !tap_vocabulary().contains(&t.as_str()) {
|
||||
if !tap_vocabulary().iter().any(|k| k.id == t.as_str()) {
|
||||
faults.push(DocFault::UnknownTap { index: i, tap: t.clone() });
|
||||
}
|
||||
}
|
||||
@@ -1280,7 +1312,7 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
|
||||
"presentation",
|
||||
format!(
|
||||
"required section: persist_taps ({}) + emit",
|
||||
tap_vocabulary().join(" | ")
|
||||
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -1881,6 +1913,56 @@ mod tests {
|
||||
assert_eq!(validate_process(&doc), Vec::new());
|
||||
}
|
||||
|
||||
/// C29 (#316): a present document description passes the shape gate;
|
||||
/// absent is never a fault. Both fault arms fire and carry the doc name.
|
||||
#[test]
|
||||
fn document_description_gate_flags_empty_and_restatement() {
|
||||
let base = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
|
||||
let mut doc: ProcessDoc = serde_json::from_str(base).expect("fixture parses");
|
||||
assert!(validate_process(&doc).is_empty(), "no description → no fault");
|
||||
doc.description = Some(" ".into());
|
||||
assert!(matches!(
|
||||
validate_process(&doc).as_slice(),
|
||||
[DocFault::BadDescription { subject, fault: DocGateFault::Empty }] if subject == "p"
|
||||
));
|
||||
doc.description = Some("P".into());
|
||||
assert!(matches!(
|
||||
validate_process(&doc).as_slice(),
|
||||
[DocFault::BadDescription { fault: DocGateFault::RestatesName, .. }]
|
||||
));
|
||||
doc.description = Some("walk-forward validation methodology".into());
|
||||
assert!(validate_process(&doc).is_empty(), "described → clean");
|
||||
}
|
||||
|
||||
/// The campaign side takes the identical gate, exercised over the
|
||||
/// crate's existing valid campaign fixture (`CAMPAIGN_FIXTURE`, which
|
||||
/// validates clean — pinned by `validate_campaign_accepts_a_valid_risk_section`).
|
||||
#[test]
|
||||
fn campaign_description_gate_mirrors_process() {
|
||||
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
|
||||
assert!(validate_campaign(&doc).is_empty(), "fixture validates clean");
|
||||
doc.description = Some("".into());
|
||||
assert!(matches!(
|
||||
validate_campaign(&doc).as_slice(),
|
||||
[DocFault::BadDescription { fault: DocGateFault::Empty, .. }]
|
||||
));
|
||||
doc.description = Some("GER40 walk-forward over the momentum stack".into());
|
||||
assert!(validate_campaign(&doc).is_empty(), "described → clean");
|
||||
}
|
||||
|
||||
/// C29 (#316): a present description participates in the content id
|
||||
/// (absent ⇒ byte-identical is pinned by the existing round-trips).
|
||||
#[test]
|
||||
fn document_description_participates_in_the_content_id() {
|
||||
let base = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
|
||||
let bare: ProcessDoc = serde_json::from_str(base).unwrap();
|
||||
let mut described: ProcessDoc = serde_json::from_str(base).unwrap();
|
||||
described.description = Some("walk-forward validation methodology".into());
|
||||
let bare_id = content_id_of(&process_to_json(&bare));
|
||||
let described_id = content_id_of(&process_to_json(&described));
|
||||
assert_ne!(bare_id, described_id, "description must move the content id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_process_accepts_the_fixture_and_reports_each_fault() {
|
||||
let ok = parse_process(PROCESS_FIXTURE).unwrap();
|
||||
@@ -2072,7 +2154,8 @@ mod tests {
|
||||
/// never an open node-path namespace in the document.
|
||||
#[test]
|
||||
fn tap_vocabulary_is_the_wrap_conventions_four_sink_names() {
|
||||
assert_eq!(tap_vocabulary(), ["equity", "exposure", "r_equity", "net_r_equity"]);
|
||||
let ids: Vec<&str> = tap_vocabulary().iter().map(|t| t.id).collect();
|
||||
assert_eq!(ids, ["equity", "exposure", "r_equity", "net_r_equity"]);
|
||||
}
|
||||
|
||||
/// Each `presentation.persist_taps` entry outside `tap_vocabulary()` is an
|
||||
@@ -2092,7 +2175,7 @@ mod tests {
|
||||
);
|
||||
// every in-vocabulary name passes
|
||||
let mut all = ok.clone();
|
||||
all.presentation.persist_taps = tap_vocabulary().iter().map(|t| t.to_string()).collect();
|
||||
all.presentation.persist_taps = tap_vocabulary().iter().map(|t| t.id.to_string()).collect();
|
||||
assert_eq!(validate_campaign(&all), Vec::new());
|
||||
}
|
||||
|
||||
@@ -2157,7 +2240,10 @@ mod tests {
|
||||
assert_eq!(slot.kind, SlotKind::TapKinds);
|
||||
assert_eq!(
|
||||
slot_kind_label(slot.kind),
|
||||
format!("list of: {}", tap_vocabulary().join(" | "))
|
||||
format!(
|
||||
"list of: {}",
|
||||
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
slot_kind_label(slot.kind),
|
||||
@@ -2188,7 +2274,7 @@ mod tests {
|
||||
pres.hint,
|
||||
format!(
|
||||
"required section: persist_taps ({}) + emit",
|
||||
tap_vocabulary().join(" | ")
|
||||
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
//! CLI shell still needs its own for call sites outside any family builder).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use aura_campaign::{catch_member_panic, member_fault_prose, MemberFault};
|
||||
use aura_composites::StopRule;
|
||||
use aura_core::{Cell, ParamSpec, Scalar, Timestamp};
|
||||
use aura_engine::{
|
||||
@@ -315,7 +316,10 @@ pub fn select_winner(
|
||||
/// probe. `SweepBinder::sweep_with_lattice`'s own `resolve_axes`/arity/kind checks all
|
||||
/// run BEFORE this closure is invoked per grid point, so its body never influences the
|
||||
/// validation outcome — only its signature (`Fn(&[Cell]) -> RunReport`) needs to
|
||||
/// satisfy the terminal, at O(1) cost per point instead of a full member run.
|
||||
/// satisfy the terminal, at O(1) cost per point instead of a full member run. Reused
|
||||
/// (#278) as the discarded slot value for a member run [`catch_member_panic`]
|
||||
/// contains: the caller always resolves the captured fault and exits before this
|
||||
/// value is ever rendered or persisted, so its content is equally irrelevant there.
|
||||
fn axis_grid_probe_report() -> RunReport {
|
||||
RunReport {
|
||||
manifest: RunManifest {
|
||||
@@ -334,6 +338,48 @@ fn axis_grid_probe_report() -> RunReport {
|
||||
}
|
||||
}
|
||||
|
||||
/// The LOWEST enumeration index's captured member-panic message, found by
|
||||
/// walking `family.points` — guaranteed by `assemble_sweep`'s own
|
||||
/// `collect()` to be in grid-enumeration order regardless of which worker
|
||||
/// finished first (C1) — for the first point whose params match a captured
|
||||
/// fault. Thread-order-independent by construction (mirrors the lowest-
|
||||
/// index-fault convention `aura-campaign::exec::run_members` establishes for
|
||||
/// the identical #272 seam), without needing a separate numeric-index
|
||||
/// side-channel: the returned family already carries the points in order.
|
||||
fn lowest_point_fault(family: &SweepFamily, faults: Mutex<Vec<(Vec<Cell>, String)>>) -> Option<String> {
|
||||
let captured = faults.into_inner().expect("fault capture lock");
|
||||
family
|
||||
.points
|
||||
.iter()
|
||||
.find_map(|pt| captured.iter().find(|(p, _)| *p == pt.params).map(|(_, m)| m.clone()))
|
||||
}
|
||||
|
||||
/// The LOWEST seed's captured member-panic message (#278), found by walking
|
||||
/// `family.draws` — `monte_carlo`'s own contract guarantees seed-**input**
|
||||
/// order regardless of thread completion (C1) — for the first draw whose
|
||||
/// seed matches a captured fault. Thread-order-independent by construction,
|
||||
/// mirroring [`lowest_point_fault`]'s and `lowest_window_fault`'s identical
|
||||
/// convention one level over (per-point sweep / per-window walk-forward).
|
||||
fn lowest_seed_fault(family: &McFamily, faults: Mutex<Vec<(u64, String)>>) -> Option<String> {
|
||||
let captured = faults.into_inner().expect("fault capture lock");
|
||||
family
|
||||
.draws
|
||||
.iter()
|
||||
.find_map(|d| captured.iter().find(|(s, _)| *s == d.seed).map(|(_, m)| m.clone()))
|
||||
}
|
||||
|
||||
/// Render + exit(3) on a contained member panic (#278: the deliberate
|
||||
/// failed-cells exit, C14, never the raw 101 an uncontained panic would
|
||||
/// yield) — reuses `aura_campaign::member_fault_prose`'s established
|
||||
/// "a member panicked: …" wording rather than re-wording it, and the
|
||||
/// `aura: warning: ` class marker (#278 decision) the CLI's own `diag`
|
||||
/// macro would emit, hand-written here since that macro is presentation-
|
||||
/// crate-private (#295 boundary) and this fires from `aura-runner`.
|
||||
fn exit_on_member_panic(msg: &str) -> ! {
|
||||
eprintln!("aura: warning: {}", member_fault_prose(&MemberFault::Panic(msg.to_string())));
|
||||
std::process::exit(3);
|
||||
}
|
||||
|
||||
/// Validate the `--axis` grid against `doc`'s wrapped param space WITHOUT running any
|
||||
/// member (#253). Reuses the SAME strict, erroring axis-name check
|
||||
/// `blueprint_sweep_over` performs (`override_paths` — single-sourced, so the two
|
||||
@@ -426,15 +472,35 @@ pub fn blueprint_sweep_family(
|
||||
for (n, vals) in iter {
|
||||
binder = binder.axis(n, vals.clone());
|
||||
}
|
||||
binder
|
||||
// #278: `run_blueprint_member` runs bare here (no #272 fault boundary of its
|
||||
// own), so a member-compile panic (e.g. an `Sma::new` length assert) would
|
||||
// otherwise unwind straight through this sweep to an uncaught exit 101 —
|
||||
// contained the same way the real/campaign path contains it, via
|
||||
// `catch_member_panic` + a per-point capture, resolved to the lowest
|
||||
// enumeration index's message after the sweep joins (thread-order-
|
||||
// independent, C1).
|
||||
let faults: Mutex<Vec<(Vec<Cell>, String)>> = Mutex::new(Vec::new());
|
||||
let family = binder
|
||||
.sweep(|point| {
|
||||
// fresh per-member graph (Composite is !Clone, reload per member) run through
|
||||
// the shared reduce-mode member path — the same fn reproduction re-runs.
|
||||
run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||
match catch_member_panic(|| {
|
||||
run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||
}) {
|
||||
Ok(report) => report,
|
||||
Err(msg) => {
|
||||
faults.lock().expect("fault capture lock").push((point.to_vec(), msg));
|
||||
axis_grid_probe_report()
|
||||
}
|
||||
}
|
||||
})
|
||||
// render the sweep terminal's BindError to prose (#247), the fn's String error
|
||||
// contract — never the raw Debug struct.
|
||||
.map_err(|e| render_bind_error(&e))
|
||||
.map_err(|e| render_bind_error(&e))?;
|
||||
if let Some(msg) = lowest_point_fault(&family, faults) {
|
||||
exit_on_member_panic(&msg);
|
||||
}
|
||||
Ok(family)
|
||||
}
|
||||
|
||||
/// Sweep the LOADED blueprint over the user `--axis` grid on an in-sample window
|
||||
@@ -447,10 +513,19 @@ pub fn blueprint_sweep_family(
|
||||
/// its walk-forward in-sample twin); an axis matching neither space is refused
|
||||
/// (wrapped as `BindError::UnknownKnob`, the honest replacement for the retired
|
||||
/// "fully bound; nothing to sweep" refusal) before any member runs.
|
||||
///
|
||||
/// The returned `Option<String>` (#278) is this fn's OWN resolved member-panic
|
||||
/// capture: this fn's nested sweep runs inside `blueprint_walkforward_family`'s
|
||||
/// per-window parallel closure, so a contained member panic here must NOT
|
||||
/// `eprintln!`+`exit` on the spot (that would race across windows, the exact
|
||||
/// `#177` duplicated-rejection class the dispatch-boundary axis pre-flight
|
||||
/// above already avoids) — captured internally, resolved to the lowest-index
|
||||
/// message via [`lowest_point_fault`] before returning, for the caller to push
|
||||
/// onto its own per-window fault list only after every window has joined.
|
||||
pub fn blueprint_sweep_over(
|
||||
doc: &str, axes: &[(String, Vec<Scalar>)], from: Timestamp, to: Timestamp, data: &DataSource,
|
||||
env: &Env, binding: &ResolvedBinding,
|
||||
) -> Result<(SweepFamily, Vec<usize>), BindError> {
|
||||
) -> Result<(SweepFamily, Vec<usize>, Option<String>), BindError> {
|
||||
let reload = |d: &str| {
|
||||
blueprint_from_json(d, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary; reload is infallible")
|
||||
@@ -476,11 +551,22 @@ pub fn blueprint_sweep_over(
|
||||
for (n, vals) in iter {
|
||||
binder = binder.axis(n, vals.clone());
|
||||
}
|
||||
binder.sweep_with_lattice(|point| {
|
||||
let faults: Mutex<Vec<(Vec<Cell>, String)>> = Mutex::new(Vec::new());
|
||||
let (family, lattice) = binder.sweep_with_lattice(|point| {
|
||||
let sources = data.windowed_sources(from, to, env, &binding.columns());
|
||||
let window = window_of(&sources).expect("non-empty in-sample window");
|
||||
run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||
})
|
||||
match catch_member_panic(|| {
|
||||
run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||
}) {
|
||||
Ok(report) => report,
|
||||
Err(msg) => {
|
||||
faults.lock().expect("fault capture lock").push((point.to_vec(), msg));
|
||||
axis_grid_probe_report()
|
||||
}
|
||||
}
|
||||
})?;
|
||||
let fault = lowest_point_fault(&family, faults);
|
||||
Ok((family, lattice, fault))
|
||||
}
|
||||
|
||||
/// Run the winner params over an out-of-sample window `[from,to]` on the loaded
|
||||
@@ -509,6 +595,37 @@ pub fn run_oos_blueprint(
|
||||
(Vec::new(), report)
|
||||
}
|
||||
|
||||
/// A discarded placeholder [`WindowRun`] for a window whose IS refit or OOS
|
||||
/// run was contained after a member panic (#278): `chosen_params` matches
|
||||
/// `space`'s arity with inert zero cells (the `assemble_walk_forward` arity
|
||||
/// invariant), `oos_equity` is empty (an empty segment leaves the stitched
|
||||
/// curve unbroken, same convention as a genuinely equity-less window), and
|
||||
/// the report reuses [`axis_grid_probe_report`]'s zero-cost placeholder.
|
||||
/// Never rendered: the caller always resolves the captured window fault and
|
||||
/// exits before touching a faulted window's run.
|
||||
fn placeholder_window_run(space: &[ParamSpec]) -> WindowRun {
|
||||
WindowRun {
|
||||
chosen_params: space.iter().map(|_| Cell::from_i64(0)).collect(),
|
||||
oos_equity: Vec::new(),
|
||||
oos_report: axis_grid_probe_report(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The LOWEST roll-order window's captured member-panic message (#278),
|
||||
/// found by walking `result.windows` — guaranteed by `assemble_walk_forward`
|
||||
/// to be in roll order regardless of thread completion (C1) — for the first
|
||||
/// window whose bounds match a captured fault. Thread-order-independent by
|
||||
/// construction, mirroring [`lowest_point_fault`]'s identical convention one
|
||||
/// level down (per-point within one window's IS sweep) and
|
||||
/// `aura-campaign::exec`'s own lowest-index walk-forward fault attribution.
|
||||
fn lowest_window_fault(result: &WalkForwardResult, faults: Mutex<Vec<(WindowBounds, String)>>) -> Option<String> {
|
||||
let captured = faults.into_inner().expect("fault capture lock");
|
||||
result
|
||||
.windows
|
||||
.iter()
|
||||
.find_map(|w| captured.iter().find(|(b, _)| *b == w.bounds).map(|(_, m)| m.clone()))
|
||||
}
|
||||
|
||||
/// The loaded-blueprint IS-refit walk-forward: per IS window, re-optimize the
|
||||
/// blueprint over the user `--axis` grid, select by `sqn_normalized`, run the
|
||||
/// winner OOS, reusing the generic `walk_forward` driver + `select_winner`;
|
||||
@@ -568,18 +685,45 @@ pub fn blueprint_walkforward_family(
|
||||
eprintln!("aura: {}", render_bind_error(&e));
|
||||
std::process::exit(2);
|
||||
}
|
||||
walk_forward(roller, space.clone(), |w: WindowBounds| {
|
||||
let (is_family, lattice) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env, &binding)
|
||||
// #278: `blueprint_sweep_over`'s inner sweep and `run_oos_blueprint`'s single
|
||||
// member run both drive `run_blueprint_member` bare, so either could
|
||||
// otherwise unwind an uncaught member-compile panic straight through this
|
||||
// window closure. Both are contained (`blueprint_sweep_over` resolves its
|
||||
// own capture and returns it; `run_oos_blueprint` via `catch_member_panic`
|
||||
// directly) and captured PER WINDOW here rather than printed on the spot —
|
||||
// this closure runs across windows in parallel (`walk_forward`), so an
|
||||
// inline `eprintln!`+`exit` would race the same way the axis pre-flight
|
||||
// above was written to avoid (#177). The lowest roll-order window's fault
|
||||
// is resolved once, after every window has joined.
|
||||
let window_faults: Mutex<Vec<(WindowBounds, String)>> = Mutex::new(Vec::new());
|
||||
let result = walk_forward(roller, space.clone(), |w: WindowBounds| {
|
||||
let (is_family, lattice, is_fault) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env, &binding)
|
||||
.expect("axes validated in the dispatch-boundary pre-flight");
|
||||
if let Some(msg) = is_fault {
|
||||
window_faults.lock().expect("fault capture lock").push((w, msg));
|
||||
return placeholder_window_run(&space);
|
||||
}
|
||||
let (best, selection) = match select_winner(&is_family, WINNER_SELECTION_METRIC, select, Some(&lattice)) {
|
||||
Ok(v) => v,
|
||||
Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); }
|
||||
};
|
||||
let (oos_equity, mut oos_report) =
|
||||
run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env, &binding, &overrides);
|
||||
oos_report.manifest.selection = Some(selection);
|
||||
WindowRun { chosen_params: best.params, oos_equity, oos_report }
|
||||
})
|
||||
match catch_member_panic(|| {
|
||||
run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env, &binding, &overrides)
|
||||
}) {
|
||||
Ok((oos_equity, mut oos_report)) => {
|
||||
oos_report.manifest.selection = Some(selection);
|
||||
WindowRun { chosen_params: best.params, oos_equity, oos_report }
|
||||
}
|
||||
Err(msg) => {
|
||||
window_faults.lock().expect("fault capture lock").push((w, msg));
|
||||
placeholder_window_run(&space)
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(msg) = lowest_window_fault(&result, window_faults) {
|
||||
exit_on_member_panic(&msg);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// A fresh seeded synthetic price walk for one Monte-Carlo draw — `blueprint_mc_family`'s
|
||||
@@ -642,11 +786,35 @@ pub fn blueprint_mc_family(
|
||||
// re-runs the shared reduce-mode member path over its own seeded synthetic walk.
|
||||
let seeds: Vec<u64> = (1..=n_seeds).collect();
|
||||
let base_point: Vec<Scalar> = Vec::new();
|
||||
// #278: `run_blueprint_member` ran bare here, the one family builder without
|
||||
// the #272 fault boundary its sweep/walk-forward siblings gained in 51096a3
|
||||
// — a member-compile panic (e.g. an `Sma::new` length assert) would otherwise
|
||||
// unwind straight through `monte_carlo`'s `run_indexed` to an uncaught exit
|
||||
// 101. Contained the same way: `catch_member_panic` + a per-seed capture,
|
||||
// resolved to the LOWEST seed's message after the join (thread-order-
|
||||
// independent, C1) via `lowest_seed_fault`.
|
||||
let faults: Mutex<Vec<(u64, String)>> = Mutex::new(Vec::new());
|
||||
let family = monte_carlo(&base_point, &seeds, |seed, _base| {
|
||||
let sources = synthetic_walk_sources(seed);
|
||||
let window = window_of(&sources).expect("non-empty synthetic walk");
|
||||
run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||
match catch_member_panic(|| {
|
||||
run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||
}) {
|
||||
Ok(report) => report,
|
||||
Err(msg) => {
|
||||
faults.lock().expect("fault capture lock").push((seed, msg));
|
||||
axis_grid_probe_report()
|
||||
}
|
||||
}
|
||||
});
|
||||
// The captured fault must win BEFORE the vacuous-mc guard below: a faulted
|
||||
// draw's placeholder report is metrics-identical across every faulted seed,
|
||||
// so a run with >= 2 faulted seeds (or one faulted + one real draw sharing
|
||||
// its placeholder's zero metrics) could otherwise trip the vacuous refusal
|
||||
// instead of surfacing the real member fault.
|
||||
if let Some(msg) = lowest_seed_fault(&family, faults) {
|
||||
exit_on_member_panic(&msg);
|
||||
}
|
||||
// Silent-vacuous MC guard (refuse-don't-guess, C10): with >= 2 seeds, if every draw's
|
||||
// metrics are bit-identical to the first, no seed reached a distinguishable realization —
|
||||
// the strategy never warmed over the fixed synthetic walk (e.g. a lookback as deep as the
|
||||
|
||||
@@ -18,10 +18,17 @@ pub mod member;
|
||||
pub mod project;
|
||||
pub mod reproduce;
|
||||
pub mod runner;
|
||||
pub mod tap_plan;
|
||||
pub mod tap_recorder;
|
||||
pub mod translate;
|
||||
|
||||
pub use project::Env;
|
||||
pub use runner::DefaultMemberRunner;
|
||||
pub use tap_plan::{
|
||||
bind_tap_plan, BoundTaps, FoldBuildCtx, FoldEntry, FoldOutput, FoldRegistry, FoldSink, TapPlan,
|
||||
TapPlanError, TapSubscription,
|
||||
};
|
||||
pub use tap_recorder::TapRecorder;
|
||||
|
||||
/// A refusal a library function reports instead of exiting the process
|
||||
/// itself. The shell (`aura-cli`'s `dispatch_reproduce`) is the single place
|
||||
|
||||
@@ -7,15 +7,14 @@
|
||||
//! into an `IcReport`) is unrelated to this run path and stays in the CLI
|
||||
//! shell (`dispatch_measure_ic`), which assembles and prints that DTO.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::mpsc;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{ColumnarTrace, Composite, Harness, MeasurementReport, RunManifest, TapBindError};
|
||||
use aura_std::Recorder;
|
||||
use aura_core::{Scalar, Timestamp};
|
||||
use aura_engine::{Composite, Harness, MeasurementReport, RunManifest};
|
||||
|
||||
use crate::member::{key_supply, resolve_run_data, wrapped_bound_defaults, RunData};
|
||||
use crate::project::Env;
|
||||
use crate::tap_plan::{bind_tap_plan, TapPlan};
|
||||
|
||||
/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`,
|
||||
/// falling back to `"unknown"`) — `measurement_manifest`'s `RunManifest.commit`
|
||||
@@ -56,10 +55,11 @@ pub fn measurement_manifest(
|
||||
/// eq/ex/r R-evaluation, KEEPING the declared-tap bind → drain → persist (C27).
|
||||
/// No broker, no risk executor, no per-cycle equity/exposure/r recorders — this
|
||||
/// is where the measured O(cycles) retention is removed. The tap machinery is
|
||||
/// duplicated (not extracted) so `run_signal_r` stays byte-identical.
|
||||
#[allow(clippy::type_complexity)]
|
||||
/// the shared `bind_tap_plan`/`BoundTaps` pair — one wiring for both entry
|
||||
/// points, so they cannot drift (#283).
|
||||
pub fn run_measurement(
|
||||
signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env,
|
||||
plan: TapPlan,
|
||||
) -> MeasurementReport {
|
||||
// topology_hash's own two-line body, inlined (mirrors member::run_signal_r):
|
||||
// `content_id_of` over the canonical (#164) blueprint JSON — the CLI shell's
|
||||
@@ -88,22 +88,12 @@ pub fn run_measurement(
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
// Bind each declared tap to a Recorder (mirrors run_signal_r).
|
||||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||
let mut tap_drains: Vec<(String, ScalarKind, mpsc::Receiver<(Timestamp, Vec<Scalar>)>)> = Vec::new();
|
||||
let declared: Vec<aura_engine::FlatTap> = flat.taps.clone();
|
||||
for tap in &declared {
|
||||
if !seen.insert(tap.name.clone()) {
|
||||
eprintln!("aura: {}", TapBindError::DuplicateBind { name: tap.name.clone() });
|
||||
std::process::exit(1);
|
||||
}
|
||||
let kind = flat.signatures[tap.node].output[tap.field].kind;
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let sig = Recorder::builder(vec![kind], Firing::Any, tx.clone()).schema().clone();
|
||||
flat.bind_tap(&tap.name, Box::new(Recorder::new(&[kind], Firing::Any, tx)), sig)
|
||||
.expect("declared tap binds (name from flat.taps, kind from its own signature)");
|
||||
tap_drains.push((tap.name.clone(), kind, rx));
|
||||
}
|
||||
// Bind each declared tap per the plan's subscription (mirrors
|
||||
// run_signal_r — the shared bind_tap_plan/BoundTaps pair IS the mirror).
|
||||
let bound = bind_tap_plan(&mut flat, plan, env, &run_name).unwrap_or_else(|e| {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let mut h = Harness::bootstrap(flat).expect("valid measurement harness");
|
||||
h.run_bound(key_supply(&binding, sources))
|
||||
@@ -116,20 +106,95 @@ pub fn run_measurement(
|
||||
manifest.topology_hash = Some(topo);
|
||||
manifest.project = env.provenance();
|
||||
|
||||
// Drain + persist each declared tap (mirrors run_signal_r).
|
||||
let tap_names: Vec<String> = tap_drains.iter().map(|(n, _, _)| n.clone()).collect();
|
||||
if !tap_drains.is_empty() {
|
||||
let tap_traces: Vec<ColumnarTrace> = tap_drains
|
||||
.into_iter()
|
||||
.map(|(name, kind, rx)| {
|
||||
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
|
||||
ColumnarTrace::from_rows(&name, &[kind], &rows)
|
||||
})
|
||||
.collect();
|
||||
env.trace_store().write(&run_name, &manifest, &tap_traces).unwrap_or_else(|e| {
|
||||
eprintln!("aura: writing tap traces failed: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
}
|
||||
// Close the tap plan (mirrors run_signal_r; nothing buffered, #283).
|
||||
let tap_names: Vec<String> = bound.declared_names().to_vec();
|
||||
bound.finish(&manifest).unwrap_or_else(|e| {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
MeasurementReport { manifest, taps: tap_names }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::project::ProjectEnv;
|
||||
use crate::tap_plan::TapSubscription;
|
||||
use aura_engine::blueprint_from_json;
|
||||
use aura_vocabulary::std_vocabulary;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A fresh project root so the run's `runs/` lands in a temp dir.
|
||||
fn temp_project_env(name: &str) -> (Env, PathBuf) {
|
||||
let root = std::env::temp_dir()
|
||||
.join(format!("aura-measure-plan-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create temp project root");
|
||||
let env = Env::with_project(ProjectEnv {
|
||||
root: root.clone(),
|
||||
toml: Default::default(),
|
||||
commit: None,
|
||||
native: None,
|
||||
});
|
||||
(env, root)
|
||||
}
|
||||
|
||||
/// The shipped `examples/r_sma.json` with one declared tap on node 0
|
||||
/// ("fast", SMA(length=2)) field 0 — the same patch shape as
|
||||
/// `aura-cli/tests/tap_recording.rs`.
|
||||
fn tapped_r_sma() -> Composite {
|
||||
let doc = include_str!("../../aura-cli/examples/r_sma.json");
|
||||
let mut v: serde_json::Value = serde_json::from_str(doc).expect("parse r_sma.json");
|
||||
v["blueprint"]["taps"] =
|
||||
serde_json::json!([{"name": "fast_tap", "from": {"node": 0, "field": 0}}]);
|
||||
let patched = serde_json::to_string(&v).expect("re-serialize");
|
||||
blueprint_from_json(&patched, &|t| std_vocabulary(t)).expect("tapped r_sma loads")
|
||||
}
|
||||
|
||||
/// The exact synthetic price array the `RunData::Synthetic` path feeds
|
||||
/// (`r_sma_prices()`, main.rs) — duplicated so the fold value is pinned
|
||||
/// against an independently-derived expectation, not a blind capture
|
||||
/// (the `tap_recording.rs` discipline).
|
||||
const R_SMA_PRICES: [f64; 18] = [
|
||||
1.0000, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034, 1.0012, 0.9998,
|
||||
1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn measurement_fold_plan_writes_one_row_trace_through_the_shared_pair() {
|
||||
let (env, root) = temp_project_env("fold");
|
||||
let mut plan = TapPlan::empty();
|
||||
plan.subscribe("fast_tap", TapSubscription::named("mean"));
|
||||
let report = run_measurement(tapped_r_sma(), &[], RunData::Synthetic, 0, &env, plan);
|
||||
assert_eq!(report.taps, vec!["fast_tap".to_string()]);
|
||||
|
||||
let text = std::fs::read_to_string(
|
||||
root.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
|
||||
)
|
||||
.expect("one-row fold trace persisted");
|
||||
let v: serde_json::Value = serde_json::from_str(&text).expect("parse");
|
||||
assert_eq!(v["kinds"], serde_json::json!(["F64"]));
|
||||
let ts = v["ts"].as_array().expect("ts");
|
||||
let col = v["columns"][0].as_array().expect("col");
|
||||
assert_eq!((ts.len(), col.len()), (1, 1), "exactly one summary row");
|
||||
// SMA(2) series (warm from sample 2), folded mean — sequential sum,
|
||||
// same operation order as FoldState (bit-identical, IEEE-754).
|
||||
let sma: Vec<f64> = (1..R_SMA_PRICES.len())
|
||||
.map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
|
||||
.collect();
|
||||
let mean = sma.iter().sum::<f64>() / sma.len() as f64;
|
||||
assert_eq!(col[0].as_f64().expect("f64 row"), mean, "streamed mean == slice mean");
|
||||
assert_eq!(ts[0].as_i64().expect("ts"), R_SMA_PRICES.len() as i64, "last folded ts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measurement_tap_free_blueprint_writes_nothing() {
|
||||
let (env, root) = temp_project_env("tapfree");
|
||||
let doc = include_str!("../../aura-cli/examples/r_sma.json");
|
||||
let signal = blueprint_from_json(doc, &|t| std_vocabulary(t)).expect("r_sma loads");
|
||||
let report =
|
||||
run_measurement(signal, &[], RunData::Synthetic, 0, &env, TapPlan::record_all());
|
||||
assert!(report.taps.is_empty(), "no declared taps");
|
||||
assert!(!root.join("runs").exists(), "a tap-free run writes no runs/ entry");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
//! `CliMemberRunner`, and any downstream World program) drive real data
|
||||
//! through.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::sync::{mpsc, Arc, LazyLock};
|
||||
|
||||
use aura_composites::{cost_graph, risk_executor, StopRule};
|
||||
use aura_core::{zip_params, Cell, Firing, ParamSpec, PrimitiveBuilder, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{
|
||||
blueprint_from_json, window_of, BlueprintNode, ColumnarTrace, Composite, GraphBuilder, Harness,
|
||||
RunManifest, VecSource,
|
||||
blueprint_from_json, window_of, BlueprintNode, Composite, GraphBuilder, Harness, RunManifest,
|
||||
VecSource,
|
||||
};
|
||||
use aura_backtest::{
|
||||
summarize, summarize_r, RunMetrics, RunReport, SimBroker, PM_FIELD_NAMES, PM_RECORD_KINDS,
|
||||
@@ -26,6 +26,7 @@ use aura_strategy::{cost_port, GEOMETRY_WIDTH};
|
||||
|
||||
use crate::binding::ResolvedBinding;
|
||||
use crate::project::Env;
|
||||
use crate::tap_plan::{bind_tap_plan, TapPlan};
|
||||
use crate::translate::{R_SMA_STOP_LENGTH, R_SMA_STOP_K};
|
||||
|
||||
/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`,
|
||||
@@ -480,6 +481,7 @@ pub fn resolve_run_data(
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn run_signal_r(
|
||||
signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env,
|
||||
plan: TapPlan,
|
||||
) -> RunReport {
|
||||
// topology_hash's own two-line body, inlined: `content_id_of` over the
|
||||
// canonical (#164) blueprint JSON — the CLI shell's `topology_hash`
|
||||
@@ -517,25 +519,14 @@ pub fn run_signal_r(
|
||||
eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
// Bind each declared tap to a fresh `Recorder` + channel, before bootstrap
|
||||
// — `flat.taps` already carries the signal's declared taps hoisted to the
|
||||
// root. Dedup is the caller's per `TapBindError::DuplicateBind`'s doc (the
|
||||
// engine keeps no cross-call state).
|
||||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||
let mut tap_drains: Vec<(String, ScalarKind, mpsc::Receiver<(Timestamp, Vec<Scalar>)>)> = Vec::new();
|
||||
let declared: Vec<aura_engine::FlatTap> = flat.taps.clone();
|
||||
for tap in &declared {
|
||||
if !seen.insert(tap.name.clone()) {
|
||||
eprintln!("aura: {}", aura_engine::TapBindError::DuplicateBind { name: tap.name.clone() });
|
||||
std::process::exit(1);
|
||||
}
|
||||
let kind = flat.signatures[tap.node].output[tap.field].kind;
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let sig = Recorder::builder(vec![kind], Firing::Any, tx.clone()).schema().clone();
|
||||
flat.bind_tap(&tap.name, Box::new(Recorder::new(&[kind], Firing::Any, tx)), sig)
|
||||
.expect("declared tap binds (name from flat.taps, kind from its own signature)");
|
||||
tap_drains.push((tap.name.clone(), kind, rx));
|
||||
}
|
||||
// Bind each declared tap per the plan's subscription, before bootstrap
|
||||
// (#283): typed refusals for bad plans, record consumers hold their
|
||||
// streaming writer in-graph, folds accumulate O(1), live closures run
|
||||
// inline. Dedup stays caller-owned per TapBindError::DuplicateBind's doc.
|
||||
let bound = bind_tap_plan(&mut flat, plan, env, &run_name).unwrap_or_else(|e| {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let mut h = Harness::bootstrap(flat).expect("valid r-sma harness");
|
||||
// `sources` were opened via `resolve_run_data(&data, env, &binding)` against
|
||||
// this SAME `binding`, and `key_supply` keys them by that binding's own role
|
||||
@@ -557,23 +548,13 @@ pub fn run_signal_r(
|
||||
manifest.project = env.provenance();
|
||||
let mut metrics = summarize(&aura_engine::f64_field(&eq_rows, 0), &aura_engine::f64_field(&ex_rows, 0));
|
||||
metrics.r = Some(summarize_r(&r_rows, &[]));
|
||||
// Drain + persist each declared tap's series, guarded so a tap-free
|
||||
// `aura run` stays byte-identical to today (no `runs/` write at all).
|
||||
// `manifest` is built above so the persisted `index.json` carries this
|
||||
// run's own provenance.
|
||||
if !tap_drains.is_empty() {
|
||||
let tap_traces: Vec<ColumnarTrace> = tap_drains
|
||||
.into_iter()
|
||||
.map(|(name, kind, rx)| {
|
||||
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
|
||||
ColumnarTrace::from_rows(&name, &[kind], &rows)
|
||||
})
|
||||
.collect();
|
||||
env.trace_store().write(&run_name, &manifest, &tap_traces).unwrap_or_else(|e| {
|
||||
eprintln!("aura: writing tap traces failed: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
}
|
||||
// Close the tap plan: drain the ≤1-message channels, write fold rows,
|
||||
// then `index.json` — nothing was buffered during the run (#283). A
|
||||
// tap-free (or nothing-persisting) plan wrote nothing at all.
|
||||
bound.finish(&manifest).unwrap_or_else(|e| {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
RunReport { manifest, metrics }
|
||||
}
|
||||
|
||||
@@ -801,6 +782,7 @@ pub fn reopen_all(signal: Composite, overrides: &[String]) -> Composite {
|
||||
fn r_breakout_signal(channel: Option<i64>) -> Composite {
|
||||
use aura_std::{Delay, Gt, Latch};
|
||||
let mut g = GraphBuilder::new("r_breakout_signal");
|
||||
g.doc("rolling-extreme breakout latched into a long/short signal");
|
||||
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
|
||||
let mut mx_b = RollingMax::builder().named("channel_hi");
|
||||
let mut mn_b = RollingMin::builder().named("channel_lo");
|
||||
@@ -846,6 +828,7 @@ fn r_breakout_signal(channel: Option<i64>) -> Composite {
|
||||
fn r_meanrev_signal(window: Option<i64>, band_k: Option<f64>) -> Composite {
|
||||
use aura_std::{Add, Ema, Gt, Latch, Mul, Scale, Sqrt};
|
||||
let mut g = GraphBuilder::new("r_meanrev_signal");
|
||||
g.doc("EMA deviation against a volatility band, latched into a mean-reversion signal");
|
||||
let (mut mean_b, mut var_b) =
|
||||
(Ema::builder().named("mean_window"), Ema::builder().named("var_window"));
|
||||
if let Some(n) = window {
|
||||
@@ -910,6 +893,7 @@ fn r_meanrev_signal(window: Option<i64>, band_k: Option<f64>) -> Composite {
|
||||
fn r_channel_signal(channel: Option<i64>) -> Composite {
|
||||
use aura_std::{Delay, Gt, Latch};
|
||||
let mut g = GraphBuilder::new("hl_channel");
|
||||
g.doc("prior high/low channel breaks latched into a directional signal");
|
||||
let delay_hi = g.add(Delay::builder().named("prev_high").bind("lag", Scalar::i64(1)));
|
||||
let delay_lo = g.add(Delay::builder().named("prev_low").bind("lag", Scalar::i64(1)));
|
||||
let mut mx_b = RollingMax::builder().named("channel_hi");
|
||||
@@ -954,6 +938,9 @@ mod tests {
|
||||
use aura_engine::blueprint_to_json;
|
||||
use aura_strategy::{ConstantCost, VolSlippageCost};
|
||||
use aura_vocabulary::std_vocabulary;
|
||||
use crate::tap_plan::{bind_tap_plan, TapPlanError, TapSubscription};
|
||||
use crate::project::ProjectEnv;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[test]
|
||||
/// Independently pins the shipped `r_meanrev_signal` carve's FADE direction —
|
||||
@@ -1158,8 +1145,8 @@ mod tests {
|
||||
let env = Env::std();
|
||||
let loaded = blueprint_from_json(include_str!("../../aura-cli/examples/r_breakout.json"), &|t| std_vocabulary(t))
|
||||
.expect("shipped r_breakout example loads");
|
||||
let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env);
|
||||
let via_carve = run_signal_r(r_breakout_signal(Some(R_BREAKOUT_CHANNEL)), &[], RunData::Synthetic, 0, &env);
|
||||
let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env, TapPlan::record_all());
|
||||
let via_carve = run_signal_r(r_breakout_signal(Some(R_BREAKOUT_CHANNEL)), &[], RunData::Synthetic, 0, &env, TapPlan::record_all());
|
||||
assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve");
|
||||
}
|
||||
|
||||
@@ -1311,13 +1298,14 @@ mod tests {
|
||||
let env = Env::std();
|
||||
let loaded = blueprint_from_json(include_str!("../../aura-cli/examples/r_meanrev.json"), &|t| std_vocabulary(t))
|
||||
.expect("shipped r_meanrev example loads");
|
||||
let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env);
|
||||
let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env, TapPlan::record_all());
|
||||
let via_carve = run_signal_r(
|
||||
r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K)),
|
||||
&[],
|
||||
RunData::Synthetic,
|
||||
0,
|
||||
&env,
|
||||
TapPlan::record_all(),
|
||||
);
|
||||
assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve");
|
||||
}
|
||||
@@ -1646,4 +1634,133 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A fresh project root so a run's `runs/` lands in a temp dir.
|
||||
fn temp_project_env(name: &str) -> (Env, PathBuf) {
|
||||
let root = std::env::temp_dir()
|
||||
.join(format!("aura-member-plan-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create temp project root");
|
||||
let env = Env::with_project(ProjectEnv {
|
||||
root: root.clone(),
|
||||
toml: Default::default(),
|
||||
commit: None,
|
||||
native: None,
|
||||
});
|
||||
(env, root)
|
||||
}
|
||||
|
||||
/// The shipped `examples/r_sma.json` with one declared tap on node 0
|
||||
/// ("fast", SMA(length=2)) field 0 — the `tap_recording.rs` patch shape.
|
||||
/// `Composite` is `!Clone`, so each run loads it afresh.
|
||||
fn tapped_r_sma() -> Composite {
|
||||
let doc = include_str!("../../aura-cli/examples/r_sma.json");
|
||||
let mut v: serde_json::Value = serde_json::from_str(doc).expect("parse r_sma.json");
|
||||
v["blueprint"]["taps"] =
|
||||
serde_json::json!([{"name": "fast_tap", "from": {"node": 0, "field": 0}}]);
|
||||
let patched = serde_json::to_string(&v).expect("re-serialize");
|
||||
blueprint_from_json(&patched, &|t| std_vocabulary(t)).expect("tapped r_sma loads")
|
||||
}
|
||||
|
||||
fn read_tap_json(root: &Path) -> serde_json::Value {
|
||||
let text = std::fs::read_to_string(
|
||||
root.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
|
||||
)
|
||||
.expect("persisted tap trace");
|
||||
serde_json::from_str(&text).expect("parse tap trace")
|
||||
}
|
||||
|
||||
/// #283 acceptance: a fold plan's one-row summary and a live plan's
|
||||
/// collected stream both agree with the recorded series of the same
|
||||
/// blueprint (C1 — three identical runs, three drain policies), and a
|
||||
/// live-only plan persists nothing at all.
|
||||
#[test]
|
||||
fn fold_and_live_plans_agree_with_the_recorded_series() {
|
||||
// Run A: record (the charting question).
|
||||
let (env_a, root_a) = temp_project_env("record");
|
||||
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_a, TapPlan::record_all());
|
||||
let series = read_tap_json(&root_a);
|
||||
let ts: Vec<i64> =
|
||||
series["ts"].as_array().unwrap().iter().map(|v| v.as_i64().unwrap()).collect();
|
||||
let vals: Vec<f64> =
|
||||
series["columns"][0].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
assert!(!vals.is_empty(), "the recorded series is the reference");
|
||||
|
||||
// Run B: fold to mean (the aggregate question, same blueprint).
|
||||
let (env_b, root_b) = temp_project_env("fold");
|
||||
let mut plan_b = TapPlan::record_all();
|
||||
plan_b.subscribe("fast_tap", TapSubscription::named("mean"));
|
||||
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, plan_b);
|
||||
let row = read_tap_json(&root_b);
|
||||
let mean = vals.iter().sum::<f64>() / vals.len() as f64; // sequential — FoldState's order
|
||||
assert_eq!(row["ts"], serde_json::json!([*ts.last().unwrap()]));
|
||||
assert_eq!(row["columns"], serde_json::json!([[mean]]), "fold row == slice mean, bit-exact");
|
||||
|
||||
// Run C: live only (nothing persisted; the closure sees the series).
|
||||
let (env_c, root_c) = temp_project_env("live");
|
||||
let (live_tx, live_rx) = std::sync::mpsc::channel();
|
||||
let mut plan_c = TapPlan::empty();
|
||||
plan_c.subscribe(
|
||||
"fast_tap",
|
||||
TapSubscription::live(move |ts, cell| {
|
||||
let _ = live_tx.send((ts.0, cell.f64()));
|
||||
}),
|
||||
);
|
||||
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_c, plan_c);
|
||||
let got: Vec<(i64, f64)> = live_rx.try_iter().collect();
|
||||
let want: Vec<(i64, f64)> = ts.iter().copied().zip(vals.iter().copied()).collect();
|
||||
assert_eq!(got, want, "the live closure saw exactly the recorded series (C1)");
|
||||
assert!(!root_c.join("runs").exists(), "a live-only plan persists nothing");
|
||||
}
|
||||
|
||||
/// Plan refusals are typed and fire before any store I/O.
|
||||
#[test]
|
||||
fn bind_tap_plan_refuses_unknown_tap_and_unknown_label_before_the_store() {
|
||||
let (env, root) = temp_project_env("refusals");
|
||||
|
||||
// Unknown tap name.
|
||||
let mut flat = tapped_r_sma().compile_with_params(&[]).expect("tapped r_sma compiles");
|
||||
let mut plan = TapPlan::record_all();
|
||||
plan.subscribe("no_such_tap", TapSubscription::named("mean"));
|
||||
// `BoundTaps` (the `Ok` side) carries no `Debug`, so `expect_err` is
|
||||
// unavailable here — match explicitly instead (same refusal intent).
|
||||
let err = match bind_tap_plan(&mut flat, plan, &env, "sma_signal") {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("unknown tap must be refused"),
|
||||
};
|
||||
assert!(
|
||||
matches!(err, TapPlanError::UnknownTap { ref name } if name == "no_such_tap"),
|
||||
"{err:?}"
|
||||
);
|
||||
|
||||
// Unknown label — the refusal enumerates the roster.
|
||||
let mut flat2 = tapped_r_sma().compile_with_params(&[]).expect("tapped r_sma compiles");
|
||||
let mut plan2 = TapPlan::record_all();
|
||||
plan2.subscribe("fast_tap", TapSubscription::named("p95"));
|
||||
let err2 = match bind_tap_plan(&mut flat2, plan2, &env, "sma_signal") {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("unknown label must be refused"),
|
||||
};
|
||||
let msg = err2.to_string();
|
||||
assert!(msg.starts_with("unknown fold 'p95'") && msg.contains("mean"), "{msg}");
|
||||
|
||||
assert!(!root.join("runs").exists(), "refusals fire before any store write");
|
||||
}
|
||||
|
||||
/// C1: two identical record-all runs produce byte-identical tap files.
|
||||
#[test]
|
||||
fn two_identical_record_runs_produce_byte_identical_tap_files() {
|
||||
let (env_a, root_a) = temp_project_env("det-a");
|
||||
let (env_b, root_b) = temp_project_env("det-b");
|
||||
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_a, TapPlan::record_all());
|
||||
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, TapPlan::record_all());
|
||||
let a = std::fs::read(
|
||||
root_a.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
|
||||
)
|
||||
.expect("run a trace");
|
||||
let b = std::fs::read(
|
||||
root_b.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
|
||||
)
|
||||
.expect("run b trace");
|
||||
assert_eq!(a, b, "same input, same bytes (the index carries env provenance; the tap file must not)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! vocabulary charter. `Env` is the per-invocation context every verb reads:
|
||||
//! merged resolver, runs root, data path, provenance.
|
||||
|
||||
use aura_core::PrimitiveBuilder;
|
||||
use aura_core::{DocGateFault, PrimitiveBuilder, doc_gate};
|
||||
use aura_core::project::{
|
||||
AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_VERSION, AURA_PROJECT_SYMBOL,
|
||||
CORE_VERSION, ProjectDescriptor, RUSTC_VERSION, StrSlice,
|
||||
@@ -63,6 +63,11 @@ pub enum ProjectError {
|
||||
/// handing off to `load_crate`, so this never reaches the cargo-metadata
|
||||
/// probe (whose raw os-error text would otherwise leak through).
|
||||
PointerDirMissing(PathBuf),
|
||||
/// C29 load seam (#316): a resolved extension-vocabulary entry whose doc
|
||||
/// fails the shape gate — the load refuses rather than admit an
|
||||
/// undescribed node. Field presence is compile-enforced; shape is not,
|
||||
/// so the gate carries the failed rule for the refusal prose.
|
||||
UndescribedVocabularyEntry { type_id: String, fault: DocGateFault },
|
||||
}
|
||||
|
||||
impl fmt::Display for ProjectError {
|
||||
@@ -100,6 +105,19 @@ impl fmt::Display for ProjectError {
|
||||
"node crate at `{pointer}` (from [nodes] in Aura.toml): {inner}"
|
||||
),
|
||||
Self::PointerDirMissing(p) => write!(f, "{} does not exist", p.display()),
|
||||
Self::UndescribedVocabularyEntry { type_id, fault } => {
|
||||
let rule = match fault {
|
||||
DocGateFault::Empty => "has an empty doc",
|
||||
DocGateFault::RestatesName => {
|
||||
"has a doc that merely restates its name"
|
||||
}
|
||||
};
|
||||
write!(
|
||||
f,
|
||||
"vocabulary entry `{type_id}` {rule} — every vocabulary \
|
||||
entry ships a one-line meaning (C29)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -444,7 +462,7 @@ fn stale_warning(
|
||||
) -> Option<String> {
|
||||
if source_mtime > dylib_mtime {
|
||||
Some(format!(
|
||||
"warning: {} may be stale — dylib built {} but a project source \
|
||||
"aura: warning: {} may be stale — dylib built {} but a project source \
|
||||
file was modified {} (run `cargo build` to refresh); proceeding \
|
||||
with the existing dylib",
|
||||
dylib_path.display(),
|
||||
@@ -579,6 +597,18 @@ fn load_crate(crate_root: &Path, release: bool) -> Result<NativeEnv, ProjectErro
|
||||
let type_id_list = (desc.type_ids)();
|
||||
check_charter(&namespace, type_id_list, &|t| resolver(t))?;
|
||||
|
||||
// C29 load seam (#316): every resolved entry must describe itself. The
|
||||
// charter cross-check above guarantees each listed id resolves, so a
|
||||
// `None` here is unreachable — skipped rather than panicked on (a
|
||||
// hostile dylib must never panic the host).
|
||||
for &t in type_id_list {
|
||||
if let Some(builder) = resolver(t) {
|
||||
doc_gate(t, builder.schema().doc).map_err(|fault| {
|
||||
ProjectError::UndescribedVocabularyEntry { type_id: t.to_string(), fault }
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(NativeEnv { namespace, dylib_sha256, resolver, type_id_list })
|
||||
}
|
||||
|
||||
@@ -886,6 +916,7 @@ mod tests {
|
||||
let source_mtime = UNIX_EPOCH + Duration::from_secs(2_003_702_400); // 2033-06-30
|
||||
let path = PathBuf::from("/tmp/x.so");
|
||||
let msg = stale_warning(&path, dylib_mtime, source_mtime).expect("source is newer");
|
||||
assert!(msg.starts_with("aura: warning: "), "carries the warning class marker (#278): {msg}");
|
||||
assert!(msg.contains("2001"), "names the dylib's mtime: {msg}");
|
||||
assert!(msg.contains("2033"), "names the source's newer mtime: {msg}");
|
||||
}
|
||||
@@ -903,4 +934,32 @@ mod tests {
|
||||
"older source: not stale"
|
||||
);
|
||||
}
|
||||
|
||||
/// The C29 load-seam refusal prose (`UndescribedVocabularyEntry`) names
|
||||
/// the entry and the failed rule for BOTH `DocGateFault` arms. Only
|
||||
/// `Empty` is reachable end-to-end today (the fixture in
|
||||
/// `aura-cli/tests/project_load.rs` uses `doc: ""`); `RestatesName` has
|
||||
/// no such fixture, so this pins its rendered message directly against
|
||||
/// `Display` — a regression there would otherwise pass every test.
|
||||
#[test]
|
||||
fn undescribed_vocabulary_entry_display_names_the_entry_and_rule() {
|
||||
let empty = ProjectError::UndescribedVocabularyEntry {
|
||||
type_id: "ns::Foo".to_string(),
|
||||
fault: DocGateFault::Empty,
|
||||
};
|
||||
assert_eq!(
|
||||
empty.to_string(),
|
||||
"vocabulary entry `ns::Foo` has an empty doc — every vocabulary \
|
||||
entry ships a one-line meaning (C29)"
|
||||
);
|
||||
let restates = ProjectError::UndescribedVocabularyEntry {
|
||||
type_id: "ns::Foo".to_string(),
|
||||
fault: DocGateFault::RestatesName,
|
||||
};
|
||||
assert_eq!(
|
||||
restates.to_string(),
|
||||
"vocabulary entry `ns::Foo` has a doc that merely restates its \
|
||||
name — every vocabulary entry ships a one-line meaning (C29)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ pub fn tap_channel(tap: &str) -> Option<TapChannel> {
|
||||
debug_assert!(
|
||||
aura_research::tap_vocabulary()
|
||||
.iter()
|
||||
.all(|t| matches!(*t, "equity" | "exposure" | "r_equity" | "net_r_equity")),
|
||||
.all(|t| matches!(t.id, "equity" | "exposure" | "r_equity" | "net_r_equity")),
|
||||
"tap_vocabulary drifted from the channels persist_campaign_traces routes"
|
||||
);
|
||||
match tap {
|
||||
@@ -259,6 +259,28 @@ pub fn tap_channel(tap: &str) -> Option<TapChannel> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The note text for a requested tap outside the closed vocabulary (#278):
|
||||
/// pure over just the tap name, so the exact marker-bearing text
|
||||
/// `persist_campaign_traces` writes to stderr is unit-testable without
|
||||
/// booting a real archive run (the `tap_channel` `None` arm that reaches
|
||||
/// this is defensive — `validate_campaign` already refuses an unknown tap
|
||||
/// name before this function ever runs).
|
||||
fn tap_not_produced_note(tap: &str) -> String {
|
||||
format!("aura: note: tap \"{tap}\" is not produced by this run; skipped")
|
||||
}
|
||||
|
||||
/// The note text for a cell with neither a nominee nor a non-empty terminal
|
||||
/// family (#278): pure over the record's own cell fields, so the exact
|
||||
/// marker-bearing text `persist_campaign_traces` writes to stderr is
|
||||
/// unit-testable without booting a real archive run — the `cell_member_fanout`
|
||||
/// twin above already covers the emptiness logic itself.
|
||||
fn no_nominee_note(strategy: &str, instrument: &str, window_ms: (i64, i64)) -> String {
|
||||
format!(
|
||||
"aura: note: cell {strategy}/{instrument}/[{}, {}]: no nominee; no traces persisted",
|
||||
window_ms.0, window_ms.1
|
||||
)
|
||||
}
|
||||
|
||||
/// The content-derived on-disk key of one campaign cell under
|
||||
/// `traces/<trace_name>/` — the `member_key` discipline (content, never a
|
||||
/// runtime ordinal): strategy content-id prefix + instrument +
|
||||
@@ -368,10 +390,10 @@ pub fn persist_campaign_traces(
|
||||
for tap in taps {
|
||||
match tap_channel(tap) {
|
||||
Some(TapChannel::Net) if campaign.cost.is_empty() => eprintln!(
|
||||
"aura: tap \"{tap}\" needs a cost model; add a cost block to the campaign document; skipped"
|
||||
"aura: note: tap \"{tap}\" needs a cost model; add a cost block to the campaign document; skipped"
|
||||
),
|
||||
Some(ch) => routed.push((tap.as_str(), ch)),
|
||||
None => eprintln!("aura: tap \"{tap}\" is not produced by this run; skipped"),
|
||||
None => eprintln!("{}", tap_not_produced_note(tap)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,8 +410,8 @@ pub fn persist_campaign_traces(
|
||||
let members = cell_member_fanout(cell_out);
|
||||
if members.is_empty() {
|
||||
eprintln!(
|
||||
"aura: cell {}/{}/[{}, {}]: no nominee; no traces persisted",
|
||||
cell_rec.strategy, cell_rec.instrument, cell_rec.window_ms.0, cell_rec.window_ms.1
|
||||
"{}",
|
||||
no_nominee_note(&cell_rec.strategy, &cell_rec.instrument, cell_rec.window_ms)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -753,4 +775,27 @@ mod tests {
|
||||
assert_eq!(tap_channel("net_r_equity"), Some(TapChannel::Net));
|
||||
assert_eq!(tap_channel("bogus"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #278: the unrecognized-tap note carries the `note` class marker AND
|
||||
/// names the offending tap — the exact text `persist_campaign_traces`
|
||||
/// writes to stderr on `tap_channel`'s defensive `None` arm.
|
||||
fn tap_not_produced_note_carries_the_note_marker_and_names_the_tap() {
|
||||
let msg = tap_not_produced_note("bogus");
|
||||
assert!(msg.starts_with("aura: note: "), "carries the note class marker: {msg}");
|
||||
assert!(msg.contains("\"bogus\""), "names the offending tap: {msg}");
|
||||
assert!(msg.contains("is not produced by this run"), "states the reason: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #278: the no-candidate-cell note carries the `note` class marker AND
|
||||
/// names the cell (strategy/instrument/window) — the exact text
|
||||
/// `persist_campaign_traces` writes to stderr when `cell_member_fanout`
|
||||
/// (unit-tested above) reports no nominee and no non-empty family.
|
||||
fn no_nominee_note_carries_the_note_marker_and_names_the_cell() {
|
||||
let msg = no_nominee_note("bb34aa55", "GER40", (1_000, 2_000));
|
||||
assert!(msg.starts_with("aura: note: "), "carries the note class marker: {msg}");
|
||||
assert!(msg.contains("bb34aa55/GER40/[1000, 2000]"), "names the cell: {msg}");
|
||||
assert!(msg.contains("no nominee; no traces persisted"), "states the reason: {msg}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
//! The per-run tap subscription plan (#283, 2026-07-21 design revision) and
|
||||
//! the layered fold registry it resolves against. A tap stays a pure
|
||||
//! declaration (C27); this module is where the run/measurement side declares
|
||||
//! what consumes it. Record, fold, and live are ONE mechanism — a consumer
|
||||
//! of the tap's `(Timestamp, Cell)` stream; the only real axis is data-layer
|
||||
//! serializability: a `Named` subscription (label + scalar params) is fully
|
||||
//! expressible as data, a `Live` closure is the single deliberately non-data
|
||||
//! variant. The registry mirrors the node-vocabulary pattern
|
||||
//! (`std_vocabulary` + `Env::resolve`): a closed, typed vocabulary whose
|
||||
//! growth is a new Rust entry (C25) — core seeds here, higher layers
|
||||
//! (e.g. trading) register their own entries without bleeding into the core.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
|
||||
use aura_core::{Cell, Firing, Node, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{ColumnarTrace, FlatGraph, RunManifest, TapBindError};
|
||||
use aura_registry::{TapWriterOpener, TraceStoreError, TraceStreamer};
|
||||
use aura_std::{fold_binds_at, fold_output_kind, FoldKind, TapFold, TapLive};
|
||||
|
||||
use crate::project::Env;
|
||||
use crate::tap_recorder::TapRecorder;
|
||||
|
||||
/// What consumes one declared tap during a run — the drain policy, declared
|
||||
/// on the run/measurement side (#283), never on the tap.
|
||||
pub enum TapSubscription {
|
||||
/// A consumer from the plan's fold registry, by label, with scalar-typed
|
||||
/// construction parameters (v1 core entries take none).
|
||||
Named { label: String, params: Vec<(String, Scalar)> },
|
||||
/// An in-process consumer; loss policy is the closure's own.
|
||||
Live(Box<dyn FnMut(Timestamp, Cell) + Send>),
|
||||
}
|
||||
|
||||
impl TapSubscription {
|
||||
/// A named subscription with no construction parameters.
|
||||
pub fn named(label: &str) -> Self {
|
||||
TapSubscription::Named { label: label.to_string(), params: Vec::new() }
|
||||
}
|
||||
|
||||
/// A named subscription with construction parameters.
|
||||
pub fn named_with(label: &str, params: Vec<(String, Scalar)>) -> Self {
|
||||
TapSubscription::Named { label: label.to_string(), params }
|
||||
}
|
||||
|
||||
/// Sugar for a live subscription around a closure.
|
||||
pub fn live(consumer: impl FnMut(Timestamp, Cell) + Send + 'static) -> Self {
|
||||
TapSubscription::Live(Box::new(consumer))
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-run tap plan: name → subscription; unnamed taps get the default. The
|
||||
/// plan carries the registry it resolves against (the `Env`-at-resolve
|
||||
/// pattern), so a layered host injects its extended vocabulary as data. The
|
||||
/// only defaults are `named("record")` (`record_all` — the one entry that
|
||||
/// binds at every kind and takes no params) and `None` (empty plan; unnamed
|
||||
/// taps stay unbound, inert per C27) — fold/live subscriptions are per-tap,
|
||||
/// never a default, structurally (the field is private and no constructor
|
||||
/// sets another).
|
||||
pub struct TapPlan {
|
||||
pub(crate) registry: FoldRegistry,
|
||||
pub(crate) default_named: Option<(String, Vec<(String, Scalar)>)>,
|
||||
pub(crate) by_name: BTreeMap<String, TapSubscription>,
|
||||
}
|
||||
|
||||
impl TapPlan {
|
||||
/// Record every declared tap — the CLI's plan on both verbs.
|
||||
pub fn record_all() -> Self {
|
||||
Self::record_all_with(FoldRegistry::core())
|
||||
}
|
||||
|
||||
/// `record_all` over an injected (layered) registry.
|
||||
pub fn record_all_with(registry: FoldRegistry) -> Self {
|
||||
TapPlan {
|
||||
registry,
|
||||
default_named: Some(("record".to_string(), Vec::new())),
|
||||
by_name: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// No default: only explicitly subscribed taps are consumed.
|
||||
pub fn empty() -> Self {
|
||||
TapPlan { registry: FoldRegistry::core(), default_named: None, by_name: BTreeMap::new() }
|
||||
}
|
||||
|
||||
/// Subscribe one tap by name (replaces an earlier subscription for it).
|
||||
pub fn subscribe(&mut self, tap: &str, sub: TapSubscription) {
|
||||
self.by_name.insert(tap.to_string(), sub);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistence shape of an entry's consumer.
|
||||
pub enum FoldOutput {
|
||||
/// The full series, streamed (the `record` entry).
|
||||
Series,
|
||||
/// One summary row at finalize, of this kind.
|
||||
Row(ScalarKind),
|
||||
}
|
||||
|
||||
/// The sink a build call receives — matching the entry's declared
|
||||
/// [`FoldOutput`]: `Row` entries get the one-row channel, `Series` entries
|
||||
/// the deferred writer opener + terminal-outcome channel.
|
||||
pub enum FoldSink {
|
||||
Row(Sender<(Timestamp, Vec<Scalar>)>),
|
||||
Series(TapWriterOpener, Sender<Result<(), TraceStoreError>>),
|
||||
}
|
||||
|
||||
/// What the wiring hands a registry entry's `build`.
|
||||
pub struct FoldBuildCtx {
|
||||
/// The tap's column kind (already `binds_at`-validated).
|
||||
pub kind: ScalarKind,
|
||||
/// Construction parameters, already validated against the entry's schema.
|
||||
pub params: Vec<(String, Scalar)>,
|
||||
pub sink: FoldSink,
|
||||
}
|
||||
|
||||
/// One registry entry: a labelled consumer constructor with docs, a param
|
||||
/// schema, bind rules, and its persistence shape.
|
||||
pub struct FoldEntry {
|
||||
pub label: &'static str,
|
||||
/// One line for help generation and roster-enumerating refusals.
|
||||
pub doc: &'static str,
|
||||
/// Scalar-typed construction parameters (the node vocabulary's param
|
||||
/// plane). All v1 core entries: empty — the seam ships now because it
|
||||
/// sits in every entry's build signature (C25: a param that would carry
|
||||
/// logic is a new entry, never a freetext hole).
|
||||
pub params: Vec<ParamSpec>,
|
||||
/// May this entry bind a tap column of this kind?
|
||||
pub binds_at: Box<dyn Fn(ScalarKind) -> bool>,
|
||||
/// What the consumer persists, given the tap's column kind.
|
||||
pub output: Box<dyn Fn(ScalarKind) -> FoldOutput>,
|
||||
/// Build the consumer node. `ctx.params` arrive validated; `ctx.sink`
|
||||
/// matches `output` (the wiring consults `output` first — an entry may
|
||||
/// `panic!` on the wrong variant as a wiring-contract violation).
|
||||
pub build: Box<dyn Fn(FoldBuildCtx) -> Box<dyn Node>>,
|
||||
}
|
||||
|
||||
/// The label → entry map. Layered: `core()` seeds the standard vocabulary;
|
||||
/// `register` adds (or deliberately shadows) an entry — dependency
|
||||
/// injection, no core edit.
|
||||
pub struct FoldRegistry {
|
||||
entries: BTreeMap<&'static str, FoldEntry>,
|
||||
}
|
||||
|
||||
impl FoldRegistry {
|
||||
/// The core vocabulary: `record`, `count`, `sum`, `mean`, `min`, `max`,
|
||||
/// `first`, `last`.
|
||||
pub fn core() -> Self {
|
||||
let mut r = FoldRegistry { entries: BTreeMap::new() };
|
||||
r.register(FoldEntry {
|
||||
label: "record",
|
||||
doc: "persist the full series, lossless, at constant memory (any kind)",
|
||||
params: Vec::new(),
|
||||
binds_at: Box::new(|_| true),
|
||||
output: Box::new(|_| FoldOutput::Series),
|
||||
build: Box::new(|ctx| {
|
||||
let FoldSink::Series(opener, outcome_tx) = ctx.sink else {
|
||||
panic!("wiring contract: record is a Series entry");
|
||||
};
|
||||
Box::new(TapRecorder::new(ctx.kind, opener, outcome_tx))
|
||||
}),
|
||||
});
|
||||
for (label, doc, fold) in [
|
||||
("count", "number of warm rows (any kind; i64 row)", FoldKind::Count),
|
||||
("sum", "sum of the series (f64 taps; f64 row)", FoldKind::Sum),
|
||||
("mean", "arithmetic mean of the series (f64 taps; f64 row)", FoldKind::Mean),
|
||||
("min", "minimum of the series (f64 taps; f64 row)", FoldKind::Min),
|
||||
("max", "maximum of the series (f64 taps; f64 row)", FoldKind::Max),
|
||||
(
|
||||
"first",
|
||||
"first warm value, at its own timestamp (any kind; kind-preserving row)",
|
||||
FoldKind::First,
|
||||
),
|
||||
("last", "last warm value (any kind; kind-preserving row)", FoldKind::Last),
|
||||
] {
|
||||
r.register(FoldEntry {
|
||||
label,
|
||||
doc,
|
||||
params: Vec::new(),
|
||||
binds_at: Box::new(move |k| fold_binds_at(fold, k)),
|
||||
output: Box::new(move |k| FoldOutput::Row(fold_output_kind(fold, k))),
|
||||
build: Box::new(move |ctx| {
|
||||
let FoldSink::Row(tx) = ctx.sink else {
|
||||
panic!("wiring contract: {fold:?} is a Row entry");
|
||||
};
|
||||
Box::new(TapFold::new(ctx.kind, fold, tx))
|
||||
}),
|
||||
});
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Add an entry. A later registration under an existing label replaces
|
||||
/// it (layers may deliberately shadow).
|
||||
pub fn register(&mut self, entry: FoldEntry) {
|
||||
self.entries.insert(entry.label, entry);
|
||||
}
|
||||
|
||||
pub fn get(&self, label: &str) -> Option<&FoldEntry> {
|
||||
self.entries.get(label)
|
||||
}
|
||||
|
||||
/// `(label, doc)` in label order — the help surface and the refusal
|
||||
/// roster.
|
||||
pub fn roster(&self) -> Vec<(&'static str, &'static str)> {
|
||||
self.entries.values().map(|e| (e.label, e.doc)).collect()
|
||||
}
|
||||
|
||||
/// The labels alone, label order (refusal messages).
|
||||
pub fn labels(&self) -> Vec<&'static str> {
|
||||
self.entries.keys().copied().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed tap-plan fault — the pre-bootstrap refusals plus the terminal
|
||||
/// store fault. Entry points map every variant to the established
|
||||
/// `aura: ` + exit-1 refusal register via `Display`.
|
||||
pub enum TapPlanError {
|
||||
/// The plan names a tap the blueprint does not declare.
|
||||
UnknownTap { name: String },
|
||||
/// The plan names a label the registry does not carry.
|
||||
UnknownLabel { label: String, roster: Vec<&'static str> },
|
||||
/// The entry's bind rule rejects the tap's column kind.
|
||||
KindMismatch { tap: String, label: String, kind: ScalarKind },
|
||||
/// A param binding names no schema param (`takes` = the schema's names).
|
||||
UnknownParam { label: String, name: String, takes: Vec<String> },
|
||||
/// A schema param is unbound.
|
||||
MissingParam { label: String, name: String, kind: ScalarKind },
|
||||
/// A param binding's kind mismatches its schema declaration.
|
||||
ParamKind { label: String, name: String, expected: ScalarKind, got: ScalarKind },
|
||||
/// Two declared taps share a name (the caller-owned dedup guard).
|
||||
Bind(aura_engine::TapBindError),
|
||||
/// The trace store failed (begin/write/finish) — the terminal register.
|
||||
Store(TraceStoreError),
|
||||
}
|
||||
|
||||
impl fmt::Display for TapPlanError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
TapPlanError::UnknownTap { name } => {
|
||||
write!(f, "the tap plan names '{name}', but the blueprint declares no such tap")
|
||||
}
|
||||
TapPlanError::UnknownLabel { label, roster } => {
|
||||
write!(f, "unknown fold '{label}' — available: {}", roster.join(", "))
|
||||
}
|
||||
TapPlanError::KindMismatch { tap, label, kind } => {
|
||||
write!(f, "fold '{label}' cannot bind tap '{tap}' of kind {kind:?}")
|
||||
}
|
||||
TapPlanError::UnknownParam { label, name, takes } => {
|
||||
if takes.is_empty() {
|
||||
write!(f, "fold '{label}' takes no parameters (got '{name}')")
|
||||
} else {
|
||||
write!(f, "fold '{label}' has no parameter '{name}' — takes: {}", takes.join(", "))
|
||||
}
|
||||
}
|
||||
TapPlanError::MissingParam { label, name, kind } => {
|
||||
write!(f, "fold '{label}' requires parameter '{name}' ({kind:?})")
|
||||
}
|
||||
TapPlanError::ParamKind { label, name, expected, got } => {
|
||||
write!(f, "fold '{label}' parameter '{name}' is {expected:?}, got {got:?}")
|
||||
}
|
||||
TapPlanError::Bind(e) => write!(f, "{e}"),
|
||||
TapPlanError::Store(e) => write!(f, "writing tap traces failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for TapPlanError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "TapPlanError({self})")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TraceStoreError> for TapPlanError {
|
||||
fn from(e: TraceStoreError) -> Self {
|
||||
TapPlanError::Store(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate one `Named` subscription's param bindings against the entry's
|
||||
/// schema: every binding names a schema param of the right kind; every
|
||||
/// schema param is bound.
|
||||
pub(crate) fn validate_params(
|
||||
entry: &FoldEntry,
|
||||
bound: &[(String, Scalar)],
|
||||
) -> Result<(), TapPlanError> {
|
||||
let takes: Vec<String> = entry.params.iter().map(|p| p.name.clone()).collect();
|
||||
for (name, value) in bound {
|
||||
match entry.params.iter().find(|p| &p.name == name) {
|
||||
None => {
|
||||
return Err(TapPlanError::UnknownParam {
|
||||
label: entry.label.to_string(),
|
||||
name: name.clone(),
|
||||
takes,
|
||||
})
|
||||
}
|
||||
Some(p) if p.kind != value.kind() => {
|
||||
return Err(TapPlanError::ParamKind {
|
||||
label: entry.label.to_string(),
|
||||
name: name.clone(),
|
||||
expected: p.kind,
|
||||
got: value.kind(),
|
||||
})
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
for p in &entry.params {
|
||||
if !bound.iter().any(|(n, _)| n == &p.name) {
|
||||
return Err(TapPlanError::MissingParam {
|
||||
label: entry.label.to_string(),
|
||||
name: p.name.clone(),
|
||||
kind: p.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The one-input sink schema every tap consumer binds with (empty output =
|
||||
/// pure consumer, C8; the port name is a non-load-bearing debug symbol).
|
||||
fn tap_sink_schema(kind: ScalarKind) -> NodeSchema {
|
||||
NodeSchema {
|
||||
inputs: vec![PortSpec { kind, firing: Firing::Any, name: "in".to_string() }],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "internal recording sink bound to a declared tap",
|
||||
}
|
||||
}
|
||||
|
||||
/// The bound half of a tap plan: what `bind_tap_plan` leaves for the caller
|
||||
/// to drain after the run. Both declared-tap entry points call the pair, so
|
||||
/// the mechanism cannot drift between them.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub struct BoundTaps {
|
||||
streamer: Option<TraceStreamer>,
|
||||
declared: Vec<String>,
|
||||
/// Persisting taps (Series and Row alike), declared-tap order — the
|
||||
/// `index.json` tap order.
|
||||
persisted: Vec<String>,
|
||||
rows: Vec<(String, ScalarKind, Receiver<(Timestamp, Vec<Scalar>)>)>,
|
||||
outcomes: Vec<(String, Receiver<Result<(), TraceStoreError>>)>,
|
||||
}
|
||||
|
||||
impl BoundTaps {
|
||||
/// Every declared tap, declared order (the measurement report's roster).
|
||||
pub fn declared_names(&self) -> &[String] {
|
||||
&self.declared
|
||||
}
|
||||
|
||||
/// Drain the ≤1-message-per-tap channels and close the run: record
|
||||
/// outcomes first (declared order), then fold rows written through the
|
||||
/// same streamer, then `index.json` last. Any fault returns before the
|
||||
/// index is written — the store's crash discipline (no `index.json` →
|
||||
/// treat-as-absent) covers the partial directory, exactly like today's
|
||||
/// failed `write`. A plan that persisted nothing began no streamer and
|
||||
/// writes nothing.
|
||||
pub fn finish(mut self, manifest: &RunManifest) -> Result<(), TapPlanError> {
|
||||
for (_, rx) in &self.outcomes {
|
||||
// finalize ran inside the harness's end-of-stream flush, so the
|
||||
// single outcome is already in the channel.
|
||||
if let Ok(Err(e)) = rx.try_recv() {
|
||||
return Err(TapPlanError::Store(e));
|
||||
}
|
||||
}
|
||||
let streamer = self.streamer.take();
|
||||
for (name, kind, rx) in self.rows {
|
||||
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect(); // ≤1
|
||||
let trace = ColumnarTrace::from_rows(&name, &[kind], &rows);
|
||||
streamer
|
||||
.as_ref()
|
||||
.expect("a Row tap implies a begun streamer")
|
||||
.write_full(&trace)?;
|
||||
}
|
||||
if let Some(s) = streamer {
|
||||
s.finish(manifest, &self.persisted)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the plan against `flat.taps`, begin the streamed run write when
|
||||
/// anything persists, and bind one consumer per subscribed tap — the shared
|
||||
/// pre-bootstrap half of both declared-tap entry points. Refusals are typed
|
||||
/// and complete BEFORE any store I/O (a refused run never half-writes).
|
||||
pub fn bind_tap_plan(
|
||||
flat: &mut FlatGraph,
|
||||
mut plan: TapPlan,
|
||||
env: &Env,
|
||||
run_name: &str,
|
||||
) -> Result<BoundTaps, TapPlanError> {
|
||||
let declared_taps: Vec<aura_engine::FlatTap> = flat.taps.clone();
|
||||
|
||||
// Dedup guard (caller-owned per TapBindError::DuplicateBind's doc).
|
||||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||
for tap in &declared_taps {
|
||||
if !seen.insert(tap.name.clone()) {
|
||||
return Err(TapPlanError::Bind(TapBindError::DuplicateBind { name: tap.name.clone() }));
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown-tap guard: every plan name must be declared.
|
||||
for name in plan.by_name.keys() {
|
||||
if !declared_taps.iter().any(|t| &t.name == name) {
|
||||
return Err(TapPlanError::UnknownTap { name: name.clone() });
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve each declared tap to its subscription; validate all Named
|
||||
// subscriptions fully before any store I/O.
|
||||
enum Resolved {
|
||||
Named { label: String, params: Vec<(String, Scalar)> },
|
||||
Live(Box<dyn FnMut(Timestamp, Cell) + Send>),
|
||||
}
|
||||
let mut resolved: Vec<(String, ScalarKind, Resolved)> = Vec::new();
|
||||
for tap in &declared_taps {
|
||||
let kind = flat.signatures[tap.node].output[tap.field].kind;
|
||||
let sub = match plan.by_name.remove(&tap.name) {
|
||||
Some(TapSubscription::Named { label, params }) => Resolved::Named { label, params },
|
||||
Some(TapSubscription::Live(consumer)) => Resolved::Live(consumer),
|
||||
None => match &plan.default_named {
|
||||
Some((label, params)) => {
|
||||
Resolved::Named { label: label.clone(), params: params.clone() }
|
||||
}
|
||||
None => continue, // unbound, inert (C27)
|
||||
},
|
||||
};
|
||||
if let Resolved::Named { label, params } = &sub {
|
||||
let entry = plan.registry.get(label).ok_or_else(|| TapPlanError::UnknownLabel {
|
||||
label: label.clone(),
|
||||
roster: plan.registry.labels(),
|
||||
})?;
|
||||
if !(entry.binds_at)(kind) {
|
||||
return Err(TapPlanError::KindMismatch {
|
||||
tap: tap.name.clone(),
|
||||
label: label.clone(),
|
||||
kind,
|
||||
});
|
||||
}
|
||||
validate_params(entry, params)?;
|
||||
}
|
||||
resolved.push((tap.name.clone(), kind, sub));
|
||||
}
|
||||
|
||||
// Anything persisting? Then begin the run (directory creation is the
|
||||
// pre-run refusal point).
|
||||
let persists = resolved.iter().any(|(_, _, s)| matches!(s, Resolved::Named { .. }));
|
||||
let streamer = if persists { Some(env.trace_store().begin_run(run_name)?) } else { None };
|
||||
|
||||
// Bind one consumer per resolved tap.
|
||||
let mut bound = BoundTaps {
|
||||
streamer,
|
||||
declared: declared_taps.iter().map(|t| t.name.clone()).collect(),
|
||||
persisted: Vec::new(),
|
||||
rows: Vec::new(),
|
||||
outcomes: Vec::new(),
|
||||
};
|
||||
for (name, kind, sub) in resolved {
|
||||
let node: Box<dyn Node> = match sub {
|
||||
Resolved::Named { label, params } => {
|
||||
let entry = plan.registry.get(&label).expect("validated above");
|
||||
bound.persisted.push(name.clone());
|
||||
match (entry.output)(kind) {
|
||||
FoldOutput::Series => {
|
||||
let opener = bound
|
||||
.streamer
|
||||
.as_ref()
|
||||
.expect("persisting tap implies a begun streamer")
|
||||
.register_tap(&name, kind);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
bound.outcomes.push((name.clone(), rx));
|
||||
(entry.build)(FoldBuildCtx { kind, params, sink: FoldSink::Series(opener, tx) })
|
||||
}
|
||||
FoldOutput::Row(out_kind) => {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
bound.rows.push((name.clone(), out_kind, rx));
|
||||
(entry.build)(FoldBuildCtx { kind, params, sink: FoldSink::Row(tx) })
|
||||
}
|
||||
}
|
||||
}
|
||||
Resolved::Live(consumer) => Box::new(TapLive::new(kind, consumer)),
|
||||
};
|
||||
flat.bind_tap(&name, node, tap_sink_schema(kind))
|
||||
.expect("declared tap binds (name from flat.taps, kind from its own signature)");
|
||||
}
|
||||
Ok(bound)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{AnyColumn, Ctx};
|
||||
use std::sync::mpsc;
|
||||
|
||||
#[test]
|
||||
fn roster_lists_the_eight_core_entries_with_docs() {
|
||||
let r = FoldRegistry::core();
|
||||
let labels: Vec<&str> = r.roster().iter().map(|(l, _)| *l).collect();
|
||||
// BTreeMap order — alphabetical.
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec!["count", "first", "last", "max", "mean", "min", "record", "sum"]
|
||||
);
|
||||
assert!(r.roster().iter().all(|(_, doc)| !doc.is_empty()), "every entry documents itself");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_label_refusal_enumerates_the_roster() {
|
||||
let r = FoldRegistry::core();
|
||||
let e = TapPlanError::UnknownLabel { label: "p95".to_string(), roster: r.labels() };
|
||||
let msg = e.to_string();
|
||||
assert!(msg.starts_with("unknown fold 'p95'"), "{msg}");
|
||||
assert!(msg.contains("record") && msg.contains("mean"), "roster enumerated: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_folds_bind_only_at_f64_record_and_count_anywhere() {
|
||||
let r = FoldRegistry::core();
|
||||
for label in ["sum", "mean", "min", "max"] {
|
||||
let e = r.get(label).expect(label);
|
||||
assert!((e.binds_at)(ScalarKind::F64), "{label} binds f64");
|
||||
assert!(!(e.binds_at)(ScalarKind::I64), "{label} refuses i64");
|
||||
assert!(!(e.binds_at)(ScalarKind::Bool), "{label} refuses bool");
|
||||
}
|
||||
for label in ["record", "count", "first", "last"] {
|
||||
let e = r.get(label).expect(label);
|
||||
for k in [ScalarKind::F64, ScalarKind::I64, ScalarKind::Bool, ScalarKind::Timestamp] {
|
||||
assert!((e.binds_at)(k), "{label} binds {k:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_param_on_a_param_less_entry_is_refused() {
|
||||
let r = FoldRegistry::core();
|
||||
let e = validate_params(
|
||||
r.get("mean").expect("mean"),
|
||||
&[("q".to_string(), Scalar::f64(0.95))],
|
||||
)
|
||||
.expect_err("core entries take no params");
|
||||
assert_eq!(e.to_string(), "fold 'mean' takes no parameters (got 'q')");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_registered_entry_resolves_and_builds() {
|
||||
// The dependency-injection proof without a trading layer: a test-only
|
||||
// entry registered on top of core resolves by label and its consumer
|
||||
// runs (here: a re-labelled count over the committed TapFold core).
|
||||
let mut r = FoldRegistry::core();
|
||||
r.register(FoldEntry {
|
||||
label: "tally",
|
||||
doc: "test-only: warm-row count under a layer label",
|
||||
params: Vec::new(),
|
||||
binds_at: Box::new(|_| true),
|
||||
output: Box::new(|_| FoldOutput::Row(ScalarKind::I64)),
|
||||
build: Box::new(|ctx| {
|
||||
let FoldSink::Row(tx) = ctx.sink else {
|
||||
panic!("wiring contract: tally is a Row entry");
|
||||
};
|
||||
Box::new(TapFold::new(ctx.kind, FoldKind::Count, tx))
|
||||
}),
|
||||
});
|
||||
assert_eq!(r.labels().len(), 9, "core plus the layer entry");
|
||||
let entry = r.get("tally").expect("layer entry resolves");
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut node = (entry.build)(FoldBuildCtx {
|
||||
kind: ScalarKind::F64,
|
||||
params: Vec::new(),
|
||||
sink: FoldSink::Row(tx),
|
||||
});
|
||||
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
node.initialize();
|
||||
for (t, v) in [(1_i64, 5.0_f64), (2, 7.0)] {
|
||||
inputs[0].push(Scalar::f64(v)).unwrap();
|
||||
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(t))), None);
|
||||
}
|
||||
node.finalize();
|
||||
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
|
||||
assert_eq!(rows, vec![(Timestamp(2), vec![Scalar::i64(2)])]);
|
||||
}
|
||||
|
||||
/// Exercises the `record` entry's Series-sink build path (destructure
|
||||
/// `FoldSink::Series` -> construct `TapRecorder`), left uncovered by
|
||||
/// `a_layer_registered_entry_resolves_and_builds` above, which only
|
||||
/// drives a Row-sink entry.
|
||||
#[test]
|
||||
fn record_entry_builds_a_series_sink_that_persists_the_stream() {
|
||||
let root = std::env::temp_dir()
|
||||
.join(format!("aura-runner-tap_plan-record-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create temp store root");
|
||||
let store = aura_registry::TraceStore::open(&root);
|
||||
let streamer = store.begin_run("plan").expect("begin run");
|
||||
let opener = streamer.register_tap("t", ScalarKind::F64);
|
||||
|
||||
let r = FoldRegistry::core();
|
||||
let entry = r.get("record").expect("record");
|
||||
let (outcome_tx, outcome_rx) = mpsc::channel();
|
||||
let mut node = (entry.build)(FoldBuildCtx {
|
||||
kind: ScalarKind::F64,
|
||||
params: Vec::new(),
|
||||
sink: FoldSink::Series(opener, outcome_tx),
|
||||
});
|
||||
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
node.initialize();
|
||||
for (t, v) in [(1_i64, 5.0_f64), (2, 7.0)] {
|
||||
inputs[0].push(Scalar::f64(v)).unwrap();
|
||||
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(t))), None);
|
||||
}
|
||||
node.finalize();
|
||||
|
||||
let outcomes: Vec<Result<(), TraceStoreError>> = outcome_rx.try_iter().collect();
|
||||
assert_eq!(outcomes.len(), 1, "exactly one terminal outcome");
|
||||
assert!(outcomes[0].is_ok(), "record's Series build path writes cleanly: {outcomes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_params_are_validated_missing_mismatched_and_unknown() {
|
||||
// A schema-carrying test entry reaches the validation branches v1's
|
||||
// param-less core entries cannot: missing, kind-mismatched, and
|
||||
// unknown-against-a-non-empty-schema.
|
||||
let entry = FoldEntry {
|
||||
label: "windowed",
|
||||
doc: "test-only: carries a param schema",
|
||||
params: vec![ParamSpec { name: "length".to_string(), kind: ScalarKind::I64 }],
|
||||
binds_at: Box::new(|_| true),
|
||||
output: Box::new(|_| FoldOutput::Row(ScalarKind::F64)),
|
||||
build: Box::new(|_| panic!("validation-only entry is never built")),
|
||||
};
|
||||
let missing = validate_params(&entry, &[]).expect_err("length is required");
|
||||
assert_eq!(missing.to_string(), "fold 'windowed' requires parameter 'length' (I64)");
|
||||
let mismatched = validate_params(&entry, &[("length".to_string(), Scalar::f64(2.0))])
|
||||
.expect_err("kind mismatch");
|
||||
assert_eq!(mismatched.to_string(), "fold 'windowed' parameter 'length' is I64, got F64");
|
||||
let unknown = validate_params(&entry, &[("q".to_string(), Scalar::i64(1))])
|
||||
.expect_err("unknown param");
|
||||
assert_eq!(unknown.to_string(), "fold 'windowed' has no parameter 'q' — takes: length");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! `TapRecorder` — the `record` fold-registry entry's consumer (C8 sink) for
|
||||
//! one declared tap: holds the streaming trace writer **in-graph** and
|
||||
//! appends `(ctx.now(), cell)` per fired warm cycle — 16 B, `Copy`, zero heap
|
||||
//! per cycle (#77 part 2); no writer thread, no channel (#283 design
|
||||
//! revision). Lives in the assembly crate, not `aura-std`, because the
|
||||
//! writer type is `aura-registry`'s (C28 layering). Lifecycle: `initialize`
|
||||
//! opens the writer (deferred acquisition; an open error is stored and the
|
||||
//! node degrades to inert — the run completes), `eval` appends (an I/O error
|
||||
//! mid-run likewise stores + degrades), `finalize` finishes the writer and
|
||||
//! reports exactly one terminal outcome over the wiring's channel.
|
||||
|
||||
use aura_core::{Cell, Ctx, Node, ScalarKind};
|
||||
use aura_registry::{TapTraceWriter, TapWriterOpener, TraceStoreError};
|
||||
use aura_std::newest_cell;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
/// A single-column recording sink around an in-graph streaming writer.
|
||||
pub struct TapRecorder {
|
||||
kind: ScalarKind,
|
||||
opener: Option<TapWriterOpener>,
|
||||
writer: Option<TapTraceWriter>,
|
||||
error: Option<TraceStoreError>,
|
||||
emitted: bool,
|
||||
outcome_tx: Sender<Result<(), TraceStoreError>>,
|
||||
}
|
||||
|
||||
impl TapRecorder {
|
||||
pub fn new(
|
||||
kind: ScalarKind,
|
||||
opener: TapWriterOpener,
|
||||
outcome_tx: Sender<Result<(), TraceStoreError>>,
|
||||
) -> Self {
|
||||
Self { kind, opener: Some(opener), writer: None, error: None, emitted: false, outcome_tx }
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for TapRecorder {
|
||||
fn lookbacks(&self) -> Vec<usize> {
|
||||
vec![1]
|
||||
}
|
||||
|
||||
fn initialize(&mut self) {
|
||||
match self.opener.take().expect("initialize runs once per run").open() {
|
||||
Ok(w) => self.writer = Some(w),
|
||||
Err(e) => self.error = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
let cell = newest_cell(self.kind, &ctx)?; // None until warm
|
||||
if let Some(w) = self.writer.as_mut()
|
||||
&& let Err(e) = w.append(ctx.now(), cell)
|
||||
{
|
||||
// I/O failure mid-run: store it, go inert, keep consuming —
|
||||
// the run completes; the failure surfaces once at finalize.
|
||||
self.error = Some(e);
|
||||
self.writer = None;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn finalize(&mut self) {
|
||||
if self.emitted {
|
||||
return;
|
||||
}
|
||||
self.emitted = true;
|
||||
let outcome = match (self.writer.take(), self.error.take()) {
|
||||
(Some(w), None) => w.finish(),
|
||||
(_, Some(e)) => Err(e),
|
||||
(None, None) => Ok(()), // finalize without initialize: benign
|
||||
};
|
||||
let _ = self.outcome_tx.send(outcome);
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
"TapRecorder".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{AnyColumn, Scalar, Timestamp};
|
||||
use aura_registry::TraceStore;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// A fresh unique store root under the OS temp dir (aura-runner has no
|
||||
/// tempfile dev-dep; mirror of trace_store's own temp_traces_root).
|
||||
fn temp_store_root(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("aura-runner-taprec-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp store root");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialize_opens_records_after_warmup_and_reports_one_ok_outcome() {
|
||||
let root = temp_store_root("happy");
|
||||
let store = TraceStore::open(&root);
|
||||
let streamer = store.begin_run("rec").expect("begin");
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut sink =
|
||||
TapRecorder::new(ScalarKind::F64, streamer.register_tap("t", ScalarKind::F64), tx);
|
||||
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
|
||||
sink.initialize();
|
||||
// cold: nothing appended.
|
||||
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(1))), None);
|
||||
|
||||
for (t, v) in [(2_i64, 10.0_f64), (3, 20.0), (4, 30.0)] {
|
||||
inputs[0].push(Scalar::f64(v)).unwrap();
|
||||
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(t))), None);
|
||||
}
|
||||
sink.finalize();
|
||||
sink.finalize(); // second flush must not emit a second outcome
|
||||
|
||||
let outcomes: Vec<Result<(), TraceStoreError>> = rx.try_iter().collect();
|
||||
assert_eq!(outcomes.len(), 1, "exactly one terminal outcome");
|
||||
assert!(outcomes[0].is_ok(), "happy path outcome: {outcomes:?}");
|
||||
|
||||
let text = std::fs::read_to_string(root.join("traces").join("rec").join("t.json"))
|
||||
.expect("finished tap file");
|
||||
let v: serde_json::Value = serde_json::from_str(&text).expect("parse");
|
||||
assert_eq!(v["tap"], "t");
|
||||
assert_eq!(v["ts"], serde_json::json!([2, 3, 4]));
|
||||
assert_eq!(v["columns"], serde_json::json!([[10.0, 20.0, 30.0]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_open_degrades_to_inert_and_reports_one_err_outcome() {
|
||||
let root = temp_store_root("failopen");
|
||||
let store = TraceStore::open(&root);
|
||||
let streamer = store.begin_run("rec").expect("begin");
|
||||
let opener = streamer.register_tap("t", ScalarKind::F64);
|
||||
// Make the deferred open fail: remove the run dir before initialize.
|
||||
std::fs::remove_dir_all(root.join("traces").join("rec")).expect("remove run dir");
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut sink = TapRecorder::new(ScalarKind::F64, opener, tx);
|
||||
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
|
||||
sink.initialize();
|
||||
// inert but alive: eval consumes without panicking.
|
||||
inputs[0].push(Scalar::f64(1.0)).unwrap();
|
||||
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(2))), None);
|
||||
sink.finalize();
|
||||
|
||||
let outcomes: Vec<Result<(), TraceStoreError>> = rx.try_iter().collect();
|
||||
assert_eq!(outcomes.len(), 1, "exactly one terminal outcome");
|
||||
assert!(outcomes[0].is_err(), "the stored open error surfaces terminally");
|
||||
}
|
||||
|
||||
/// Exercises the mid-run branch at `eval` (lines 51-57): a successfully
|
||||
/// opened writer whose *write* later faults must store the error, drop
|
||||
/// `self.writer` (go inert — no further append attempts), and still
|
||||
/// report exactly one terminal `Err` at `finalize`, same as a failed
|
||||
/// open. `/dev/full` always faults on write, so pointing the ts temp
|
||||
/// stream at it (after a normal open) is a deterministic mid-run I/O
|
||||
/// fault once the `BufWriter`'s internal buffer forces a real flush.
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn a_mid_run_append_failure_degrades_to_inert_and_reports_one_err_outcome() {
|
||||
let root = temp_store_root("appendfail");
|
||||
let store = TraceStore::open(&root);
|
||||
let streamer = store.begin_run("rec").expect("begin");
|
||||
let opener = streamer.register_tap("t", ScalarKind::F64);
|
||||
// Retarget the ts temp stream at a permanently-full device: `open`
|
||||
// still succeeds (create/truncate are no-ops on a char device), only
|
||||
// a later flushed write faults.
|
||||
std::os::unix::fs::symlink(
|
||||
"/dev/full",
|
||||
root.join("traces").join("rec").join("t.ts.tmp"),
|
||||
)
|
||||
.expect("symlink t.ts.tmp to /dev/full");
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut sink = TapRecorder::new(ScalarKind::F64, opener, tx);
|
||||
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
|
||||
sink.initialize();
|
||||
// Enough warm evals to force the BufWriter past its internal
|
||||
// capacity and trigger a real (faulting) flush; `eval` never emits a
|
||||
// row regardless of the underlying write outcome.
|
||||
for t in 0..20_000_i64 {
|
||||
inputs[0].push(Scalar::f64(1.0)).unwrap();
|
||||
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(t))), None);
|
||||
}
|
||||
sink.finalize();
|
||||
|
||||
let outcomes: Vec<Result<(), TraceStoreError>> = rx.try_iter().collect();
|
||||
assert_eq!(outcomes.len(), 1, "exactly one terminal outcome");
|
||||
assert!(outcomes[0].is_err(), "the mid-run write fault surfaces terminally");
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ impl Abs {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "value".into() }],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "absolute value of the input series",
|
||||
},
|
||||
|_| Box::new(Abs::new()),
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ impl Add {
|
||||
],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "elementwise sum of two input series",
|
||||
},
|
||||
|_| Box::new(Add::new()),
|
||||
)
|
||||
|
||||
@@ -37,6 +37,7 @@ impl And {
|
||||
],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::Bool }],
|
||||
params: vec![],
|
||||
doc: "logical AND of two boolean series",
|
||||
},
|
||||
|_| Box::new(And::new()),
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ impl Const {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "clock".into() }],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
doc: "constant-valued stream from a single param",
|
||||
},
|
||||
|p| Box::new(Const::new(p[0].f64())),
|
||||
)
|
||||
|
||||
@@ -53,6 +53,7 @@ impl CumSum {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "running cumulative sum of the input series",
|
||||
},
|
||||
|_| Box::new(CumSum::new()),
|
||||
)
|
||||
|
||||
@@ -65,6 +65,7 @@ impl Delay {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "lag".into(), kind: ScalarKind::I64 }],
|
||||
doc: "emits the input from `lag` cycles ago — the explicit delay register (C9)",
|
||||
},
|
||||
|p| Box::new(Delay::new(p[0].i64() as usize)),
|
||||
)
|
||||
|
||||
@@ -30,6 +30,7 @@ impl Div {
|
||||
],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "elementwise quotient of two input series",
|
||||
},
|
||||
|_| Box::new(Div::new()),
|
||||
)
|
||||
|
||||
@@ -63,6 +63,7 @@ impl Ema {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "exponential moving average over the input series",
|
||||
},
|
||||
|p| Box::new(Ema::new(p[0].i64() as usize)),
|
||||
)
|
||||
|
||||
@@ -40,6 +40,7 @@ impl EqConst {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "value".into() }],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::Bool }],
|
||||
params: vec![ParamSpec { name: "target".into(), kind: ScalarKind::I64 }],
|
||||
doc: "true where the input equals a constant param",
|
||||
},
|
||||
|p| Box::new(EqConst::new(p[0].i64())),
|
||||
)
|
||||
|
||||
@@ -41,7 +41,12 @@ impl GatedRecorder {
|
||||
let build_kinds = kinds.clone();
|
||||
PrimitiveBuilder::new(
|
||||
"GatedRecorder",
|
||||
NodeSchema { inputs, output: vec![], params: vec![] }, // sink: empty output (C8)
|
||||
NodeSchema {
|
||||
inputs,
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "recording sink that persists its input while the gate is true, plus a final row at finalize",
|
||||
}, // sink: empty output (C8)
|
||||
move |_| Box::new(GatedRecorder::new(&build_kinds, gate_col, tx.clone())),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ impl Gt {
|
||||
],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::Bool }],
|
||||
params: vec![],
|
||||
doc: "true where the first input exceeds the second",
|
||||
},
|
||||
|_| Box::new(Gt::new()),
|
||||
)
|
||||
|
||||
@@ -46,6 +46,7 @@ impl Latch {
|
||||
],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "level-sensitive set/reset register holding 1.0 when latched, 0.0 when reset",
|
||||
},
|
||||
|_| Box::new(Latch::new()),
|
||||
)
|
||||
|
||||
@@ -43,6 +43,9 @@ mod sign;
|
||||
mod sma;
|
||||
mod sqrt;
|
||||
mod sub;
|
||||
mod tap_cell;
|
||||
mod tap_fold;
|
||||
mod tap_live;
|
||||
mod when;
|
||||
pub use abs::Abs;
|
||||
pub use add::Add;
|
||||
@@ -70,4 +73,7 @@ pub use sign::Sign;
|
||||
pub use sma::Sma;
|
||||
pub use sqrt::Sqrt;
|
||||
pub use sub::Sub;
|
||||
pub use tap_cell::newest_cell;
|
||||
pub use tap_fold::{fold_binds_at, fold_output_kind, FoldKind, TapFold};
|
||||
pub use tap_live::TapLive;
|
||||
pub use when::When;
|
||||
|
||||
@@ -52,7 +52,12 @@ impl LinComb {
|
||||
.collect();
|
||||
PrimitiveBuilder::new(
|
||||
"LinComb",
|
||||
NodeSchema { inputs, output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], params },
|
||||
NodeSchema {
|
||||
inputs,
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params,
|
||||
doc: "linear combination of its inputs with constant weights",
|
||||
},
|
||||
|p| Box::new(LinComb::new(
|
||||
p.iter().map(|c| c.f64()).collect(),
|
||||
)),
|
||||
|
||||
@@ -26,6 +26,7 @@ impl Max {
|
||||
],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "elementwise maximum of two input series",
|
||||
},
|
||||
|_| Box::new(Max::new()),
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ impl Min {
|
||||
],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "elementwise minimum of two input series",
|
||||
},
|
||||
|_| Box::new(Min::new()),
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ impl Mul {
|
||||
],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "elementwise product of two input series",
|
||||
},
|
||||
|_| Box::new(Mul::new()),
|
||||
)
|
||||
|
||||
@@ -53,7 +53,12 @@ impl Recorder {
|
||||
let build_kinds = kinds.clone();
|
||||
PrimitiveBuilder::new(
|
||||
"Recorder",
|
||||
NodeSchema { inputs, output: vec![], params: vec![] }, // sink: empty output (C8)
|
||||
NodeSchema {
|
||||
inputs,
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "recording sink persisting its input series into the trace store",
|
||||
}, // sink: empty output (C8)
|
||||
move |_| Box::new(Recorder::new(&build_kinds, firing, tx.clone())),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ impl RollingMax {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "rolling maximum over a fixed lookback window",
|
||||
},
|
||||
|p| Box::new(RollingMax::new(p[0].i64() as usize)),
|
||||
)
|
||||
|
||||
@@ -43,6 +43,7 @@ impl RollingMin {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "rolling minimum over a fixed lookback window",
|
||||
},
|
||||
|p| Box::new(RollingMin::new(p[0].i64() as usize)),
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ impl Scale {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "signal".into() }],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }],
|
||||
doc: "input multiplied by a constant factor param",
|
||||
},
|
||||
|p| Box::new(Scale::new(p[0].f64())),
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ impl Select {
|
||||
],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "chooses between two inputs by a boolean selector",
|
||||
},
|
||||
|_| Box::new(Select::new()),
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ impl SeriesReducer {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing, name: "col[0]".to_string() }],
|
||||
output: vec![], // sink: empty output (C8)
|
||||
params: vec![],
|
||||
doc: "folds an input series into a single summary row emitted at finalize",
|
||||
},
|
||||
move |_| Box::new(SeriesReducer::new(tx.clone())),
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ impl Sign {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "value".into() }],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "sign of the input: -1, 0 or +1",
|
||||
},
|
||||
|_| Box::new(Sign::new()),
|
||||
)
|
||||
|
||||
@@ -65,6 +65,7 @@ impl Sma {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "simple moving average over the input series",
|
||||
},
|
||||
|p| Box::new(Sma::new(p[0].i64() as usize)),
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ impl Sqrt {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "value".into() }],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "square root of the input series",
|
||||
},
|
||||
|_| Box::new(Sqrt::new()),
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ impl Sub {
|
||||
],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "elementwise difference of two input series",
|
||||
},
|
||||
|_| Box::new(Sub::new()),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Shared single-column read for the tap sinks (`TapFold`, `TapLive`, and
|
||||
//! the assembly crate's `aura_runner::TapRecorder`): the kind-dispatched
|
||||
//! newest-value read `Recorder::eval` does, re-encoded as a bare `Cell` (the
|
||||
//! C7 kind-erased carrier) instead of a kind-tagged `Scalar` — the
|
||||
//! #77-part-2 zero-heap payload.
|
||||
|
||||
use aura_core::{Cell, Ctx, ScalarKind};
|
||||
|
||||
/// The newest value of input column 0, as a `Cell`. `None` until the column
|
||||
/// is warm (the `?` on the window read), mirroring `Recorder`'s warm-up.
|
||||
pub fn newest_cell(kind: ScalarKind, ctx: &Ctx<'_>) -> Option<Cell> {
|
||||
Some(match kind {
|
||||
ScalarKind::F64 => Cell::from_f64(ctx.f64_in(0).get(0)?),
|
||||
ScalarKind::I64 => Cell::from_i64(ctx.i64_in(0).get(0)?),
|
||||
ScalarKind::Bool => Cell::from_bool(ctx.bool_in(0).get(0)?),
|
||||
ScalarKind::Timestamp => Cell::from_ts(ctx.ts_in(0).get(0)?),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! `TapFold` — a folding sink (C8) over one declared-tap column: accumulates
|
||||
//! owned per-cycle state (no channel traffic, no heap per cycle) and emits
|
||||
//! exactly one summary row on `finalize` (the #138 lifecycle; `SeriesReducer`
|
||||
//! precedent). The fold is named from the closed [`FoldKind`] vocabulary
|
||||
//! (C25: closed, typed; a new aggregate is a new named entry, never inline
|
||||
//! logic in a data artifact). C7-pure: owned state + the channel handle.
|
||||
|
||||
use aura_core::{Cell, Ctx, Node, Scalar, ScalarKind, Timestamp};
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use crate::tap_cell::newest_cell;
|
||||
|
||||
/// The closed fold vocabulary. Bind rules: `Sum`/`Mean`/`Min`/`Max` bind only
|
||||
/// at f64 taps ([`fold_binds_at`]); `Count` at any kind; `First`/`Last`
|
||||
/// kind-preserving at any kind. Output kinds: [`fold_output_kind`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FoldKind {
|
||||
Count,
|
||||
Sum,
|
||||
Mean,
|
||||
Min,
|
||||
Max,
|
||||
First,
|
||||
Last,
|
||||
}
|
||||
|
||||
/// May `fold` bind at a tap of column kind `kind`? The arithmetic folds are
|
||||
/// f64-only; `Count`/`First`/`Last` are kind-agnostic.
|
||||
pub fn fold_binds_at(fold: FoldKind, kind: ScalarKind) -> bool {
|
||||
match fold {
|
||||
FoldKind::Sum | FoldKind::Mean | FoldKind::Min | FoldKind::Max => {
|
||||
kind == ScalarKind::F64
|
||||
}
|
||||
FoldKind::Count | FoldKind::First | FoldKind::Last => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The output column kind of `fold` at a tap of column kind `kind`:
|
||||
/// `Count` → i64; the arithmetic folds → f64; `First`/`Last` preserve `kind`.
|
||||
pub fn fold_output_kind(fold: FoldKind, kind: ScalarKind) -> ScalarKind {
|
||||
match fold {
|
||||
FoldKind::Count => ScalarKind::I64,
|
||||
FoldKind::Sum | FoldKind::Mean | FoldKind::Min | FoldKind::Max => ScalarKind::F64,
|
||||
FoldKind::First | FoldKind::Last => kind,
|
||||
}
|
||||
}
|
||||
|
||||
/// Owned accumulator for every fold kind at once (a handful of words; keeping
|
||||
/// it total avoids a per-kind state enum). Sequential `sum += v` matches a
|
||||
/// slice-driven mean's operation order, so streamed and slice `Mean` agree
|
||||
/// bit-for-bit (the `SeriesFold` discipline, IEEE-754 non-associativity
|
||||
/// respected).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct FoldState {
|
||||
count: u64,
|
||||
sum: f64,
|
||||
min: f64,
|
||||
max: f64,
|
||||
first: Option<(Timestamp, Cell)>,
|
||||
last: Option<Cell>,
|
||||
}
|
||||
|
||||
impl FoldState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
count: 0,
|
||||
sum: 0.0,
|
||||
min: f64::INFINITY,
|
||||
max: f64::NEG_INFINITY,
|
||||
first: None,
|
||||
last: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn fold(&mut self, ts: Timestamp, cell: Cell, kind: ScalarKind) {
|
||||
self.count += 1;
|
||||
if kind == ScalarKind::F64 {
|
||||
let v = cell.f64();
|
||||
self.sum += v;
|
||||
if v < self.min {
|
||||
self.min = v;
|
||||
}
|
||||
if v > self.max {
|
||||
self.max = v;
|
||||
}
|
||||
}
|
||||
if self.first.is_none() {
|
||||
self.first = Some((ts, cell));
|
||||
}
|
||||
self.last = Some(cell);
|
||||
}
|
||||
|
||||
fn folded_any(&self) -> bool {
|
||||
self.count > 0
|
||||
}
|
||||
|
||||
/// The summary value; call only when `folded_any()`.
|
||||
fn output(&self, fold: FoldKind, kind: ScalarKind) -> Scalar {
|
||||
match fold {
|
||||
FoldKind::Count => Scalar::i64(self.count as i64),
|
||||
FoldKind::Sum => Scalar::f64(self.sum),
|
||||
FoldKind::Mean => Scalar::f64(self.sum / self.count as f64),
|
||||
FoldKind::Min => Scalar::f64(self.min),
|
||||
FoldKind::Max => Scalar::f64(self.max),
|
||||
FoldKind::First => {
|
||||
Scalar::from_cell(kind, self.first.expect("folded_any checked").1)
|
||||
}
|
||||
FoldKind::Last => Scalar::from_cell(kind, self.last.expect("folded_any checked")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single-column folding sink. Emits exactly one row on `finalize` (never
|
||||
/// twice, never on a never-warm tap): `(row_ts, [output])`, where `row_ts` is
|
||||
/// the last folded value's ts — except `First`, which carries the first warm
|
||||
/// value's ts (the instant its reported value was determined).
|
||||
pub struct TapFold {
|
||||
kind: ScalarKind,
|
||||
fold: FoldKind,
|
||||
state: FoldState,
|
||||
last_ts: Timestamp,
|
||||
emitted: bool,
|
||||
tx: Sender<(Timestamp, Vec<Scalar>)>,
|
||||
}
|
||||
|
||||
impl TapFold {
|
||||
pub fn new(kind: ScalarKind, fold: FoldKind, tx: Sender<(Timestamp, Vec<Scalar>)>) -> Self {
|
||||
debug_assert!(fold_binds_at(fold, kind), "caller validates fold/kind before binding");
|
||||
Self { kind, fold, state: FoldState::new(), last_ts: Timestamp(0), emitted: false, tx }
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for TapFold {
|
||||
fn lookbacks(&self) -> Vec<usize> {
|
||||
vec![1]
|
||||
}
|
||||
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
let cell = newest_cell(self.kind, &ctx)?; // None until warm
|
||||
self.state.fold(ctx.now(), cell, self.kind);
|
||||
self.last_ts = ctx.now();
|
||||
None
|
||||
}
|
||||
|
||||
fn finalize(&mut self) {
|
||||
if self.state.folded_any() && !self.emitted {
|
||||
self.emitted = true;
|
||||
let ts = match self.fold {
|
||||
FoldKind::First => self.state.first.expect("folded_any checked").0,
|
||||
_ => self.last_ts,
|
||||
};
|
||||
let _ = self.tx.send((ts, vec![self.state.output(self.fold, self.kind)]));
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
format!("TapFold({:?})", self.fold)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::AnyColumn;
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// Drive a fold over an f64 series and return the emitted rows.
|
||||
fn run_f64_fold(fold: FoldKind, values: &[f64]) -> Vec<(Timestamp, Vec<Scalar>)> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut sink = TapFold::new(ScalarKind::F64, fold, tx);
|
||||
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
for (i, &v) in values.iter().enumerate() {
|
||||
inputs[0].push(Scalar::f64(v)).unwrap();
|
||||
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(i as i64 + 1))), None);
|
||||
}
|
||||
sink.finalize();
|
||||
rx.try_iter().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mean_matches_slice_mean_bit_for_bit() {
|
||||
let values = [0.1, 0.2, 0.30000000000000004, -1.5, 2.75];
|
||||
let rows = run_f64_fold(FoldKind::Mean, &values);
|
||||
let slice_mean = values.iter().sum::<f64>() / values.len() as f64;
|
||||
assert_eq!(rows, vec![(Timestamp(5), vec![Scalar::f64(slice_mean)])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_sum_min_max_over_a_known_series() {
|
||||
let values = [3.0, -1.0, 2.0];
|
||||
assert_eq!(
|
||||
run_f64_fold(FoldKind::Count, &values),
|
||||
vec![(Timestamp(3), vec![Scalar::i64(3)])]
|
||||
);
|
||||
assert_eq!(
|
||||
run_f64_fold(FoldKind::Sum, &values),
|
||||
vec![(Timestamp(3), vec![Scalar::f64(4.0)])]
|
||||
);
|
||||
assert_eq!(
|
||||
run_f64_fold(FoldKind::Min, &values),
|
||||
vec![(Timestamp(3), vec![Scalar::f64(-1.0)])]
|
||||
);
|
||||
assert_eq!(
|
||||
run_f64_fold(FoldKind::Max, &values),
|
||||
vec![(Timestamp(3), vec![Scalar::f64(3.0)])]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_carries_the_first_warm_ts_and_last_the_last() {
|
||||
let values = [7.0, 8.0, 9.0];
|
||||
assert_eq!(
|
||||
run_f64_fold(FoldKind::First, &values),
|
||||
vec![(Timestamp(1), vec![Scalar::f64(7.0)])],
|
||||
"First reports the first warm value at ITS ts"
|
||||
);
|
||||
assert_eq!(
|
||||
run_f64_fold(FoldKind::Last, &values),
|
||||
vec![(Timestamp(3), vec![Scalar::f64(9.0)])]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_warm_tap_emits_nothing_and_finalize_emits_once() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut sink = TapFold::new(ScalarKind::F64, FoldKind::Count, tx);
|
||||
let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
// cold eval, then finalize: nothing emitted.
|
||||
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(1))), None);
|
||||
sink.finalize();
|
||||
assert!(rx.try_recv().is_err(), "a never-warm fold emits no row");
|
||||
|
||||
// warm one value; a double finalize still emits exactly one row.
|
||||
let (tx2, rx2) = mpsc::channel();
|
||||
let mut sink2 = TapFold::new(ScalarKind::I64, FoldKind::Last, tx2);
|
||||
let mut inputs2 = vec![AnyColumn::with_capacity(ScalarKind::I64, 1)];
|
||||
inputs2[0].push(Scalar::i64(42)).unwrap();
|
||||
assert_eq!(sink2.eval(Ctx::new(&inputs2, Timestamp(9))), None);
|
||||
sink2.finalize();
|
||||
sink2.finalize();
|
||||
let rows: Vec<_> = rx2.try_iter().collect();
|
||||
assert_eq!(rows, vec![(Timestamp(9), vec![Scalar::i64(42)])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_rules_and_output_kinds_are_the_specced_table() {
|
||||
for fold in [FoldKind::Sum, FoldKind::Mean, FoldKind::Min, FoldKind::Max] {
|
||||
assert!(fold_binds_at(fold, ScalarKind::F64));
|
||||
assert!(!fold_binds_at(fold, ScalarKind::I64));
|
||||
assert!(!fold_binds_at(fold, ScalarKind::Bool));
|
||||
assert!(!fold_binds_at(fold, ScalarKind::Timestamp));
|
||||
assert_eq!(fold_output_kind(fold, ScalarKind::F64), ScalarKind::F64);
|
||||
}
|
||||
for kind in [ScalarKind::F64, ScalarKind::I64, ScalarKind::Bool, ScalarKind::Timestamp] {
|
||||
assert!(fold_binds_at(FoldKind::Count, kind));
|
||||
assert!(fold_binds_at(FoldKind::First, kind));
|
||||
assert!(fold_binds_at(FoldKind::Last, kind));
|
||||
assert_eq!(fold_output_kind(FoldKind::Count, kind), ScalarKind::I64);
|
||||
assert_eq!(fold_output_kind(FoldKind::First, kind), kind);
|
||||
assert_eq!(fold_output_kind(FoldKind::Last, kind), kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! `TapLive` — the live-subscription sink (C8) for one declared tap: calls a
|
||||
//! consumer-owned closure `(Timestamp, Cell)` inline per fired warm cycle, on
|
||||
//! the sim thread, zero alloc. Loss policy is the closure's own (a UI
|
||||
//! consumer typically `try_send`s into its own channel and drops when behind)
|
||||
//! — per #283: consumer-owned logic and state, no registry ceremony.
|
||||
|
||||
use aura_core::{Cell, Ctx, Node, ScalarKind, Timestamp};
|
||||
|
||||
use crate::tap_cell::newest_cell;
|
||||
|
||||
/// A single-column live sink around a consumer closure.
|
||||
pub struct TapLive {
|
||||
kind: ScalarKind,
|
||||
consumer: Box<dyn FnMut(Timestamp, Cell) + Send>,
|
||||
}
|
||||
|
||||
impl TapLive {
|
||||
pub fn new(kind: ScalarKind, consumer: impl FnMut(Timestamp, Cell) + Send + 'static) -> Self {
|
||||
Self { kind, consumer: Box::new(consumer) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for TapLive {
|
||||
fn lookbacks(&self) -> Vec<usize> {
|
||||
vec![1]
|
||||
}
|
||||
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
let cell = newest_cell(self.kind, &ctx)?; // None until warm
|
||||
(self.consumer)(ctx.now(), cell);
|
||||
None
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
"TapLive".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{AnyColumn, Scalar};
|
||||
use std::sync::mpsc;
|
||||
|
||||
#[test]
|
||||
fn calls_the_consumer_per_warm_cycle_in_order_and_not_before() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut sink = TapLive::new(ScalarKind::F64, move |ts, cell| {
|
||||
let _ = tx.send((ts, cell.f64()));
|
||||
});
|
||||
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
||||
|
||||
// cold: consumer not called.
|
||||
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(1))), None);
|
||||
assert!(rx.try_recv().is_err(), "no call before warm-up");
|
||||
|
||||
for (t, v) in [(2_i64, 1.5_f64), (3, 2.5)] {
|
||||
inputs[0].push(Scalar::f64(v)).unwrap();
|
||||
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(t))), None);
|
||||
}
|
||||
let got: Vec<(Timestamp, f64)> = rx.try_iter().collect();
|
||||
assert_eq!(got, vec![(Timestamp(2), 1.5), (Timestamp(3), 2.5)]);
|
||||
}
|
||||
|
||||
/// `newest_cell`'s four-kind dispatch (shared with `TapFold`) is
|
||||
/// otherwise only exercised at F64/I64; the Bool and Timestamp arms
|
||||
/// need a witness too, so a regression in either goes caught.
|
||||
#[test]
|
||||
fn calls_the_consumer_at_bool_and_timestamp_kinds_too() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut sink = TapLive::new(ScalarKind::Bool, move |ts, cell| {
|
||||
let _ = tx.send((ts, cell.bool()));
|
||||
});
|
||||
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::Bool, 1)];
|
||||
inputs[0].push(Scalar::bool(true)).unwrap();
|
||||
assert_eq!(sink.eval(Ctx::new(&inputs, Timestamp(1))), None);
|
||||
assert_eq!(rx.try_recv(), Ok((Timestamp(1), true)));
|
||||
|
||||
let (tx2, rx2) = mpsc::channel();
|
||||
let mut sink2 = TapLive::new(ScalarKind::Timestamp, move |ts, cell| {
|
||||
let _ = tx2.send((ts, cell.ts()));
|
||||
});
|
||||
let mut inputs2 = vec![AnyColumn::with_capacity(ScalarKind::Timestamp, 1)];
|
||||
inputs2[0].push(Scalar::ts(Timestamp(99))).unwrap();
|
||||
assert_eq!(sink2.eval(Ctx::new(&inputs2, Timestamp(1))), None);
|
||||
assert_eq!(rx2.try_recv(), Ok((Timestamp(1), Timestamp(99))));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user