Compare commits
113 Commits
4ed6455b64
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 024e8652c0 | |||
| 7aac09d49e | |||
| f108291b7f | |||
| 567f98b4e5 | |||
| ef24f06547 | |||
| 2e532bce00 | |||
| 74281842b8 | |||
| d2b0cdf64c | |||
| 2b692a71e3 | |||
| c39f5e4762 | |||
| 521459dd50 | |||
| 6b6086fdef | |||
| 77ad0465cb | |||
| e6b60bf680 | |||
| 2f1baceec7 | |||
| 3aa63833f1 | |||
| 1476990cfd | |||
| dc5f1742f4 | |||
| 9eb6d6b4f6 | |||
| 06d7e0f30a | |||
| db8f947441 | |||
| 24782caaec | |||
| 5dc8e03249 | |||
| 696d7fe59a | |||
| da19e27b6a | |||
| 78b80ec0fd | |||
| 9cfe2965c0 | |||
| 32eb5a6a9e | |||
| 564a767974 | |||
| 4ff85b94e5 | |||
| 10570b75f8 | |||
| a851af993a | |||
| bbac29db2d | |||
| 5e32f3ccdf | |||
| 4474814fa7 | |||
| b3b7115825 | |||
| 3e1e7e21da | |||
| a3785a6ec6 | |||
| 697d81dd22 | |||
| eb2b0a132c | |||
| 14c43474ab | |||
| 6ca359ae00 | |||
| bc8fb46110 | |||
| a8b1ba45c5 | |||
| e84ad6d0d2 | |||
| f449cb06f2 | |||
| 26b3d689df | |||
| cb3330ceb5 | |||
| 4a30222fdc | |||
| 2c2c2fdef6 | |||
| 623d304b7f | |||
| 9e26be60f3 | |||
| 7392075aa6 | |||
| 05e9a00afc | |||
| 120d116982 | |||
| 938397295d | |||
| 98342246f6 | |||
| fa7453dd9f | |||
| 8dbca82756 | |||
| 73ad87b08a | |||
| 7943b123ae | |||
| 7cc3ce0d9e | |||
| e482f0ec35 | |||
| 1baee774bb | |||
| 9124275bf3 | |||
| d26f0c84a4 | |||
| 162bf849ce | |||
| 829a1984e6 | |||
| 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 | |||
| 8688a60ded | |||
| fb28b831f3 | |||
| 6b994cead3 | |||
| a5ca0e7481 | |||
| edb8c37916 | |||
| 0a70f67711 | |||
| 27ace153f2 | |||
| da37f596f2 | |||
| 803c9704cc | |||
| 63711a1d3a | |||
| a12617bb80 | |||
| 757e3ac1bd |
@@ -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
|
||||
|
||||
|
||||
Generated
+3
-2
@@ -160,6 +160,7 @@ dependencies = [
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-ingest",
|
||||
"aura-market",
|
||||
"aura-measurement",
|
||||
"aura-registry",
|
||||
"aura-research",
|
||||
@@ -167,6 +168,7 @@ dependencies = [
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"aura-vocabulary",
|
||||
"chrono-tz",
|
||||
"clap",
|
||||
"data-server",
|
||||
"libc",
|
||||
@@ -192,6 +194,7 @@ dependencies = [
|
||||
name = "aura-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono-tz",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -307,8 +310,6 @@ dependencies = [
|
||||
"aura-backtest",
|
||||
"aura-core",
|
||||
"aura-strategy",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -31,47 +31,46 @@ Invoke it as `aura <command> …` (examples below use the plain name).
|
||||
|
||||
- **Blueprint** — a serialized signal graph as data: a param-generic
|
||||
`price → bias` node graph. A blueprint file (`blueprint.json`) is the unit a
|
||||
downstream consumer loads and drives verbs over.
|
||||
downstream consumer loads and executes or varies.
|
||||
- **Open vs. closed** — a blueprint with unbound (free) numeric knobs is *open*;
|
||||
one with every knob bound is *closed*. Different verbs want different states
|
||||
(see each verb below).
|
||||
- **Family** — the set of runs one verb produces over a blueprint (a sweep grid,
|
||||
a Monte-Carlo seed set, a walk-forward window sequence). Families are persisted
|
||||
in a content-addressed store and can be listed, ranked, and reproduced.
|
||||
- **Axis** — one named, sweepable knob of a blueprint (e.g. `graph.fast.length`),
|
||||
one with every knob bound is *closed*. A plain `exec` wants closed; naming a
|
||||
knob as a campaign axis (or an `exec --override`) is what reopens it.
|
||||
- **Family** — the set of runs a campaign document's process pipeline produces
|
||||
over a blueprint (a sweep grid, a Monte-Carlo seed set, a walk-forward window
|
||||
sequence). Families are persisted in a content-addressed store and can be
|
||||
listed, ranked, and reproduced.
|
||||
- **Axis** — one named, sweepable knob of a blueprint (e.g. `fast.length`),
|
||||
bound with a comma-separated value list. A **gang** fuses several sibling
|
||||
knobs into one axis (one value drives all members).
|
||||
|
||||
## Running & orchestrating a loaded blueprint
|
||||
## Executing a document
|
||||
|
||||
These verbs all take the **blueprint file as their first positional argument**.
|
||||
Most drive a family of runs over it; `graph` renders its structure so a mis-wire
|
||||
is visible before a run. `walkforward`, `mc`, and `generalize` share
|
||||
`sweep`'s generic grammar — the blueprint positional plus `--real` and a
|
||||
repeatable `--axis <name>=<csv>` (`generalize` takes `--real <SYM1,SYM2,…>`, at
|
||||
least two instruments, with a single value per axis) — see `--help`.
|
||||
`aura exec <target>` (#319) is the one executor verb: `<target>` is either a
|
||||
loaded **blueprint** file (a single synthetic run) or a **campaign document**
|
||||
(a `.json` file or its registered 64-hex content id — instruments × windows ×
|
||||
strategy × param axes × process, see `docs/authoring-guide.md` §3). `graph`
|
||||
renders a blueprint's structure so a mis-wire is visible before a run.
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `aura run <bp.json>` | Run one backtest of a **closed** blueprint and print its report. An open (free-knob) blueprint is refused with a clean error — bind it, or use `sweep`. |
|
||||
| `aura exec <bp.json>` | Run one backtest of a **closed** blueprint and print its report. An open (free-knob) blueprint is refused with a clean error — bind it, or vary it as a campaign axis. |
|
||||
| `aura exec <bp.json> --override <node.param>=<value>` | Reopen one **bound** param for this single execution only (the value is recorded raw in the manifest); repeatable. |
|
||||
| `aura exec <bp.json> --tap <name>=<fold>` | Subscribe a declared tap to a fold (`record`/`count`/`sum`/`mean`/`min`/`max`/`first`/`last`) for this run; repeatable. |
|
||||
| `aura graph <bp.json>` | **Render** the blueprint's structure as an interactive HTML DAG so a mis-wire is visible before a run. Omit the file to render the built-in sample; a named-but-unreadable file is a usage error. |
|
||||
| `aura sweep <bp.json> --list-axes` | **Discover** the blueprint's open, sweepable knobs. Prints each as `<name>:<kind>`. Run this first to learn the axis names. |
|
||||
| `aura sweep <bp.json> --axis <name>=<csv> [--axis …]` | Run a **grid family** over the named axes and persist it. |
|
||||
| `aura mc <bp.json> --seeds <n>` | Run a **synthetic Monte-Carlo family** of `n` seeded realizations. Wants a **closed** blueprint (the inverse of sweep). |
|
||||
| `aura mc <bp.json> --real <sym> --axis <name>=<csv> [--axis …]` | Run a **Monte-Carlo R-bootstrap campaign** over recorded data — driven through the same generated-campaign pipeline as `sweep`; `--block-len`/`--resamples`/`--seed` tune the bootstrap. |
|
||||
| `aura walkforward <bp.json> --axis <name>=<csv> [--axis …]` | Run an **in-sample-refit walk-forward family** — rolling optimize + out-of-sample test across windows, stitched into one verdict + parameter stability. `--select` chooses the per-window objective. |
|
||||
| `aura graph introspect --params <bp.json\|id>` | **Discover** a blueprint's open, sweepable knobs. Prints each as `<name>:<kind>`, bound params trailing `default=<value>`. Run this first to learn the axis names for a campaign document. |
|
||||
| `aura exec <campaign.json\|id> [--parallel-instruments <n>]` | Execute a **campaign document** — a grid family, gates, walk-forward, Monte-Carlo, and cross-instrument generalization per its process document, one cell per (strategy, instrument, window). |
|
||||
| `aura runs families` | List every persisted family (id, kind, member count). |
|
||||
| `aura runs family <id> [rank <metric>]` | List one family's members, optionally ranked best-first by an R metric (e.g. `sqn_normalized`, `expectancy_r`). |
|
||||
| `aura reproduce <family-id>` | Re-derive every member of a persisted family from the content-addressed store and check it is bit-identical (the C18/C1 determinism guarantee). |
|
||||
|
||||
**Important contract — every open knob is mandatory.** On `sweep` and
|
||||
`walkforward`, the knobs enumerated by `--list-axes` are *all* required: you must
|
||||
supply an `--axis` for each open knob. There is no default value — pin a knob you
|
||||
don't want to vary with a single-value axis (`--axis name=4`). Omitting one is a
|
||||
clean error naming the missing knob, not a silent default.
|
||||
**Important contract — every open knob is mandatory on a campaign axis.** A
|
||||
campaign document's `strategies[].axes` must name every open knob
|
||||
`graph introspect --params` lists: there is no default value — pin a knob you
|
||||
don't want to vary with a single-value axis (`{"kind":"I64","values":[4]}`).
|
||||
Omitting one is a clean error naming the missing knob, not a silent default.
|
||||
|
||||
Use `aura <cmd> --help` for the full data-window (`--real`/`--from`/`--to`),
|
||||
naming (`--name`), and selection (`--select`) flags.
|
||||
Use `aura exec --help` for the exact flag grammar, `docs/authoring-guide.md`
|
||||
for the campaign document shape (data window, naming, presentation/emit).
|
||||
|
||||
## Authoring & introspecting topology
|
||||
|
||||
@@ -111,16 +110,20 @@ Each element is one op, tagged by `"op"`. Node params are bound with the **typed
|
||||
```
|
||||
|
||||
Piping this document into `aura graph build` emits a `blueprint.json` you can
|
||||
then feed to `run` / `sweep` / `mc` / `walkforward` above:
|
||||
then feed to `exec` above:
|
||||
|
||||
```sh
|
||||
aura graph build < crossover.ops.json > crossover.bp.json
|
||||
aura sweep crossover.bp.json --list-axes
|
||||
aura graph introspect --params crossover.bp.json
|
||||
```
|
||||
|
||||
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`, `doc`, and `use` (`tap` declares a recorded measurement point on an
|
||||
interior wire; `doc` declares the composite's one-line meaning, required at
|
||||
register — C29; `use` splices a registered blueprint in as a nested composite,
|
||||
by content id or label; `add` additionally takes an `args` object for
|
||||
**arg-bearing** types (`Session`, `LinComb`, `CostSum`) — structural,
|
||||
non-scalar construction consumed before `bind`; 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.487171,
|
||||
"run_ms": 3.376192
|
||||
},
|
||||
"fingerprint": "run_line_fnv=792dc4434f8e3dee",
|
||||
"fingerprint": "run_line_fnv=9bdbc3acf7b2926a",
|
||||
"reps": 3,
|
||||
"host": {
|
||||
"hostname": "Raki",
|
||||
"nproc": 24
|
||||
},
|
||||
"profile": "release",
|
||||
"commit": "13d8500",
|
||||
"date": "2026-07-17"
|
||||
"commit": "10570b7",
|
||||
"date": "2026-07-25"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -95,31 +95,18 @@ fn blueprint_stems(dir: &Path) -> Vec<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Seed one blueprint into the scratch store via a tiny synthetic sweep (the
|
||||
/// E2E `seed_blueprint` pattern) and return its content id.
|
||||
/// Seed one blueprint into the scratch store via `graph register` (#319: the
|
||||
/// surviving registration surface) and return its content id.
|
||||
fn seed_blueprint(bin: &Path, dir: &Path, file: &str, name: &str) -> Result<String, String> {
|
||||
let before = blueprint_stems(dir);
|
||||
let (out, code) = run_in(
|
||||
bin,
|
||||
dir,
|
||||
&[
|
||||
"sweep",
|
||||
file,
|
||||
"--axis",
|
||||
"sma_signal.fast.length=2,4",
|
||||
"--axis",
|
||||
"sma_signal.slow.length=8,16",
|
||||
"--name",
|
||||
name,
|
||||
],
|
||||
)?;
|
||||
let (out, code) = run_in(bin, dir, &["graph", "register", file, "--name", name])?;
|
||||
if code != Some(0) {
|
||||
return Err(format!("seed sweep failed ({code:?}): {out}"));
|
||||
return Err(format!("seed register failed ({code:?}): {out}"));
|
||||
}
|
||||
blueprint_stems(dir)
|
||||
.into_iter()
|
||||
.find(|s| !before.contains(s))
|
||||
.ok_or_else(|| "seed sweep must store a new blueprint".to_string())
|
||||
.ok_or_else(|| "seed register must store a new blueprint".to_string())
|
||||
}
|
||||
|
||||
fn register_process(bin: &Path, dir: &Path, file: &str, doc: &str) -> Result<String, String> {
|
||||
@@ -261,10 +248,10 @@ pub fn winner_fingerprint(stdout: &str) -> Result<String, String> {
|
||||
|
||||
fn campaign_rep(bin: &Path, sizing: Sizing, process_doc: &str) -> Result<RepOutcome, String> {
|
||||
let (scratch, doc) = build_scratch(bin, sizing, process_doc)?;
|
||||
let timed: TimedChild = run_timed(bin, &["campaign", "run", &doc], &scratch.0)?;
|
||||
let timed: TimedChild = run_timed(bin, &["exec", &doc], &scratch.0)?;
|
||||
if timed.exit != Some(0) {
|
||||
return Err(format!(
|
||||
"campaign run exited {:?}\nstdout: {}\nstderr: {}",
|
||||
"aura exec exited {:?}\nstdout: {}\nstderr: {}",
|
||||
timed.exit, timed.stdout, timed.stderr
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! CLI fixed cost: the spawn floor (`aura --help`, no fingerprint — nothing is
|
||||
//! computed) and a minimal data-only project run whose fingerprint is the
|
||||
//! FNV-1a hash of the run's single stdout JSON line. Fresh scratch project per
|
||||
//! computed) and a minimal data-only project exec whose fingerprint is the
|
||||
//! FNV-1a hash of the exec's single stdout JSON line. Fresh scratch project per
|
||||
//! repetition, so store counters start identical.
|
||||
|
||||
use super::{median, RepOutcome};
|
||||
@@ -11,7 +11,7 @@ use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Fingerprint of an `aura run` record line, invariant across rebuilds: the
|
||||
/// Fingerprint of an `aura exec` record line, invariant across rebuilds: the
|
||||
/// line's `manifest.commit` is the aura binary's compile-time build sha
|
||||
/// (crates/aura-cli/build.rs — the invariant-8 audit trail), so hashing the
|
||||
/// raw line would flip the fingerprint on EVERY new commit and report a
|
||||
@@ -59,9 +59,9 @@ pub fn rep(bin: &Path) -> Result<RepOutcome, String> {
|
||||
let mut run_walls = Vec::new();
|
||||
let mut first_line = None;
|
||||
for _ in 0..RUN_SAMPLES {
|
||||
let timed = run_timed(bin, &["run", "bench_sma_a.json"], &scratch.0)?;
|
||||
let timed = run_timed(bin, &["exec", "bench_sma_a.json"], &scratch.0)?;
|
||||
if timed.exit != Some(0) {
|
||||
return Err(format!("aura run exited {:?}: {}", timed.exit, timed.stdout));
|
||||
return Err(format!("aura exec exited {:?}: {}", timed.exit, timed.stdout));
|
||||
}
|
||||
run_walls.push(timed.wall_s * 1000.0);
|
||||
if first_line.is_none() {
|
||||
@@ -70,7 +70,7 @@ pub fn rep(bin: &Path) -> Result<RepOutcome, String> {
|
||||
.stdout
|
||||
.lines()
|
||||
.next()
|
||||
.ok_or("aura run must print its JSON record line")?
|
||||
.ok_or("aura exec must print its JSON record line")?
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ mod tests {
|
||||
|
||||
/// `run_reps` composed with the real campaign sweep surface — a fresh
|
||||
/// scratch project, a freshly seeded blueprint, and a genuine
|
||||
/// `aura campaign run` child process spawned per repetition (not an
|
||||
/// `aura exec` child process spawned per repetition (not an
|
||||
/// in-process shortcut). Protects that two independently built-and-run
|
||||
/// scratch campaigns agree on the winner-ordinal fingerprint extracted
|
||||
/// from the child's stdout — C1's determinism carried through the
|
||||
@@ -136,7 +136,7 @@ mod tests {
|
||||
|
||||
/// `run_reps` composed with the real CLI fixed-cost surface — a genuine
|
||||
/// `aura --help` spawn-floor wall plus a fresh scratch project and a real
|
||||
/// `aura run` child process per repetition (not an in-process shortcut).
|
||||
/// `aura exec` child process per repetition (not an in-process shortcut).
|
||||
/// Protects that two independent scratch runs agree on the run-line FNV
|
||||
/// fingerprint extracted from the child's stdout, and that the metrics
|
||||
/// map carries the wall-clock keys the fixed_cost `BaselineDoc` needs.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,15 @@ zip = "2"
|
||||
# (#[cfg(test)] use aura_composites::StopRule in main.rs's ic_tests module),
|
||||
# so this rides the same dev-only-edge idiom as `aura-analysis`/`sha2` below.
|
||||
aura-composites = { path = "../aura-composites" }
|
||||
# aura-market: the shell's own use is test-only (#271's identity-bridge test,
|
||||
# `identity_id_bridges_the_rust_configured_session_and_args_script`, needs
|
||||
# `Session::configured` as the Rust-authored twin of the args op-script path)
|
||||
# — rides the same dev-only-edge idiom as `aura-composites`/`aura-analysis`.
|
||||
aura-market = { path = "../aura-market" }
|
||||
# chrono-tz: the SAME identity-bridge test needs a `chrono_tz::Tz` to pass
|
||||
# `Session::configured` — test-only, pinned to the same version every other
|
||||
# crate's chrono-tz entry uses.
|
||||
chrono-tz = { version = "0.10", default-features = false }
|
||||
# aura-analysis: pearson_corr, used only by the ic_tests unit-test fixtures
|
||||
# (mod ic_tests in main.rs) — a test-only edge, not a production one.
|
||||
aura-analysis = { path = "../aura-analysis" }
|
||||
|
||||
@@ -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"}]}}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! `aura campaign run` — the driver that turns a stored campaign document into
|
||||
//! `aura exec`'s campaign legs (#319; formerly `aura campaign run`) — the
|
||||
//! driver that turns a stored campaign document into
|
||||
//! a realized run-set (#198). The execution *semantics* (preflight, cell loop,
|
||||
//! stage sequencing, selection, registry writes) live in `aura-campaign`; the
|
||||
//! [`MemberRunner`](aura_campaign::MemberRunner) implementation over the shipped loaded-blueprint machinery
|
||||
@@ -27,7 +28,7 @@ use aura_engine::FamilySelection;
|
||||
use aura_registry::CampaignRunRecord;
|
||||
use aura_research::{
|
||||
campaign_to_json, content_id_of, parse_campaign, parse_process, validate_campaign,
|
||||
validate_process, CampaignDoc, DocRef,
|
||||
validate_process, Axis, CampaignDoc, DocRef,
|
||||
};
|
||||
|
||||
use aura_runner::axes::is_content_id;
|
||||
@@ -108,27 +109,17 @@ struct CampaignRunLine<'a> {
|
||||
campaign_run: &'a CampaignRunRecord,
|
||||
}
|
||||
|
||||
/// Stdout shape of a campaign run. `Full` is `aura campaign run` (emit-gated
|
||||
/// member/selection lines + the always-on final `campaign_run` record line).
|
||||
/// `MemberLinesOnly` is dissolved-verb sugar: the generated document's
|
||||
/// `emit: ["family_table"]` already limits emission to member lines; the mode
|
||||
/// additionally suppresses the final record line so the verb's stdout
|
||||
/// contract is reproduced byte-for-byte. The record append is identical in
|
||||
/// both modes — presentation changes, the record does not.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(crate) enum RunPresentation {
|
||||
Full,
|
||||
MemberLinesOnly,
|
||||
}
|
||||
|
||||
/// `aura campaign run <target>`: resolve, gate, execute, emit. Every `Err`
|
||||
/// `aura exec <target>` (campaign legs): resolve, gate, execute, emit. Every `Err`
|
||||
/// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1; an `Ok`
|
||||
/// carries the failed-cell count (#272) so the caller can exit 3 on a
|
||||
/// completed-with-failures run via `exit_on_campaign_result`.
|
||||
/// completed-with-failures run via `exit_on_campaign_result`. `overrides`
|
||||
/// (#319 Task 4, the `exec --override` campaign leg) is threaded through
|
||||
/// unchanged to `run_campaign_returning`, the one injection seam.
|
||||
pub(crate) fn run_campaign(
|
||||
target: &str,
|
||||
env: &Env,
|
||||
parallel_instruments: NonZeroUsize,
|
||||
overrides: &[(String, Scalar)],
|
||||
) -> Result<usize, String> {
|
||||
// Project gate FIRST: nothing (not even the file-sugar registration)
|
||||
// touches a store outside a project.
|
||||
@@ -162,7 +153,7 @@ pub(crate) fn run_campaign(
|
||||
));
|
||||
};
|
||||
|
||||
run_campaign_by_id(&campaign_id, env, RunPresentation::Full, parallel_instruments)
|
||||
run_campaign_by_id(&campaign_id, env, parallel_instruments, overrides)
|
||||
}
|
||||
|
||||
/// An executed campaign plus the context its presenter and the dissolved-verb
|
||||
@@ -181,20 +172,15 @@ pub(crate) struct CampaignRun {
|
||||
/// The one campaign executor path from a resolved content id onward: fetch
|
||||
/// the stored canonical bytes by id (so file addressing and id addressing
|
||||
/// produce the same realization by construction) and re-run the intrinsic
|
||||
/// tier on them, execute, and emit per `presentation`. Shared by
|
||||
/// `run_campaign` (`RunPresentation::Full`) and the dissolved-verb sugar path
|
||||
/// (`verb_sugar::run_sweep_sugar`, `RunPresentation::MemberLinesOnly`) — no
|
||||
/// project gate here: the sugar path must work project-less exactly as the
|
||||
/// inline verb it replaces did (store mechanics run against `env.registry()`
|
||||
/// in both cases).
|
||||
/// tier on them, execute, and emit. Called by `run_campaign`.
|
||||
pub(crate) fn run_campaign_by_id(
|
||||
campaign_id: &str,
|
||||
env: &Env,
|
||||
presentation: RunPresentation,
|
||||
parallel_instruments: NonZeroUsize,
|
||||
overrides: &[(String, Scalar)],
|
||||
) -> Result<usize, String> {
|
||||
let run = run_campaign_returning(campaign_id, env, parallel_instruments)?;
|
||||
present_campaign(run, presentation, env)
|
||||
let run = run_campaign_returning(campaign_id, env, parallel_instruments, overrides)?;
|
||||
present_campaign(run, env)
|
||||
}
|
||||
|
||||
/// The one campaign executor path from a resolved content id up to (not
|
||||
@@ -204,18 +190,32 @@ pub(crate) fn run_campaign_by_id(
|
||||
/// outcome bundled with the context the presenter needs. Shared by
|
||||
/// `run_campaign_by_id` (which then presents) and a forthcoming dissolved-verb
|
||||
/// sugar path that reads the outcome and self-prints (lands in a later task,
|
||||
/// alongside the generalize translator's own runner).
|
||||
/// alongside the generalize translator's own runner). `overrides` (#319
|
||||
/// Task 4) is injected into the parsed document AFTER intrinsic validation
|
||||
/// and BEFORE the referential gate — the stored bytes and the recorded
|
||||
/// `campaign_id` stay those of the ORIGINAL document; only the executed
|
||||
/// run-set changes, its audit record being the raw member manifests.
|
||||
///
|
||||
/// One deliberate exception to "every refusal is an `Err`" (review Minor-4):
|
||||
/// the injection loop's axis-collision check exits directly with code 2
|
||||
/// instead of returning `Err`. That collision is a USAGE-class fault (C14) —
|
||||
/// `--override` naming a path the document already declares as an axis is a
|
||||
/// malformed invocation, exactly like `--override`'s own malformed-token
|
||||
/// refusal (`parse_override_tokens`, exit 2) — not a RUNTIME-class refusal
|
||||
/// (missing environment/recorded state, exit 1), which is what this fn's
|
||||
/// `Result<_, String>` return otherwise carries end to end.
|
||||
pub(crate) fn run_campaign_returning(
|
||||
campaign_id: &str,
|
||||
env: &Env,
|
||||
parallel_instruments: NonZeroUsize,
|
||||
overrides: &[(String, Scalar)],
|
||||
) -> Result<CampaignRun, String> {
|
||||
let registry = env.registry();
|
||||
let campaign_text = registry
|
||||
.get_campaign(campaign_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("no campaign {campaign_id} in the project store"))?;
|
||||
let campaign = parse_campaign(&campaign_text)
|
||||
let mut campaign = parse_campaign(&campaign_text)
|
||||
.map_err(|e| doc_error_prose("stored campaign document", &e))?;
|
||||
let faults = validate_campaign(&campaign);
|
||||
if !faults.is_empty() {
|
||||
@@ -225,6 +225,25 @@ pub(crate) fn run_campaign_returning(
|
||||
));
|
||||
}
|
||||
|
||||
// --override NODE.PARAM=VALUE (#246 residue, #319 Task 4): inject as a
|
||||
// single-value axis over the named bound param into EVERY strategy
|
||||
// entry. Collision with a document-declared axis refuses (an override
|
||||
// overrides a bound value, never an axis); an unknown path then falls
|
||||
// out of `validate_campaign_refs` below with its existing did-you-mean
|
||||
// prose — no `RefFault` change, no registry change.
|
||||
for (path, val) in overrides {
|
||||
for entry in &mut campaign.strategies {
|
||||
if entry.axes.contains_key(path) {
|
||||
eprintln!(
|
||||
"aura: exec: --override `{path}` collides with a declared axis \
|
||||
of the campaign; an override overrides a bound value, never an axis"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
entry.axes.insert(path.clone(), single_value_axis(*val));
|
||||
}
|
||||
}
|
||||
|
||||
// Referential gate: zero faults or refuse (the campaign-validate seam).
|
||||
let resolve = |t: &str| env.resolve(t);
|
||||
let ref_faults = registry
|
||||
@@ -295,15 +314,11 @@ pub(crate) fn run_campaign_returning(
|
||||
Ok(CampaignRun { outcome, campaign, strategies, server: runner.server() })
|
||||
}
|
||||
|
||||
/// Emit an executed campaign's results per `presentation`: the zero-survivor
|
||||
/// stderr notes, the emit-gated family/selection lines, the always-on record
|
||||
/// line (Full only), then trace persistence. Split out of `run_campaign_by_id`
|
||||
/// so the dissolved-verb sugar can consume the outcome without this stdout tail.
|
||||
fn present_campaign(
|
||||
run: CampaignRun,
|
||||
presentation: RunPresentation,
|
||||
env: &Env,
|
||||
) -> Result<usize, String> {
|
||||
/// Emit an executed campaign's results: the zero-survivor stderr notes, the
|
||||
/// emit-gated family/selection lines, the always-on record line, then trace
|
||||
/// persistence. Split out of `run_campaign_by_id` so a forthcoming caller
|
||||
/// could consume the outcome without this stdout tail.
|
||||
fn present_campaign(run: CampaignRun, env: &Env) -> Result<usize, String> {
|
||||
let CampaignRun { outcome, campaign, strategies, server } = run;
|
||||
|
||||
// Zero-survivor stderr notes (exit stays 0 — a null result is a valid
|
||||
@@ -315,8 +330,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 +348,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
|
||||
);
|
||||
}
|
||||
@@ -355,6 +370,32 @@ fn present_campaign(
|
||||
let emit_family = campaign.presentation.emit.iter().any(|e| e == "family_table");
|
||||
let emit_selection = campaign.presentation.emit.iter().any(|e| e == "selection_report");
|
||||
for cell_out in &outcome.cells {
|
||||
// #313 zero-trade note (#319 Task 5, migrated from the retiring
|
||||
// `dispatch_walkforward`/`run_walkforward_sugar` paths): every campaign
|
||||
// cell's `std::walk_forward` family, if any, gets the same shared
|
||||
// `note_zero_trade_windows` producer over its OOS members' trade
|
||||
// counts — independent of `emit` (a stderr diagnostic, not gated
|
||||
// stdout presentation).
|
||||
if let Some(wf) = cell_out.families.iter().find(|f| f.block == "std::walk_forward") {
|
||||
crate::diag::note_zero_trade_windows(
|
||||
wf.reports.iter().map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades)),
|
||||
);
|
||||
} else if !cell_out.families.is_empty() {
|
||||
// #324 (re-scoped): the non-walk-forward sibling — a cell whose
|
||||
// families are ALL non-`std::walk_forward` (grid sweep, MC
|
||||
// bootstrap, …) has no OOS-window vocabulary, so without a note
|
||||
// here a cell that never traded is indistinguishable from a
|
||||
// break-even one. Over every report across every family this
|
||||
// cell ran (a cell with zero families, e.g. gate-truncated,
|
||||
// emits nothing new here).
|
||||
let trades: Vec<u64> = cell_out
|
||||
.families
|
||||
.iter()
|
||||
.flat_map(|f| f.reports.iter())
|
||||
.map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades))
|
||||
.collect();
|
||||
crate::diag::note_zero_trade_cell(trades.into_iter());
|
||||
}
|
||||
if emit_family {
|
||||
for fam in &cell_out.families {
|
||||
for report in &fam.reports {
|
||||
@@ -381,23 +422,21 @@ fn present_campaign(
|
||||
}
|
||||
}
|
||||
}
|
||||
if presentation == RunPresentation::Full {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
|
||||
.expect("campaign run record serializes")
|
||||
);
|
||||
eprintln!(
|
||||
"campaign run {} recorded: {} cells{}",
|
||||
outcome.record.run,
|
||||
outcome.record.cells.len(),
|
||||
if failed == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", {failed} failed ({})", fail_labels.join(", "))
|
||||
}
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
|
||||
.expect("campaign run record serializes")
|
||||
);
|
||||
eprintln!(
|
||||
"aura: campaign run {} recorded: {} cells{}",
|
||||
outcome.record.run,
|
||||
outcome.record.cells.len(),
|
||||
if failed == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", {failed} failed ({})", fail_labels.join(", "))
|
||||
}
|
||||
);
|
||||
|
||||
// Trace persistence runs LAST, after the always-on record line (0109):
|
||||
// stdout stays data-pure (the record line is the wire contract) and the
|
||||
@@ -417,6 +456,13 @@ fn present_campaign(
|
||||
Ok(failed)
|
||||
}
|
||||
|
||||
/// Build the single-value `Axis` an `exec --override` injects (#319 Task 4):
|
||||
/// exactly the shape the document form `{"kind": "I64", "values": [v]}`
|
||||
/// deserializes to — `Scalar::kind` names the tag, one value in the list.
|
||||
fn single_value_axis(v: Scalar) -> Axis {
|
||||
Axis { kind: v.kind(), values: vec![v] }
|
||||
}
|
||||
|
||||
/// #272: the fault-kind label the failed-cell summary line names — the same
|
||||
/// closed `CellFaultKind` vocabulary an aggregate over `campaign_runs.jsonl`
|
||||
/// counts by. Debug-leak-free (a fixed lowercase-snake label, not a Debug
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! 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 (#319 Task 5: migrated onto the campaign
|
||||
/// walk-forward leg in `campaign_run.rs`, the surviving executor path) 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// #324 (re-scoped): the non-walk-forward sibling of `note_zero_trade_windows`
|
||||
/// — a campaign cell whose families are ALL non-walk-forward (grid sweep, MC
|
||||
/// bootstrap, …) has no per-window vocabulary to note through, yet a
|
||||
/// consumer still cannot tell "0.0 = break-even" from "0.0 = never traded"
|
||||
/// without this. Pure over the count so the wording is unit-pinned (mirrors
|
||||
/// `zero_trade_note_text`'s singular/plural split, #313).
|
||||
fn zero_trade_cell_note_text(n_reports: usize) -> String {
|
||||
if n_reports == 1 {
|
||||
"the cell's one report recorded zero trades; its metrics are vacuous, not a break-even \
|
||||
result"
|
||||
.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"the cell's {n_reports} reports all recorded zero trades; its metrics are vacuous, \
|
||||
not a break-even result"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// See `zero_trade_cell_note_text`. Takes the per-report trade counts across
|
||||
/// every family the cell ran; a no-op unless there is at least one report
|
||||
/// and every one of them traded zero times. Called from the campaign cell
|
||||
/// loop's non-walk-forward branch (`campaign_run.rs`), independent of `emit`.
|
||||
pub(crate) fn note_zero_trade_cell(mut report_trades: impl ExactSizeIterator<Item = u64>) {
|
||||
let n_reports = report_trades.len();
|
||||
if n_reports > 0 && report_trades.all(|n| n == 0) {
|
||||
note!("{}", zero_trade_cell_note_text(n_reports));
|
||||
}
|
||||
}
|
||||
|
||||
/// #324 (comment 4501): `exec <blueprint.json>`'s single-run signal/bias leg
|
||||
/// always drives the built-in synthetic stream (`RunData::Synthetic`, no
|
||||
/// direct-blueprint exec ever binds real data — only a campaign document's
|
||||
/// `data` section does) — a tiny `n_cycles`-bar smoke fixture, not market
|
||||
/// data. Zero trades on it is expected, not a broken strategy; a caller that
|
||||
/// validates against it needs telling so. Unconditional — the caller gates
|
||||
/// on the zero-trade condition before calling.
|
||||
pub(crate) fn note_synthetic_smoke_zero_trades(n_cycles: usize) {
|
||||
note!(
|
||||
"the built-in synthetic stream ({n_cycles} cycles) is a small smoke fixture; zero \
|
||||
trades is expected here — bind real data via a campaign document's `data` section \
|
||||
(`aura exec <campaign.json>`) for a meaningful run"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{zero_trade_cell_note_text, 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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #324: the cell-level sibling pluralizes the same way and names the
|
||||
/// vacuous-metrics property, not just the raw zero count.
|
||||
fn zero_trade_cell_note_pluralizes() {
|
||||
assert_eq!(
|
||||
zero_trade_cell_note_text(1),
|
||||
"the cell's one report recorded zero trades; its metrics are vacuous, not a \
|
||||
break-even result"
|
||||
);
|
||||
assert_eq!(
|
||||
zero_trade_cell_note_text(4),
|
||||
"the cell's 4 reports all recorded zero trades; its metrics are vacuous, not a \
|
||||
break-even result"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+868
-1998
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,8 @@ pub enum ProcessSub {
|
||||
Introspect(DocIntrospectCmd),
|
||||
/// Register a valid process document into the store under the runs root.
|
||||
Register { file: PathBuf },
|
||||
/// Print a registered process document's canonical bytes to stdout.
|
||||
Show { id: String },
|
||||
}
|
||||
|
||||
#[derive(clap::Args)]
|
||||
@@ -63,7 +65,7 @@ impl DocIntrospectCmd {
|
||||
/// Parse `--parallel-instruments` in domain terms: clap's built-in
|
||||
/// `NonZeroUsize` parser would leak the Rust type name ("number would be
|
||||
/// zero for non-zero type") at exactly the moment a user needs guidance.
|
||||
fn parse_parallel_instruments(s: &str) -> Result<std::num::NonZeroUsize, String> {
|
||||
pub(crate) fn parse_parallel_instruments(s: &str) -> Result<std::num::NonZeroUsize, String> {
|
||||
s.parse::<usize>().ok().and_then(std::num::NonZeroUsize::new).ok_or_else(|| {
|
||||
"must be a whole number of at least 1 — it bounds how many distinct \
|
||||
instruments are resident in parallel"
|
||||
@@ -90,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();
|
||||
@@ -104,10 +107,12 @@ fn print_metric_roster() {
|
||||
(true, false) => "rankable",
|
||||
(false, false) => "annotation",
|
||||
};
|
||||
// C29 (#315): the roster carries each metric's one-line meaning, not
|
||||
// only where it may be used.
|
||||
if generalize {
|
||||
println!("{name:<24} {base} | generalize");
|
||||
println!("{name:<24} {base} | generalize — {}", m.doc);
|
||||
} else {
|
||||
println!("{name:<24} {base}");
|
||||
println!("{name:<24} {base} — {}", m.doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,7 +161,7 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
|
||||
format!("data.windows[{index}]: from_ms must be earlier than to_ms")
|
||||
}
|
||||
DocFault::BadRegime { index } => {
|
||||
format!("risk[{index}]: stop length must be >= 1 and k must be > 0")
|
||||
format!("risk[{index}]: stop length must be >= 1, k must be > 0, and a fixed-stop distance must be > 0")
|
||||
}
|
||||
DocFault::BadCost { index } => {
|
||||
format!("cost[{index}]: the component's price-unit knob must be finite and >= 0")
|
||||
@@ -179,7 +184,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: {})",
|
||||
@@ -188,6 +193,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)"
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +223,7 @@ pub fn process_cmd(cmd: ProcessCmd, env: &Env) {
|
||||
introspect_process(i)
|
||||
}
|
||||
ProcessSub::Register { file } => register_process(file, env),
|
||||
ProcessSub::Show { id } => show_process(id, env),
|
||||
};
|
||||
if let Err(m) = result {
|
||||
eprintln!("aura: {m}");
|
||||
@@ -247,6 +263,15 @@ fn validate_process_file(file: &PathBuf) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
if env.provenance().is_none() {
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|d| d.display().to_string())
|
||||
.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"process register needs a project: the document store lives under the \
|
||||
project store root (no Aura.toml found up from {cwd})"
|
||||
));
|
||||
}
|
||||
let doc = parse_valid_process(file)
|
||||
.map_err(|m| format!("refusing to register: {m}"))?;
|
||||
let canonical = process_to_json(&doc);
|
||||
@@ -259,6 +284,60 @@ fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve `id` against a document store's full candidate content-id list —
|
||||
/// mirroring the exact-first / unique-match / else-refuse shape
|
||||
/// `aura_runner::reproduce::load_family` established for #298's family/run
|
||||
/// handle. Called only once the caller's own exact-match lookup has already
|
||||
/// missed, so a filter match here is always a strict, shorter prefix (ids
|
||||
/// are fixed-length hashes; a full-length miss cannot be a prefix of
|
||||
/// another). `Ok(None)`: no candidate matches — genuinely unknown, the
|
||||
/// caller's existing not-found prose applies unchanged. `Err`: two or more
|
||||
/// candidates share the prefix — refused by name, listing every candidate,
|
||||
/// rather than guessed.
|
||||
pub(crate) fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, String> {
|
||||
let matches: Vec<&String> = candidates.iter().filter(|c| c.starts_with(prefix)).collect();
|
||||
match matches.as_slice() {
|
||||
[] => Ok(None),
|
||||
[only] => Ok(Some((*only).clone())),
|
||||
many => {
|
||||
let listed = many.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");
|
||||
Err(format!("ambiguous id \"{prefix}\": candidates {listed}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_process(id: &str, env: &Env) -> Result<(), String> {
|
||||
if env.provenance().is_none() {
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|d| d.display().to_string())
|
||||
.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"process show needs a project: the document store lives under the \
|
||||
project store root (no Aura.toml found up from {cwd})"
|
||||
));
|
||||
}
|
||||
let registry = env.registry();
|
||||
if let Some(bytes) = registry.get_process(id).map_err(|e| e.to_string())? {
|
||||
// `print!`, not `println!`: `bytes` is the content-addressed canonical
|
||||
// form the store keyed `id` on — the CLI is a transport and must not
|
||||
// frame the canonical bytes with a trailing newline (#164, mirroring
|
||||
// `graph_construct::build_cmd`).
|
||||
print!("{bytes}");
|
||||
return Ok(());
|
||||
}
|
||||
let candidates = registry.list_process_ids().map_err(|e| e.to_string())?;
|
||||
match resolve_id_prefix(id, &candidates)? {
|
||||
Some(full_id) => match registry.get_process(&full_id).map_err(|e| e.to_string())? {
|
||||
Some(bytes) => {
|
||||
print!("{bytes}");
|
||||
Ok(())
|
||||
}
|
||||
None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))),
|
||||
},
|
||||
None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
fn introspect_process(cmd: &DocIntrospectCmd) -> Result<(), String> {
|
||||
if cmd.metrics {
|
||||
print_metric_roster();
|
||||
@@ -295,6 +374,13 @@ fn describe_one_block(id: &str) -> Result<(), String> {
|
||||
for s in schema.slots {
|
||||
let req = if s.required { "required" } else { "optional" };
|
||||
println!(" {:<20} {req}, {}", s.name, slot_kind_label(s.kind));
|
||||
// C29 (#315): the tap slot's closed value vocabulary carries its
|
||||
// per-entry meanings, indented under the slot line.
|
||||
if matches!(s.kind, aura_research::SlotKind::TapKinds) {
|
||||
for t in aura_research::tap_vocabulary() {
|
||||
println!(" {:<14} {}", t.id, t.doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -325,22 +411,11 @@ pub enum CampaignSub {
|
||||
Introspect(DocIntrospectCmd),
|
||||
/// Register a valid campaign document into the store under the runs root.
|
||||
Register { file: PathBuf },
|
||||
/// Execute a stored campaign into a realized run-set (a .json file is
|
||||
/// register-then-run sugar; the canonical address is the content id).
|
||||
Run {
|
||||
target: String,
|
||||
/// Bound on distinct instruments resident in parallel (the RAM
|
||||
/// lever; 1 reproduces the sequential loop's footprint).
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
value_parser = parse_parallel_instruments,
|
||||
)]
|
||||
parallel_instruments: std::num::NonZeroUsize,
|
||||
},
|
||||
/// List stored campaign realizations, or dump one campaign's records
|
||||
/// (the bare store lines, not the run-emit wrapper).
|
||||
Runs { campaign: Option<String>, run: Option<usize> },
|
||||
/// Print a registered campaign document's canonical bytes to stdout.
|
||||
Show { id: String },
|
||||
}
|
||||
|
||||
/// The one authoring trap the referential tier can name outright: a ref that
|
||||
@@ -354,6 +429,14 @@ fn prefix_hint(id: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// `show_campaign`'s not-found refusal prose — campaigns have no `RefFault`
|
||||
/// sibling (unlike processes' `ref_fault_prose`), so this is the single
|
||||
/// source both the exact-miss and prefix-resolved-but-then-missing arms
|
||||
/// route through, keeping the two copies from drifting.
|
||||
fn campaign_not_found_prose(id: &str) -> String {
|
||||
format!("campaign {id} not found in the project store{}", prefix_hint(id))
|
||||
}
|
||||
|
||||
pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
|
||||
match f {
|
||||
RefFault::ProcessNotFound(id) => {
|
||||
@@ -366,8 +449,14 @@ pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
|
||||
RefFault::StrategyUnloadable { id, error } => {
|
||||
format!("strategy {id} cannot be loaded: {error}")
|
||||
}
|
||||
RefFault::AxisNotInParamSpace { strategy, axis } => {
|
||||
format!("strategy {strategy}: axis \"{axis}\" is not in the param space")
|
||||
RefFault::AxisNotInParamSpace { strategy, axis, raw_candidate } => {
|
||||
let mut msg = format!("strategy {strategy}: axis \"{axis}\" is not in the param space");
|
||||
if let Some(candidate) = raw_candidate {
|
||||
msg.push_str(&format!(
|
||||
"; axis names are raw node.param paths — did you mean \"{candidate}\"?"
|
||||
));
|
||||
}
|
||||
msg
|
||||
}
|
||||
RefFault::AxisKindMismatch { strategy, axis } => {
|
||||
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
|
||||
@@ -440,18 +529,7 @@ fn campaign_runs(campaign: Option<&str>, run: Option<usize>, env: &Env) -> Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// #272: `Run` threads a failed-cell count (exit 3 on completed-with-failures,
|
||||
/// via `exit_on_campaign_result`), so it is pulled out of the unified
|
||||
/// `Result<(), String>` match the other four subcommands still share.
|
||||
pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
|
||||
if let CampaignSub::Run { target, parallel_instruments } = &cmd.sub {
|
||||
crate::exit_on_campaign_result(crate::campaign_run::run_campaign(
|
||||
target,
|
||||
env,
|
||||
*parallel_instruments,
|
||||
));
|
||||
return;
|
||||
}
|
||||
let result = match &cmd.sub {
|
||||
CampaignSub::Validate { file } => validate_campaign_file(file, env),
|
||||
CampaignSub::Introspect(i) => {
|
||||
@@ -460,7 +538,7 @@ pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
|
||||
}
|
||||
CampaignSub::Register { file } => register_campaign(file, env),
|
||||
CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env),
|
||||
CampaignSub::Run { .. } => unreachable!("handled above"),
|
||||
CampaignSub::Show { id } => show_campaign(id, env),
|
||||
};
|
||||
if let Err(m) = result {
|
||||
eprintln!("aura: {m}");
|
||||
@@ -550,6 +628,15 @@ fn validate_campaign_file(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
if env.provenance().is_none() {
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|d| d.display().to_string())
|
||||
.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"campaign register needs a project: the document store lives under the \
|
||||
project store root (no Aura.toml found up from {cwd})"
|
||||
));
|
||||
}
|
||||
let doc = parse_valid_campaign(file)
|
||||
.map_err(|m| format!("refusing to register: {m}"))?;
|
||||
let canonical = campaign_to_json(&doc);
|
||||
@@ -562,6 +649,36 @@ fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show_campaign(id: &str, env: &Env) -> Result<(), String> {
|
||||
if env.provenance().is_none() {
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|d| d.display().to_string())
|
||||
.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"campaign show needs a project: the document store lives under the \
|
||||
project store root (no Aura.toml found up from {cwd})"
|
||||
));
|
||||
}
|
||||
let registry = env.registry();
|
||||
if let Some(bytes) = registry.get_campaign(id).map_err(|e| e.to_string())? {
|
||||
// `print!`, not `println!`: see `show_process` — the CLI must not
|
||||
// frame the canonical bytes with a trailing newline (#164).
|
||||
print!("{bytes}");
|
||||
return Ok(());
|
||||
}
|
||||
let candidates = registry.list_campaign_ids().map_err(|e| e.to_string())?;
|
||||
match resolve_id_prefix(id, &candidates)? {
|
||||
Some(full_id) => match registry.get_campaign(&full_id).map_err(|e| e.to_string())? {
|
||||
Some(bytes) => {
|
||||
print!("{bytes}");
|
||||
Ok(())
|
||||
}
|
||||
None => Err(campaign_not_found_prose(id)),
|
||||
},
|
||||
None => Err(campaign_not_found_prose(id)),
|
||||
}
|
||||
}
|
||||
|
||||
fn introspect_campaign(cmd: &DocIntrospectCmd) -> Result<(), String> {
|
||||
if cmd.metrics {
|
||||
print_metric_roster();
|
||||
@@ -625,6 +742,7 @@ mod tests {
|
||||
ref_fault_prose(&RefFault::AxisNotInParamSpace {
|
||||
strategy: "9f3a".into(),
|
||||
axis: "nope".into(),
|
||||
raw_candidate: None,
|
||||
}),
|
||||
ref_fault_prose(&RefFault::AxisKindMismatch {
|
||||
strategy: "9f3a".into(),
|
||||
@@ -645,6 +763,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #328: `ref_fault_prose` appends the did-you-mean clause, contiguous
|
||||
/// and verbatim, exactly when `raw_candidate` is present — the document-
|
||||
/// side refusal seam's mirror of the sweep `--axis` intake translation
|
||||
/// (`aura-runner`'s `axes.rs` classify predicate), sharing the same
|
||||
/// `axis names are raw node.param paths` clause pinned there.
|
||||
#[test]
|
||||
fn ref_fault_prose_renders_the_did_you_mean_when_a_raw_candidate_is_present() {
|
||||
let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace {
|
||||
strategy: "9f3a".into(),
|
||||
axis: "graph.fast.length".into(),
|
||||
raw_candidate: Some("fast.length".into()),
|
||||
});
|
||||
assert_eq!(
|
||||
msg,
|
||||
"strategy 9f3a: axis \"graph.fast.length\" is not in the param space; \
|
||||
axis names are raw node.param paths — did you mean \"fast.length\"?"
|
||||
);
|
||||
}
|
||||
|
||||
/// #328 (negative): a rejected axis with no `raw_candidate` (stripping one
|
||||
/// leading segment yields no param-space hit, or the axis has no leading
|
||||
/// segment to strip at all) renders today's prose unchanged — no
|
||||
/// speculative suggestion.
|
||||
#[test]
|
||||
fn ref_fault_prose_omits_the_did_you_mean_when_no_raw_candidate() {
|
||||
let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace {
|
||||
strategy: "9f3a".into(),
|
||||
axis: "nope".into(),
|
||||
raw_candidate: None,
|
||||
});
|
||||
assert_eq!(msg, "strategy 9f3a: axis \"nope\" is not in the param space");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #194 (the prefix trap): a doc ref that carries the display `content:`
|
||||
/// prefix (a copy-paste from register/introspect output) is bare-only in
|
||||
@@ -700,6 +851,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
|
||||
@@ -718,4 +898,58 @@ mod tests {
|
||||
);
|
||||
assert!(!prose.contains("CostInstrumentKeys"), "Debug leak: {prose}");
|
||||
}
|
||||
|
||||
/// #300 fieldtest (df_4): outside a project (no Aura.toml → `Env::std()`),
|
||||
/// show's refusal names the missing project — the honest degradation
|
||||
/// `validate` and `campaign runs` already share ("no Aura.toml found up
|
||||
/// from <cwd>") — never a "project store" that does not exist. Blaming the
|
||||
/// id sends an author in the wrong directory chasing a registration that
|
||||
/// was never the problem. The refusal itself (Err → exit 1) is correct
|
||||
/// and stays.
|
||||
#[test]
|
||||
fn show_outside_a_project_names_the_missing_aura_toml_not_the_id() {
|
||||
let env = Env::std();
|
||||
for (verb, result) in [
|
||||
("process show", show_process("deadbeef", &env)),
|
||||
("campaign show", show_campaign("deadbeef", &env)),
|
||||
] {
|
||||
let msg = result.expect_err("show outside a project must refuse");
|
||||
assert!(
|
||||
msg.contains("no Aura.toml found up from"),
|
||||
"{verb} outside a project must name the missing Aura.toml: {msg}"
|
||||
);
|
||||
assert!(
|
||||
!msg.contains("not found in the project store"),
|
||||
"{verb} outside a project must not blame the id against a \
|
||||
store that does not exist: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #302: the ambiguity arm of `resolve_id_prefix` — content ids are
|
||||
/// hash-derived, not choosable, so two documents sharing a printed
|
||||
/// prefix cannot be constructed honestly through an E2E fixture
|
||||
/// (`show_resolves_a_unique_prefix_to_the_full_id_bytes` pins the
|
||||
/// unique-match arm end-to-end instead). This unit test is the only pin
|
||||
/// the ambiguous-refusal arm gets: a prefix shared by more than one
|
||||
/// candidate refuses rather than guesses, and the refusal names every
|
||||
/// sharing candidate so the author can pick the intended one.
|
||||
#[test]
|
||||
fn resolve_id_prefix_refuses_an_ambiguous_prefix_listing_candidates() {
|
||||
let candidates = vec!["4e2d1111".to_string(), "4e2d2222".to_string(), "9f3a0000".to_string()];
|
||||
|
||||
let err = resolve_id_prefix("4e2d", &candidates).expect_err("shared prefix must refuse");
|
||||
assert!(err.contains("4e2d1111"), "ambiguity refusal must list candidate 1: {err}");
|
||||
assert!(err.contains("4e2d2222"), "ambiguity refusal must list candidate 2: {err}");
|
||||
assert!(!err.contains("9f3a0000"), "ambiguity refusal must not list a non-matching id: {err}");
|
||||
|
||||
// Companion sanity for the two other arms at the same seam: a unique
|
||||
// prefix resolves, and no matching prefix reports as unresolved
|
||||
// rather than fabricating one.
|
||||
assert_eq!(
|
||||
resolve_id_prefix("9f3a", &candidates).expect("unique prefix must resolve"),
|
||||
Some("9f3a0000".to_string())
|
||||
);
|
||||
assert_eq!(resolve_id_prefix("dead", &candidates).expect("no match is not an error"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 @@ impl Scale {
|
||||
}],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }],
|
||||
// C29: every vocabulary entry ships a one-line meaning — the
|
||||
// doc field is required (E0063 without it) and gated at load.
|
||||
doc: "scalar gain: emits the input times the factor param",
|
||||
},
|
||||
|p| Box::new(Scale::new(p[0].f64())),
|
||||
)
|
||||
@@ -219,22 +224,32 @@ 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)
|
||||
|
||||
This directory is an aura project: blueprints + research documents over the
|
||||
std vocabulary, anchored by `Aura.toml`. There is no crate and no build step.
|
||||
|
||||
- Run: `aura run blueprints/signal.json` (the starter is closed — all
|
||||
params bound; bound values are defaults — any `--axis` may override them
|
||||
(#246))
|
||||
- Sweep: `aura sweep blueprints/signal.json --axis __NAME_SNAKE___signal.fast.length=2,4,8`
|
||||
(`aura sweep <bp> --list-axes` lists the open + bound-overridable axes)
|
||||
- Run: `aura exec blueprints/signal.json` (single smoke run, synthetic stream
|
||||
— the starter is closed; all params bound; bound values are defaults —
|
||||
`--override NODE.PARAM=VALUE` may override one for this run (#246))
|
||||
- Campaign: `aura exec <campaign.json>` executes a registered campaign
|
||||
document (file or content id) — the multi-cell/axis surface
|
||||
- Axes: `aura graph introspect --params blueprints/signal.json` lists the
|
||||
open + bound-overridable axes, RAW `<node>.<param>` names (#328)
|
||||
- Native nodes: when the project needs its first project-specific node,
|
||||
`aura nodes new <name>` scaffolds a node crate beside this project and
|
||||
attaches it via `[nodes]` in `Aura.toml` (build it with `cargo build`).
|
||||
- Topology is data (`blueprints/*.json`); results land in `runs/`.
|
||||
- Execution model: a strategy emits a bias in [-1,+1] per cycle, held as the
|
||||
continuously-tracked target position; a protective stop defines the risk
|
||||
unit R, and quality metrics are R-based. Entry signals become held state
|
||||
via the signal-side latch/edge-pulse idiom (see
|
||||
`aura graph introspect --vocabulary`).
|
||||
- Data plane: process/campaign documents are authored directly with
|
||||
`aura process` / `aura campaign`, growing one from a bare `{}` via
|
||||
`aura campaign introspect --unwired`.
|
||||
"#;
|
||||
|
||||
/// Render a data-only project template: only `__NAME__`/`__NAME_SNAKE__`
|
||||
@@ -280,7 +295,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 +336,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 +369,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 +386,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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -445,11 +460,47 @@ mod tests {
|
||||
}
|
||||
let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
|
||||
assert!(claude.contains("demo-lab"));
|
||||
// The CLAUDE.md sweep quickstart targets the closed starter itself
|
||||
// with the rendered blueprint-name axis prefix (#246: a bound param
|
||||
// is a default an axis overrides).
|
||||
// The CLAUDE.md quickstart targets the closed starter itself through
|
||||
// the surviving surface (#319): exec for both document classes, the
|
||||
// #246 override residue, and raw-namespace axis discovery (#328).
|
||||
assert!(claude.contains("blueprints/signal.json"));
|
||||
assert!(claude.contains("demo_lab_signal.fast.length"));
|
||||
assert!(claude.contains("--override NODE.PARAM=VALUE"));
|
||||
assert!(claude.contains("graph introspect --params"));
|
||||
}
|
||||
|
||||
/// #315: the scaffolded project CLAUDE.md teaches the execution semantics
|
||||
/// (bias as held target position, protective stop, R) and the document
|
||||
/// data plane — the two things the 2026-07-22 field agent had to discover
|
||||
/// forensically.
|
||||
#[test]
|
||||
fn project_claude_md_teaches_execution_semantics_and_the_data_plane() {
|
||||
let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
|
||||
assert!(claude.contains("target position"), "names the held-target model: {claude}");
|
||||
assert!(claude.contains("protective stop"), "names the stop that defines R: {claude}");
|
||||
assert!(claude.contains("aura process"), "points at the document layer: {claude}");
|
||||
assert!(claude.contains("introspect --unwired"), "names the bare-{{}} ramp: {claude}");
|
||||
}
|
||||
|
||||
/// #323: the authoring guide's S0 worked literal is byte-identical to the
|
||||
/// scaffold's `LIB_RS` template (rendered with the guide's `my_lab`
|
||||
/// namespace). The scaffold e2e tests compile that template for real, so
|
||||
/// this equality pin is a transitive compile guard — the guide literal
|
||||
/// can no longer drift silently from the schema (it broke against the
|
||||
/// required `NodeSchema.doc` once).
|
||||
#[test]
|
||||
fn guide_worked_example_matches_the_scaffold_template() {
|
||||
let guide = include_str!("../../../docs/authoring-guide.md");
|
||||
let anchor = "### Worked example: `Scale`";
|
||||
let after = &guide[guide.find(anchor).expect("guide keeps the S0 worked example")..];
|
||||
let start = after.find("```rust\n").expect("worked example is a rust fence") + 8;
|
||||
let fence = &after[start..];
|
||||
let fence = &fence[..fence.find("\n```").expect("fence closes") + 1];
|
||||
let body = &LIB_RS[LIB_RS.find("use aura_core::{").expect("template body starts at the import")..];
|
||||
let expected = body.replace("__NS__", "my_lab");
|
||||
assert_eq!(
|
||||
fence, expected,
|
||||
"docs/authoring-guide.md S0 literal drifted from the compiled scaffold template"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#183, fieldtest F2 gap): the scaffold's own starter node teaches
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,47 +5,136 @@
|
||||
//! handled cleanly, never a panic.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
mod common;
|
||||
use common::{fresh_project_with_data, ScratchGuard, ScratchPath};
|
||||
|
||||
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the test
|
||||
/// crate; the binary is named `aura` in `Cargo.toml`).
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||||
|
||||
/// A fresh, unique working directory so the spawned `aura sweep` writes its
|
||||
/// `./runs/runs.jsonl` into a throwaway dir, never dirtying the repo. Unique
|
||||
/// per test + per process; no external tempfile dependency (mirrors `cli_run.rs`).
|
||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||
dir
|
||||
fn run_code_in(dir: &Path, args: &[&str]) -> (String, Option<i32>) {
|
||||
let out = Command::new(BIN).args(args).current_dir(dir).output().expect("binary runs");
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
(text, out.status.code())
|
||||
}
|
||||
|
||||
fn write_doc(dir: &Path, name: &str, text: &str) -> std::path::PathBuf {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, text).expect("write doc");
|
||||
p
|
||||
}
|
||||
|
||||
/// The minimal executable pipeline (one sweep stage) — copied verbatim from
|
||||
/// `research_docs.rs`'s constant of the same name.
|
||||
const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "sweep-only",
|
||||
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
|
||||
}"#;
|
||||
|
||||
/// Seed one blueprint into the built demo project's store via `aura graph
|
||||
/// register` and return its content id — copied verbatim from
|
||||
/// `research_docs.rs`'s recipe of the same name (#319 — the retired `sweep`
|
||||
/// side-effect seeding this used to ride played no role in the returned
|
||||
/// content id, a pure function of the loaded blueprint's canonical bytes;
|
||||
/// `name` labels the registered id, mirroring the old per-call sweep family
|
||||
/// name).
|
||||
fn seed_blueprint(dir: &Path, name: &str) -> String {
|
||||
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let (out, code) = run_code_in(dir, &["graph", "register", &closed_bp, "--name", name]);
|
||||
assert_eq!(code, Some(0), "seed register failed: {out}");
|
||||
out.lines()
|
||||
.find(|l| l.starts_with("registered blueprint "))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered blueprint ")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("id")
|
||||
.trim_start_matches("content:")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Register `doc` as a process document in the project store; returns its
|
||||
/// id — copied verbatim from `research_docs.rs`'s recipe of the same name.
|
||||
fn register_process_doc(dir: &Path, file: &str, doc: &str) -> String {
|
||||
write_doc(dir, file, doc);
|
||||
let (out, code) = run_code_in(dir, &["process", "register", file]);
|
||||
assert_eq!(code, Some(0), "process register failed: {out}");
|
||||
out.lines()
|
||||
.find(|l| l.starts_with("registered process "))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered process ")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("id")
|
||||
.trim_start_matches("content:")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A one-strategy, two-axis (2×2) campaign document over `bp_id`/`proc_id` —
|
||||
/// copied verbatim from `exec.rs`'s `campaign_doc_json_for` of the same
|
||||
/// shape, which yields exactly four family members (the four-line stream
|
||||
/// this test's property needs).
|
||||
fn campaign_doc_json_for(instrument: &str, bp_id: &str, proc_id: &str, window: (i64, i64)) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "broken-pipe",
|
||||
"data": {{ "instruments": ["{instrument}"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
|
||||
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
|
||||
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
|
||||
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
|
||||
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
|
||||
"seed": 7,
|
||||
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
|
||||
}}"#,
|
||||
from = window.0,
|
||||
to = window.1,
|
||||
)
|
||||
}
|
||||
|
||||
/// Property: a family-emitting command whose stdout reader closes early exits on
|
||||
/// the EPIPE cleanly — it does NOT panic (`failed printing to stdout: Broken
|
||||
/// pipe`) and does NOT exit 101. `aura sweep` streams four family-member JSON
|
||||
/// lines via `println!`; when the reader closes stdout after the first line, the
|
||||
/// next write hits a closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at
|
||||
/// startup, so that write returns EPIPE and `println!` panics rather than the
|
||||
/// process terminating quietly — exactly the behaviour a CLI must not have. This
|
||||
/// is the contract: any aura subcommand can be piped into `| head` / `| less` /
|
||||
/// pipe`) and does NOT exit 101. `aura exec <campaign.json>` streams four
|
||||
/// family-member JSON lines via `println!` (a 2×2 axis grid — #319: the
|
||||
/// surviving surface for what a bare `aura sweep` demo grid used to stream);
|
||||
/// when the reader closes stdout after the first line, the next write hits a
|
||||
/// closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at startup, so that
|
||||
/// write returns EPIPE and `println!` panics rather than the process
|
||||
/// terminating quietly — exactly the behaviour a CLI must not have. This is
|
||||
/// the contract: any aura subcommand can be piped into `| head` / `| less` /
|
||||
/// a UI pane that goes away, and it must finish quietly, not panic.
|
||||
///
|
||||
/// Autonomous: constructs its own early-closing reader inline (no `head`, no
|
||||
/// shell, no fixture files) by spawning the binary with a piped stdout and
|
||||
/// dropping the read handle after a single short read, which closes the pipe's
|
||||
/// read end and makes the writer's next write hit EPIPE.
|
||||
#[test]
|
||||
fn family_emitting_command_survives_early_reader_close() {
|
||||
let cwd = temp_cwd("broken-pipe-sweep");
|
||||
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),
|
||||
ScratchPath::File(dir.join("broken-pipe.process.json")),
|
||||
ScratchPath::File(dir.join("broken-pipe.campaign.json")),
|
||||
]);
|
||||
let bp_id = seed_blueprint(&dir, "broken-pipe-seed");
|
||||
let proc_id = register_process_doc(&dir, "broken-pipe.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||||
let doc = campaign_doc_json_for("SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999));
|
||||
write_doc(&dir, "broken-pipe.campaign.json", &doc);
|
||||
|
||||
let mut child = Command::new(BIN)
|
||||
.arg("sweep")
|
||||
.current_dir(&cwd)
|
||||
.args(["exec", "broken-pipe.campaign.json"])
|
||||
.current_dir(&dir)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn aura sweep");
|
||||
.expect("spawn aura exec");
|
||||
|
||||
// Read just enough to guarantee the writer is mid-stream, then drop the read
|
||||
// handle. Dropping it closes the read end of the pipe; the next `println!`
|
||||
@@ -63,9 +152,7 @@ fn family_emitting_command_survives_early_reader_close() {
|
||||
if let Some(mut err) = child.stderr.take() {
|
||||
let _ = err.read_to_string(&mut stderr);
|
||||
}
|
||||
let status = child.wait().expect("reap aura sweep");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
let status = child.wait().expect("reap aura exec");
|
||||
|
||||
// The defect: stderr carries the broken-pipe panic.
|
||||
assert!(
|
||||
|
||||
+2740
-4507
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
//! Shared fixture helpers for the `aura-cli` end-to-end test binaries
|
||||
//! (`research_docs.rs`, `cli_run.rs`, `project_load.rs`), all of which build
|
||||
//! and drive the `tests/fixtures/demo-project` fixture through the real
|
||||
//! `aura` binary (#223).
|
||||
//! (`research_docs.rs`, `cli_run.rs`, `project_load.rs`, `exec.rs`, and —
|
||||
//! for [`synthetic_data`] alone — `project_new.rs`, `cli_broken_pipe.rs`,
|
||||
//! `graph_construct.rs`), all of which build and drive the
|
||||
//! `tests/fixtures/demo-project` fixture through the real `aura` binary
|
||||
//! (#223).
|
||||
//!
|
||||
//! **Per-test project directories, no shared store.** [`fresh_project`] mints
|
||||
//! a unique tempdir per test, wired to the shared, once-built fixture crate
|
||||
@@ -9,10 +11,10 @@
|
||||
//! no two tests ever race on the same `runs/` store — libtest's default
|
||||
//! thread parallelism applies without a serializing lock (#250).
|
||||
//!
|
||||
//! Each of the three test binaries compiles this file as its own `mod
|
||||
//! common`, so an item unused by one binary trips that binary's `-D
|
||||
//! warnings` `dead_code` lint; see the `#[allow(dead_code)]` markers below
|
||||
//! rather than deleting a helper just because one binary doesn't need it.
|
||||
//! Each consuming test binary compiles this file as its own `mod common`, so
|
||||
//! an item unused by one binary trips that binary's `-D warnings` `dead_code`
|
||||
//! lint; see the `#[allow(dead_code)]` markers below rather than deleting a
|
||||
//! helper just because one binary doesn't need it.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
@@ -182,7 +184,7 @@ impl Drop for ScratchGuard {
|
||||
/// the two no-window `generalize` E2E tests get a real, tiny, hostless
|
||||
/// archive instead of streaming the 6.6 GB host archive (#250).
|
||||
#[allow(dead_code)]
|
||||
mod synthetic_data {
|
||||
pub mod synthetic_data {
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
//! End-to-end pins for the self-description surfaces (#315, #323, #319): the
|
||||
//! help opens with the two-layer concept, `exec` names both document classes
|
||||
//! it executes, every introspection roster carries per-entry meanings, and
|
||||
//! `graph build --help` carries the op-list reference. Driven over the
|
||||
//! built binary — the zero-setup surface a reader without repo access gets.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||||
|
||||
/// Run `aura <args>` (no stdin); return (stdout, stderr, success).
|
||||
fn run(args: &[&str]) -> (String, String, bool) {
|
||||
let out = Command::new(BIN).args(args).output().expect("spawn aura");
|
||||
(
|
||||
String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
out.status.success(),
|
||||
)
|
||||
}
|
||||
|
||||
/// #315: `aura --help` opens with the concepts paragraph — the two layers,
|
||||
/// the bias-as-target-position + protective-stop execution model, and how
|
||||
/// traces come to exist. Each pin sits on one authored line of the explicit
|
||||
/// `long_about` string, so clap's wrapping cannot split it.
|
||||
#[test]
|
||||
fn top_level_help_opens_with_the_two_layer_concept() {
|
||||
let (out, err, ok) = run(&["--help"]);
|
||||
assert!(ok, "aura --help failed: {err}");
|
||||
assert!(
|
||||
out.contains("the one executor over both document classes"),
|
||||
"names exec + the document classes: {out}"
|
||||
);
|
||||
assert!(out.contains("directly authorable"), "names the document data plane: {out}");
|
||||
assert!(out.contains("bias in [-1,+1]"), "names the bias output: {out}");
|
||||
assert!(out.contains("defines the risk unit"), "names the protective stop / R: {out}");
|
||||
assert!(out.contains("introspect --unwired"), "names the bare-{{}} ramp: {out}");
|
||||
assert!(out.contains("aura chart"), "names the trace consumers: {out}");
|
||||
}
|
||||
|
||||
/// #319: `exec --help` names both document classes it accepts — the sugar
|
||||
/// verbs' retirement leaves `exec` as the one executor over a campaign
|
||||
/// document (file or content id) or a signal blueprint (single run).
|
||||
#[test]
|
||||
fn exec_help_names_both_document_classes() {
|
||||
let (out, err, ok) = run(&["exec", "--help"]);
|
||||
assert!(ok, "aura exec --help failed: {err}");
|
||||
assert!(out.contains("campaign"), "names the campaign document class: {out}");
|
||||
assert!(out.contains("blueprint"), "names the blueprint document class: {out}");
|
||||
assert!(out.contains("single run"), "names the blueprint leg's single-run shape: {out}");
|
||||
}
|
||||
|
||||
/// #323: `graph build --help` carries the op-list reference — the op kinds
|
||||
/// and fields are learnable from the binary, not only from serde refusals.
|
||||
/// #317: the `use` op joins the roster (nine -> ten). #331: the `name` op
|
||||
/// joins the roster (ten -> eleven).
|
||||
#[test]
|
||||
fn graph_build_help_carries_the_op_reference() {
|
||||
let (out, err, ok) = run(&["graph", "build", "--help"]);
|
||||
assert!(ok, "aura graph build --help failed: {err}");
|
||||
for op in [
|
||||
r#"{"op":"source""#,
|
||||
r#"{"op":"input""#,
|
||||
r#"{"op":"add""#,
|
||||
r#"{"op":"feed""#,
|
||||
r#"{"op":"connect""#,
|
||||
r#"{"op":"expose""#,
|
||||
r#"{"op":"tap""#,
|
||||
r#"{"op":"gang""#,
|
||||
r#"{"op":"doc""#,
|
||||
r#"{"op":"use""#,
|
||||
r#"{"op":"name""#,
|
||||
] {
|
||||
assert!(out.contains(op), "op reference carries {op}: {out}");
|
||||
}
|
||||
assert!(out.contains("graph introspect --vocabulary"), "points at the node roster: {out}");
|
||||
}
|
||||
|
||||
/// #317: `graph introspect --registered` lists every registry label — row
|
||||
/// shape `<label> <id prefix> <root doc>` — and the empty-store form reads
|
||||
/// as prose, not a blank/garbled listing.
|
||||
#[test]
|
||||
fn graph_introspect_registered_lists_labels() {
|
||||
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-help-registered");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||
|
||||
// Empty store: prose, not a blank listing, exit 0.
|
||||
let out_empty = Command::new(BIN)
|
||||
.args(["graph", "introspect", "--registered"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura");
|
||||
assert!(out_empty.status.success(), "empty store still exits 0");
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&out_empty.stdout),
|
||||
"no labels registered\n",
|
||||
"the empty-store form reads as prose"
|
||||
);
|
||||
|
||||
// Register + label a described open pattern, then list it.
|
||||
let pattern = r#"[
|
||||
{"op":"doc","text":"a reusable smoothing pattern"},
|
||||
{"op":"input","role":"x"},
|
||||
{"op":"add","type":"SMA","name":"sma"},
|
||||
{"op":"feed","role":"x","into":["sma.series"]},
|
||||
{"op":"expose","from":"sma.value","as":"out"}
|
||||
]"#;
|
||||
let bp_path = dir.join("pattern.ops.json");
|
||||
std::fs::write(&bp_path, pattern).expect("write pattern fixture");
|
||||
let built = Command::new(BIN)
|
||||
.args(["graph", "build"])
|
||||
.current_dir(&dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.and_then(|mut child| {
|
||||
use std::io::Write;
|
||||
child.stdin.take().unwrap().write_all(pattern.as_bytes())?;
|
||||
child.wait_with_output()
|
||||
})
|
||||
.expect("build the pattern");
|
||||
assert!(built.status.success(), "the pattern builds");
|
||||
let envelope = dir.join("pattern.bp.json");
|
||||
std::fs::write(&envelope, &built.stdout).expect("write built envelope");
|
||||
|
||||
let reg = Command::new(BIN)
|
||||
.args(["graph", "register", envelope.to_str().unwrap(), "--name", "smooth"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura register");
|
||||
assert!(reg.status.success(), "register --name succeeds: {}", String::from_utf8_lossy(®.stderr));
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["graph", "introspect", "--registered"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura");
|
||||
assert!(out.status.success(), "listing succeeds");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(stdout.contains("smooth"), "lists the label: {stdout}");
|
||||
assert!(stdout.contains("a reusable smoothing pattern"), "lists the root doc: {stdout}");
|
||||
let row = stdout.lines().find(|l| l.starts_with("smooth")).expect("smooth row");
|
||||
let fields: Vec<&str> = row.split_whitespace().collect();
|
||||
assert_eq!(fields[1].len(), 12, "the id prefix is 12 hex chars: {row}");
|
||||
assert!(fields[1].chars().all(|c| c.is_ascii_hexdigit()), "the prefix is hex: {row}");
|
||||
}
|
||||
|
||||
/// #315: the node vocabulary listing carries each type's one-line meaning —
|
||||
/// no more bare names that teach nothing.
|
||||
#[test]
|
||||
fn graph_introspect_vocabulary_carries_meanings() {
|
||||
let (out, err, ok) = run(&["graph", "introspect", "--vocabulary"]);
|
||||
assert!(ok, "vocabulary listing failed: {err}");
|
||||
let sma = out
|
||||
.lines()
|
||||
.find(|l| l.split_whitespace().next() == Some("SMA"))
|
||||
.unwrap_or_else(|| panic!("no SMA line: {out}"));
|
||||
assert!(sma.contains("moving average"), "SMA line carries its meaning: {sma}");
|
||||
for l in out.lines().filter(|l| !l.trim().is_empty()) {
|
||||
assert!(l.split_whitespace().count() >= 2, "bare name without meaning: {l}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #315: `--node <T>`'s head line carries the type's meaning beside its label.
|
||||
#[test]
|
||||
fn graph_introspect_node_head_line_carries_the_meaning() {
|
||||
let (out, err, ok) = run(&["graph", "introspect", "--node", "SMA"]);
|
||||
assert!(ok, "node introspect failed: {err}");
|
||||
let head = out.lines().next().unwrap_or("");
|
||||
assert!(head.contains("moving average"), "head line carries the meaning: {out}");
|
||||
}
|
||||
|
||||
/// #332: `--folds` renders the fold-REGISTRY roster (the same roster
|
||||
/// `aura run --tap TAP=FOLD` resolves labels against), not the aura-std
|
||||
/// `FoldKind` table — lowercase, parseable labels, including the `record`
|
||||
/// entry that has no `FoldKind` counterpart. Previously this surface printed
|
||||
/// capitalized `FoldKind` ids (`Mean`) and omitted `record`, so the help
|
||||
/// text's own discovery surface described input `--tap` refused.
|
||||
#[test]
|
||||
fn graph_introspect_folds_lists_the_fold_registry_roster() {
|
||||
let (out, err, ok) = run(&["graph", "introspect", "--folds"]);
|
||||
assert!(ok, "folds listing failed: {err}");
|
||||
let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
assert_eq!(lines.len(), 8, "one line per registry entry (record + 7 folds): {out}");
|
||||
assert!(
|
||||
!out.split_whitespace().any(|w| w == "Mean"),
|
||||
"no capitalized FoldKind id leaks through: {out}"
|
||||
);
|
||||
let record = lines.iter().find(|l| l.starts_with("record ")).expect("record row");
|
||||
assert!(record.contains("series"), "record row meaning: {record}");
|
||||
let mean = lines.iter().find(|l| l.starts_with("mean ")).expect("mean row");
|
||||
assert!(mean.contains("f64") && mean.contains("mean"), "mean row rules + meaning: {mean}");
|
||||
let count = lines.iter().find(|l| l.starts_with("count ")).expect("count row");
|
||||
assert!(count.contains("i64") && count.contains("any"), "count row rules: {count}");
|
||||
}
|
||||
|
||||
/// #315: the metric roster carries each metric's one-line meaning beside its
|
||||
/// applicability tags.
|
||||
#[test]
|
||||
fn process_introspect_metrics_carries_meanings() {
|
||||
let (out, err, ok) = run(&["process", "introspect", "--metrics"]);
|
||||
assert!(ok, "metric roster failed: {err}");
|
||||
let er = out
|
||||
.lines()
|
||||
.find(|l| l.split_whitespace().next() == Some("expectancy_r"))
|
||||
.unwrap_or_else(|| panic!("no expectancy_r line: {out}"));
|
||||
assert!(er.contains("mean realized R"), "meaning text on the roster line: {er}");
|
||||
for l in out.lines().filter(|l| !l.trim().is_empty()) {
|
||||
assert!(l.contains(" — "), "roster line without meaning: {l}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #315: the persisted-tap vocabulary's meanings are readable where the taps
|
||||
/// are advertised — under the `std::presentation` block's slot listing.
|
||||
#[test]
|
||||
fn campaign_introspect_block_presentation_lists_tap_meanings() {
|
||||
let (out, err, ok) = run(&["campaign", "introspect", "--block", "std::presentation"]);
|
||||
assert!(ok, "presentation describe failed: {err}");
|
||||
assert!(out.contains("cumulative pip equity"), "equity tap meaning: {out}");
|
||||
assert!(out.contains("after the cost model"), "net_r_equity tap meaning: {out}");
|
||||
}
|
||||
|
||||
/// #323: `introspect --unwired` enumerates the optional C29 `description`
|
||||
/// slot for both document kinds — the authoring side of the gate is
|
||||
/// advertised where every other slot is.
|
||||
#[test]
|
||||
fn introspect_unwired_lists_the_description_slot() {
|
||||
let dir = std::env::temp_dir().join(format!("aura-selfdesc-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let bare = dir.join("bare.json");
|
||||
std::fs::write(&bare, "{}").unwrap();
|
||||
for family in ["process", "campaign"] {
|
||||
let (out, err, ok) = run(&[family, "introspect", "--unwired", bare.to_str().unwrap()]);
|
||||
assert!(ok, "{family} --unwired failed: {err}");
|
||||
assert!(
|
||||
out.contains("open slot: description"),
|
||||
"{family} --unwired lists the description slot: {out}"
|
||||
);
|
||||
assert!(out.contains("C29-gated"), "{family} hint names the gate: {out}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ fn run_measurement(cwd: &Path) {
|
||||
let bp = cwd.join("measurement.json");
|
||||
std::fs::write(&bp, two_tap_blueprint_json()).expect("write blueprint");
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp.to_str().unwrap()])
|
||||
.args(["exec", bp.to_str().unwrap()])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
|
||||
@@ -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)
|
||||
@@ -30,13 +38,13 @@ fn aura(args: &[&str], cwd: &Path) -> Output {
|
||||
#[test]
|
||||
fn project_run_resolves_demo_node_and_is_bit_identical() {
|
||||
let dir = built_project();
|
||||
let a = aura(&["run", "demo_signal.json"], dir);
|
||||
let a = aura(&["exec", "demo_signal.json"], dir);
|
||||
assert!(
|
||||
a.status.success(),
|
||||
"run failed: {}",
|
||||
String::from_utf8_lossy(&a.stderr)
|
||||
);
|
||||
let b = aura(&["run", "demo_signal.json"], dir);
|
||||
let b = aura(&["exec", "demo_signal.json"], dir);
|
||||
assert!(b.status.success());
|
||||
assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)");
|
||||
}
|
||||
@@ -44,7 +52,7 @@ fn project_run_resolves_demo_node_and_is_bit_identical() {
|
||||
#[test]
|
||||
fn project_run_manifest_carries_provenance() {
|
||||
let dir = built_project();
|
||||
let out = aura(&["run", "demo_signal.json"], dir);
|
||||
let out = aura(&["exec", "demo_signal.json"], dir);
|
||||
assert!(out.status.success());
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_slice(&out.stdout).expect("run report is JSON");
|
||||
@@ -75,7 +83,7 @@ fn outside_a_project_the_demo_blueprint_is_unknown() {
|
||||
// cwd = the aura-cli crate dir: no Aura.toml anywhere up the tree.
|
||||
let cwd = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let bp = fixture_dir().join("demo_signal.json");
|
||||
let out = aura(&["run", bp.to_str().unwrap()], cwd);
|
||||
let out = aura(&["exec", bp.to_str().unwrap()], cwd);
|
||||
assert!(!out.status.success());
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
@@ -96,31 +104,91 @@ fn outside_a_project_the_demo_blueprint_is_unknown() {
|
||||
/// `<project-root>/runs/`, and no stray `runs/` must appear under the
|
||||
/// subdirectory. This is the property that makes `[paths]` project-relative
|
||||
/// rather than shell-relative (C17).
|
||||
///
|
||||
/// #319 vehicle note: the synthetic in-process family builder this test used
|
||||
/// to drive via bare `sweep --axis` (no `--real`) retired with the quintet —
|
||||
/// no surviving CLI surface produces a family record without touching a real
|
||||
/// archive (`exec`'s campaign leg always resolves against `DataServer`), so
|
||||
/// the vehicle is now a one-cell campaign over the `fresh_project_with_data`
|
||||
/// synthetic `SYMA` archive (mirrors `exec.rs`'s `seed_blueprint` +
|
||||
/// `campaign_doc_json_for` recipe), executed from a project subdirectory.
|
||||
#[test]
|
||||
fn project_registry_anchors_at_discovered_root_not_invocation_cwd() {
|
||||
let dir = built_project();
|
||||
let sub = dir.join("src");
|
||||
let (dir, _fixture) = common::fresh_project_with_data();
|
||||
let sub = dir.join("sub");
|
||||
std::fs::create_dir_all(&sub).expect("create invocation subdirectory");
|
||||
let runs_dir = dir.join("runs");
|
||||
std::fs::remove_dir_all(&runs_dir).ok();
|
||||
let closed_bp =
|
||||
format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
|
||||
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let reg_out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["graph", "register", &closed_bp, "--name", "proj-anchor-seed"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura graph register");
|
||||
assert!(reg_out.status.success(), "register failed: {}", String::from_utf8_lossy(®_out.stderr));
|
||||
let reg_text = String::from_utf8_lossy(®_out.stdout);
|
||||
let bp_id = reg_text
|
||||
.lines()
|
||||
.find(|l| l.starts_with("registered blueprint "))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered blueprint ")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("id")
|
||||
.to_string();
|
||||
|
||||
let process_doc = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "sweep-only",
|
||||
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
|
||||
}"#;
|
||||
std::fs::write(dir.join("anchor.process.json"), process_doc).expect("write process doc");
|
||||
let proc_out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["process", "register", "anchor.process.json"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura process register");
|
||||
assert!(proc_out.status.success(), "process register failed: {}", String::from_utf8_lossy(&proc_out.stderr));
|
||||
let proc_text = String::from_utf8_lossy(&proc_out.stdout);
|
||||
let proc_id = proc_text
|
||||
.lines()
|
||||
.find(|l| l.starts_with("registered process "))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered process ")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("id")
|
||||
.trim_start_matches("content:")
|
||||
.to_string();
|
||||
|
||||
// The established `SYMA` window (2024-03..06, fully inside its 2024-01..08
|
||||
// span) other #319 vehicle campaigns use.
|
||||
let campaign_doc = format!(
|
||||
r#"{{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "run-seam",
|
||||
"data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1709251200000, "to_ms": 1719791999999 }} ] }},
|
||||
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
|
||||
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
|
||||
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
|
||||
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
|
||||
"seed": 7,
|
||||
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
|
||||
}}"#
|
||||
);
|
||||
std::fs::write(dir.join("anchor.campaign.json"), &campaign_doc).expect("write campaign doc");
|
||||
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args([
|
||||
"sweep",
|
||||
&closed_bp,
|
||||
"--axis",
|
||||
"sma_signal.fast.length=2,4",
|
||||
"--axis",
|
||||
"sma_signal.slow.length=8,16",
|
||||
"--name",
|
||||
"proj-anchor",
|
||||
])
|
||||
.args(["exec", "../anchor.campaign.json"])
|
||||
.current_dir(&sub)
|
||||
.output()
|
||||
.expect("spawn aura sweep");
|
||||
.expect("spawn aura exec");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"sweep failed: {}",
|
||||
"exec failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(
|
||||
@@ -154,7 +222,7 @@ fn vocabulary_charter_violation_refuses_end_to_end() {
|
||||
"badcharter fixture build failed:\n{}",
|
||||
String::from_utf8_lossy(&build.stderr)
|
||||
);
|
||||
let run = aura(&["run", "x.json"], &dir);
|
||||
let run = aura(&["exec", "x.json"], &dir);
|
||||
assert_eq!(
|
||||
run.status.code(),
|
||||
Some(1),
|
||||
@@ -164,6 +232,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(&["exec", "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(&["exec", "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) {
|
||||
@@ -207,7 +347,7 @@ fn stale_dylib_warns_naming_both_timestamps_but_still_runs() {
|
||||
set_mtime(&dylib, DYLIB_MTIME);
|
||||
set_mtime(&src, SOURCE_MTIME);
|
||||
|
||||
let out = aura(&["run", "demo_signal.json"], dir);
|
||||
let out = aura(&["exec", "demo_signal.json"], dir);
|
||||
|
||||
// Restore sane mtimes before asserting, so a failed assertion does not
|
||||
// leave the shared fixture wedged "stale" (or with a future mtime that
|
||||
@@ -271,7 +411,7 @@ fn nested_nodes_dir() -> PathBuf {
|
||||
fn data_only_project_runs_and_stamps_commit_only_provenance() {
|
||||
let dir = dataonly_dir();
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["run", "signal.json"])
|
||||
.args(["exec", "signal.json"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
@@ -307,7 +447,7 @@ fn data_only_project_runs_and_stamps_commit_only_provenance() {
|
||||
fn multi_crate_pointer_refuses_end_to_end() {
|
||||
let dir = multicrate_dir();
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["run", "x.json"])
|
||||
.args(["exec", "x.json"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
@@ -347,7 +487,7 @@ fn nodes_pointer_crate_resolves_and_stamps_its_namespace() {
|
||||
String::from_utf8_lossy(&build.stderr)
|
||||
);
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["run", "pointer_signal.json"])
|
||||
.args(["exec", "pointer_signal.json"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
@@ -375,7 +515,7 @@ fn data_only_project_hints_attach_for_namespaced_ids() {
|
||||
let bp = tmp.join("bp.json");
|
||||
std::fs::write(&bp, r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"nosuch::Node"}}],"edges":[],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0}],"source":"F64"}],"output":[{"node":0,"field":0,"name":"bias"}]}}"#).unwrap();
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["run", bp.to_str().unwrap()])
|
||||
.args(["exec", bp.to_str().unwrap()])
|
||||
.current_dir(&tmp)
|
||||
.output()
|
||||
.expect("spawn aura");
|
||||
@@ -399,7 +539,7 @@ fn missing_artifact_refuses_with_build_hint() {
|
||||
"[package]\nname = \"nobuild\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[workspace]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let out = aura(&["run", "x.json"], &tmp);
|
||||
let out = aura(&["exec", "x.json"], &tmp);
|
||||
assert_eq!(out.status.code(), Some(1), "runtime refusal");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("cargo build"), "build hint present: {err}");
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! E2E: the `aura new` scaffolder (#241 T4). Proves the data-only authoring
|
||||
//! loop — scaffold → `aura run`/`aura sweep`, zero Rust toolchain interaction
|
||||
//! — plus the refusal contract. Scaffolded projects live in per-test temp
|
||||
//! dirs.
|
||||
//! loop — scaffold → `aura exec`, zero Rust toolchain interaction — plus the
|
||||
//! refusal contract. Scaffolded projects live in per-test temp dirs.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
mod common;
|
||||
|
||||
fn aura(args: &[&str], cwd: &Path) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(args)
|
||||
@@ -61,17 +62,17 @@ fn init_and_commit(dir: &Path) {
|
||||
///
|
||||
/// Documented deviation from the task text: the plan's wording asks this
|
||||
/// headline to also assert that the run "writes a manifest under `x/runs/`".
|
||||
/// Verified against `dispatch_run`/`run_signal_r` (main.rs, around the
|
||||
/// `tx_req`/`rx_req` channel comment): plain `aura run` never touches the
|
||||
/// trace store — it only prints the `RunReport` to stdout; only
|
||||
/// `sweep`/`mc`/campaign verbs persist (`env.trace_store()`), a pre-#241
|
||||
/// split this task's file list does not touch (giving `run` a store write
|
||||
/// would widen scope beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new`
|
||||
/// and this test file). There is thus no on-disk manifest for THIS verb to
|
||||
/// assert against; the assertion below proves that absence directly instead
|
||||
/// of only asserting it in prose, and the persisted-store property the plan
|
||||
/// wanted is covered by `data_only_project_sweeps_without_any_build` below,
|
||||
/// which does assert `proj.join("runs").exists()`.
|
||||
/// Verified against `dispatch_exec`/`exec_blueprint_leg`/`run_signal_r`
|
||||
/// (main.rs): a single-blueprint `aura exec` never touches the trace store —
|
||||
/// it only prints the `RunReport` to stdout; only exec's campaign leg
|
||||
/// persists (`env.trace_store()`), a pre-#241 split this task's file list
|
||||
/// does not touch (giving the blueprint leg a store write would widen scope
|
||||
/// beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new` and this test
|
||||
/// file). There is thus no on-disk manifest for THIS leg to assert against;
|
||||
/// the assertion below proves that absence directly instead of only
|
||||
/// asserting it in prose, and the persisted-store property the plan wanted
|
||||
/// is covered by `data_only_project_execs_a_campaign_without_any_build`
|
||||
/// below, which does assert `proj.join("runs").exists()`.
|
||||
#[test]
|
||||
fn new_scaffolds_and_runs_the_data_only_loop() {
|
||||
let base = tmp("loop");
|
||||
@@ -102,13 +103,13 @@ fn new_scaffolds_and_runs_the_data_only_loop() {
|
||||
assert!(!proj.join("Cargo.toml").exists(), "data-only project must have no Cargo.toml");
|
||||
assert!(!proj.join("src/lib.rs").exists(), "data-only project must have no src/lib.rs");
|
||||
|
||||
let a = aura(&["run", "blueprints/signal.json"], &proj);
|
||||
let a = aura(&["exec", "blueprints/signal.json"], &proj);
|
||||
assert!(
|
||||
a.status.success(),
|
||||
"run failed: {}",
|
||||
"exec failed: {}",
|
||||
String::from_utf8_lossy(&a.stderr)
|
||||
);
|
||||
let b = aura(&["run", "blueprints/signal.json"], &proj);
|
||||
let b = aura(&["exec", "blueprints/signal.json"], &proj);
|
||||
assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)");
|
||||
let v: serde_json::Value = serde_json::from_slice(&a.stdout).expect("report is JSON");
|
||||
let p = &v["manifest"]["project"];
|
||||
@@ -116,50 +117,140 @@ fn new_scaffolds_and_runs_the_data_only_loop() {
|
||||
assert!(p.get("namespace").is_none(), "no crate was loaded, namespace must be absent: {p}");
|
||||
assert!(
|
||||
!proj.join("runs").exists(),
|
||||
"plain `aura run` must not persist a trace store (only sweep/mc/campaign do) — \
|
||||
the task text's \"writes a manifest under x/runs/\" does not hold for this verb"
|
||||
"a plain single-blueprint `aura exec` must not persist a trace store (only exec's \
|
||||
campaign leg does) — the task text's \"writes a manifest under x/runs/\" does not \
|
||||
hold for this leg"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// #241 headline: a fresh data-only project sweeps a dissolved verb (#218's
|
||||
/// gate) with zero build step — using the scaffolded (closed) starter
|
||||
/// blueprint and the exact axis the scaffolded CLAUDE.md advertises.
|
||||
/// The minimal executable pipeline (one sweep stage) — copied verbatim from
|
||||
/// `research_docs.rs`'s constant of the same name.
|
||||
const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "sweep-only",
|
||||
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
|
||||
}"#;
|
||||
|
||||
/// Writes a tiny, self-contained January-2024 `SYMA` archive into
|
||||
/// `<proj>/data` and points the scaffolded `Aura.toml`'s `[paths]` table at
|
||||
/// it. #319: `exec`'s campaign leg (the surviving surface for what `aura
|
||||
/// sweep --axis` used to do inline) needs a registered instrument, unlike a
|
||||
/// plain blueprint run — this keeps the scaffolder's "zero host setup"
|
||||
/// headline intact (no real archive mount) by minting the archive straight
|
||||
/// into the scaffold instead.
|
||||
fn seed_synthetic_archive(proj: &Path) {
|
||||
let data_dir = proj.join("data");
|
||||
std::fs::create_dir_all(&data_dir).expect("create synthetic archive dir");
|
||||
common::synthetic_data::write_symbol_archive(&data_dir, "SYMA", (2024, 1), (2024, 1));
|
||||
let toml_path = proj.join("Aura.toml");
|
||||
let toml = std::fs::read_to_string(&toml_path).expect("read scaffolded Aura.toml");
|
||||
let toml = toml.replacen("runs = \"runs\"\n", "runs = \"runs\"\ndata = \"data\"\n", 1);
|
||||
std::fs::write(&toml_path, toml).expect("point Aura.toml at the synthetic archive");
|
||||
}
|
||||
|
||||
/// The id extracted from a `registered <kind> <id> (<path>)` line (#194: bare
|
||||
/// id, no `content:` prefix — tolerant of the pre-#194 prefix all the same).
|
||||
fn registered_id(stdout: &str, kind: &str) -> String {
|
||||
let needle = format!("registered {kind} ");
|
||||
stdout
|
||||
.lines()
|
||||
.find(|l| l.starts_with(&needle))
|
||||
.unwrap_or_else(|| panic!("no \"{needle}\" line: {stdout}"))
|
||||
.trim_start_matches(&needle)
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("id")
|
||||
.trim_start_matches("content:")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A one-strategy, one-axis campaign document over `bp_id`/`proc_id` — the
|
||||
/// surviving surface for what `aura sweep --axis` used to do inline over the
|
||||
/// scaffolded starter's bound `fast.length` (#246: a bound param is a default
|
||||
/// an axis overrides). Windowed fully inside `seed_synthetic_archive`'s
|
||||
/// January-2024 `SYMA` archive.
|
||||
fn one_axis_campaign_doc(bp_id: &str, proc_id: &str, axis_name: &str, axis_values: &str) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "quickstart",
|
||||
"data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1704067200000, "to_ms": 1706745599999 }} ] }},
|
||||
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
|
||||
"axes": {{ "{axis_name}": {{ "kind": "I64", "values": [{axis_values}] }} }} }} ],
|
||||
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
|
||||
"seed": 1,
|
||||
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
|
||||
}}"#
|
||||
)
|
||||
}
|
||||
|
||||
/// #241 headline: a fresh data-only project executes a small campaign — #319's
|
||||
/// surviving surface for the retired `aura sweep` — with zero build step,
|
||||
/// using the scaffolded (closed) starter blueprint and the exact axis the
|
||||
/// scaffolded CLAUDE.md advertises. Registering the blueprint straight (`aura
|
||||
/// graph register`, not a sweep side-effect) and seeding a tiny synthetic
|
||||
/// archive (`seed_synthetic_archive`) keeps the loop free of any host data
|
||||
/// mount, matching the pre-retirement headline.
|
||||
///
|
||||
/// #246: the closed starter IS the sweep target — a bound param is a default
|
||||
/// an axis overrides.
|
||||
/// #246: the closed starter IS the campaign axis target — a bound param is a
|
||||
/// default an axis overrides.
|
||||
#[test]
|
||||
fn data_only_project_sweeps_without_any_build() {
|
||||
fn data_only_project_execs_a_campaign_without_any_build() {
|
||||
let base = tmp("sweep");
|
||||
let new = aura(&["new", "scratch"], &base);
|
||||
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
|
||||
let proj = base.join("scratch");
|
||||
let out = aura(
|
||||
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4"],
|
||||
&proj,
|
||||
);
|
||||
seed_synthetic_archive(&proj);
|
||||
|
||||
let reg = aura(&["graph", "register", "blueprints/signal.json"], &proj);
|
||||
assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(®.stderr));
|
||||
let bp_id = registered_id(&String::from_utf8_lossy(®.stdout), "blueprint");
|
||||
|
||||
std::fs::write(proj.join("quickstart.process.json"), SWEEP_ONLY_PROCESS_DOC)
|
||||
.expect("write process doc");
|
||||
let preg = aura(&["process", "register", "quickstart.process.json"], &proj);
|
||||
assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr));
|
||||
let proc_id = registered_id(&String::from_utf8_lossy(&preg.stdout), "process");
|
||||
|
||||
let doc = one_axis_campaign_doc(&bp_id, &proc_id, "fast.length", "2, 4");
|
||||
std::fs::write(proj.join("quickstart.campaign.json"), &doc).expect("write campaign doc");
|
||||
let out = aura(&["exec", "quickstart.campaign.json"], &proj);
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(proj.join("runs").exists(), "store must land inside the project");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Property (#246): the scaffold's sweep quickstart genuinely re-opens the
|
||||
/// Property (#246): the scaffold's campaign quickstart genuinely re-opens the
|
||||
/// bound `fast.length` param — the persisted family has exactly one member
|
||||
/// per axis value, not a single collapsed member. A silent regression where
|
||||
/// the bound-param override is dropped (member always built from the
|
||||
/// blueprint's own default) would still exit 0 and still create a `runs`
|
||||
/// store, so `data_only_project_sweeps_without_any_build` above cannot catch
|
||||
/// it; only the member count can.
|
||||
/// store, so `data_only_project_execs_a_campaign_without_any_build` above
|
||||
/// cannot catch it; only the member count can.
|
||||
#[test]
|
||||
fn data_only_project_sweep_over_the_starter_opens_one_member_per_axis_value() {
|
||||
fn data_only_project_campaign_over_the_starter_opens_one_member_per_axis_value() {
|
||||
let base = tmp("sweep-members");
|
||||
let new = aura(&["new", "scratch"], &base);
|
||||
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
|
||||
let proj = base.join("scratch");
|
||||
let out = aura(
|
||||
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4,8"],
|
||||
&proj,
|
||||
);
|
||||
seed_synthetic_archive(&proj);
|
||||
|
||||
let reg = aura(&["graph", "register", "blueprints/signal.json"], &proj);
|
||||
assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(®.stderr));
|
||||
let bp_id = registered_id(&String::from_utf8_lossy(®.stdout), "blueprint");
|
||||
|
||||
std::fs::write(proj.join("members.process.json"), SWEEP_ONLY_PROCESS_DOC)
|
||||
.expect("write process doc");
|
||||
let preg = aura(&["process", "register", "members.process.json"], &proj);
|
||||
assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr));
|
||||
let proc_id = registered_id(&String::from_utf8_lossy(&preg.stdout), "process");
|
||||
|
||||
let doc = one_axis_campaign_doc(&bp_id, &proc_id, "fast.length", "2, 4, 8");
|
||||
std::fs::write(proj.join("members.campaign.json"), &doc).expect("write campaign doc");
|
||||
let out = aura(&["exec", "members.campaign.json"], &proj);
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let fams = aura(&["runs", "families"], &proj);
|
||||
assert!(fams.status.success(), "runs families exit: {:?}", fams.status);
|
||||
@@ -301,10 +392,10 @@ fn new_outside_a_work_tree_leaves_a_resolvable_head() {
|
||||
|
||||
// ...so the first quickstart run stamps a commit into the manifest, not
|
||||
// an empty provenance block.
|
||||
let run = aura(&["run", "blueprints/signal.json"], &proj);
|
||||
let run = aura(&["exec", "blueprints/signal.json"], &proj);
|
||||
assert!(
|
||||
run.status.success(),
|
||||
"run failed: {}",
|
||||
"exec failed: {}",
|
||||
String::from_utf8_lossy(&run.stderr)
|
||||
);
|
||||
let v: serde_json::Value = serde_json::from_slice(&run.stdout).expect("report is JSON");
|
||||
|
||||
@@ -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.
|
||||
@@ -149,72 +149,110 @@ fn fresh_project() -> (&'static PathBuf, (RunsCleanup, MutexGuard<'static, ()>))
|
||||
}
|
||||
|
||||
/// Acceptance box 1 (#235): a project-authored node with an OPEN param is
|
||||
/// discoverable as a sweep axis, sweeps over real data into a persisted family,
|
||||
/// and that family reproduces bit-identically — the defining research loop of a
|
||||
/// genuine external project ("sweep my OWN node's knob"), proven end to end.
|
||||
/// discoverable as an axis, execs over real data (a campaign — #319: the
|
||||
/// surviving surface for what `aura sweep --axis` used to do inline) into a
|
||||
/// persisted family, and that family reproduces bit-identically — the
|
||||
/// defining research loop of a genuine external project ("vary my OWN node's
|
||||
/// knob"), proven end to end.
|
||||
#[test]
|
||||
fn project_node_open_param_sweeps_and_reproduces() {
|
||||
let (dir, _g) = fresh_project();
|
||||
|
||||
// (a) `--list-axes` discovers the PROJECT node's own open knob, followed by
|
||||
// the fixture's three BOUND params as `default=`-lines (#246: every bound
|
||||
// param is an equally re-openable `--axis`, listed regardless of how many
|
||||
// knobs happen to be open alongside it). NOT gated: enumerating axes needs
|
||||
// no data, so the discoverability half runs on every host. The wrapped
|
||||
// name is `<blueprint>.<node>.<param>`.
|
||||
let axes = aura_in(dir, &["sweep", "blueprints/scaled_open.json", "--list-axes"]);
|
||||
// (a) `graph introspect --params` discovers the PROJECT node's own open
|
||||
// knob, followed by the fixture's three BOUND params as `default=`-lines
|
||||
// (#246: every bound param is an equally re-openable axis, listed
|
||||
// regardless of how many knobs happen to be open alongside it). NOT
|
||||
// gated: enumerating axes needs no data, so the discoverability half runs
|
||||
// on every host. The name is RAW `<node>.<param>` (#328: the blueprint
|
||||
// name stays out of axis paths).
|
||||
let axes = aura_in(dir, &["graph", "introspect", "--params", "blueprints/scaled_open.json"]);
|
||||
assert!(
|
||||
axes.status.success(),
|
||||
"--list-axes stderr: {}",
|
||||
"graph introspect --params stderr: {}",
|
||||
String::from_utf8_lossy(&axes.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&axes.stdout),
|
||||
"scaled_signal.gain.factor:F64\n\
|
||||
scaled_signal.fast.length:I64 default=2\n\
|
||||
scaled_signal.slow.length:I64 default=4\n\
|
||||
scaled_signal.bias.scale:F64 default=0.5\n",
|
||||
"gain.factor:F64\n\
|
||||
fast.length:I64 default=2\n\
|
||||
slow.length:I64 default=4\n\
|
||||
bias.scale:F64 default=0.5\n",
|
||||
"the project node's own OPEN param is the one open axis, alongside the \
|
||||
fixture's bound defaults; stderr: {}",
|
||||
String::from_utf8_lossy(&axes.stderr)
|
||||
);
|
||||
|
||||
// The real-data sweep/reproduce legs are archive-gated.
|
||||
// The real-data campaign/reproduce legs are archive-gated.
|
||||
if !local_data_present() {
|
||||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||||
return;
|
||||
}
|
||||
|
||||
// (b) sweep the project node's knob over real GER40 data -> two members.
|
||||
let sweep = aura_in(
|
||||
dir,
|
||||
&[
|
||||
"sweep",
|
||||
"blueprints/scaled_open.json",
|
||||
"--real",
|
||||
"GER40",
|
||||
"--from",
|
||||
GER40_FROM_MS,
|
||||
"--to",
|
||||
GER40_TO_MS,
|
||||
"--axis",
|
||||
"scaled_signal.gain.factor=0.5,1.0",
|
||||
"--name",
|
||||
"knob",
|
||||
],
|
||||
// (b) exec a campaign over the project node's knob, real GER40 data ->
|
||||
// two members. Register the blueprint straight (not a sweep side-effect)
|
||||
// and a minimal sweep-only process, then reference both by content id.
|
||||
let reg = aura_in(dir, &["graph", "register", "blueprints/scaled_open.json"]);
|
||||
assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(®.stderr));
|
||||
let reg_out = String::from_utf8_lossy(®.stdout).into_owned();
|
||||
let bp_id = reg_out
|
||||
.lines()
|
||||
.find(|l| l.starts_with("registered blueprint "))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered blueprint ")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("blueprint id")
|
||||
.to_string();
|
||||
|
||||
std::fs::write(dir.join("knob-reproduce.process.json"), SWEEP_ONLY_PROCESS)
|
||||
.expect("write process doc");
|
||||
let preg = aura_in(dir, &["process", "register", "knob-reproduce.process.json"]);
|
||||
assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr));
|
||||
let preg_out = String::from_utf8_lossy(&preg.stdout).into_owned();
|
||||
let proc_id = preg_out
|
||||
.lines()
|
||||
.find(|l| l.starts_with("registered process "))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered process ")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("process id")
|
||||
.to_string();
|
||||
|
||||
let campaign = format!(
|
||||
r#"{{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "knob-reproduce",
|
||||
"data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
|
||||
"strategies": [ {{ "ref": {{ "content_id": "{bp}" }},
|
||||
"axes": {{ "gain.factor": {{ "kind": "F64", "values": [0.5, 1.0] }} }} }} ],
|
||||
"process": {{ "ref": {{ "content_id": "{proc}" }} }},
|
||||
"seed": 7,
|
||||
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
|
||||
}}"#,
|
||||
from = GER40_FROM_MS,
|
||||
to = GER40_TO_MS,
|
||||
bp = bp_id,
|
||||
proc = proc_id,
|
||||
);
|
||||
std::fs::write(dir.join("knob-reproduce.campaign.json"), &campaign).expect("write campaign doc");
|
||||
|
||||
let exec = aura_in(dir, &["exec", "knob-reproduce.campaign.json"]);
|
||||
assert!(
|
||||
sweep.status.success(),
|
||||
"sweep stderr: {}",
|
||||
String::from_utf8_lossy(&sweep.stderr)
|
||||
exec.status.success(),
|
||||
"exec stderr: {}",
|
||||
String::from_utf8_lossy(&exec.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&sweep.stdout).into_owned();
|
||||
let lines: Vec<&str> = stdout.lines().collect();
|
||||
let stdout = String::from_utf8_lossy(&exec.stdout).into_owned();
|
||||
let lines: Vec<&str> = stdout.lines().filter(|l| l.starts_with(r#"{"family_id":"#)).collect();
|
||||
assert_eq!(lines.len(), 2, "one member line per factor point: {stdout}");
|
||||
|
||||
// Each member's manifest carries the swept PROJECT-node param binding under
|
||||
// its wrapped name — the load-bearing proof that it is MY node's knob that
|
||||
// varied across the family, not some incidental std axis.
|
||||
// Each member's manifest carries the swept PROJECT-node param binding
|
||||
// under its RAW name (this cycle's tidy fix, #328 batch 2: the
|
||||
// real/campaign route's own `manifest.params` mints raw too now) — the
|
||||
// load-bearing proof that it is MY node's knob that varied across the
|
||||
// family, not some incidental std axis.
|
||||
for (line, factor) in lines.iter().zip([0.5_f64, 1.0]) {
|
||||
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
|
||||
assert_eq!(
|
||||
@@ -227,7 +265,7 @@ fn project_node_open_param_sweeps_and_reproduces() {
|
||||
.expect("manifest.params is an array");
|
||||
let bound = params
|
||||
.iter()
|
||||
.find(|p| p[0].as_str() == Some("scaled_signal.gain.factor"))
|
||||
.find(|p| p[0].as_str() == Some("gain.factor"))
|
||||
.and_then(|p| p[1]["F64"].as_f64());
|
||||
assert_eq!(
|
||||
bound,
|
||||
@@ -322,7 +360,7 @@ fn project_node_open_param_runs_one_campaign_cell() {
|
||||
);
|
||||
std::fs::write(dir.join("knob.campaign.json"), &campaign).expect("write campaign doc");
|
||||
|
||||
let run = aura_in(dir, &["campaign", "run", "knob.campaign.json"]);
|
||||
let run = aura_in(dir, &["exec", "knob.campaign.json"]);
|
||||
let out = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&run.stdout),
|
||||
|
||||
@@ -282,7 +282,8 @@ fn process_introspect_metrics_plus_another_mode_is_usage_exit_2() {
|
||||
|
||||
#[test]
|
||||
fn process_register_stores_content_addressed_under_runs_root() {
|
||||
let dir = temp_cwd("process-register");
|
||||
// #305: register needs a project (the show/runs guard family).
|
||||
let (dir, _fixture) = fresh_project();
|
||||
write_doc(&dir, "p.process.json", PROCESS_DOC);
|
||||
let (out, code) = run_code_in(&dir, &["process", "register", "p.process.json"]);
|
||||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||
@@ -318,34 +319,9 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
|
||||
]);
|
||||
|
||||
// Seed one real, content-addressed blueprint into the project's own
|
||||
// store via a real sweep over its bound params, which the axes reopen
|
||||
// as overridable defaults (#246) (mirrors `project_load.rs`'s anchor
|
||||
// test).
|
||||
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let (sweep_out, sweep_code) = run_code_in(
|
||||
&dir,
|
||||
&[
|
||||
"sweep",
|
||||
&closed_bp,
|
||||
"--axis",
|
||||
"sma_signal.fast.length=2,4",
|
||||
"--axis",
|
||||
"sma_signal.slow.length=8,16",
|
||||
"--name",
|
||||
"campaign-ref-seed",
|
||||
],
|
||||
);
|
||||
assert_eq!(sweep_code, Some(0), "seed sweep failed: {sweep_out}");
|
||||
let bp_id = std::fs::read_dir(runs_dir.join("blueprints"))
|
||||
.expect("blueprints dir")
|
||||
.next()
|
||||
.expect("one stored blueprint")
|
||||
.expect("dir entry")
|
||||
.path()
|
||||
.file_stem()
|
||||
.expect("stem")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
// store via `aura graph register` (#319 — the retired `sweep`
|
||||
// side-effect seeding this used to ride).
|
||||
let bp_id = seed_blueprint(&dir, "campaign-ref-seed");
|
||||
|
||||
// Register a valid AND executable process into the same project store:
|
||||
// this test's OK campaign must pass all three validate tiers, and
|
||||
@@ -376,10 +352,11 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
|
||||
.trim_start_matches("content:")
|
||||
.to_string();
|
||||
|
||||
// "fast.length": the axis name is the RAW composite's `param_space` name.
|
||||
// "fast.length": the axis name is the RAW composite's `param_space` name —
|
||||
// the SAME namespace `aura sweep --axis` itself binds against now (#328).
|
||||
// `validate_campaign_refs` loads the stored blueprint bare, unlike the
|
||||
// sweep's `wrap_r`-wrapped axis probe, so it does NOT carry the
|
||||
// "sma_signal." prefix `aura sweep --axis` binds against.
|
||||
// sweep's `wrap_r`-wrapped axis probe, so it never carries a wrap prefix
|
||||
// to begin with.
|
||||
let campaign = format!(
|
||||
r#"{{
|
||||
"format_version": 1,
|
||||
@@ -439,32 +416,8 @@ fn campaign_validate_resolves_identity_ref_via_index_first_lookup_then_index_hit
|
||||
]);
|
||||
|
||||
// Seed one real, content-addressed blueprint (mirrors the content_id
|
||||
// sibling test above).
|
||||
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let (sweep_out, sweep_code) = run_code_in(
|
||||
&dir,
|
||||
&[
|
||||
"sweep",
|
||||
&closed_bp,
|
||||
"--axis",
|
||||
"sma_signal.fast.length=2,4",
|
||||
"--axis",
|
||||
"sma_signal.slow.length=8,16",
|
||||
"--name",
|
||||
"campaign-identity-seed",
|
||||
],
|
||||
);
|
||||
assert_eq!(sweep_code, Some(0), "seed sweep failed: {sweep_out}");
|
||||
let bp_id = std::fs::read_dir(runs_dir.join("blueprints"))
|
||||
.expect("blueprints dir")
|
||||
.next()
|
||||
.expect("one stored blueprint")
|
||||
.expect("dir entry")
|
||||
.path()
|
||||
.file_stem()
|
||||
.expect("stem")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
// sibling test above; #319 — the retired `sweep` side-effect seeding).
|
||||
let bp_id = seed_blueprint(&dir, "campaign-identity-seed");
|
||||
let bp_path = runs_dir.join("blueprints").join(format!("{bp_id}.json"));
|
||||
|
||||
// The identity id of the SAME stored bytes — `--content-id FILE
|
||||
@@ -596,6 +549,42 @@ fn campaign_validate_outside_project_skips_referential_tier() {
|
||||
assert!(out.contains("referential checks skipped (no Aura.toml found up from "));
|
||||
}
|
||||
|
||||
/// #305: `register` outside a project (no Aura.toml up from the cwd) refuses
|
||||
/// up front — exit 1, prose naming the invoked verb and the missing Aura.toml
|
||||
/// (the honest-degradation guard family `show`, `campaign runs`, and the
|
||||
/// dissolved run verbs already share) — and leaves NO store. Today the missing
|
||||
/// `env.provenance()` guard lets `Env::registry()`'s relative `runs/` fallback
|
||||
/// silently materialise `./runs/` in the innocent cwd and register the
|
||||
/// document into a store no project owns, exit 0.
|
||||
#[test]
|
||||
fn register_outside_a_project_refuses_and_leaves_no_store() {
|
||||
for (verb, file, doc) in [
|
||||
("process", "p.process.json", PROCESS_DOC),
|
||||
("campaign", "c.campaign.json", CAMPAIGN_DOC),
|
||||
] {
|
||||
let dir = temp_cwd(&format!("{verb}-register-outside-project"));
|
||||
write_doc(&dir, file, doc);
|
||||
let (out, code) = run_code_in(&dir, &[verb, "register", file]);
|
||||
assert_eq!(
|
||||
code,
|
||||
Some(1),
|
||||
"{verb} register outside a project must refuse; stdout/stderr: {out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains(&format!("{verb} register needs a project")),
|
||||
"{verb} register's refusal must name the invoked verb: {out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("no Aura.toml found up from"),
|
||||
"{verb} register's refusal must name the missing Aura.toml: {out}"
|
||||
);
|
||||
assert!(
|
||||
!dir.join("runs").exists(),
|
||||
"{verb} register refused outside a project must leave no ./runs/ store"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_validate_refuses_empty_axis_prose_exit_1() {
|
||||
let dir = temp_cwd("campaign-validate-empty-axis");
|
||||
@@ -673,7 +662,7 @@ fn campaign_validate_refuses_bad_risk_regime_prose_exit_1() {
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "validate", "risk-bad.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains("risk[0]: stop length must be >= 1 and k must be > 0"),
|
||||
out.contains("risk[0]: stop length must be >= 1, k must be > 0, and a fixed-stop distance must be > 0"),
|
||||
"stdout/stderr: {out}"
|
||||
);
|
||||
assert!(!out.contains("BadRegime"), "Debug leak: {out}");
|
||||
@@ -834,13 +823,14 @@ fn campaign_introspect_unwired_lists_the_optional_risk_slot_on_bare_envelope() {
|
||||
}
|
||||
|
||||
/// #216/#234: bound (non-empty) risk AND cost lists close both optional
|
||||
/// slots — a complete document with both reports no open slots at all.
|
||||
/// slots — with the optional C29 description also present (#323), a complete
|
||||
/// document reports no open slots at all.
|
||||
#[test]
|
||||
fn campaign_introspect_unwired_omits_bound_risk_and_cost_slots() {
|
||||
let dir = temp_cwd("campaign-introspect-unwired-risk-bound");
|
||||
let with_both = CAMPAIGN_DOC.replacen(
|
||||
"\"seed\": 1,",
|
||||
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 2.0 } } ],",
|
||||
"\"seed\": 1,\n \"description\": \"a screen with bound risk and cost\",\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 2.0 } } ],",
|
||||
1,
|
||||
);
|
||||
assert_ne!(with_both, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
|
||||
@@ -958,7 +948,7 @@ fn campaign_introspect_unwired_reports_the_spec_example_slots() {
|
||||
write_doc(&dir, "draft.campaign.json", draft);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]);
|
||||
assert_eq!(code, Some(0));
|
||||
assert!(out.contains("open slot: process.ref (required, content id of a process document)"));
|
||||
assert!(out.contains("open slot: process.ref (required, { content_id }: content id of a process document)"));
|
||||
assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)"));
|
||||
}
|
||||
|
||||
@@ -984,7 +974,7 @@ fn campaign_introspect_unwired_reports_placeholder_refs() {
|
||||
"strategy `ref: null` placeholder not enumerated: {out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("open slot: process.ref (required, content id of a process document)"),
|
||||
out.contains("open slot: process.ref (required, { content_id }: content id of a process document)"),
|
||||
"process `ref: {{}}` placeholder not enumerated: {out}"
|
||||
);
|
||||
}
|
||||
@@ -1031,7 +1021,8 @@ fn campaign_introspect_content_id_prints_hash() {
|
||||
|
||||
#[test]
|
||||
fn campaign_register_stores_content_addressed_under_runs_root() {
|
||||
let dir = temp_cwd("campaign-register");
|
||||
// #305: register needs a project (the show/runs guard family).
|
||||
let (dir, _fixture) = fresh_project();
|
||||
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "register", "c.campaign.json"]);
|
||||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||
@@ -1166,7 +1157,8 @@ fn wrong_kind_refusal_names_the_kind_key() {
|
||||
/// have already made.
|
||||
#[test]
|
||||
fn campaign_register_refuses_invalid_document_and_writes_nothing() {
|
||||
let dir = temp_cwd("campaign-register-invalid");
|
||||
// #305: register needs a project (the show/runs guard family).
|
||||
let (dir, _fixture) = fresh_project();
|
||||
let bad = CAMPAIGN_DOC.replacen(r#""values": [8]"#, r#""values": []"#, 1);
|
||||
write_doc(&dir, "bad.campaign.json", &bad);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "register", "bad.campaign.json"]);
|
||||
@@ -1187,7 +1179,8 @@ fn campaign_register_refuses_invalid_document_and_writes_nothing() {
|
||||
/// would silently persist an unenforceable risk regime.
|
||||
#[test]
|
||||
fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
|
||||
let dir = temp_cwd("campaign-register-invalid-risk");
|
||||
// #305: register needs a project (the show/runs guard family).
|
||||
let (dir, _fixture) = fresh_project();
|
||||
let bad = CAMPAIGN_DOC.replacen(
|
||||
"\"seed\": 1,",
|
||||
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 0, \"k\": 2.0 } } ],",
|
||||
@@ -1199,7 +1192,7 @@ fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(out.contains("refusing to register:"), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains("risk[0]: stop length must be >= 1 and k must be > 0"),
|
||||
out.contains("risk[0]: stop length must be >= 1, k must be > 0, and a fixed-stop distance must be > 0"),
|
||||
"stdout/stderr: {out}"
|
||||
);
|
||||
assert!(
|
||||
@@ -1214,7 +1207,8 @@ fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
|
||||
/// precedent).
|
||||
#[test]
|
||||
fn campaign_register_refuses_invalid_cost_section_and_writes_nothing() {
|
||||
let dir = temp_cwd("campaign-register-invalid-cost");
|
||||
// #305: register needs a project (the show/runs guard family).
|
||||
let (dir, _fixture) = fresh_project();
|
||||
let bad = CAMPAIGN_DOC.replacen(
|
||||
"\"seed\": 1,",
|
||||
"\"seed\": 1,\n \"cost\": [ { \"constant\": { \"cost_per_trade\": -2.0 } } ],",
|
||||
@@ -1235,35 +1229,26 @@ fn campaign_register_refuses_invalid_cost_section_and_writes_nothing() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Seed one blueprint (its params bound, reopened by the sweep axes per
|
||||
/// #246) into the built demo project's store via a real sweep and return
|
||||
/// its content id (the referential test's recipe).
|
||||
/// Seed one blueprint into the built demo project's store via `aura graph
|
||||
/// register` and return its content id (the referential test's recipe).
|
||||
/// `#319` retired the `sweep`-side-effect seeding this used to ride (its
|
||||
/// `--axis`/reopen mechanics played no role in the returned content id,
|
||||
/// which is a pure function of the loaded blueprint's canonical bytes);
|
||||
/// `name` labels the registered id for readability/uniqueness across call
|
||||
/// sites, mirroring the old per-call sweep family name.
|
||||
fn seed_blueprint(dir: &Path, name: &str) -> String {
|
||||
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let (out, code) = run_code_in(
|
||||
dir,
|
||||
&[
|
||||
"sweep",
|
||||
&closed_bp,
|
||||
"--axis",
|
||||
"sma_signal.fast.length=2,4",
|
||||
"--axis",
|
||||
"sma_signal.slow.length=8,16",
|
||||
"--name",
|
||||
name,
|
||||
],
|
||||
);
|
||||
assert_eq!(code, Some(0), "seed sweep failed: {out}");
|
||||
std::fs::read_dir(dir.join("runs").join("blueprints"))
|
||||
.expect("blueprints dir")
|
||||
let (out, code) = run_code_in(dir, &["graph", "register", &closed_bp, "--name", name]);
|
||||
assert_eq!(code, Some(0), "seed register failed: {out}");
|
||||
out.lines()
|
||||
.find(|l| l.starts_with("registered blueprint "))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered blueprint ")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("one stored blueprint")
|
||||
.expect("dir entry")
|
||||
.path()
|
||||
.file_stem()
|
||||
.expect("stem")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
.expect("id")
|
||||
.trim_start_matches("content:")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Register `doc` as a process document in the project store; returns its id.
|
||||
@@ -1411,6 +1396,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
|
||||
@@ -1438,7 +1436,7 @@ const WF_PROCESS_DOC: &str = r#"{
|
||||
fn campaign_run_outside_project_refuses() {
|
||||
let dir = temp_cwd("campaign-run-outside-project");
|
||||
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "c.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "c.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(out.contains("campaign run needs a project"), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
@@ -1452,7 +1450,7 @@ fn campaign_run_outside_project_refuses() {
|
||||
#[test]
|
||||
fn campaign_run_bogus_target_refuses() {
|
||||
let (dir, _fixture) = fresh_project();
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "no-such-target"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "no-such-target"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains("'no-such-target' is neither a readable .json file nor a 64-hex content id"),
|
||||
@@ -1465,7 +1463,7 @@ fn campaign_run_bogus_target_refuses() {
|
||||
fn campaign_run_unknown_id_refuses() {
|
||||
let (dir, _fixture) = fresh_project();
|
||||
let id = "0".repeat(64);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", &id]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", &id]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains(&format!("no campaign {id} in the project store")),
|
||||
@@ -1583,7 +1581,7 @@ fn campaign_run_refuses_mc_before_walk_forward() {
|
||||
let bp_id = seed_blueprint(&dir, "campaign-run-mcwf-seed");
|
||||
let proc_id = register_process_doc(&dir, "mcwf.process.json", MC_BEFORE_WF_PROCESS_DOC);
|
||||
write_doc(&dir, "mcwf.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "mcwf.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "mcwf.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
@@ -1633,7 +1631,7 @@ fn campaign_run_refuses_a_non_terminal_selection_free_sweep() {
|
||||
let proc_id =
|
||||
register_process_doc(&dir, "selfree.process.json", SELECTION_FREE_SWEEP_THEN_GATE_PROCESS_DOC);
|
||||
write_doc(&dir, "selfree.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "selfree.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "selfree.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains(
|
||||
@@ -1747,7 +1745,7 @@ fn campaign_run_refuses_a_grid_stage_not_immediately_before_walk_forward() {
|
||||
"gridgate.campaign.json",
|
||||
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""),
|
||||
);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "gridgate.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "gridgate.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
@@ -1800,7 +1798,7 @@ fn campaign_run_synthetic_e2e_grid_then_wf_persists_no_sweep_family() {
|
||||
"",
|
||||
),
|
||||
);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "gridwf.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "gridwf.campaign.json"]);
|
||||
assert_eq!(code, Some(0), "campaign run failed: {out}");
|
||||
|
||||
let line = out
|
||||
@@ -1862,7 +1860,7 @@ fn campaign_run_synthetic_e2e_cell_order_is_document_order_under_parallel_instru
|
||||
for k in ["1", "2"] {
|
||||
let (out, code) = run_code_in(
|
||||
&dir,
|
||||
&["campaign", "run", "cellorder.campaign.json", "--parallel-instruments", k],
|
||||
&["exec", "cellorder.campaign.json", "--parallel-instruments", k],
|
||||
);
|
||||
assert_eq!(code, Some(0), "K={k}: campaign run failed: {out}");
|
||||
let line = out
|
||||
@@ -1923,7 +1921,7 @@ fn campaign_run_synthetic_e2e_parallel_instruments_contains_a_real_per_cell_faul
|
||||
|
||||
let (out, code) = run_code_in(
|
||||
&dir,
|
||||
&["campaign", "run", "parfault.campaign.json", "--parallel-instruments", "2"],
|
||||
&["exec", "parfault.campaign.json", "--parallel-instruments", "2"],
|
||||
);
|
||||
assert_eq!(code, Some(3), "one contained fault, one success: {out}");
|
||||
let line = out
|
||||
@@ -1937,6 +1935,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, &["exec", "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
|
||||
@@ -1959,7 +2008,7 @@ fn campaign_run_refuses_single_instrument_generalize() {
|
||||
let bp_id = seed_blueprint(&dir, "campaign-run-gen1-seed");
|
||||
let proc_id = register_process_doc(&dir, "gen1.process.json", GENERALIZE_PROCESS_DOC);
|
||||
write_doc(&dir, "gen1.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "gen1.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "gen1.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains("std::generalize needs at least 2"),
|
||||
@@ -2020,7 +2069,7 @@ fn campaign_run_refuses_generalize_non_r_metric() {
|
||||
}}"#
|
||||
);
|
||||
write_doc(&dir, "genpip.campaign.json", &campaign_doc);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "genpip.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "genpip.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
// #207 (fieldtest 0108 F8): the refusal names the REAL rule (the rankable
|
||||
// R-expectancy family), never the mislabel "pip metric" — max_r_drawdown is
|
||||
@@ -2084,7 +2133,7 @@ fn process_validate_permits_wf_after_mc_but_campaign_run_refuses_it() {
|
||||
let bp_id = seed_blueprint(&dir, "campaign-run-wfmc-seed");
|
||||
let proc_id = register_process_doc(&dir, "wfmc.process.json", WF_AFTER_MC_PROCESS_DOC);
|
||||
write_doc(&dir, "wfmc.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "wfmc.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "wfmc.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains("std::walk_forward cannot follow std::monte_carlo"),
|
||||
@@ -2293,7 +2342,7 @@ fn campaign_run_refuses_zero_resamples_monte_carlo_before_any_member_runs() {
|
||||
let bp_id = seed_blueprint(&dir, "campaign-run-zeromc-seed");
|
||||
let proc_id = register_process_doc(&dir, "zeromc.process.json", ZERO_RESAMPLES_MC_PROCESS_DOC);
|
||||
write_doc(&dir, "zeromc.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "zeromc.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "zeromc.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains("process stage 1: monte_carlo resamples must be > 0"),
|
||||
@@ -2328,7 +2377,7 @@ fn campaign_run_refuses_unknown_tap_at_validate() {
|
||||
"badtap.campaign.json",
|
||||
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"r_record\"", ""),
|
||||
);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "badtap.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "badtap.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(out.contains("campaign document invalid:"), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
@@ -2371,7 +2420,7 @@ fn campaign_run_valid_tap_reaches_the_member_data_seam() {
|
||||
"taps.campaign.json",
|
||||
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"equity\"", ""),
|
||||
);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "taps.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "taps.campaign.json"]);
|
||||
assert_eq!(code, Some(3), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
!out.contains("unknown tap"),
|
||||
@@ -2441,7 +2490,7 @@ fn campaign_run_real_e2e_sweep_gate_walkforward() {
|
||||
"\"family_table\", \"selection_report\"",
|
||||
),
|
||||
);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "wf.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "wf.campaign.json"]);
|
||||
|
||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||
if code == Some(3)
|
||||
@@ -2618,7 +2667,7 @@ fn campaign_run_synthetic_e2e_cost_block_nets_the_pooled_oos_bootstrap() {
|
||||
// Both runs share the store (the cost-e2e sibling pattern) — the record
|
||||
// line is read from each run's own stdout, so no isolation is needed.
|
||||
let pooled = |doc: &str| -> serde_json::Value {
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", doc]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", doc]);
|
||||
assert_eq!(code, Some(0), "campaign run failed: {out}");
|
||||
let line = out
|
||||
.lines()
|
||||
@@ -2695,7 +2744,7 @@ fn campaign_run_synthetic_e2e_cost_block_nets_the_per_survivor_bootstrap() {
|
||||
write_doc(&dir, "net_ps.campaign.json", &with_cost);
|
||||
|
||||
let per_survivor = |doc: &str| -> Vec<(u64, serde_json::Value)> {
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", doc]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", doc]);
|
||||
assert_eq!(code, Some(0), "campaign run failed: {out}");
|
||||
let line = out
|
||||
.lines()
|
||||
@@ -2757,7 +2806,7 @@ fn campaign_run_synthetic_e2e_per_instrument_cost_resolves_per_cell() {
|
||||
(1709251200000, 1717199999999),
|
||||
);
|
||||
write_doc(&dir, "percost.campaign.json", &doc);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "percost.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "percost.campaign.json"]);
|
||||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||
|
||||
// Member emit lines carry report.manifest; group the stamped resolved
|
||||
@@ -2818,7 +2867,7 @@ fn campaign_run_synthetic_e2e_vol_tf_regime_realizes_and_stamps() {
|
||||
);
|
||||
assert_ne!(with_risk, base, "replacen must actually match the seed field");
|
||||
write_doc(&dir, "voltf.campaign.json", &with_risk);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "voltf.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "voltf.campaign.json"]);
|
||||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||
|
||||
let record_line = out
|
||||
@@ -2943,7 +2992,7 @@ fn campaign_run_real_e2e_bindings_override_rebinds_price_to_open() {
|
||||
write_doc(&dir, "close.campaign.json", &base);
|
||||
write_doc(&dir, "open.campaign.json", &rebound);
|
||||
|
||||
let (out_close, code_close) = run_code_in(&dir, &["campaign", "run", "close.campaign.json"]);
|
||||
let (out_close, code_close) = run_code_in(&dir, &["exec", "close.campaign.json"]);
|
||||
if code_close == Some(3)
|
||||
&& (out_close.contains("no recorded geometry") || out_close.contains("no data for instrument"))
|
||||
{
|
||||
@@ -2951,7 +3000,7 @@ fn campaign_run_real_e2e_bindings_override_rebinds_price_to_open() {
|
||||
return;
|
||||
}
|
||||
assert_eq!(code_close, Some(0), "stdout/stderr: {out_close}");
|
||||
let (out_open, code_open) = run_code_in(&dir, &["campaign", "run", "open.campaign.json"]);
|
||||
let (out_open, code_open) = run_code_in(&dir, &["exec", "open.campaign.json"]);
|
||||
assert_eq!(code_open, Some(0), "stdout/stderr: {out_open}");
|
||||
|
||||
let first_member = |out: &str| -> serde_json::Value {
|
||||
@@ -3006,7 +3055,7 @@ fn campaign_run_real_e2e_non_default_regime_stamps_its_own_stop() {
|
||||
);
|
||||
assert_ne!(with_regime, base, "replacen must actually match the fixture's seed field");
|
||||
write_doc(&dir, "regime.campaign.json", &with_regime);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "regime.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "regime.campaign.json"]);
|
||||
|
||||
if code == Some(3)
|
||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||
@@ -3037,6 +3086,68 @@ fn campaign_run_real_e2e_non_default_regime_stamps_its_own_stop() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Property (#338, the `campaign_run_real_e2e_non_default_regime_stamps_its_own_stop`
|
||||
/// precedent above, `Fixed` edition): a campaign document's `RiskRegime::Fixed`
|
||||
/// reaches the real `CliMemberRunner::run_member` path and IS the stop actually
|
||||
/// stamped into each emitted member's manifest as `stop_distance` — making the
|
||||
/// shipped `FixedStop` composite campaign-reachable end to end, not merely
|
||||
/// intrinsically valid.
|
||||
#[test]
|
||||
fn campaign_run_real_e2e_fixed_regime_stamps_its_own_distance() {
|
||||
let (dir, _fixture) = fresh_project();
|
||||
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("fixed_regime.process.json")),
|
||||
ScratchPath::File(dir.join("fixed_regime.campaign.json")),
|
||||
]);
|
||||
let bp_id = seed_blueprint(&dir, "campaign-run-fixed-regime-seed");
|
||||
let proc_id = register_process_doc(&dir, "fixed_regime.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||||
let base = campaign_doc_json(
|
||||
&bp_id,
|
||||
&proc_id,
|
||||
(1725148800000, 1727740799999),
|
||||
"",
|
||||
"\"family_table\"",
|
||||
);
|
||||
let with_regime = base.replacen(
|
||||
"\"seed\": 7,",
|
||||
"\"seed\": 7,\n \"risk\": [ { \"fixed\": { \"distance\": 12.0 } } ],",
|
||||
1,
|
||||
);
|
||||
assert_ne!(with_regime, base, "replacen must actually match the fixture's seed field");
|
||||
write_doc(&dir, "fixed_regime.campaign.json", &with_regime);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "fixed_regime.campaign.json"]);
|
||||
|
||||
if code == Some(3)
|
||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||
{
|
||||
eprintln!("skip: no local GER40 data for the fixed-regime campaign e2e");
|
||||
return;
|
||||
}
|
||||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||
let member_lines: Vec<&str> =
|
||||
out.lines().filter(|l| l.starts_with("{\"family_id\":")).collect();
|
||||
assert!(!member_lines.is_empty(), "family_table member lines emitted: {out}");
|
||||
for line in &member_lines {
|
||||
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
|
||||
let params = v["report"]["manifest"]["params"]
|
||||
.as_array()
|
||||
.expect("manifest.params is an array");
|
||||
let get = |name: &str| params.iter().find(|p| p[0].as_str() == Some(name));
|
||||
assert_eq!(
|
||||
get("stop_distance").and_then(|p| p[1]["F64"].as_f64()),
|
||||
Some(12.0),
|
||||
"the campaign's own fixed distance is stamped, not the R_SMA vol-stop default: {line}"
|
||||
);
|
||||
assert!(
|
||||
get("stop_length").is_none() && get("stop_k").is_none(),
|
||||
"a Fixed stop stamps no vol knobs: {line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Property (#210 T3/T4 — per-cell regime resolution, not a shared last-write):
|
||||
/// a campaign with TWO distinct non-default risk regimes stamps EACH regime's
|
||||
/// OWN stop into ITS OWN cell's members, never the other regime's values and
|
||||
@@ -3076,7 +3187,7 @@ fn campaign_run_real_e2e_two_regimes_each_stamp_their_own_stop() {
|
||||
);
|
||||
assert_ne!(with_regimes, base, "replacen must actually match the fixture's seed field");
|
||||
write_doc(&dir, "tworegimes.campaign.json", &with_regimes);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "tworegimes.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "tworegimes.campaign.json"]);
|
||||
|
||||
if code == Some(3)
|
||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||
@@ -3163,7 +3274,7 @@ fn campaign_persist_non_default_regime_does_not_false_fail_c1() {
|
||||
);
|
||||
assert_ne!(with_regime, base, "replacen must actually match the fixture's seed field");
|
||||
write_doc(&dir, "regime-persist.campaign.json", &with_regime);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "regime-persist.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "regime-persist.campaign.json"]);
|
||||
|
||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||
if code == Some(3)
|
||||
@@ -3243,7 +3354,7 @@ fn campaign_run_real_e2e_costed_campaign_persists_without_drift_alarm() {
|
||||
);
|
||||
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
|
||||
write_doc(&dir, "cost-persist.campaign.json", &with_cost);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "cost-persist.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "cost-persist.campaign.json"]);
|
||||
|
||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||
if code == Some(3)
|
||||
@@ -3338,7 +3449,7 @@ fn campaign_run_real_e2e_net_r_equity_tap_persists_the_cost_adjusted_curve() {
|
||||
);
|
||||
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
|
||||
write_doc(&dir, "net-persist.campaign.json", &with_cost);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "net-persist.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "net-persist.campaign.json"]);
|
||||
|
||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||
if code == Some(3)
|
||||
@@ -3452,7 +3563,7 @@ fn campaign_run_real_e2e_vol_slippage_cost_persists_without_drift_alarm() {
|
||||
);
|
||||
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
|
||||
write_doc(&dir, "volcost-persist.campaign.json", &with_cost);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "volcost-persist.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "volcost-persist.campaign.json"]);
|
||||
|
||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||
if code == Some(3)
|
||||
@@ -3541,7 +3652,7 @@ fn campaign_persist_two_regimes_land_in_distinct_trace_dirs() {
|
||||
);
|
||||
assert_ne!(with_regimes, base, "replacen must actually match the fixture's seed field");
|
||||
write_doc(&dir, "tworegimes-persist.campaign.json", &with_regimes);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "tworegimes-persist.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "tworegimes-persist.campaign.json"]);
|
||||
|
||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||
if code == Some(3)
|
||||
@@ -3619,7 +3730,7 @@ fn campaign_run_real_e2e_skips_unproducible_tap_gracefully() {
|
||||
"",
|
||||
),
|
||||
);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "mixedtap.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "mixedtap.campaign.json"]);
|
||||
|
||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||
if code == Some(3)
|
||||
@@ -3642,7 +3753,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}"
|
||||
);
|
||||
@@ -3735,7 +3846,7 @@ fn campaign_run_real_e2e_cost_block_nets_members_and_persists_net_r_equity() {
|
||||
|
||||
// (a) the cost-less baseline: net == gross, and net_r_equity skips with
|
||||
// the remedy notice while equity still persists.
|
||||
let (out_gross, code_gross) = run_code_in(&dir, &["campaign", "run", "costless.campaign.json"]);
|
||||
let (out_gross, code_gross) = run_code_in(&dir, &["exec", "costless.campaign.json"]);
|
||||
if code_gross == Some(3)
|
||||
&& (out_gross.contains("no recorded geometry") || out_gross.contains("no data for instrument"))
|
||||
{
|
||||
@@ -3745,7 +3856,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}"
|
||||
);
|
||||
@@ -3757,7 +3868,7 @@ fn campaign_run_real_e2e_cost_block_nets_members_and_persists_net_r_equity() {
|
||||
|
||||
// (b) the costed run: gross identical member-for-member, net strictly
|
||||
// below gross, no skip notice, net_r_equity persisted on disk.
|
||||
let (out_net, code_net) = run_code_in(&dir, &["campaign", "run", "costnet.campaign.json"]);
|
||||
let (out_net, code_net) = run_code_in(&dir, &["exec", "costnet.campaign.json"]);
|
||||
assert_eq!(code_net, Some(0), "costed run: {out_net}");
|
||||
assert!(
|
||||
!out_net.contains("skipped"),
|
||||
@@ -3856,7 +3967,7 @@ fn campaign_run_real_e2e_carry_cost_persists_without_drift_alarm() {
|
||||
);
|
||||
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
|
||||
write_doc(&dir, "carrycost-persist.campaign.json", &with_cost);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "carrycost-persist.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "carrycost-persist.campaign.json"]);
|
||||
|
||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||
if code == Some(3)
|
||||
@@ -3928,7 +4039,7 @@ fn campaign_run_real_e2e_sweep_monte_carlo_per_survivor() {
|
||||
"mc.campaign.json",
|
||||
&campaign_doc_json(&bp_id, &proc_id, (1725148800000, 1727740799999), "", ""),
|
||||
);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "mc.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "mc.campaign.json"]);
|
||||
|
||||
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||
if code == Some(3)
|
||||
@@ -3999,7 +4110,7 @@ fn campaign_run_by_content_id_matches_file_sugar_refusal() {
|
||||
let proc_id = register_process_doc(&dir, "mc2.process.json", MC_PROCESS_DOC);
|
||||
write_doc(&dir, "mc2.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||||
|
||||
let (file_out, file_code) = run_code_in(&dir, &["campaign", "run", "mc2.campaign.json"]);
|
||||
let (file_out, file_code) = run_code_in(&dir, &["exec", "mc2.campaign.json"]);
|
||||
assert_eq!(file_code, Some(3), "stdout/stderr: {file_out}");
|
||||
|
||||
let (reg_out, reg_code) = run_code_in(&dir, &["campaign", "register", "mc2.campaign.json"]);
|
||||
@@ -4014,7 +4125,7 @@ fn campaign_run_by_content_id_matches_file_sugar_refusal() {
|
||||
.expect("id")
|
||||
.trim_start_matches("content:")
|
||||
.to_string();
|
||||
let (id_out, id_code) = run_code_in(&dir, &["campaign", "run", &id]);
|
||||
let (id_out, id_code) = run_code_in(&dir, &["exec", &id]);
|
||||
assert_eq!(id_code, Some(3), "stdout/stderr: {id_out}");
|
||||
|
||||
let file_line = file_out.lines().find(|l| l.starts_with("aura: ")).expect("file refusal line");
|
||||
@@ -4062,8 +4173,8 @@ fn campaign_run_tolerates_content_prefix_on_target() {
|
||||
.trim_start_matches("content:")
|
||||
.to_string();
|
||||
|
||||
let (bare_out, _) = run_code_in(&dir, &["campaign", "run", &id]);
|
||||
let (pfx_out, _) = run_code_in(&dir, &["campaign", "run", &format!("content:{id}")]);
|
||||
let (bare_out, _) = run_code_in(&dir, &["exec", &id]);
|
||||
let (pfx_out, _) = run_code_in(&dir, &["exec", &format!("content:{id}")]);
|
||||
|
||||
let pfx_line = pfx_out.lines().find(|l| l.starts_with("aura: ")).expect("prefixed refusal line");
|
||||
assert!(
|
||||
@@ -4115,7 +4226,7 @@ fn campaign_run_accepts_a_graph_register_seeded_strategy() {
|
||||
|
||||
let proc_id = register_process_doc(&dir, "onramp.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||||
write_doc(&dir, "onramp.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "onramp.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "onramp.campaign.json"]);
|
||||
assert_eq!(code, Some(3), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains("no recorded geometry") || out.contains("no data for instrument"),
|
||||
@@ -4144,7 +4255,7 @@ fn campaign_run_invalid_file_refuses_before_touching_store() {
|
||||
let bad_path = write_doc(&dir, "bad.campaign.json", &bad);
|
||||
let _cleanup = ScratchGuard(vec![ScratchPath::Dir(runs_dir.clone()), ScratchPath::File(bad_path)]);
|
||||
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "bad.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "bad.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains("aura: campaign document invalid:"),
|
||||
@@ -4197,7 +4308,7 @@ fn campaign_over_a_gapped_archive_records_the_uncovered_cell_and_continues() {
|
||||
}}"#,
|
||||
);
|
||||
write_doc(&dir, "gap.campaign.json", &doc);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "run", "gap.campaign.json"]);
|
||||
let (out, code) = run_code_in(&dir, &["exec", "gap.campaign.json"]);
|
||||
assert_eq!(code, Some(3), "stdout/stderr: {out}");
|
||||
|
||||
let record_line = out
|
||||
@@ -4239,7 +4350,7 @@ fn campaign_over_a_gapped_archive_records_the_uncovered_cell_and_continues() {
|
||||
/// clap refuses it as a usage error (exit 2) before any target resolution.
|
||||
#[test]
|
||||
fn campaign_run_rejects_a_zero_parallel_instruments_bound() {
|
||||
let (out, code) = run_code(&["campaign", "run", "unused", "--parallel-instruments", "0"]);
|
||||
let (out, code) = run_code(&["exec", "unused", "--parallel-instruments", "0"]);
|
||||
assert_eq!(code, Some(2), "clap usage error expected, got: {out}");
|
||||
// Domain prose, not clap's raw NonZeroUsize wording ("number would be
|
||||
// zero for non-zero type") — the fieldtest friction finding.
|
||||
|
||||
@@ -34,7 +34,7 @@ fn measurement_blueprint_runs_bare_and_emits_its_tap() {
|
||||
std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
@@ -56,3 +56,85 @@ fn measurement_blueprint_runs_bare_and_emits_its_tap() {
|
||||
assert_eq!(trace["tap"], "fast_tap");
|
||||
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
|
||||
}
|
||||
|
||||
/// #310: the measurement arm honours the `--tap` selector — a `last`
|
||||
/// fold lands one summary row instead of the full series.
|
||||
#[test]
|
||||
fn measurement_run_honours_the_tap_selector() {
|
||||
let cwd = temp_cwd("selector-last");
|
||||
let bp_path = cwd.join("measurement.json");
|
||||
std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
|
||||
let out = Command::new(BIN)
|
||||
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=last"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
|
||||
.expect("read fold trace");
|
||||
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
|
||||
let rows = trace["columns"][0].as_array().expect("columns[0] array");
|
||||
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
|
||||
// last warm SMA(2) value over the synthetic stream = (1.0097 + 1.0092) / 2
|
||||
let got = rows[0].as_f64().expect("f64 row");
|
||||
let expected = (1.0097_f64 + 1.0092) / 2.0;
|
||||
assert!((got - expected).abs() < 1e-9, "last row: got {got}, expected {expected}");
|
||||
}
|
||||
|
||||
/// #331 delta re-review, the third authored-file intake route: `dispatch_run`
|
||||
/// loads a FRESH FILE via `blueprint_from_json` and, on this measurement arm,
|
||||
/// feeds `signal.name()` straight into `bind_tap_plan` -> `TraceStore::begin_run`
|
||||
/// (`trace_store.rs` joins the name unsanitized) — a hand-crafted envelope with
|
||||
/// `"name":"../x"` used to reach `--tap fast_tap=record` and escape
|
||||
/// `runs/traces/<name>/` for `runs/x/` (one level up, since `traces/../x`
|
||||
/// normalizes there) instead of refusing at the same root-name choke point
|
||||
/// `register`/`introspect --content-id <FILE>` already gate through. This pins
|
||||
/// the closed route: exit nonzero (dispatch_run's own bad-input-file
|
||||
/// convention, exit 2), the shared `name_gate_fault_prose` wording, and — the
|
||||
/// property that actually matters — the escaped directory is never created.
|
||||
#[test]
|
||||
fn run_refuses_a_hand_crafted_envelope_with_a_bad_root_name_before_any_trace_write() {
|
||||
let cwd = temp_cwd("bad-root-name");
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_str(&measurement_blueprint_json()).expect("parse measurement blueprint");
|
||||
v["blueprint"]["name"] = serde_json::json!("../x");
|
||||
let bad = serde_json::to_string(&v).expect("re-serialize with a shape-violating root name");
|
||||
let bp_path = cwd.join("bad-root-name.json");
|
||||
std::fs::write(&bp_path, &bad).expect("write bad-root-name blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=record"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(2),
|
||||
"a shape-violating root name refuses at dispatch_run's own bad-file exit code: {:?} stderr={}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains(r#"blueprint name "../x" is invalid"#),
|
||||
"the shared name_gate_fault_prose wording names the offending root name: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
out.stdout.is_empty(),
|
||||
"a refused run must not print a report: {:?}",
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
);
|
||||
|
||||
// The property that actually matters: the escaped write target (one level
|
||||
// up from `traces/`, since `trace_store.rs` joins the name unsanitized)
|
||||
// was never created — the gate fires before `begin_run` ever touches disk.
|
||||
assert!(
|
||||
!cwd.join("runs/x").exists(),
|
||||
"the escaped trace directory must never be created"
|
||||
);
|
||||
assert!(
|
||||
!cwd.join("runs/traces").exists(),
|
||||
"no trace directory of any shape is written on a refused run"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ fn assert_clean_refusal(bp_json: &str, tag: &str, what: &str) {
|
||||
std::fs::write(&bp_path, bp_json).expect("write blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ fn single_run_persists_a_declared_tap_series() {
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
@@ -100,7 +100,7 @@ fn tap_free_run_writes_no_trace_store() {
|
||||
let cwd = temp_cwd("tap-free-writes-nothing");
|
||||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", &bp])
|
||||
.args(["exec", &bp])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
@@ -124,6 +124,21 @@ fn duplicate_tap_blueprint_json() -> String {
|
||||
serde_json::to_string(&v).expect("re-serialize duplicate-tap blueprint")
|
||||
}
|
||||
|
||||
/// The shipped `examples/r_sma.json` blueprint with two distinctly named
|
||||
/// declared taps: `fast_tap` on node 0 ("fast", SMA(2)) and `slow_tap` on
|
||||
/// node 1 ("slow") — the smallest fixture on which an explicit `--tap`
|
||||
/// plan and the record-all default can be compared file-by-file (#310).
|
||||
fn two_tap_blueprint_json() -> String {
|
||||
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let doc = std::fs::read_to_string(path).expect("read 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}},
|
||||
{"name": "slow_tap", "from": {"node": 1, "field": 0}},
|
||||
]);
|
||||
serde_json::to_string(&v).expect("re-serialize two-tap blueprint")
|
||||
}
|
||||
|
||||
/// Property: **a blueprint declaring two taps under the same name is refused
|
||||
/// on the single-run path with a named error and exit code 1, before any
|
||||
/// trace is persisted** — the CLI-owned `TapBindError::DuplicateBind` dedup
|
||||
@@ -137,7 +152,7 @@ fn single_run_refuses_a_duplicate_tap_name_before_persisting_anything() {
|
||||
std::fs::write(&bp_path, duplicate_tap_blueprint_json()).expect("write dup-tap blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
@@ -150,3 +165,258 @@ 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(["exec", 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(["exec", 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");
|
||||
}
|
||||
|
||||
/// #310: `--tap fast_tap=mean` subscribes the declared tap to the `mean`
|
||||
/// fold instead of the record-all default — the trace store holds ONE
|
||||
/// summary row whose value is the mean of the full SMA(2) series, and
|
||||
/// `index.json` lists exactly the subscribed tap.
|
||||
#[test]
|
||||
fn run_tap_selector_persists_a_fold_summary_row() {
|
||||
let cwd = temp_cwd("tap-selector-mean");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let index_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
|
||||
.expect("read index.json");
|
||||
let index: serde_json::Value = serde_json::from_str(&index_text).expect("parse index.json");
|
||||
assert_eq!(index["taps"], serde_json::json!(["fast_tap"]));
|
||||
|
||||
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
|
||||
.expect("read fold trace");
|
||||
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
|
||||
assert_eq!(trace["tap"], "fast_tap");
|
||||
let rows = trace["columns"][0].as_array().expect("columns[0] array");
|
||||
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
|
||||
let sma: Vec<f64> = (1..R_SMA_PRICES.len())
|
||||
.map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
|
||||
.collect();
|
||||
let expected = sma.iter().sum::<f64>() / sma.len() as f64;
|
||||
let got = rows[0].as_f64().expect("f64 row");
|
||||
assert!((got - expected).abs() < 1e-9, "mean row: got {got}, expected {expected}");
|
||||
}
|
||||
|
||||
/// #310: an all-`record` explicit plan is byte-identical to the no-flag
|
||||
/// record-all default — the selector replaces the default without
|
||||
/// changing what `record` itself writes (C1 determinism across runs).
|
||||
#[test]
|
||||
fn run_explicit_record_plan_matches_the_record_all_default() {
|
||||
let cwd_a = temp_cwd("record-default");
|
||||
let cwd_b = temp_cwd("record-explicit");
|
||||
for cwd in [&cwd_a, &cwd_b] {
|
||||
std::fs::write(cwd.join("two_tap.json"), two_tap_blueprint_json())
|
||||
.expect("write two-tap blueprint");
|
||||
}
|
||||
let run = |cwd: &std::path::Path, extra: &[&str]| {
|
||||
let mut args = vec!["exec", "two_tap.json"];
|
||||
args.extend_from_slice(extra);
|
||||
let out = Command::new(BIN)
|
||||
.args(&args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
run(&cwd_a, &[]);
|
||||
run(&cwd_b, &["--tap", "fast_tap=record", "--tap", "slow_tap=record"]);
|
||||
|
||||
for file in ["fast_tap.json", "slow_tap.json"] {
|
||||
let a = std::fs::read(cwd_a.join("runs/traces/sma_signal").join(file)).expect("default trace");
|
||||
let b = std::fs::read(cwd_b.join("runs/traces/sma_signal").join(file)).expect("explicit trace");
|
||||
assert_eq!(a, b, "{file} must be byte-identical across default and explicit record plans");
|
||||
}
|
||||
let taps_of = |cwd: &std::path::Path| -> serde_json::Value {
|
||||
let text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
|
||||
.expect("read index.json");
|
||||
serde_json::from_str::<serde_json::Value>(&text).expect("parse index.json")["taps"].clone()
|
||||
};
|
||||
assert_eq!(taps_of(&cwd_a), taps_of(&cwd_b));
|
||||
}
|
||||
|
||||
/// #310: an unknown fold label is refused through the registry's
|
||||
/// roster-enumerating refusal, before any store write.
|
||||
#[test]
|
||||
fn run_tap_selector_refuses_an_unknown_label_with_the_roster() {
|
||||
let cwd = temp_cwd("tap-selector-unknown-label");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
let out = Command::new(BIN)
|
||||
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=medain"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(!out.status.success(), "an unknown fold label must refuse");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("medain") && stderr.contains("mean"),
|
||||
"refusal must name the label and enumerate the roster: {stderr}"
|
||||
);
|
||||
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
|
||||
}
|
||||
|
||||
/// #310/#333: a selection naming a tap the blueprint does not declare is
|
||||
/// refused typed (UndeclaredTap), before any store write, and the refusal
|
||||
/// enumerates the blueprint's declared taps (the same way the unknown-fold
|
||||
/// refusal enumerates the fold-registry roster) — on the two-tap fixture,
|
||||
/// both `fast_tap` and `slow_tap` must be named so the author does not have
|
||||
/// to reopen the blueprint JSON.
|
||||
#[test]
|
||||
fn run_tap_selector_refuses_an_undeclared_tap() {
|
||||
let cwd = temp_cwd("tap-selector-undeclared");
|
||||
let bp_path = cwd.join("two_tap.json");
|
||||
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
|
||||
let out = Command::new(BIN)
|
||||
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "nope=mean"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(!out.status.success(), "an undeclared tap must refuse");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("nope"), "refusal must name the offending tap: {stderr}");
|
||||
assert!(
|
||||
stderr.contains("fast_tap") && stderr.contains("slow_tap"),
|
||||
"refusal must enumerate the blueprint's declared taps: {stderr}"
|
||||
);
|
||||
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
|
||||
}
|
||||
|
||||
/// #334: an explicit `--tap` plan that leaves a declared tap unbound emits
|
||||
/// the C14 benign skipped-tap note on stderr, per unbound tap, naming it —
|
||||
/// exit code stays 0 (this is a note, not a refusal). `fast_tap` (subscribed)
|
||||
/// gets no such note; `slow_tap` (left unlisted) does.
|
||||
#[test]
|
||||
fn run_tap_selector_notes_unbound_declared_taps_on_stderr() {
|
||||
let cwd = temp_cwd("tap-selector-unbound-note");
|
||||
let bp_path = cwd.join("two_tap.json");
|
||||
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("aura: note: declared tap \"slow_tap\" unbound this run"),
|
||||
"stderr must note the unbound declared tap: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("\"fast_tap\" unbound"),
|
||||
"the subscribed tap must not be noted as unbound: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #334: the record-all default (no `--tap` flag) leaves no declared tap
|
||||
/// unbound — nothing is skipped, so the note must never appear.
|
||||
#[test]
|
||||
fn run_with_no_tap_flag_emits_no_unbound_note() {
|
||||
let cwd = temp_cwd("tap-selector-default-no-note");
|
||||
let bp_path = cwd.join("two_tap.json");
|
||||
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
!stderr.contains("unbound this run"),
|
||||
"the record-all default must never note a skipped tap: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #310: a malformed or duplicate `--tap` is a usage error (exit 2)
|
||||
/// before anything runs.
|
||||
#[test]
|
||||
fn run_tap_selector_refuses_malformed_and_duplicate_pairs() {
|
||||
let cwd = temp_cwd("tap-selector-usage");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
for args in [
|
||||
vec!["--tap", "fast_tapmean"],
|
||||
vec!["--tap", "fast_tap=mean", "--tap", "fast_tap=last"],
|
||||
] {
|
||||
let mut full = vec!["exec", bp_path.to_str().expect("utf-8 path")];
|
||||
full.extend(args);
|
||||
let out = Command::new(BIN)
|
||||
.args(&full)
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert_eq!(out.status.code(), Some(2), "usage errors exit 2");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("--tap"), "usage message names the flag: {stderr}");
|
||||
assert!(!cwd.join("runs").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,8 +52,8 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
|
||||
let sqrt = g.add(Sqrt::builder()); // → σ (price units)
|
||||
// k·σ: weights[0] bound (Some) or open and named `stop_k` (None).
|
||||
let scale = g.add(match knobs {
|
||||
Some((_, k)) => LinComb::builder(1).bind("weights[0]", Scalar::f64(k)),
|
||||
None => LinComb::builder(1).named("stop_k"),
|
||||
Some((_, k)) => LinComb::configured(1).bind("weights[0]", Scalar::f64(k)),
|
||||
None => LinComb::configured(1).named("stop_k"),
|
||||
});
|
||||
g.feed(price, [delay.input("series"), sub.input("lhs")]);
|
||||
g.connect(delay.output("value"), sub.input("rhs"));
|
||||
@@ -174,7 +174,7 @@ pub fn cost_graph(cost_nodes: Vec<PrimitiveBuilder>) -> Composite {
|
||||
let entry = g.input_role("entry_price");
|
||||
let stop = g.input_role("stop_price");
|
||||
|
||||
let agg = g.add(CostSum::builder(n));
|
||||
let agg = g.add(CostSum::configured(n));
|
||||
|
||||
// Per-role geometry fan targets, collected across all nodes, fed once per role.
|
||||
let mut closed_t = Vec::with_capacity(n);
|
||||
|
||||
@@ -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)] }),
|
||||
)
|
||||
|
||||
@@ -7,6 +7,10 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
# ArgValue::Tz carries chrono_tz::Tz (the closed IANA table, exact names only) —
|
||||
# already a workspace dependency of four other crates (see docs/specs/
|
||||
# construction-args.md § Dependency note).
|
||||
chrono-tz = { version = "0.10", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -44,7 +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,
|
||||
doc_gate, zip_params, ArgKind, ArgOpError, ArgSpec, ArgValue, BindOpError, BoundParam,
|
||||
ConstructionArg, DocGateFault, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
|
||||
PrimitiveBuilder,
|
||||
};
|
||||
pub use scalar::{Scalar, ScalarKind, Timestamp};
|
||||
|
||||
@@ -112,6 +112,7 @@ pub struct PrimitiveBuilder {
|
||||
instance_name: Option<String>,
|
||||
schema: NodeSchema,
|
||||
bound: Vec<BoundParam>,
|
||||
args: ArgsState, // NEW: `Plain` for every existing (non-arg-bearing) node
|
||||
// The build closure's type is exactly the recipe contract (a param slice in, a
|
||||
// boxed node out); a type alias would not clarify it.
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -127,7 +128,108 @@ impl PrimitiveBuilder {
|
||||
schema: NodeSchema,
|
||||
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
|
||||
) -> Self {
|
||||
Self { name, instance_name: None, schema, bound: Vec::new(), build: Box::new(build) }
|
||||
Self { name, instance_name: None, schema, bound: Vec::new(), args: ArgsState::Plain, build: Box::new(build) }
|
||||
}
|
||||
|
||||
/// An arg-bearing recipe: introspectable (doc + declared `ArgSpec`s) but
|
||||
/// not yet constructible. `schema` carries only the doc line (empty
|
||||
/// inputs/output/params — the real signature forms inside `make`, once
|
||||
/// `try_args` has parsed values to hand it); `build` on a pending builder
|
||||
/// panics (the `bind` panic-contract twin) — the data path always goes
|
||||
/// through `try_args` first, so no pending builder ever reaches `build`.
|
||||
pub fn pending(
|
||||
name: &'static str,
|
||||
doc: &'static str,
|
||||
specs: &'static [ArgSpec],
|
||||
make: fn(&[(String, ArgValue)]) -> PrimitiveBuilder,
|
||||
) -> Self {
|
||||
Self {
|
||||
name,
|
||||
instance_name: None,
|
||||
schema: NodeSchema { doc, ..Default::default() },
|
||||
bound: Vec::new(),
|
||||
args: ArgsState::Pending { specs, make },
|
||||
build: Box::new(move |_| {
|
||||
panic!(
|
||||
"PrimitiveBuilder::build: `{name}` is an unconfigured arg-bearing type — \
|
||||
call try_args first"
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate raw `(name, value)` pairs against the declared `ArgSpec`s
|
||||
/// (strict form — see `ArgKind::parse`), then run `make`, returning the
|
||||
/// configured builder (`args: Configured{..}`) with this builder's
|
||||
/// `instance_name` carried over. `Plain` + empty raw is `Ok(self)`
|
||||
/// (uniform op application, #157's try_bind precedent); `Plain` +
|
||||
/// non-empty raw is `NotArgBearing`. Validation order (deterministic):
|
||||
/// first unknown arg name, then a duplicate, then the first spec-order
|
||||
/// missing arg, then per-kind parse failures (`BadValue`).
|
||||
pub fn try_args(self, raw: &[(String, String)]) -> Result<Self, ArgOpError> {
|
||||
let (specs, make) = match &self.args {
|
||||
ArgsState::Plain | ArgsState::Configured { .. } => {
|
||||
return if raw.is_empty() { Ok(self) } else { Err(ArgOpError::NotArgBearing) };
|
||||
}
|
||||
ArgsState::Pending { specs, make } => (*specs, *make),
|
||||
};
|
||||
|
||||
for (name, _) in raw {
|
||||
if !specs.iter().any(|s| s.name == name.as_str()) {
|
||||
return Err(ArgOpError::UnknownArg(name.clone()));
|
||||
}
|
||||
}
|
||||
for i in 0..raw.len() {
|
||||
if raw[i + 1..].iter().any(|(n, _)| n == &raw[i].0) {
|
||||
return Err(ArgOpError::DuplicateArg(raw[i].0.clone()));
|
||||
}
|
||||
}
|
||||
for spec in specs {
|
||||
if !raw.iter().any(|(n, _)| n.as_str() == spec.name) {
|
||||
return Err(ArgOpError::MissingArg(spec.name.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut values = Vec::with_capacity(specs.len());
|
||||
let mut accepted = Vec::with_capacity(specs.len());
|
||||
for spec in specs {
|
||||
let raw_val = &raw.iter().find(|(n, _)| n.as_str() == spec.name).expect("presence checked above").1;
|
||||
let value = spec.kind.parse(raw_val).map_err(|()| ArgOpError::BadValue {
|
||||
arg: spec.name.to_string(),
|
||||
kind: spec.kind,
|
||||
got: raw_val.clone(),
|
||||
})?;
|
||||
values.push((spec.name.to_string(), value));
|
||||
accepted.push(ConstructionArg { name: spec.name.to_string(), value: raw_val.clone() });
|
||||
}
|
||||
|
||||
let mut built = make(&values);
|
||||
built.instance_name = self.instance_name;
|
||||
built.args = ArgsState::Configured { values: accepted };
|
||||
Ok(built)
|
||||
}
|
||||
|
||||
/// The declared construction-arg specs (a view into the pending recipe);
|
||||
/// empty unless this builder `is_pending()`.
|
||||
pub fn arg_specs(&self) -> &[ArgSpec] {
|
||||
match &self.args {
|
||||
ArgsState::Pending { specs, .. } => specs,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The accepted, verbatim construction-arg pairs (the serialize surface);
|
||||
/// empty unless this builder was configured via `try_args`.
|
||||
pub fn construction_args(&self) -> &[ConstructionArg] {
|
||||
match &self.args {
|
||||
ArgsState::Configured { values } => values,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// True for an arg-bearing recipe not yet configured via `try_args`.
|
||||
pub fn is_pending(&self) -> bool {
|
||||
matches!(self.args, ArgsState::Pending { .. })
|
||||
}
|
||||
/// Set this node instance's explicit name. Must be non-empty: the name forms
|
||||
/// a knob-address segment (`<composite>.<name>.<param>`, via `node_name()` in
|
||||
@@ -324,7 +426,10 @@ impl PrimitiveBuilder {
|
||||
/// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the
|
||||
/// data-level construction surface (`GraphSession`, #157). `bind` keeps its
|
||||
/// panic contract (and its pinned messages); `try_bind` reports the same three
|
||||
/// conditions as values.
|
||||
/// conditions as values. `AlreadyGanged` is a fourth condition `try_bind`
|
||||
/// itself never raises — only `Composite::bind_path`'s gang guard (#317
|
||||
/// follow-up) does, refusing a ganged member's raw path at the use seam
|
||||
/// before it silently de-fuses the gang.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BindOpError {
|
||||
/// No still-open param has this name.
|
||||
@@ -333,6 +438,141 @@ pub enum BindOpError {
|
||||
AmbiguousParam(String),
|
||||
/// The value's kind does not equal the param's declared kind.
|
||||
KindMismatch { param: String, expected: ScalarKind, got: ScalarKind },
|
||||
/// `param` (the full qualified path) is a member of the gang named
|
||||
/// `gang` — binding it directly would freeze that one member while the
|
||||
/// gang's public knob keeps driving its siblings. Bind the gang's own
|
||||
/// public knob instead (accepted residue: unbindable at the use seam,
|
||||
/// #317).
|
||||
AlreadyGanged { param: String, gang: String },
|
||||
}
|
||||
|
||||
/// Closed construction-arg kinds (spec §Closedness) — deliberately NOT
|
||||
/// `ScalarKind`: the four scalar kinds are the streamed set (invariant 4); args
|
||||
/// are bootstrap metadata, never streamed, so the two closedness axes stay
|
||||
/// separate types.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ArgKind {
|
||||
/// An IANA timezone name (`chrono_tz::Tz`'s own parser — exact, case-sensitive).
|
||||
Tz,
|
||||
/// A local wall-clock time, strict zero-padded `HH:MM` (00-23 / 00-59).
|
||||
TimeOfDay,
|
||||
/// A plain positive decimal count (>= 1, no leading zeros).
|
||||
Count,
|
||||
}
|
||||
|
||||
impl ArgKind {
|
||||
/// Parse `raw` in this kind's strict canonical form. The accepted form IS
|
||||
/// the canonical form (spec §Closedness) — there is no normalization
|
||||
/// layer, so a near-miss string refuses rather than being silently
|
||||
/// rewritten (content ids stay input-variance-free by refusal).
|
||||
// The spec's pinned shape (`Result<ArgValue, ()>`, mirroring `ArgOpError`'s
|
||||
// caller-side `BadValue` wrapping the plain refusal): the unit error carries
|
||||
// no information of its own — `try_args` is the only caller and always maps
|
||||
// it to `ArgOpError::BadValue`, which is where the real detail lives.
|
||||
#[allow(clippy::result_unit_err)]
|
||||
pub fn parse(&self, raw: &str) -> Result<ArgValue, ()> {
|
||||
match self {
|
||||
ArgKind::Tz => raw.parse::<chrono_tz::Tz>().map(ArgValue::Tz).map_err(|_| ()),
|
||||
ArgKind::TimeOfDay => {
|
||||
let bytes = raw.as_bytes();
|
||||
if bytes.len() != 5 || bytes[2] != b':' {
|
||||
return Err(());
|
||||
}
|
||||
let (h, m) = (&raw[0..2], &raw[3..5]);
|
||||
if !h.bytes().all(|b| b.is_ascii_digit()) || !m.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(());
|
||||
}
|
||||
let hour: u32 = h.parse().map_err(|_| ())?;
|
||||
let minute: u32 = m.parse().map_err(|_| ())?;
|
||||
if hour > 23 || minute > 59 {
|
||||
return Err(());
|
||||
}
|
||||
Ok(ArgValue::TimeOfDay { hour, minute })
|
||||
}
|
||||
ArgKind::Count => {
|
||||
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(());
|
||||
}
|
||||
if raw.len() > 1 && raw.starts_with('0') {
|
||||
return Err(()); // leading zero: not the canonical form
|
||||
}
|
||||
let n: usize = raw.parse().map_err(|_| ())?;
|
||||
if n == 0 {
|
||||
return Err(()); // Count is >= 1
|
||||
}
|
||||
Ok(ArgValue::Count(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The static, per-kind hint line (introspection surface, C29) — a closed
|
||||
/// table keyed only by `ArgKind`, never per-entry freetext.
|
||||
pub fn hint(&self) -> &'static str {
|
||||
match self {
|
||||
ArgKind::Tz => "IANA timezone name, e.g. Europe/Berlin",
|
||||
ArgKind::TimeOfDay => "local wall-clock HH:MM",
|
||||
ArgKind::Count => "positive integer count",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One declared construction arg of an arg-bearing `PrimitiveBuilder` (the
|
||||
/// pending recipe's twin of `ParamSpec`): its render name and closed `ArgKind`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ArgSpec {
|
||||
pub name: &'static str,
|
||||
pub kind: ArgKind,
|
||||
}
|
||||
|
||||
/// A parsed, validated construction-arg value (in-memory only; documents carry
|
||||
/// the canonical string form in [`ConstructionArg`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ArgValue {
|
||||
Tz(chrono_tz::Tz),
|
||||
TimeOfDay { hour: u32, minute: u32 },
|
||||
Count(usize),
|
||||
}
|
||||
|
||||
/// A consumed construction arg — the id-bearing, serialized twin of
|
||||
/// `BoundParam`. `value` is the accepted strict-form string, stored verbatim
|
||||
/// (never re-normalized — see `ArgKind::parse`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConstructionArg {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// A fallible-`try_args` fault — the construction-arg twin of `BindOpError`,
|
||||
/// for the data-level construction surface.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ArgOpError {
|
||||
/// Args were given, but this type declares none (a `Plain` builder).
|
||||
NotArgBearing,
|
||||
/// A given arg name is not among the declared `ArgSpec`s.
|
||||
UnknownArg(String),
|
||||
/// The same arg name was given more than once.
|
||||
DuplicateArg(String),
|
||||
/// A declared arg has no value in the given set.
|
||||
MissingArg(String),
|
||||
/// A given value failed its declared kind's strict-form parse.
|
||||
BadValue { arg: String, kind: ArgKind, got: String },
|
||||
}
|
||||
|
||||
/// The args-channel state of a [`PrimitiveBuilder`] (private: callers only
|
||||
/// ever observe it through `arg_specs`/`construction_args`/`is_pending`).
|
||||
enum ArgsState {
|
||||
/// No construction args declared (every existing node, unaffected).
|
||||
Plain,
|
||||
/// An arg-bearing recipe: declared `ArgSpec`s and the `make` that turns a
|
||||
/// parsed value set into the real, configured builder.
|
||||
Pending {
|
||||
specs: &'static [ArgSpec],
|
||||
#[allow(clippy::type_complexity)]
|
||||
make: fn(&[(String, ArgValue)]) -> PrimitiveBuilder,
|
||||
},
|
||||
/// A configured arg-bearing builder: the accepted, verbatim (name, value)
|
||||
/// pairs — the render/serialize surface (`construction_args`).
|
||||
Configured { values: Vec<ConstructionArg> },
|
||||
}
|
||||
|
||||
/// A node's declared interface: its inputs (in order) and its output record — an
|
||||
@@ -345,6 +585,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 +649,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 +716,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 +753,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 +785,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 +800,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 +810,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 +852,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 +929,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 +948,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 +964,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 +1016,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 +1038,169 @@ 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(()));
|
||||
}
|
||||
|
||||
// --- construction args (ArgKind/ArgSpec/ArgValue/ConstructionArg/ArgOpError) ---
|
||||
|
||||
const DEMO_ARG_SPECS: &[ArgSpec] =
|
||||
&[ArgSpec { name: "tz", kind: ArgKind::Tz }, ArgSpec { name: "open", kind: ArgKind::TimeOfDay }];
|
||||
|
||||
/// A minimal `make`: the fixture does not need `values` to shape a real
|
||||
/// schema (that's `Session::make`'s job, Task 2) — it only proves
|
||||
/// `try_args` reaches `make` with parsed values and carries the result
|
||||
/// forward as `Configured`.
|
||||
fn demo_make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
|
||||
let _ = values;
|
||||
PrimitiveBuilder::new(
|
||||
"Demo",
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only arg-bearing schema" },
|
||||
|_| Box::new(Bare),
|
||||
)
|
||||
}
|
||||
|
||||
fn demo_pending() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::pending("Demo", "test-only arg-bearing schema", DEMO_ARG_SPECS, demo_make)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_not_arg_bearing_on_a_plain_builder_with_args() {
|
||||
let plain = PrimitiveBuilder::new("Bare", NodeSchema::default(), |_| Box::new(Bare));
|
||||
assert_eq!(
|
||||
plain.try_args(&[("x".into(), "1".into())]).err(),
|
||||
Some(ArgOpError::NotArgBearing),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_plain_with_empty_raw_is_ok_unchanged() {
|
||||
let plain = PrimitiveBuilder::new(
|
||||
"Bare",
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "plain doc" },
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
let after = plain.try_args(&[]).expect("empty raw against a Plain builder is Ok(self)");
|
||||
assert_eq!(after.schema().doc, "plain doc");
|
||||
assert!(after.construction_args().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_unknown_arg_names_the_first_unknown() {
|
||||
assert_eq!(
|
||||
demo_pending()
|
||||
.try_args(&[("nope".into(), "x".into()), ("tz".into(), "Europe/Berlin".into())])
|
||||
.err(),
|
||||
Some(ArgOpError::UnknownArg("nope".into())),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_duplicate_arg_names_the_repeated_name() {
|
||||
assert_eq!(
|
||||
demo_pending()
|
||||
.try_args(&[
|
||||
("tz".into(), "Europe/Berlin".into()),
|
||||
("tz".into(), "Europe/Berlin".into()),
|
||||
("open".into(), "09:30".into()),
|
||||
])
|
||||
.err(),
|
||||
Some(ArgOpError::DuplicateArg("tz".into())),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_missing_arg_names_the_first_missing_in_spec_order() {
|
||||
assert_eq!(
|
||||
demo_pending().try_args(&[("tz".into(), "Europe/Berlin".into())]).err(),
|
||||
Some(ArgOpError::MissingArg("open".into())),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_bad_value_names_arg_kind_and_the_rejected_string() {
|
||||
assert_eq!(
|
||||
demo_pending()
|
||||
.try_args(&[("tz".into(), "berlin".into()), ("open".into(), "09:30".into())])
|
||||
.err(),
|
||||
Some(ArgOpError::BadValue { arg: "tz".into(), kind: ArgKind::Tz, got: "berlin".into() }),
|
||||
);
|
||||
}
|
||||
|
||||
/// Strict form IS the canonical form (spec §Closedness): no normalization
|
||||
/// layer, so a near-miss string refuses rather than being rewritten.
|
||||
#[test]
|
||||
fn arg_kind_parse_strict_form_refusals_and_accepts() {
|
||||
assert!(ArgKind::TimeOfDay.parse("9:30").is_err(), "not zero-padded");
|
||||
assert!(ArgKind::Tz.parse("berlin").is_err(), "not the exact IANA name");
|
||||
assert!(ArgKind::Count.parse("02").is_err(), "leading zero");
|
||||
assert!(ArgKind::TimeOfDay.parse("09:30").is_ok());
|
||||
assert!(ArgKind::Tz.parse("Europe/Berlin").is_ok());
|
||||
assert!(ArgKind::Count.parse("2").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_accepts_a_valid_set_and_echoes_verbatim_pairs() {
|
||||
let configured = demo_pending()
|
||||
.try_args(&[("tz".into(), "America/New_York".into()), ("open".into(), "09:30".into())])
|
||||
.expect("a full valid set configures");
|
||||
assert!(!configured.is_pending());
|
||||
assert_eq!(
|
||||
configured.construction_args(),
|
||||
&[
|
||||
ConstructionArg { name: "tz".into(), value: "America/New_York".into() },
|
||||
ConstructionArg { name: "open".into(), value: "09:30".into() },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_arg_specs_are_visible_before_configuration() {
|
||||
let pending = demo_pending();
|
||||
assert!(pending.is_pending());
|
||||
assert_eq!(pending.arg_specs().iter().map(|s| s.name).collect::<Vec<_>>(), ["tz", "open"]);
|
||||
assert!(pending.construction_args().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "unconfigured")]
|
||||
fn pending_build_panics() {
|
||||
let _ = demo_pending().build(&[]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
//! C23) and no external dependency (C16).
|
||||
|
||||
use aura_core::{
|
||||
Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, ScalarKind,
|
||||
BindOpError, Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
|
||||
ScalarKind,
|
||||
};
|
||||
|
||||
use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target};
|
||||
@@ -129,7 +130,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,
|
||||
@@ -186,6 +195,38 @@ pub struct GangMember {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Deterministic shape gate for authored composite render names (#331) —
|
||||
/// shape only, never content judgement (C29 discipline): non-empty, no path
|
||||
/// separator (`/` or `\`), and not the special segments `.` or `..`. The rule
|
||||
/// exists because the name keys a trace directory on disk unsanitized
|
||||
/// (`traces/<name>/`, `crates/aura-registry/src/trace_store.rs`). Applied at
|
||||
/// both data-borne birth routes for a name (the skeptic's two-seam finding):
|
||||
/// the `Op::Name` op intake (`construction.rs`) and the CLI's
|
||||
/// blueprint-envelope root-name intake — never at store read-back (C29:
|
||||
/// registered artifacts are never retroactively invalidated), and never on
|
||||
/// the Rust builder API (`GraphBuilder::new`, role-2 native authoring).
|
||||
pub fn name_gate(name: &str) -> Result<(), NameGateFault> {
|
||||
if name.is_empty() {
|
||||
return Err(NameGateFault::Empty);
|
||||
}
|
||||
if name.contains('/') || name.contains('\\') {
|
||||
return Err(NameGateFault::ContainsSeparator);
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
return Err(NameGateFault::DotSegment);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The [`name_gate`] shape violation — WHY the name was refused; the caller
|
||||
/// already holds the offending name (WHAT) to build a message from.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NameGateFault {
|
||||
Empty,
|
||||
ContainsSeparator,
|
||||
DotSegment,
|
||||
}
|
||||
|
||||
/// A reusable sub-graph fragment compiled away by inlining (C9/C23). It is **not**
|
||||
/// a [`Node`]: it is never `eval`'d. It holds interior items (local indices),
|
||||
/// interior edges (local indices), input roles (role `r` fans into the interior
|
||||
@@ -232,6 +273,37 @@ impl Composite {
|
||||
pub fn doc(&self) -> Option<&str> {
|
||||
self.doc.as_deref()
|
||||
}
|
||||
/// Rename this composite's render symbol in place (#317, `Op::Use`): the
|
||||
/// interior — nodes, edges, roles, output — is untouched; only `name()`
|
||||
/// changes, so a fetched, registered subgraph renders (and path-prefixes
|
||||
/// its `param_space()`, via `collect_params`'s composite arm) under the
|
||||
/// caller-chosen instance identifier instead of its own authored name.
|
||||
pub(crate) fn renamed(mut self, name: impl Into<String>) -> Composite {
|
||||
self.name = name.into();
|
||||
self
|
||||
}
|
||||
/// Apply one path-qualified bind after `Op::Use`'s splice (#317): the
|
||||
/// same underlying mechanic every bind goes through
|
||||
/// (`PrimitiveBuilder::try_bind`), reached by walking `path` down through
|
||||
/// nested composite frames by node/composite-name prefix — the segmentation
|
||||
/// `reopen_in` (#246) also walks, but binding an open param instead of
|
||||
/// unbinding a bound one. A path matching no node at any level is
|
||||
/// `BindOpError::UnknownParam(path)` (the WHOLE qualified path — no open
|
||||
/// param lives there); a path that reaches a primitive but whose leaf
|
||||
/// param name itself is unknown/ambiguous/ill-kinded rewrites `try_bind`'s
|
||||
/// own fault to carry the full qualified path in place of the bare leaf
|
||||
/// name, so every fault out of this walk names the same thing: the path
|
||||
/// the caller wrote. A path landing on a GANGED member's own param is
|
||||
/// `BindOpError::AlreadyGanged` (review finding, #317 follow-up) — binding
|
||||
/// it directly would silently de-fuse the gang (the member frozen while
|
||||
/// the gang's public knob keeps driving its siblings); the gang's own
|
||||
/// public knob stays unbindable at the use seam (accepted residue). On
|
||||
/// `Err` `self` is dropped (consumed) — the same discard-on-ambiguity
|
||||
/// posture `reopen` uses.
|
||||
pub(crate) fn bind_path(mut self, path: &str, value: Scalar) -> Result<Composite, BindOpError> {
|
||||
bind_path_in(&mut self.nodes, &self.gangs, path, path, value)?;
|
||||
Ok(self)
|
||||
}
|
||||
/// Install declared measurement taps (the output-side twin of `input_roles`).
|
||||
/// Fluent, mirroring `with_doc`. Empty by default.
|
||||
pub fn with_taps(mut self, taps: Vec<Tap>) -> Self {
|
||||
@@ -289,6 +361,24 @@ impl Composite {
|
||||
out
|
||||
}
|
||||
|
||||
/// Every declared measurement tap across the whole blueprint, depth-first
|
||||
/// through nested composites — the taps-as-data twin of `param_space()`
|
||||
/// (#337, the positive discovery view #333's refusal roster deliberately
|
||||
/// deferred). Each entry is `(tap name, source wire, column kind)`, the
|
||||
/// wire rendered `<node>.<field>` in the LOCAL frame the tap was declared
|
||||
/// in. Unlike a param's path, neither the tap's own name nor its wire's
|
||||
/// node identifier is prefixed by an enclosing composite: a tap hoists
|
||||
/// BARE to the flat graph (`inline_composite` keeps `tap.name` verbatim),
|
||||
/// so this view renders exactly the load-bearing name `compile` keeps.
|
||||
/// Bounds-total like `derive_signature`: a structurally-invalid wire (out
|
||||
/// of a producer's output arity) yields no row rather than a panic — the
|
||||
/// real gate is `compile`'s own `resolve_tap_wire`.
|
||||
pub fn declared_taps(&self) -> Vec<(String, String, ScalarKind)> {
|
||||
let mut out = Vec::new();
|
||||
collect_taps(self, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Re-open ONE bound param at its `param_space()`-style path (#246): the
|
||||
/// param returns to the open surface at the slot `collect_params` order
|
||||
/// dictates; its bound value is forgotten by this value (the authored
|
||||
@@ -502,7 +592,8 @@ pub enum ReopenError {
|
||||
|
||||
/// One entry of the aggregated BOUND param surface (#246): the path-qualified
|
||||
/// twin of [`ParamSpec`] carrying the bound value — the default a sweep axis
|
||||
/// may override, and what `--list-axes` renders as `default=`.
|
||||
/// may override, and what `aura graph introspect --params` renders as
|
||||
/// `default=`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct BoundSpec {
|
||||
pub name: String,
|
||||
@@ -1058,6 +1149,31 @@ fn collect_params(items: &[BlueprintNode], gangs: &[Gang], prefix: &str, out: &m
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursive walk for `Composite::declared_taps` (#337): this level's OWN
|
||||
/// declared taps first (resolved against this level's OWN `nodes` — a tap's
|
||||
/// `from` wire is always local to the frame it was declared in), then
|
||||
/// recurse into every nested composite's own tap list. No prefix threading
|
||||
/// (unlike `collect_params`): a tap's name and its wire's node identifier
|
||||
/// stay bare at every depth, mirroring `inline_composite`'s own hoist.
|
||||
fn collect_taps(c: &Composite, out: &mut Vec<(String, String, ScalarKind)>) {
|
||||
for tap in &c.taps {
|
||||
if let Some(item) = c.nodes.get(tap.from.node) {
|
||||
let node_name = match item {
|
||||
BlueprintNode::Primitive(b) => b.node_name(),
|
||||
BlueprintNode::Composite(inner) => inner.name().to_string(),
|
||||
};
|
||||
if let Some(field) = item.signature().output.get(tap.from.field) {
|
||||
out.push((tap.name.clone(), format!("{node_name}.{}", field.name), field.kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
for item in &c.nodes {
|
||||
if let BlueprintNode::Composite(inner) = item {
|
||||
collect_taps(inner, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursive mutable walk for `Composite::reopen` (#246): mirrors
|
||||
/// `collect_params`' prefix rules (lockstep with `expansion_map`) — a leaf owns
|
||||
/// `<node>.<param>`, a composite prefixes its `name()` and recurses. Returns
|
||||
@@ -1089,6 +1205,92 @@ fn reopen_in(items: &mut [BlueprintNode], path: &str) -> usize {
|
||||
hits
|
||||
}
|
||||
|
||||
/// Recursive mutable walk for `Composite::bind_path` (#317): mirrors
|
||||
/// `reopen_in`'s node/composite-name prefix segmentation, but BINDS an open
|
||||
/// param (`PrimitiveBuilder::try_bind`) instead of unbinding a bound one.
|
||||
/// `full_path` is the whole original path (named in every fault this
|
||||
/// produces); `rest` is the remaining suffix at this recursion depth. `gangs`
|
||||
/// is the CURRENT frame's gang table (mirrors `collect_params`'s `gangs`
|
||||
/// parameter) — before a matched primitive's leaf param reaches `try_bind`,
|
||||
/// its (node, original-pos) is checked against every gang member; a hit
|
||||
/// refuses with `BindOpError::AlreadyGanged` rather than silently freezing
|
||||
/// the member out from under its gang's public knob (review finding,
|
||||
/// #317 follow-up). Uses `Vec::remove`/`insert` (not `&mut` in place) because
|
||||
/// `try_bind` consumes its receiver — the same reason `lower_items` moves
|
||||
/// `BlueprintNode`s by value rather than mutating through a reference.
|
||||
fn bind_path_in(
|
||||
items: &mut Vec<BlueprintNode>,
|
||||
gangs: &[Gang],
|
||||
full_path: &str,
|
||||
rest: &str,
|
||||
value: Scalar,
|
||||
) -> Result<(), BindOpError> {
|
||||
// The prefix this frame has already consumed off `full_path` (composite
|
||||
// names and up) — used to qualify a gang's public name the same way
|
||||
// `collect_params` does, so the refusal below names the SAME address
|
||||
// `param_space()` would show for that gang.
|
||||
let prefix = &full_path[..full_path.len() - rest.len()];
|
||||
for i in 0..items.len() {
|
||||
let child_rest = match &items[i] {
|
||||
BlueprintNode::Primitive(b) => rest.strip_prefix(&format!("{}.", b.node_name())),
|
||||
BlueprintNode::Composite(c) => rest.strip_prefix(&format!("{}.", c.name())),
|
||||
};
|
||||
let Some(child_rest) = child_rest else { continue };
|
||||
let child_rest = child_rest.to_string();
|
||||
// Gang guard (#317 follow-up review finding): a raw path landing on a
|
||||
// GANGED member's own param must not silently freeze it while the
|
||||
// gang's public knob keeps driving its siblings — refuse by
|
||||
// identifier before ever reaching `try_bind`. The gang's own public
|
||||
// knob staying unbindable at the use seam is accepted residue.
|
||||
if let BlueprintNode::Primitive(b) = &items[i]
|
||||
&& let Some(pos) = b.params().iter().position(|p| p.name == child_rest).map(|idx| b.original_pos(idx))
|
||||
&& let Some(g) = gangs.iter().find(|g| g.members.iter().any(|m| m.node == i && m.pos == pos))
|
||||
{
|
||||
return Err(BindOpError::AlreadyGanged {
|
||||
param: full_path.to_string(),
|
||||
gang: format!("{prefix}{}", g.name),
|
||||
});
|
||||
}
|
||||
let item = items.remove(i);
|
||||
return match item {
|
||||
BlueprintNode::Primitive(b) => match b.try_bind(&child_rest, value) {
|
||||
Ok(b2) => {
|
||||
items.insert(i, BlueprintNode::Primitive(b2));
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(qualify_bind_error(e, full_path)),
|
||||
},
|
||||
BlueprintNode::Composite(mut c) => {
|
||||
let r = bind_path_in(&mut c.nodes, &c.gangs, full_path, &child_rest, value);
|
||||
items.insert(i, BlueprintNode::Composite(c));
|
||||
r
|
||||
}
|
||||
};
|
||||
}
|
||||
// no node at any level matched `rest`'s leading segment: no open param
|
||||
// lives at this path.
|
||||
Err(BindOpError::UnknownParam(full_path.to_string()))
|
||||
}
|
||||
|
||||
/// Rewrite a leaf `try_bind` fault to carry the FULL qualified path (#317)
|
||||
/// in place of the bare leaf param name `try_bind` only ever sees (it has no
|
||||
/// visibility past the one primitive it was called on).
|
||||
fn qualify_bind_error(e: BindOpError, full_path: &str) -> BindOpError {
|
||||
match e {
|
||||
BindOpError::UnknownParam(_) => BindOpError::UnknownParam(full_path.to_string()),
|
||||
BindOpError::AmbiguousParam(_) => BindOpError::AmbiguousParam(full_path.to_string()),
|
||||
BindOpError::KindMismatch { expected, got, .. } => {
|
||||
BindOpError::KindMismatch { param: full_path.to_string(), expected, got }
|
||||
}
|
||||
// `try_bind` never raises this one (it has no gang awareness) — the
|
||||
// gang guard above constructs it directly, already fully qualified.
|
||||
// Kept here only so this match stays total over `BindOpError`.
|
||||
BindOpError::AlreadyGanged { gang, .. } => {
|
||||
BindOpError::AlreadyGanged { param: full_path.to_string(), gang }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only twin of `collect_params` over the BOUND surface (#246): same
|
||||
/// recursion shape and prefix rules, but enumerating `bound_params()` (with
|
||||
/// values) instead of the open `params()`. Gangs are irrelevant here — a gang
|
||||
@@ -1393,6 +1595,19 @@ mod tests {
|
||||
use aura_strategy::Bias;
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// `name_gate` (#331) unit table: an ordinary name passes; an empty
|
||||
/// name, one containing either path separator, and the two dot segments
|
||||
/// each fault with their own fault variant.
|
||||
#[test]
|
||||
fn name_gate_accepts_ordinary_names_and_refuses_shape_violations() {
|
||||
assert_eq!(name_gate("ny_momentum"), Ok(()));
|
||||
assert_eq!(name_gate(""), Err(NameGateFault::Empty));
|
||||
assert_eq!(name_gate("a/b"), Err(NameGateFault::ContainsSeparator));
|
||||
assert_eq!(name_gate("a\\b"), Err(NameGateFault::ContainsSeparator));
|
||||
assert_eq!(name_gate("."), Err(NameGateFault::DotSegment));
|
||||
assert_eq!(name_gate(".."), Err(NameGateFault::DotSegment));
|
||||
}
|
||||
|
||||
/// One knob fanning into two sibling open params passes the gate; the
|
||||
/// value carries the gang table.
|
||||
#[test]
|
||||
@@ -1695,7 +1910,7 @@ mod tests {
|
||||
Composite::new(
|
||||
"sig3",
|
||||
vec![
|
||||
LinComb::builder(2).into(),
|
||||
LinComb::configured(2).into(),
|
||||
Bias::builder().into(),
|
||||
Sma::builder().named("c").into(),
|
||||
],
|
||||
@@ -1734,7 +1949,7 @@ mod tests {
|
||||
Composite::new(
|
||||
"sig3",
|
||||
vec![
|
||||
LinComb::builder(2).into(),
|
||||
LinComb::configured(2).into(),
|
||||
Bias::builder().into(),
|
||||
Sma::builder().named("c").into(),
|
||||
],
|
||||
@@ -1838,6 +2053,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 +2062,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 +2486,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 +2494,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 +2507,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 +2519,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 +3072,7 @@ mod tests {
|
||||
inputs: vec![f64_any()],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
};
|
||||
let h = Harness::bootstrap(FlatGraph {
|
||||
nodes: vec![
|
||||
@@ -3098,7 +3322,7 @@ mod tests {
|
||||
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
||||
let strategy = Composite::new(
|
||||
"strategy",
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
|
||||
vec![],
|
||||
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
||||
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
||||
@@ -3196,7 +3420,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// #246: bound_param_space mirrors param_space's path qualification and
|
||||
/// carries the bound value (the default the CLI renders in --list-axes).
|
||||
/// carries the bound value (the default the CLI renders in
|
||||
/// `aura graph introspect --params`).
|
||||
#[test]
|
||||
fn bound_param_space_is_path_qualified_with_values() {
|
||||
use aura_strategy::Bias;
|
||||
@@ -3306,7 +3531,7 @@ mod tests {
|
||||
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
||||
let strategy = Composite::new(
|
||||
"strategy",
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
|
||||
// fan fast_slow's output into both LinComb terms so every interior slot
|
||||
// is wired (the totality check, cycle 0040); param order is unaffected.
|
||||
vec![
|
||||
@@ -3366,7 +3591,7 @@ mod tests {
|
||||
use aura_std::{LinComb, Sma};
|
||||
let bp = Composite::new(
|
||||
"root",
|
||||
vec![Sma::builder().into(), LinComb::builder(2).into()],
|
||||
vec![Sma::builder().into(), LinComb::configured(2).into()],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![], // output
|
||||
@@ -3454,6 +3679,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 +3782,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");
|
||||
@@ -3771,4 +3997,60 @@ mod tests {
|
||||
// the inner Sub lowered to flat node 0; the hoisted tap points at it
|
||||
assert_eq!(flat.taps, vec![crate::harness::FlatTap { name: "inner_d".into(), node: 0, field: 0 }]);
|
||||
}
|
||||
|
||||
/// The build-free discovery twin (#337) of the compile-time hoist above:
|
||||
/// `declared_taps()` finds the SAME nested-composite tap without ever
|
||||
/// compiling — its wire is rendered in the LOCAL frame it was declared in
|
||||
/// (`sub.value`, the un-named Sub's default node name + its own output
|
||||
/// field), never composite-path-prefixed (unlike a param's path).
|
||||
#[test]
|
||||
fn declared_taps_finds_a_nested_composites_tap_without_compiling() {
|
||||
let inner = Composite::new(
|
||||
"inner",
|
||||
vec![Sub::builder().into()],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "x".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
|
||||
source: None,
|
||||
}],
|
||||
vec![OutField { node: 0, field: 0, name: "o".into() }],
|
||||
)
|
||||
.with_taps(vec![Tap { name: "inner_d".into(), from: TapWire { node: 0, field: 0 } }]);
|
||||
let root = Composite::new(
|
||||
"root",
|
||||
vec![BlueprintNode::Composite(inner)],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "a".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![],
|
||||
);
|
||||
assert_eq!(
|
||||
root.declared_taps(),
|
||||
vec![("inner_d".to_string(), "sub.value".to_string(), ScalarKind::F64)]
|
||||
);
|
||||
}
|
||||
|
||||
/// Bounds-total (mirrors `derive_signature`): a tap wire naming a field
|
||||
/// beyond its producer's output arity yields no row rather than a panic —
|
||||
/// the real gate stays `compile`'s own `TapWireOutOfRange`.
|
||||
#[test]
|
||||
fn declared_taps_is_bounds_total_over_an_out_of_range_wire() {
|
||||
let bp = Composite::new(
|
||||
"m",
|
||||
vec![Sub::builder().into()],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "a".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![],
|
||||
)
|
||||
.with_taps(vec![Tap { name: "bad".into(), from: TapWire { node: 0, field: 9 } }]);
|
||||
assert_eq!(bp.declared_taps(), Vec::new(), "an out-of-range wire yields no row, not a panic");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,29 @@
|
||||
//! closure and the declared schema are re-derived on load from the injected
|
||||
//! resolver. This is the inverse of the lossy render half (`model_to_json`); it
|
||||
//! carries no node logic (C17) and references a closed vocabulary (C24).
|
||||
//!
|
||||
//! Construction args (#271) are structural, id-bearing data — like `bound`
|
||||
//! values, unlike debug-symbol names — so `strip_debug_symbols` leaves
|
||||
//! `PrimitiveData.args` untouched.
|
||||
|
||||
use crate::blueprint::{BlueprintNode, Composite, Gang, OutField, Role};
|
||||
use crate::harness::Edge;
|
||||
use aura_core::{BoundParam, PrimitiveBuilder};
|
||||
use aura_core::{BoundParam, ConstructionArg, PrimitiveBuilder};
|
||||
|
||||
/// The format version the loader understands. Bumped only by a load-bearing
|
||||
/// (Tier-2) change; additive optional fields do not bump it (#156). Pre-ship
|
||||
/// dormancy (#61, 2026-07-10): while the project is unshipped every document
|
||||
/// lives in-repo and reader/writer change atomically, so the Tier-2 bump
|
||||
/// discipline activates at the first external ship — which consciously
|
||||
/// freezes v1 (gangs included).
|
||||
pub const BLUEPRINT_FORMAT_VERSION: u32 = 1;
|
||||
/// The CEILING format version this build's loader understands (and the
|
||||
/// writer may ever emit) — bumped only by a load-bearing (Tier-2) change;
|
||||
/// additive optional fields do not bump it (#156). Pre-ship dormancy (#61,
|
||||
/// 2026-07-10): while the project is unshipped every document lives in-repo
|
||||
/// and reader/writer change atomically, so the Tier-2 bump discipline
|
||||
/// activates at the first external ship.
|
||||
///
|
||||
/// #271 (data-driven version): the writer no longer emits a single fixed
|
||||
/// version. A document emits `1` when args-free (byte-identical to every
|
||||
/// pre-#271 document — content ids stable, C18) and `2` the moment any
|
||||
/// primitive, at any nesting depth, carries construction `args` — the
|
||||
/// must-understand signal a pre-#271 loader needs. The loader accepts the
|
||||
/// closed range `1..=BLUEPRINT_FORMAT_VERSION`.
|
||||
pub const BLUEPRINT_FORMAT_VERSION: u32 = 2;
|
||||
|
||||
/// Top-level envelope: the version is read before the payload is interpreted.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
@@ -61,24 +72,64 @@ pub enum NodeData {
|
||||
}
|
||||
|
||||
/// A primitive node as data: its compiled-in type identity, optional instance
|
||||
/// name, and bound params. The schema + build closure are re-derived on load.
|
||||
/// name, its accepted construction args (#271), and bound params. The schema
|
||||
/// + build closure are re-derived on load.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct PrimitiveData {
|
||||
#[serde(rename = "type")]
|
||||
pub type_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// The consumed `(name, value)` construction-arg pairs (#271) — the
|
||||
/// id-bearing, serialized twin of `bound`. Additive-optional: absent
|
||||
/// (empty) for every args-free primitive, so a document with no args
|
||||
/// anywhere stays byte-identical to its pre-#271 form.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub args: Vec<ArgData>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub bound: Vec<BoundParam>,
|
||||
}
|
||||
|
||||
/// One serialized construction-arg pair (#271) — the wire twin of
|
||||
/// [`aura_core::ConstructionArg`]: `value` is the accepted strict-form
|
||||
/// string, stored verbatim (never re-normalized).
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ArgData {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Serializer failure (typed, named).
|
||||
#[derive(Debug)]
|
||||
pub enum SerializeError {
|
||||
Json(serde_json::Error),
|
||||
/// #341: an arg-bearing (`PrimitiveBuilder::pending`) node reached the
|
||||
/// serialize seam still unconfigured. Only the Rust `GraphBuilder`/
|
||||
/// `Composite::new` path can build this state — the data path
|
||||
/// (`add_node`) always runs `try_args` first — but emitting it as an
|
||||
/// args-free v1 document would produce a document `blueprint_from_json`
|
||||
/// can only refuse as `LoadError::BadArg(ArgOpError::MissingArg(..))` at
|
||||
/// LOAD time; refusing here instead names the fault at its origin.
|
||||
/// `missing_args` is the pending recipe's full declared `ArgSpec` list
|
||||
/// (none configured yet, so every declared arg is missing).
|
||||
PendingBuilder { node_type: String, missing_args: Vec<String> },
|
||||
}
|
||||
|
||||
fn project(c: &Composite) -> CompositeData {
|
||||
impl std::fmt::Display for SerializeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SerializeError::Json(e) => write!(f, "{e}"),
|
||||
SerializeError::PendingBuilder { node_type, missing_args } => write!(
|
||||
f,
|
||||
"node \"{node_type}\" is an unconfigured arg-bearing builder (missing arg(s): \
|
||||
{}); call try_args to configure it before it enters a graph",
|
||||
missing_args.join(", ")
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn project(c: &Composite) -> Result<CompositeData, SerializeError> {
|
||||
// Canonical gang order (bind-order-independent, mirroring `project_node`'s
|
||||
// bound-param canonicalization below): each gang's members ascending
|
||||
// `(node, pos)`, then the gangs themselves ordered by their (now-sorted)
|
||||
@@ -89,21 +140,30 @@ fn project(c: &Composite) -> CompositeData {
|
||||
}
|
||||
gangs.sort_by_key(|g| (g.members[0].node, g.members[0].pos));
|
||||
|
||||
CompositeData {
|
||||
Ok(CompositeData {
|
||||
name: c.name().to_string(),
|
||||
doc: c.doc().map(str::to_string),
|
||||
nodes: c.nodes().iter().map(project_node).collect(),
|
||||
nodes: c.nodes().iter().map(project_node).collect::<Result<Vec<_>, _>>()?,
|
||||
edges: c.edges().to_vec(),
|
||||
input_roles: c.input_roles().to_vec(),
|
||||
output: c.output().to_vec(),
|
||||
taps: c.taps().to_vec(),
|
||||
gangs,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn project_node(n: &BlueprintNode) -> NodeData {
|
||||
fn project_node(n: &BlueprintNode) -> Result<NodeData, SerializeError> {
|
||||
match n {
|
||||
BlueprintNode::Primitive(b) => {
|
||||
// #341: refuse a still-pending arg-bearing recipe outright — an
|
||||
// args-free v1 document built from it would only surface its
|
||||
// fault at load (`LoadError::BadArg(MissingArg)`), never here.
|
||||
if b.is_pending() {
|
||||
return Err(SerializeError::PendingBuilder {
|
||||
node_type: b.label(),
|
||||
missing_args: b.arg_specs().iter().map(|s| s.name.to_string()).collect(),
|
||||
});
|
||||
}
|
||||
// Canonical: bound params in ascending original-slot order, mirroring
|
||||
// the loader's re-bind canonicalization, so serialization is
|
||||
// bind-order-independent (a precondition for content-addressing, #158).
|
||||
@@ -111,18 +171,52 @@ fn project_node(n: &BlueprintNode) -> NodeData {
|
||||
// construction once a multi-param node enters the vocabulary.
|
||||
let mut bound = b.bound_params().to_vec();
|
||||
bound.sort_by_key(|bp| bp.pos);
|
||||
NodeData::Primitive(PrimitiveData {
|
||||
// Construction args (#271) are declared in a fixed `ArgSpec` order
|
||||
// (not player-chosen like binds), so no re-canonicalization is
|
||||
// needed — `construction_args()` already returns them in that
|
||||
// deterministic order.
|
||||
let args: Vec<ArgData> = b
|
||||
.construction_args()
|
||||
.iter()
|
||||
.map(|ConstructionArg { name, value }| ArgData { name: name.clone(), value: value.clone() })
|
||||
.collect();
|
||||
Ok(NodeData::Primitive(PrimitiveData {
|
||||
type_id: b.label(),
|
||||
name: b.instance_name().map(str::to_string),
|
||||
args,
|
||||
bound,
|
||||
})
|
||||
}))
|
||||
}
|
||||
BlueprintNode::Composite(c) => NodeData::Composite(project(c)),
|
||||
BlueprintNode::Composite(c) => Ok(NodeData::Composite(project(c)?)),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_doc(c: &Composite) -> BlueprintDoc {
|
||||
BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) }
|
||||
/// Any primitive, at any nesting depth, carries construction args (#271) —
|
||||
/// the must-understand signal for the data-driven version (spec §Data-driven
|
||||
/// format version).
|
||||
fn has_args(b: &CompositeData) -> bool {
|
||||
b.nodes.iter().any(|n| match n {
|
||||
NodeData::Primitive(p) => !p.args.is_empty(),
|
||||
NodeData::Composite(c) => has_args(c),
|
||||
})
|
||||
}
|
||||
|
||||
/// The version THIS document must declare (#271): `1` for an args-free
|
||||
/// document (byte-identical to every pre-#271 document, C18) or `2` the
|
||||
/// moment any primitive anywhere carries args — never the fixed
|
||||
/// `BLUEPRINT_FORMAT_VERSION` ceiling.
|
||||
fn document_version(b: &CompositeData) -> u32 {
|
||||
if has_args(b) {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn build_doc(c: &Composite) -> Result<BlueprintDoc, SerializeError> {
|
||||
let blueprint = project(c)?;
|
||||
let format_version = document_version(&blueprint);
|
||||
Ok(BlueprintDoc { format_version, blueprint })
|
||||
}
|
||||
|
||||
fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
|
||||
@@ -133,7 +227,7 @@ fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
|
||||
/// field order, defaults omitted (`skip_serializing_if`). An absent optional is
|
||||
/// byte-identical to the pre-extension form.
|
||||
pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
|
||||
serialize_doc(&build_doc(c))
|
||||
serialize_doc(&build_doc(c)?)
|
||||
}
|
||||
|
||||
/// The identity-canonical form (#171): the canonical document with every
|
||||
@@ -146,7 +240,7 @@ pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
|
||||
/// form ONLY — never a load path and never the reproduction store's byte form
|
||||
/// (`reproduce` re-binds params by name, so instance names are load-bearing there).
|
||||
pub fn blueprint_identity_json(c: &Composite) -> Result<String, SerializeError> {
|
||||
let mut doc = build_doc(c);
|
||||
let mut doc = build_doc(c)?;
|
||||
strip_debug_symbols(&mut doc.blueprint);
|
||||
serialize_doc(&doc)
|
||||
}
|
||||
@@ -200,11 +294,16 @@ pub enum LoadError {
|
||||
/// addition needs finer granularity than a version bump.
|
||||
UnsupportedVersion { found: u32, supported: u32 },
|
||||
/// `resolve` returned `None` for a serialized `type_id` (outside the injected
|
||||
/// vocabulary — unknown node, or a construction-arg/sink node not in #155's set).
|
||||
/// vocabulary — unknown node, or a sink node not in #155's set).
|
||||
UnknownNodeType(String),
|
||||
/// The document's gangs section fails structural validation against its
|
||||
/// own nodes (a hand-edited or corrupted document).
|
||||
Gang(crate::blueprint::CompileError),
|
||||
/// A primitive's `args` (#271) failed `try_args`: unknown/duplicate/
|
||||
/// missing arg, a malformed value, OR (the pending-with-no-args refusal)
|
||||
/// an arg-bearing type serialized/hand-written with an empty `args` —
|
||||
/// a document naming an arg-bearing type without args must not load.
|
||||
BadArg(aura_core::ArgOpError),
|
||||
}
|
||||
|
||||
fn reconstruct(
|
||||
@@ -220,6 +319,15 @@ fn reconstruct(
|
||||
if let Some(n) = &p.name {
|
||||
b = b.named(n);
|
||||
}
|
||||
// Apply construction args (#271) BEFORE bound params — the
|
||||
// `try_args` seam. An arg-bearing type with no `args` in the
|
||||
// document refuses HERE as `MissingArg` (no pending builder
|
||||
// ever reaches `bind`/enters the built graph); a `Plain` type
|
||||
// has empty `p.args`, so this is `Ok(self)` unchanged for
|
||||
// every pre-#271 document.
|
||||
let args: Vec<(String, String)> =
|
||||
p.args.iter().map(|a| (a.name.clone(), a.value.clone())).collect();
|
||||
b = b.try_args(&args).map_err(LoadError::BadArg)?;
|
||||
// Re-apply bound params BY NAME. The effective param vector is
|
||||
// order-independent (each `bind` computes its slot against the
|
||||
// shrunk schema); ascending original `pos` is the canonical order.
|
||||
@@ -251,7 +359,9 @@ pub fn blueprint_from_json(
|
||||
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
|
||||
) -> Result<Composite, LoadError> {
|
||||
let doc: BlueprintDoc = serde_json::from_str(data).map_err(LoadError::Json)?;
|
||||
if doc.format_version != BLUEPRINT_FORMAT_VERSION {
|
||||
// #271: the loader accepts the closed range 1..=BLUEPRINT_FORMAT_VERSION
|
||||
// (today 1..=2) — a data-driven ceiling, not a single fixed version.
|
||||
if !(1..=BLUEPRINT_FORMAT_VERSION).contains(&doc.format_version) {
|
||||
return Err(LoadError::UnsupportedVersion {
|
||||
found: doc.format_version,
|
||||
supported: BLUEPRINT_FORMAT_VERSION,
|
||||
@@ -266,7 +376,8 @@ mod tests {
|
||||
use crate::blueprint::{Composite, GangMember, OutField, Role};
|
||||
use crate::harness::{Edge, Target};
|
||||
use crate::VecSource; // crate-root re-export (blueprint.rs tests import it the same way, e.g. :923)
|
||||
use aura_core::{Scalar, ScalarKind, Timestamp};
|
||||
use aura_core::{ArgOpError, Scalar, ScalarKind, Timestamp};
|
||||
use aura_market::Session;
|
||||
use aura_std::{Recorder, Sma, Sub};
|
||||
use aura_strategy::Bias;
|
||||
use std::sync::mpsc;
|
||||
@@ -436,11 +547,14 @@ mod tests {
|
||||
assert!(matches!(err, LoadError::UnknownNodeType(t) if t == "NoSuchNode"));
|
||||
}
|
||||
|
||||
/// #271 flipped pin (named contract change, not a regression): `format_version: 2`
|
||||
/// used to be refused (v1 was the only understood version); now that v2 loads
|
||||
/// (the args-bearing tier), only a version PAST the new ceiling is unsupported.
|
||||
#[test]
|
||||
fn unsupported_version_fails_named() {
|
||||
let json = r#"{"format_version":2,"blueprint":{"name":"x","nodes":[]}}"#;
|
||||
let json = r#"{"format_version":3,"blueprint":{"name":"x","nodes":[]}}"#;
|
||||
let err = blueprint_from_json(json, &|t| aura_vocabulary::std_vocabulary(t)).err().unwrap();
|
||||
assert!(matches!(err, LoadError::UnsupportedVersion { found: 2, supported: 1 }));
|
||||
assert!(matches!(err, LoadError::UnsupportedVersion { found: 3, supported: 2 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -813,4 +927,171 @@ mod tests {
|
||||
"the gang itself is identity-bearing"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- construction args (#271) --------------------------------------
|
||||
|
||||
// A single-node composite around an arg-configured `Session` (arity-free —
|
||||
// a `trigger` input role feeds it, its `bars_since_open` output re-exports).
|
||||
fn session_arg_fixture(tz: chrono_tz::Tz) -> Composite {
|
||||
Composite::new(
|
||||
"sess",
|
||||
vec![Session::configured(9, 30, tz, 15).into()],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "trigger".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![OutField { node: 0, field: 0, name: "bars".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
/// #271 acceptance (engine layer): an args-bearing composite serializes its
|
||||
/// construction-arg pairs, declares `format_version: 2` (the data-driven
|
||||
/// must-understand tier, spec §Data-driven format version), and round-trips
|
||||
/// byte-stably through load -> re-serialize.
|
||||
#[test]
|
||||
fn args_round_trip_preserves_pairs_and_version_2() {
|
||||
let c = session_arg_fixture(chrono_tz::Europe::Berlin);
|
||||
let json = blueprint_to_json(&c).expect("serializes");
|
||||
assert!(json.contains(r#""format_version":2"#), "args-bearing document is version 2: {json}");
|
||||
assert!(
|
||||
json.contains(r#""args":[{"name":"tz","value":"Europe/Berlin"},{"name":"open","value":"09:30"}]"#),
|
||||
"carries the verbatim accepted arg pairs: {json}"
|
||||
);
|
||||
let loaded = blueprint_from_json(&json, &fixture_resolver()).expect("loads");
|
||||
assert_eq!(
|
||||
blueprint_to_json(&loaded).expect("re-serializes"),
|
||||
json,
|
||||
"args round-trip byte-stably"
|
||||
);
|
||||
}
|
||||
|
||||
/// #271 acceptance (C18 stability): a document with NO args anywhere stays
|
||||
/// version 1 and byte-identical to its pre-#271 canonical form — the exact
|
||||
/// golden `signal_serializes_to_canonical_golden` already pins, re-asserted
|
||||
/// here under the #271-specific test name the plan names.
|
||||
#[test]
|
||||
fn args_free_document_serializes_byte_identical_and_version_1() {
|
||||
let signal = crate::test_fixtures::sink_free_sma_cross_signal();
|
||||
let json = blueprint_to_json(&signal).expect("serializes");
|
||||
assert!(json.starts_with(r#"{"format_version":1,"#), "args-free document stays version 1: {json}");
|
||||
assert!(!json.contains("\"args\""), "an args-free document emits no args key: {json}");
|
||||
let golden = r#"{"format_version":1,"blueprint":{"name":"sma_cross","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias"}}],"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}]}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
assert_eq!(json, golden, "byte-identical to the pre-#271 canonical form (content ids stable, C18)");
|
||||
}
|
||||
|
||||
/// #271 (nested propagation): an args-bearing primitive nested INSIDE an
|
||||
/// outer composite still trips the outer document's version to 2 — the
|
||||
/// version walk recurses through `NodeData::Composite`, not just the
|
||||
/// top-level node list.
|
||||
#[test]
|
||||
fn nested_spliced_args_composite_is_version_2() {
|
||||
let inner = session_arg_fixture(chrono_tz::Europe::Berlin);
|
||||
let outer = Composite::new(
|
||||
"outer",
|
||||
vec![crate::blueprint::BlueprintNode::Composite(inner)],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "trigger".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![OutField { node: 0, field: 0, name: "bars".into() }],
|
||||
);
|
||||
let json = blueprint_to_json(&outer).expect("serializes");
|
||||
assert!(json.contains(r#""format_version":2"#), "a nested arg-bearing primitive still trips version 2: {json}");
|
||||
assert!(json.contains("\"args\""), "the nested primitive's args survive: {json}");
|
||||
}
|
||||
|
||||
/// #271 (identity is arg-bearing): construction args are structural,
|
||||
/// id-bearing data — like a bound value, unlike a debug-symbol name — so
|
||||
/// two builds differing ONLY in one accepted arg value never share an
|
||||
/// identity JSON, while renaming the composite (a pure debug symbol)
|
||||
/// still does not affect it.
|
||||
#[test]
|
||||
fn identity_json_retains_args() {
|
||||
let berlin = session_arg_fixture(chrono_tz::Europe::Berlin);
|
||||
let paris = session_arg_fixture(chrono_tz::Europe::Paris);
|
||||
assert_ne!(
|
||||
blueprint_identity_json(&berlin).expect("identity-serializes"),
|
||||
blueprint_identity_json(&paris).expect("identity-serializes"),
|
||||
"a differing construction-arg value survives the identity projection"
|
||||
);
|
||||
// renaming the composite (a debug symbol) does not affect identity.
|
||||
let renamed = Composite::new(
|
||||
"sess_renamed",
|
||||
vec![Session::configured(9, 30, chrono_tz::Europe::Berlin, 15).into()],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "trigger".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![OutField { node: 0, field: 0, name: "bars".into() }],
|
||||
);
|
||||
assert_eq!(
|
||||
blueprint_identity_json(&berlin).expect("identity-serializes"),
|
||||
blueprint_identity_json(&renamed).expect("identity-serializes"),
|
||||
"the composite's own debug name does not affect identity"
|
||||
);
|
||||
}
|
||||
|
||||
/// #271 (refuse, don't guess): a hand-written document naming an
|
||||
/// arg-bearing type (`Session`) with NO `args` key refuses on load as
|
||||
/// `LoadError::BadArg(ArgOpError::MissingArg(..))` — a pending builder
|
||||
/// must never silently enter the built graph.
|
||||
#[test]
|
||||
fn loading_arg_bearing_type_without_args_refuses() {
|
||||
let json = r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"Session"}}]}}"#;
|
||||
let err = blueprint_from_json(json, &fixture_resolver()).err().unwrap();
|
||||
assert!(
|
||||
matches!(&err, LoadError::BadArg(ArgOpError::MissingArg(a)) if a == "tz"),
|
||||
"an arg-bearing type with no args refuses as MissingArg, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #341 (refuse at the serialize seam, not just at load): a Rust-built
|
||||
/// graph holding a still-`Session::builder()`-pending node (no
|
||||
/// `try_args` applied) refuses to serialize outright — the twin of
|
||||
/// `loading_arg_bearing_type_without_args_refuses` at the OTHER end of
|
||||
/// the wire, the fault named at its origin instead of surfacing only
|
||||
/// once the emitted document is reloaded. Only the Rust `GraphBuilder`
|
||||
/// path can build this state; `add_node`'s data path always runs
|
||||
/// `try_args` first (fenced separately).
|
||||
#[test]
|
||||
fn project_node_refuses_a_pending_arg_bearing_builder() {
|
||||
let mut gb = crate::GraphBuilder::new("has_pending");
|
||||
gb.add(Session::builder());
|
||||
let composite = gb.build().expect("an unwired single-node graph builds");
|
||||
|
||||
let err = blueprint_to_json(&composite).expect_err("a pending builder must not serialize");
|
||||
assert!(
|
||||
err.to_string().contains("try_args"),
|
||||
"the refusal prose hints at the fix: {err}"
|
||||
);
|
||||
match err {
|
||||
SerializeError::PendingBuilder { node_type, missing_args } => {
|
||||
assert_eq!(node_type, "Session", "names the pending node's type: {missing_args:?}");
|
||||
assert_eq!(
|
||||
missing_args,
|
||||
vec!["tz".to_string(), "open".to_string()],
|
||||
"names every declared arg as missing (none configured yet)"
|
||||
);
|
||||
}
|
||||
SerializeError::Json(e) => panic!("wrong error variant: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The fenced happy path (#341, negative twin): a `try_args`-configured
|
||||
/// `Session` (the SAME recipe, just no longer pending) serializes fine —
|
||||
/// `session_arg_fixture`'s existing round-trip coverage
|
||||
/// (`args_round_trip_preserves_pairs_and_version_2`) already proves
|
||||
/// this; re-asserted here beside the refusal for the reader who lands on
|
||||
/// one and wants the other in view.
|
||||
#[test]
|
||||
fn project_node_serializes_a_configured_arg_bearing_builder() {
|
||||
let c = session_arg_fixture(chrono_tz::Europe::Berlin);
|
||||
assert!(blueprint_to_json(&c).is_ok(), "a configured builder serializes");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -52,12 +52,13 @@ mod sweep;
|
||||
mod walkforward;
|
||||
|
||||
pub use blueprint::{
|
||||
BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang, GangFault,
|
||||
GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder, Tap, TapWire,
|
||||
name_gate, BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang,
|
||||
GangFault, GangMember, NameGateFault, OutField, RandomBinder, ReopenError, Role, SweepBinder,
|
||||
Tap, TapWire,
|
||||
};
|
||||
pub use blueprint_serde::{
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,
|
||||
LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, ArgData, BlueprintDoc,
|
||||
CompositeData, LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
|
||||
};
|
||||
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
|
||||
pub use construction::{replay, GraphSession, Op, OpError};
|
||||
@@ -88,7 +89,8 @@ pub use walkforward::{
|
||||
// (SourceSpec.kind is a ScalarKind; sources/Recorder columns are Scalar /
|
||||
// Firing / Timestamp) so a graph builder has one import surface, not two.
|
||||
pub use aura_core::{
|
||||
BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind, Timestamp,
|
||||
ArgKind, ArgOpError, BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind,
|
||||
Timestamp,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -43,6 +43,7 @@ fn recorder_sig(kinds: &[ScalarKind]) -> NodeSchema {
|
||||
.collect(),
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,10 @@ fn params() -> [Scalar; 3] {
|
||||
fn signal_ops() -> Vec<Op> {
|
||||
vec![
|
||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
|
||||
Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] },
|
||||
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
|
||||
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
|
||||
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
||||
@@ -58,7 +58,7 @@ fn signal_ops() -> Vec<Op> {
|
||||
#[test]
|
||||
fn replayed_construction_compiles_identically_to_graphbuilder() {
|
||||
// (a) op-script replay through the public construction surface.
|
||||
let replay_flat = replay("root", signal_ops(), &std_vocabulary)
|
||||
let replay_flat = replay("root", signal_ops(), &std_vocabulary, &|_: &str| None)
|
||||
.expect("op-script resolves")
|
||||
.compile_with_params(¶ms())
|
||||
.expect("replay compiles");
|
||||
@@ -96,14 +96,14 @@ fn replayed_construction_compiles_identically_to_graphbuilder() {
|
||||
#[test]
|
||||
fn replay_attributes_eager_fault_to_its_op_index() {
|
||||
let ops = vec![
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 0 ok
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, // 1 ok
|
||||
Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }, // 2 fails
|
||||
Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }, // 3 never runs
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }, // 0 ok
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }, // 1 ok
|
||||
Op::Add { type_id: "Nope".into(), as_name: None, args: vec![], bind: vec![] }, // 2 fails
|
||||
Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] }, // 3 never runs
|
||||
];
|
||||
// `.err().unwrap()` (not `unwrap_err`): the Ok arm `Composite` holds build
|
||||
// closures and is not `Debug`, which `unwrap_err`'s bound would require.
|
||||
let err = replay("g", ops, &std_vocabulary).err().unwrap();
|
||||
let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap();
|
||||
assert_eq!(err, (2, OpError::UnknownNodeType("Nope".into())));
|
||||
}
|
||||
|
||||
@@ -120,15 +120,15 @@ fn replay_attributes_eager_fault_to_its_op_index() {
|
||||
fn replay_attributes_holistic_fault_to_finalize_step() {
|
||||
let ops = vec![
|
||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 }, // 0
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 1
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }, // 2
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, // 3
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }, // 1
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] }, // 2
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }, // 3
|
||||
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }, // 4
|
||||
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, // 5
|
||||
// sub.rhs deliberately left unwired -> only finalize can decide totality.
|
||||
];
|
||||
let finalize_index = ops.len(); // 6 — the implicit finalize step
|
||||
let err = replay("g", ops, &std_vocabulary).err().unwrap();
|
||||
let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap();
|
||||
assert!(matches!(&err, (i, OpError::UnconnectedPort { .. }) if *i == finalize_index),
|
||||
"the holistic fault is still attributed to the finalize step, got {err:?}");
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ fn build_harness(
|
||||
Resample::builder().schema().clone(), // 0
|
||||
Delay::builder().schema().clone(), // 1
|
||||
Gt::builder().schema().clone(), // 2
|
||||
Session::builder(9, 0, Berlin, 15).schema().clone(), // 3
|
||||
Session::configured(9, 0, Berlin, 15).schema().clone(), // 3
|
||||
EqConst::builder().schema().clone(), // 4
|
||||
EqConst::builder().schema().clone(), // 5
|
||||
And::builder().schema().clone(), // 6
|
||||
|
||||
@@ -21,7 +21,7 @@ fn run_meanrev_bias(closes: &[f64], n: i64, k: f64) -> Vec<f64> {
|
||||
let sq = g.add(Mul::builder()); // dev * dev
|
||||
let var = g.add(Ema::builder().bind("length", Scalar::i64(n))); // EWMA variance
|
||||
let sigma = g.add(Sqrt::builder());
|
||||
let band = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k))); // k*sigma
|
||||
let band = g.add(LinComb::configured(1).bind("weights[0]", Scalar::f64(k))); // k*sigma
|
||||
let upper = g.add(Add::builder()); // mean + k*sigma
|
||||
let lower = g.add(Sub::builder()); // mean - k*sigma
|
||||
let gt_hi = g.add(Gt::builder()); // price > upper
|
||||
|
||||
@@ -112,7 +112,7 @@ pub fn build_harness() -> (Harness, Taps) {
|
||||
Resample::builder().schema().clone(), // 0
|
||||
Delay::builder().schema().clone(), // 1
|
||||
Gt::builder().schema().clone(), // 2
|
||||
Session::builder(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
|
||||
Session::configured(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
|
||||
.schema()
|
||||
.clone(), // 3
|
||||
EqConst::builder().schema().clone(), // 4
|
||||
@@ -248,7 +248,7 @@ pub fn ger40_breakout_blueprint(
|
||||
);
|
||||
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
|
||||
let gt = g.add(Gt::builder());
|
||||
let session = g.add(Session::builder(open_hour, open_minute, tz, bar_period_minutes));
|
||||
let session = g.add(Session::configured(open_hour, open_minute, tz, bar_period_minutes));
|
||||
// The ONLY two params: the EqConst targets, named so the space reads
|
||||
// `entry_bar.target` / `exit_bar.target`. Their `target` is left UNBOUND.
|
||||
let entry_bar = g.add(EqConst::builder().named("entry_bar"));
|
||||
|
||||
@@ -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())),
|
||||
)
|
||||
|
||||
@@ -33,10 +33,17 @@
|
||||
//! **Cold guard.** If the trigger column is empty (not yet fired) → `None`.
|
||||
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind,
|
||||
ArgKind, ArgSpec, ArgValue, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
|
||||
PrimitiveBuilder, Scalar, ScalarKind,
|
||||
};
|
||||
use chrono::{TimeZone, Timelike};
|
||||
|
||||
/// The declared construction args of a `Session` recipe (spec §Concrete code
|
||||
/// shapes): the timezone and local open time — structural, non-scalar
|
||||
/// configuration, never a swept param.
|
||||
const SESSION_ARGS: &[ArgSpec] =
|
||||
&[ArgSpec { name: "tz", kind: ArgKind::Tz }, ArgSpec { name: "open", kind: ArgKind::TimeOfDay }];
|
||||
|
||||
/// Frankfurt-session bar indexer. Holds the open offset (minutes past local
|
||||
/// midnight), the IANA timezone, and the bar period — baked at construction,
|
||||
/// never streamed and never a swept param (a timezone is not a scalar).
|
||||
@@ -60,22 +67,57 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The param-generic recipe for a blueprint primitive. The open time,
|
||||
/// timezone, and period are **structural** config baked into the closure
|
||||
/// (like `SimBroker::builder` bakes `pip_size`), NOT scalar params — the
|
||||
/// declared param list is empty and the `build_fn` ignores its slice (like
|
||||
/// `Gt` / `Latch`).
|
||||
pub fn builder(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder {
|
||||
/// Roster factory: zero-arg, arg-bearing (spec §Concrete code shapes) — the
|
||||
/// timezone and open time come through the `args` channel (`try_args`),
|
||||
/// declared by [`SESSION_ARGS`]. `period_minutes` becomes an ordinary
|
||||
/// `ParamSpec` once `make` runs (a real scalar knob, unlike tz/open).
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::pending(
|
||||
"Session",
|
||||
"bars elapsed since the session open, from the configured open time and timezone",
|
||||
SESSION_ARGS,
|
||||
Self::make,
|
||||
)
|
||||
}
|
||||
|
||||
/// Turn a validated `(tz, open)` value pair into the real signature: one
|
||||
/// `trigger` input, one `bars_since_open` output, and `period_minutes` as
|
||||
/// the sole (now scalar) `ParamSpec` — read from the param slice at build.
|
||||
fn make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
|
||||
let tz = values
|
||||
.iter()
|
||||
.find_map(|(n, v)| match (n.as_str(), v) {
|
||||
("tz", ArgValue::Tz(tz)) => Some(*tz),
|
||||
_ => None,
|
||||
})
|
||||
.expect("try_args validated `tz` as ArgKind::Tz before calling make");
|
||||
let (open_hour, open_minute) = values
|
||||
.iter()
|
||||
.find_map(|(n, v)| match (n.as_str(), v) {
|
||||
("open", ArgValue::TimeOfDay { hour, minute }) => Some((*hour, *minute)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("try_args validated `open` as ArgKind::TimeOfDay before calling make");
|
||||
PrimitiveBuilder::new(
|
||||
"Session",
|
||||
NodeSchema {
|
||||
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![],
|
||||
params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
|
||||
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)),
|
||||
move |p| Box::new(Session::new(open_hour, open_minute, tz, p[0].i64())),
|
||||
)
|
||||
}
|
||||
|
||||
/// Rust-path convenience — the same recipe as the data path (twin
|
||||
/// identity): `builder().try_args([tz, open]) + .bind("period_minutes", …)`.
|
||||
pub fn configured(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder {
|
||||
Self::builder()
|
||||
.try_args(&[("tz".to_string(), tz.name().to_string()), ("open".to_string(), format!("{open_hour:02}:{open_minute:02}"))])
|
||||
.expect("configured: a valid chrono_tz::Tz and in-range hour/minute always parse")
|
||||
.bind("period_minutes", Scalar::i64(period_minutes))
|
||||
}
|
||||
}
|
||||
|
||||
/// The zero-arg `SessionFrankfurt` roster preset (#261): the Frankfurt open
|
||||
@@ -99,6 +141,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())),
|
||||
)
|
||||
@@ -242,4 +285,36 @@ mod tests {
|
||||
let inputs = trigger_input();
|
||||
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None);
|
||||
}
|
||||
|
||||
/// Twin identity (spec §aura-market): `configured` is exactly
|
||||
/// `builder().try_args([tz, open]).bind("period_minutes", …)` — same
|
||||
/// accepted args, same declared signature, whichever path authored it.
|
||||
#[test]
|
||||
fn session_configured_twin_equals_builder_try_args() {
|
||||
let via_configured = Session::configured(9, 30, Berlin, 15);
|
||||
let via_try_args = Session::builder()
|
||||
.try_args(&[("tz".into(), "Europe/Berlin".into()), ("open".into(), "09:30".into())])
|
||||
.expect("valid tz/open configure")
|
||||
.bind("period_minutes", Scalar::i64(15));
|
||||
assert_eq!(via_configured.construction_args(), via_try_args.construction_args());
|
||||
assert_eq!(via_configured.schema(), via_try_args.schema());
|
||||
}
|
||||
|
||||
/// Behaviour re-anchor (#271 Task 2): the DST-aware headline property
|
||||
/// (local 09:45 reads bar 3 in both CEST summer and CET winter) holds
|
||||
/// identically when the node is built through `configured` instead of
|
||||
/// `Session::new` directly.
|
||||
#[test]
|
||||
fn session_configured_behaviour_unchanged() {
|
||||
let mut node = Session::configured(9, 0, Berlin, 15).build(&[]);
|
||||
let mut inputs = trigger_input();
|
||||
|
||||
let summer = Berlin.with_ymd_and_hms(2024, 7, 1, 9, 45, 0).unwrap().timestamp_nanos_opt().unwrap();
|
||||
inputs[0].push(Scalar::f64(0.0)).unwrap();
|
||||
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(summer))).map(|r| r[0].i64()), Some(3));
|
||||
|
||||
let winter = Berlin.with_ymd_and_hms(2024, 1, 2, 9, 45, 0).unwrap().timestamp_nanos_opt().unwrap();
|
||||
inputs[0].push(Scalar::f64(0.0)).unwrap();
|
||||
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(winter))).map(|r| r[0].i64()), Some(3));
|
||||
}
|
||||
}
|
||||
|
||||
+537
-22
@@ -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> {
|
||||
@@ -156,6 +173,95 @@ impl Registry {
|
||||
}
|
||||
}
|
||||
|
||||
/// The label sidecar path (#317) — a sibling of `blueprint_identity_index.jsonl`,
|
||||
/// the same "fixed-name sidecar, per-directory isolation" discipline (#191).
|
||||
fn blueprint_names_path(&self) -> PathBuf {
|
||||
self.path.with_file_name("blueprint_names.jsonl")
|
||||
}
|
||||
|
||||
/// Raw, ungated append: one label line, unconditionally, under the
|
||||
/// registry's write mutex (#276). No shape or content-id check —
|
||||
/// [`Registry::put_blueprint_label`] is the gated public entry; this
|
||||
/// exists so tests can hand-append a stale line, mirroring
|
||||
/// `append_identity_index`.
|
||||
fn append_blueprint_label_line(&self, name: &str, content_id: &str) -> Result<(), RegistryError> {
|
||||
let _guard = self.append_write_lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let path = self.blueprint_names_path();
|
||||
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let line = serde_json::to_string(&BlueprintNameLine {
|
||||
name: name.to_string(),
|
||||
content_id: content_id.to_string(),
|
||||
})
|
||||
.expect("two plain strings serialize");
|
||||
let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
|
||||
writeln!(file, "{line}")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Label a stored blueprint (#317): appends `{"name":..,"content_id":..}`
|
||||
/// to `blueprint_names.jsonl`, a sidecar beside the identity index. Gated
|
||||
/// on a deterministic label shape (nonempty after trim, no whitespace, no
|
||||
/// control characters — `RegistryError::BadLabel`, C29 discipline: shape
|
||||
/// only, never content judgement) and on `content_id` resolving via
|
||||
/// [`Registry::get_blueprint`] (`RegistryError::UnknownBlueprint`
|
||||
/// otherwise) — both checked BEFORE the write lock is taken. Re-labelling
|
||||
/// an existing name appends a new line (a repoint); resolution is
|
||||
/// latest-wins (see [`Registry::resolve_blueprint_label`]).
|
||||
pub fn put_blueprint_label(&self, name: &str, content_id: &str) -> Result<(), RegistryError> {
|
||||
gate_label_shape(name)?;
|
||||
if self.get_blueprint(content_id)?.is_none() {
|
||||
return Err(RegistryError::UnknownBlueprint(content_id.to_string()));
|
||||
}
|
||||
self.append_blueprint_label_line(name, content_id)
|
||||
}
|
||||
|
||||
/// Resolve a label to the content id of the LATEST line naming it, skipping
|
||||
/// any line whose content id no longer resolves via `get_blueprint`
|
||||
/// (self-repair spirit, #191) — so "latest" always means the latest VALID
|
||||
/// entry, even when a later append points at a since-vanished id. Missing
|
||||
/// sidecar file -> `None` (treat-as-empty, like `load_identity_index`).
|
||||
pub fn resolve_blueprint_label(&self, name: &str) -> Option<String> {
|
||||
self.latest_blueprint_labels().remove(name)
|
||||
}
|
||||
|
||||
/// Every registered label, latest-wins-deduped and name-sorted — the
|
||||
/// discovery surface's data source (`graph introspect --registered`).
|
||||
/// Dangling entries (content id no longer resolves) are skipped.
|
||||
pub fn blueprint_labels(&self) -> Vec<(String, String)> {
|
||||
self.latest_blueprint_labels().into_iter().collect()
|
||||
}
|
||||
|
||||
/// Every content id currently stored in the blueprint store — the
|
||||
/// candidate list a `use` ref's content-id-PREFIX resolution (#302
|
||||
/// semantics, #317) filters against, mirroring `list_process_ids`/
|
||||
/// `list_campaign_ids`. `blueprints_dir()` and `doc_dir("blueprints")`
|
||||
/// name the same directory, so `list_doc_ids`'s filename-stem walk
|
||||
/// applies unchanged.
|
||||
pub fn list_blueprint_ids(&self) -> Result<Vec<String>, RegistryError> {
|
||||
self.list_doc_ids("blueprints")
|
||||
}
|
||||
|
||||
/// Shared latest-wins-and-dangling-skipping read of `blueprint_names.jsonl`
|
||||
/// into a name -> content_id map (`BTreeMap` gives the name-sorted order
|
||||
/// `blueprint_labels` promises for free). A garbage line (torn write,
|
||||
/// manual edit) is skipped, like every other JSONL sidecar read in this
|
||||
/// module.
|
||||
fn latest_blueprint_labels(&self) -> std::collections::BTreeMap<String, String> {
|
||||
let Ok(text) = fs::read_to_string(self.blueprint_names_path()) else {
|
||||
return std::collections::BTreeMap::new();
|
||||
};
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
for raw in text.lines() {
|
||||
let Ok(line) = serde_json::from_str::<BlueprintNameLine>(raw) else { continue };
|
||||
if self.get_blueprint(&line.content_id).ok().flatten().is_some() {
|
||||
map.insert(line.name, line.content_id);
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// The content-addressed document-store dir for one document kind —
|
||||
/// a sibling of the runs store, like `blueprints/`.
|
||||
fn doc_dir(&self, kind: &str) -> PathBuf {
|
||||
@@ -189,6 +295,28 @@ impl Registry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every content id currently stored for one document kind — the
|
||||
/// filename stems under `doc_dir(kind)` (`doc_path`'s write-one/read-one
|
||||
/// mapping, read back), for prefix resolution (#302). A missing store
|
||||
/// dir (nothing registered yet) is treat-as-empty, not an error, the
|
||||
/// same discipline `find_blueprint_by_identity`'s scan applies to a
|
||||
/// missing blueprints dir.
|
||||
fn list_doc_ids(&self, kind: &str) -> Result<Vec<String>, RegistryError> {
|
||||
let entries = match fs::read_dir(self.doc_dir(kind)) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) => return Err(RegistryError::Io(e)),
|
||||
};
|
||||
let mut ids = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
if let Some(id) = entry.path().file_stem().and_then(|s| s.to_str().map(String::from)) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Store a canonical process document; returns its content id.
|
||||
pub fn put_process(&self, canonical_json: &str) -> Result<String, RegistryError> {
|
||||
self.put_doc("processes", canonical_json)
|
||||
@@ -199,6 +327,12 @@ impl Registry {
|
||||
self.get_doc("processes", content_id)
|
||||
}
|
||||
|
||||
/// Every content id currently registered in the process store — the
|
||||
/// candidate list `show`'s prefix resolution (#302) filters against.
|
||||
pub fn list_process_ids(&self) -> Result<Vec<String>, RegistryError> {
|
||||
self.list_doc_ids("processes")
|
||||
}
|
||||
|
||||
/// Store a canonical campaign document; returns its content id.
|
||||
pub fn put_campaign(&self, canonical_json: &str) -> Result<String, RegistryError> {
|
||||
self.put_doc("campaigns", canonical_json)
|
||||
@@ -209,6 +343,12 @@ impl Registry {
|
||||
self.get_doc("campaigns", content_id)
|
||||
}
|
||||
|
||||
/// Every content id currently registered in the campaign store — the
|
||||
/// candidate list `show`'s prefix resolution (#302) filters against.
|
||||
pub fn list_campaign_ids(&self) -> Result<Vec<String>, RegistryError> {
|
||||
self.list_doc_ids("campaigns")
|
||||
}
|
||||
|
||||
/// The store path a process document with this content id lives at —
|
||||
/// the same single mapping the put/get pair routes through, exposed so
|
||||
/// consumers never re-derive the layout.
|
||||
@@ -222,6 +362,57 @@ impl Registry {
|
||||
}
|
||||
}
|
||||
|
||||
/// One label->content mapping, one JSON object per line in
|
||||
/// `blueprint_names.jsonl` (#317) — the `IdentityIndexLine` sidecar-line
|
||||
/// shape, mirrored for the label store.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct BlueprintNameLine {
|
||||
name: String,
|
||||
content_id: String,
|
||||
}
|
||||
|
||||
/// Deterministic label-shape gate for `put_blueprint_label` (#317): nonempty
|
||||
/// after trim, no whitespace, no control characters. String shape only —
|
||||
/// never content judgement (the C29 discipline `doc_gate` also follows).
|
||||
fn gate_label_shape(name: &str) -> Result<(), RegistryError> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must be nonempty" });
|
||||
}
|
||||
if name.chars().any(char::is_whitespace) {
|
||||
return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must not contain whitespace" });
|
||||
}
|
||||
if name.chars().any(|c| c.is_control()) {
|
||||
return Err(RegistryError::BadLabel {
|
||||
name: name.to_string(),
|
||||
rule: "must not contain control characters",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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"). `pub` (#317): the `use` construction
|
||||
/// seam (`aura-cli`) re-runs this SAME walk over a freshly-fetched composite
|
||||
/// before it enters a new composition (fail fast on a doc-less fetched
|
||||
/// entry) — the one walk, two call sites (register's write-path gate, use's
|
||||
/// read-path gate), never a second implementation.
|
||||
pub 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)]
|
||||
@@ -230,7 +421,18 @@ pub enum RefFault {
|
||||
StrategyNotFound(String),
|
||||
IdentityUnmatched(String),
|
||||
StrategyUnloadable { id: String, error: String },
|
||||
AxisNotInParamSpace { strategy: String, axis: String },
|
||||
AxisNotInParamSpace {
|
||||
strategy: String,
|
||||
axis: String,
|
||||
/// #328 translation aid: stripping ONE leading segment off `axis`
|
||||
/// (the wrapped `<blueprint>.<node>.<param>` shape a stale transcript
|
||||
/// might quote) and finding that remainder in the strategy's
|
||||
/// `param_space()` or `bound_param_space()` — `Some` iff the strip-
|
||||
/// then-hit lands, computed at fault-construction time so the prose
|
||||
/// layer never re-derives it. `None` when no leading segment strips
|
||||
/// to a hit (today's prose stays unchanged, no speculation).
|
||||
raw_candidate: Option<String>,
|
||||
},
|
||||
AxisKindMismatch { strategy: String, axis: String },
|
||||
/// An open param of the resolved strategy is bound by no campaign axis —
|
||||
/// the executor's every-open-knob-required rule, mirrored at validate
|
||||
@@ -332,10 +534,22 @@ impl Registry {
|
||||
});
|
||||
}
|
||||
}
|
||||
None => faults.push(RefFault::AxisNotInParamSpace {
|
||||
strategy: label.clone(),
|
||||
axis: axis.clone(),
|
||||
}),
|
||||
None => {
|
||||
// #328 did-you-mean: strip one leading segment off the
|
||||
// rejected axis (the wrapped-name shape a stale
|
||||
// transcript might quote) and check whether the
|
||||
// remainder is a legal raw axis here — never
|
||||
// speculating when it isn't.
|
||||
let raw_candidate = axis.split_once('.').map(|(_, rest)| rest).filter(|stripped| {
|
||||
space.iter().any(|p| p.name == *stripped)
|
||||
|| bound.iter().any(|b| b.name == *stripped)
|
||||
}).map(str::to_string);
|
||||
faults.push(RefFault::AxisNotInParamSpace {
|
||||
strategy: label.clone(),
|
||||
axis: axis.clone(),
|
||||
raw_candidate,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// The reverse direction: every open param must be bound by some
|
||||
@@ -860,6 +1074,22 @@ 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 },
|
||||
/// Label sidecar (#317): a `put_blueprint_label` name failing the
|
||||
/// deterministic label-shape gate (nonempty after trim, no whitespace, no
|
||||
/// control characters). By-identifier, naming the violated rule.
|
||||
BadLabel { name: String, rule: &'static str },
|
||||
/// Label sidecar (#317): a `put_blueprint_label` content id that does not
|
||||
/// resolve via [`Registry::get_blueprint`] — the label sidecar never
|
||||
/// labels a blueprint the store does not have.
|
||||
UnknownBlueprint(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for RegistryError {
|
||||
@@ -883,6 +1113,29 @@ 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)"
|
||||
),
|
||||
},
|
||||
RegistryError::BadLabel { name, rule } => {
|
||||
write!(f, "blueprint label {name:?}: {rule}")
|
||||
}
|
||||
RegistryError::UnknownBlueprint(id) => {
|
||||
write!(f, "blueprint: no stored blueprint with content id \"{id}\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1033,13 +1286,178 @@ 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}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A described fixture blueprint, ready for `put_blueprint`'s C29 gate —
|
||||
/// `put_blueprint_label`'s content-id verification routes through
|
||||
/// `get_blueprint`, so its target must first be a gate-passing entry.
|
||||
fn described_fixture_blueprint(name: &str) -> String {
|
||||
format!(
|
||||
r#"{{"format_version":1,"blueprint":{{"name":"{name}","doc":"a described fixture blueprint","nodes":[]}}}}"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Property (#317): a label round-trips to the blueprint it names, and
|
||||
/// re-labelling the SAME name repoints it — resolution and the discovery
|
||||
/// listing both answer with the latest target, never the first.
|
||||
#[test]
|
||||
fn blueprint_label_round_trips_latest_wins() {
|
||||
let reg = Registry::open(temp_family_dir("label_round_trip"));
|
||||
let a = described_fixture_blueprint("a");
|
||||
let b = described_fixture_blueprint("b");
|
||||
reg.put_blueprint("ha", &a).expect("put a");
|
||||
reg.put_blueprint("hb", &b).expect("put b");
|
||||
|
||||
reg.put_blueprint_label("x", "ha").expect("label x -> a");
|
||||
assert_eq!(reg.resolve_blueprint_label("x"), Some("ha".to_string()));
|
||||
|
||||
reg.put_blueprint_label("x", "hb").expect("re-label x -> b");
|
||||
assert_eq!(reg.resolve_blueprint_label("x"), Some("hb".to_string()), "latest label wins");
|
||||
assert_eq!(
|
||||
reg.blueprint_labels(),
|
||||
vec![("x".to_string(), "hb".to_string())],
|
||||
"the discovery listing dedups to the latest target",
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#317): the label-shape gate is deterministic string shape
|
||||
/// only (nonempty after trim, no whitespace, no control characters) —
|
||||
/// each bad shape refuses with `BadLabel` naming a (non-empty) rule.
|
||||
#[test]
|
||||
fn blueprint_label_refuses_bad_shapes() {
|
||||
let reg = Registry::open(temp_family_dir("label_bad_shapes"));
|
||||
let bp = described_fixture_blueprint("s");
|
||||
reg.put_blueprint("h1", &bp).expect("put");
|
||||
|
||||
for bad in ["", " ", "a b", "a\tb"] {
|
||||
match reg.put_blueprint_label(bad, "h1") {
|
||||
Err(RegistryError::BadLabel { name, rule }) => {
|
||||
assert_eq!(name, bad);
|
||||
assert!(!rule.is_empty(), "BadLabel must name the violated rule for {bad:?}");
|
||||
}
|
||||
other => panic!("expected BadLabel for {bad:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Property (#317): a label may only ever point at a blueprint the store
|
||||
/// actually has — labelling an unregistered content id refuses
|
||||
/// by-identifier, never silently writing a dangling line.
|
||||
#[test]
|
||||
fn blueprint_label_refuses_unknown_content_id() {
|
||||
let reg = Registry::open(temp_family_dir("label_unknown_id"));
|
||||
match reg.put_blueprint_label("x", "never-written") {
|
||||
Err(RegistryError::UnknownBlueprint(id)) => assert_eq!(id, "never-written"),
|
||||
other => panic!("expected UnknownBlueprint, got {other:?}"),
|
||||
}
|
||||
assert_eq!(reg.resolve_blueprint_label("x"), None, "the refused label must not have written");
|
||||
}
|
||||
|
||||
/// Property (#317, #191 spirit): a dangling LATEST line (content id no
|
||||
/// longer resolves) is skipped by resolution, which falls back to the
|
||||
/// earlier still-valid line for the same name — latest-wins means
|
||||
/// latest-VALID-wins.
|
||||
#[test]
|
||||
fn blueprint_label_resolution_skips_a_dangling_entry() {
|
||||
let reg = Registry::open(temp_family_dir("label_dangling"));
|
||||
let bp = described_fixture_blueprint("s");
|
||||
reg.put_blueprint("h1", &bp).expect("put");
|
||||
reg.put_blueprint_label("x", "h1").expect("label x -> h1");
|
||||
|
||||
// hand-append a dangling line after the valid one, mirroring
|
||||
// `append_identity_index`'s stale-line test shape: the content id it
|
||||
// names was never registered.
|
||||
reg.append_blueprint_label_line("x", "never-written").expect("append stale line");
|
||||
|
||||
assert_eq!(
|
||||
reg.resolve_blueprint_label("x"),
|
||||
Some("h1".to_string()),
|
||||
"the dangling latest line is skipped; the earlier valid line for \"x\" wins",
|
||||
);
|
||||
assert_eq!(reg.blueprint_labels(), vec![("x".to_string(), "h1".to_string())]);
|
||||
}
|
||||
|
||||
/// Property (#317, #276 discipline): N threads appending labels through
|
||||
/// one shared `&Registry` must leave `blueprint_names.jsonl` well-formed —
|
||||
/// every successful append surviving as its own intact, resolvable line,
|
||||
/// none torn or merged (mirrors `concurrent_appends_keep_the_family_
|
||||
/// store_well_formed`).
|
||||
#[test]
|
||||
fn concurrent_label_appends_keep_the_sidecar_well_formed() {
|
||||
let reg = Registry::open(temp_family_dir("concurrent_label"));
|
||||
let bp = described_fixture_blueprint("s");
|
||||
reg.put_blueprint("h1", &bp).expect("put");
|
||||
let n_threads = 8usize;
|
||||
let iters = 50usize;
|
||||
|
||||
let total_ok: usize = std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = (0..n_threads)
|
||||
.map(|tid| {
|
||||
let reg = ®
|
||||
scope.spawn(move || {
|
||||
let mut ok = 0usize;
|
||||
for iter in 0..iters {
|
||||
let name = format!("t{tid}-l{iter}");
|
||||
if reg.put_blueprint_label(&name, "h1").is_ok() {
|
||||
ok += 1;
|
||||
}
|
||||
}
|
||||
ok
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles.into_iter().map(|h| h.join().expect("worker thread joined")).sum()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
reg.blueprint_labels().len(),
|
||||
total_ok,
|
||||
"every successful label append must survive as its own intact, resolvable entry",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_stores_round_trip_by_content_id() {
|
||||
let reg = Registry::open(temp_family_dir("document_store_round_trip"));
|
||||
@@ -1759,21 +2177,20 @@ mod tests {
|
||||
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
|
||||
|
||||
// A minimal fixture composite with one OPEN param, built from a
|
||||
// zero-arg `aura-std` node (`Bias`) so `std_vocabulary` can actually
|
||||
// plain `aura-std` node (`Bias`) so `std_vocabulary` can actually
|
||||
// resolve it on load. `aura-composites`' shipped composites (vol_stop,
|
||||
// risk_executor*) all route through `LinComb`, whose builder needs a
|
||||
// structural arity argument and is therefore deliberately absent from
|
||||
// the zero-arg `std_vocabulary` roster (see aura-std/src/vocabulary.rs)
|
||||
// — they cannot round-trip through `blueprint_from_json` with this
|
||||
// resolver, so this fixture stands in as the "real, open-param,
|
||||
// vocabulary-loadable composite" the test needs.
|
||||
// risk_executor*) route through `LinComb`, an arg-bearing type (#271,
|
||||
// `aura-vocabulary/src/lib.rs`) — using `Bias` here keeps this fixture
|
||||
// independent of that construction channel, so it stands in as the
|
||||
// "real, open-param, vocabulary-loadable composite" the test needs.
|
||||
let composite = Composite::new(
|
||||
"fixture",
|
||||
vec![aura_strategy::Bias::builder().named("b").into()],
|
||||
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();
|
||||
@@ -1857,6 +2274,98 @@ mod tests {
|
||||
assert_eq!(reg.validate_campaign_refs(&by_identity, &resolve).expect("io ok"), Vec::new());
|
||||
}
|
||||
|
||||
/// #328: a rejected axis shaped like a stale wrapped transcript (one
|
||||
/// bogus leading segment glued in front of a real raw param name) carries
|
||||
/// that real name as `AxisNotInParamSpace::raw_candidate` — computed at
|
||||
/// fault-construction time in `validate_campaign_refs`, never re-derived
|
||||
/// by the prose layer.
|
||||
#[test]
|
||||
fn axis_not_in_param_space_carries_a_raw_candidate_when_strippable() {
|
||||
use aura_engine::blueprint_to_json;
|
||||
|
||||
let reg = Registry::open(temp_family_dir("axis_raw_candidate_hit"));
|
||||
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
|
||||
let composite = bias_fixture();
|
||||
let real = composite.param_space().first().expect("fixture has an open param").clone();
|
||||
let real_name = real.name.clone();
|
||||
|
||||
let blueprint_json = blueprint_to_json(&composite).expect("serializes");
|
||||
let bp_id = aura_research::content_id_of(&blueprint_json);
|
||||
reg.put_blueprint(&bp_id, &blueprint_json).expect("seed blueprint");
|
||||
let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
|
||||
let proc_id = reg.put_process(process).expect("seed process");
|
||||
|
||||
let wrapped_axis = format!("graph.{real_name}");
|
||||
let campaign_text = format!(
|
||||
concat!(
|
||||
r#"{{"format_version":1,"kind":"campaign","name":"c","#,
|
||||
r#""data":{{"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#,
|
||||
r#""strategies":[{{"ref":{{"content_id":"{bp}"}},"#,
|
||||
r#""axes":{{"{axis}":{{"kind":"F64","values":[0.5]}}}}}}],"#,
|
||||
r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#,
|
||||
r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"#
|
||||
),
|
||||
bp = bp_id,
|
||||
axis = wrapped_axis,
|
||||
proc = proc_id,
|
||||
);
|
||||
let doc = aura_research::parse_campaign(&campaign_text).expect("campaign parses");
|
||||
let faults = reg.validate_campaign_refs(&doc, &resolve).expect("io ok");
|
||||
let hit = faults
|
||||
.iter()
|
||||
.find(|f| matches!(f, RefFault::AxisNotInParamSpace { axis, .. } if axis == &wrapped_axis));
|
||||
match hit {
|
||||
Some(RefFault::AxisNotInParamSpace { raw_candidate, .. }) => {
|
||||
assert_eq!(raw_candidate.as_deref(), Some(real_name.as_str()));
|
||||
}
|
||||
other => panic!("expected AxisNotInParamSpace for {wrapped_axis:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// #328 (negative): a rejected axis whose stripped remainder ALSO misses
|
||||
/// the param space carries no `raw_candidate` — the fault never
|
||||
/// speculates when stripping one leading segment lands nowhere.
|
||||
#[test]
|
||||
fn axis_not_in_param_space_carries_no_candidate_when_stripped_remainder_also_misses() {
|
||||
use aura_engine::blueprint_to_json;
|
||||
|
||||
let reg = Registry::open(temp_family_dir("axis_raw_candidate_miss"));
|
||||
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
|
||||
let composite = bias_fixture();
|
||||
|
||||
let blueprint_json = blueprint_to_json(&composite).expect("serializes");
|
||||
let bp_id = aura_research::content_id_of(&blueprint_json);
|
||||
reg.put_blueprint(&bp_id, &blueprint_json).expect("seed blueprint");
|
||||
let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
|
||||
let proc_id = reg.put_process(process).expect("seed process");
|
||||
|
||||
let bogus_axis = "bogus.alsobogus";
|
||||
let campaign_text = format!(
|
||||
concat!(
|
||||
r#"{{"format_version":1,"kind":"campaign","name":"c","#,
|
||||
r#""data":{{"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#,
|
||||
r#""strategies":[{{"ref":{{"content_id":"{bp}"}},"#,
|
||||
r#""axes":{{"{axis}":{{"kind":"F64","values":[0.5]}}}}}}],"#,
|
||||
r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#,
|
||||
r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"#
|
||||
),
|
||||
bp = bp_id,
|
||||
axis = bogus_axis,
|
||||
proc = proc_id,
|
||||
);
|
||||
let doc = aura_research::parse_campaign(&campaign_text).expect("campaign parses");
|
||||
let faults = reg.validate_campaign_refs(&doc, &resolve).expect("io ok");
|
||||
let hit = faults
|
||||
.iter()
|
||||
.find(|f| matches!(f, RefFault::AxisNotInParamSpace { axis, .. } if axis == bogus_axis));
|
||||
match hit {
|
||||
Some(RefFault::AxisNotInParamSpace { raw_candidate, .. }) => {
|
||||
assert_eq!(raw_candidate, &None);
|
||||
}
|
||||
other => panic!("expected AxisNotInParamSpace for {bogus_axis:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Property (#191): the identity-index sidecar is a last-line-wins cache
|
||||
/// over identity id → content id — a missing file reads as empty (no
|
||||
/// error, since the scan is the truth path), a later append for the same
|
||||
@@ -1904,6 +2413,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
|
||||
@@ -1918,6 +2428,7 @@ mod tests {
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
.with_doc("fixture composite for the registry seeder test")
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2058,7 +2569,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");
|
||||
@@ -2113,7 +2625,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");
|
||||
@@ -2206,7 +2719,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"
|
||||
@@ -2279,7 +2793,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");
|
||||
|
||||
@@ -31,6 +31,11 @@ pub enum FamilyKind {
|
||||
Sweep,
|
||||
MonteCarlo,
|
||||
WalkForward,
|
||||
/// Dead machinery since the #319 sugar retirement (C18): no producer
|
||||
/// mints a merged cross-instrument family anymore — the campaign-run
|
||||
/// record's `generalizations[]` carries the data. The variant stays for
|
||||
/// store read-back of historically minted families (never retroactively
|
||||
/// invalidated, C29).
|
||||
CrossInstrument,
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+291
-45
@@ -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.
|
||||
@@ -295,10 +295,17 @@ fn select_from(
|
||||
) -> Result<SelectRule, String> {
|
||||
let s = require_str(m, "select", block)?;
|
||||
// SelectRule's own derived Deserialize (which carries the #[serde(rename)]
|
||||
// wire strings) is the type oracle, mirroring kind_tag/scalar_from_bare:
|
||||
// no second hardcoded copy of the three select-rule wire strings.
|
||||
serde_json::from_value(serde_json::Value::String(s.clone()))
|
||||
.map_err(|_| format!("block {block}: unknown select rule \"{s}\""))
|
||||
// wire strings) is the type oracle for *parsing*, mirroring
|
||||
// kind_tag/scalar_from_bare. The refusal's roster text below is sourced
|
||||
// from slot_kind_label(SlotKind::SelectRule) rather than a second
|
||||
// hardcoded copy of the three wire strings: CLI describe output and the
|
||||
// document refusal stay in sync by construction (add a SelectRule
|
||||
// variant, update one string).
|
||||
serde_json::from_value(serde_json::Value::String(s.clone())).map_err(|_| {
|
||||
let roster =
|
||||
slot_kind_label(SlotKind::SelectRule).trim_start_matches("select rule: ");
|
||||
format!("block {block}: unknown select rule \"{s}\" (accepted: {roster})")
|
||||
})
|
||||
}
|
||||
|
||||
fn mode_from(
|
||||
@@ -506,16 +513,17 @@ pub struct Axis {
|
||||
}
|
||||
|
||||
/// A protective-stop regime: a serializable, content-addressable mirror of the
|
||||
/// runtime `StopRule` structural axis (C10/C20). The sole implemented variant is
|
||||
/// the vol-stop; the shipped fixed-stop rule is admitted as a future additive
|
||||
/// variant (it runs as a composite today but is not yet campaign-reachable).
|
||||
/// Externally tagged so adding `Fixed` is additive — no content-id churn on
|
||||
/// stored `Vol` regimes.
|
||||
/// runtime `StopRule` structural axis (C10/C20). `Fixed{distance}` (#338) makes
|
||||
/// the shipped `FixedStop` composite campaign-reachable, beside the vol-stop
|
||||
/// family — `distance` is the same price-unit knob `FixedStop`'s own `distance`
|
||||
/// param carries. Externally tagged so each variant was additive on arrival —
|
||||
/// no content-id churn on stored `Vol`/`VolTf` regimes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||
pub enum RiskRegime {
|
||||
Vol { length: i64, k: f64 },
|
||||
VolTf { period_minutes: i64, length: i64, k: f64 },
|
||||
Fixed { distance: f64 },
|
||||
}
|
||||
|
||||
/// One component of the campaign's cost model (#234): a closed, additive
|
||||
@@ -720,6 +728,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
|
||||
@@ -739,25 +755,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" },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -766,12 +782,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
|
||||
@@ -814,16 +841,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);
|
||||
}
|
||||
@@ -887,6 +922,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);
|
||||
}
|
||||
@@ -934,6 +974,14 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
|
||||
faults.push(DocFault::BadRegime { index: i });
|
||||
}
|
||||
}
|
||||
RiskRegime::Fixed { distance } => {
|
||||
// Mirrors `FixedStop::new`'s own `distance > 0.0` assert
|
||||
// (`aura-strategy`) — refused gracefully here instead of
|
||||
// reaching that assert and panicking.
|
||||
if *distance <= 0.0 || distance.is_nan() {
|
||||
faults.push(DocFault::BadRegime { index: i });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, c) in doc.cost.iter().enumerate() {
|
||||
@@ -993,7 +1041,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() });
|
||||
}
|
||||
}
|
||||
@@ -1063,7 +1111,7 @@ pub fn slot_kind_label(kind: SlotKind) -> &'static str {
|
||||
SlotKind::Strings => "list of strings",
|
||||
SlotKind::Windows => "list of { from_ms, to_ms }",
|
||||
SlotKind::Ref => "one of { content_id } | { identity_id }",
|
||||
SlotKind::ContentRef => "content id of a process document",
|
||||
SlotKind::ContentRef => "{ content_id }: content id of a process document",
|
||||
SlotKind::Axes => "map: param name -> { kind, values }",
|
||||
SlotKind::EmitKinds => "list of: family_table | selection_report",
|
||||
SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity",
|
||||
@@ -1262,20 +1310,39 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
|
||||
}
|
||||
if ref_slot_is_open(v.get("process").and_then(|p| p.get("ref"))) {
|
||||
// deliberately narrower than the strategy-ref hint: validate_campaign
|
||||
// refuses an identity_id process ref, so the guide must not offer it
|
||||
slots.push(open("process.ref", "required, content id of a process document"));
|
||||
// refuses an identity_id process ref, so the guide must not offer it.
|
||||
// The tagged { content_id } shape is stated explicitly (#329) so the
|
||||
// hint cannot be misread as accepting a bare id string.
|
||||
slots.push(open("process.ref", "required, { content_id }: content id of a process document"));
|
||||
}
|
||||
if v.get("seed").and_then(|s| s.as_u64()).is_none() {
|
||||
slots.push(open("seed", "required, non-negative integer"));
|
||||
}
|
||||
if v.get("presentation").is_none() {
|
||||
slots.push(open(
|
||||
match v.get("presentation") {
|
||||
None => slots.push(open(
|
||||
"presentation",
|
||||
format!(
|
||||
"required section: persist_taps ({}) + emit",
|
||||
tap_vocabulary().join(" | ")
|
||||
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
|
||||
),
|
||||
));
|
||||
)),
|
||||
Some(p) => {
|
||||
if p.get("persist_taps").and_then(|x| x.as_array()).is_none() {
|
||||
slots.push(open(
|
||||
"presentation.persist_taps",
|
||||
format!(
|
||||
"required, list of: {}",
|
||||
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
|
||||
),
|
||||
));
|
||||
}
|
||||
if p.get("emit").and_then(|x| x.as_array()).is_none() {
|
||||
slots.push(open(
|
||||
"presentation.emit",
|
||||
format!("required, list of: {}", emit_vocabulary().join(" | ")),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(slots)
|
||||
}
|
||||
@@ -1287,6 +1354,14 @@ fn envelope_open_slots(v: &serde_json::Value, kind: &str, slots: &mut Vec<OpenSl
|
||||
if v.get("kind").and_then(|k| k.as_str()) != Some(kind) {
|
||||
slots.push(open("kind", format!("required, must be \"{kind}\"")));
|
||||
}
|
||||
// C29 (#323): the description slot is enumerable like every other
|
||||
// envelope slot — optional, so only listed while absent.
|
||||
if v.get("description").is_none() {
|
||||
slots.push(open(
|
||||
"description",
|
||||
"optional, string — a one-line meaning (C29-gated when present)",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1381,10 +1456,57 @@ mod tests {
|
||||
assert!(matches!(parse_process("not json"), Err(DocError::NotJson(_))));
|
||||
}
|
||||
|
||||
/// #301 (fieldtest cycle-300 df_3): the unknown-select-rule refusal must
|
||||
/// teach the accepted vocabulary, not just refuse — an author who learned
|
||||
/// bare `plateau` at the CLI pastes it into a document and needs the
|
||||
/// roster (argmax | plateau:mean | plateau:worst) in the refusal prose to
|
||||
/// self-serve the fix. The schema stays strict: bare "plateau" remains
|
||||
/// refused in documents (#300 F7 — it is a CLI-only argv alias), and the
|
||||
/// prose stays sparse — the roster only, no alias-bridging sentence.
|
||||
#[test]
|
||||
fn unknown_select_rule_refusal_enumerates_accepted_rules() {
|
||||
let bare_plateau = PROCESS_FIXTURE.replacen("plateau:worst", "plateau", 1);
|
||||
let err =
|
||||
parse_process(&bare_plateau).expect_err("bare plateau stays refused in documents");
|
||||
let DocError::Malformed(msg) = err else {
|
||||
panic!("expected DocError::Malformed, got {err:?}");
|
||||
};
|
||||
// The existing contract survives: the offending value stays named, quoted.
|
||||
assert!(
|
||||
msg.contains("unknown select rule \"plateau\""),
|
||||
"offending value no longer named: {msg}"
|
||||
);
|
||||
// The new behaviour: the refusal enumerates the accepted rules.
|
||||
for accepted in ["argmax", "plateau:mean", "plateau:worst"] {
|
||||
assert!(
|
||||
msg.contains(accepted),
|
||||
"accepted rule {accepted:?} not enumerated in the refusal: {msg}"
|
||||
);
|
||||
}
|
||||
// Precise and sparse: the roster alone rescues the paste case — no
|
||||
// alias-hint sentence bridging plateau -> plateau:mean.
|
||||
assert!(!msg.contains("alias"), "refusal grew an alias hint: {msg}");
|
||||
}
|
||||
|
||||
/// The refusal derives its roster from slot_kind_label by trimming the
|
||||
/// "select rule: " prefix. That trim is a positional contract: if the
|
||||
/// label's wording ever drifts, trim_start_matches degrades to a no-op
|
||||
/// and the refusal grows a redundant prefix without any test noticing.
|
||||
/// This pin makes that drift loud.
|
||||
#[test]
|
||||
fn select_rule_label_keeps_the_prefix_the_refusal_trims() {
|
||||
assert!(
|
||||
slot_kind_label(SlotKind::SelectRule).starts_with("select rule: "),
|
||||
"slot_kind_label(SelectRule) no longer starts with \"select rule: \" — \
|
||||
update select_from's roster derivation alongside the label"
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) const CAMPAIGN_FIXTURE: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "ger40-momentum-screen",
|
||||
"description": "GER40 momentum screen over the fast/slow SMA grid.",
|
||||
"data": {
|
||||
"instruments": ["GER40"],
|
||||
"windows": [ { "from_ms": 1704067200000, "to_ms": 1719792000000 } ]
|
||||
@@ -1553,6 +1675,18 @@ mod tests {
|
||||
assert_eq!(back, r);
|
||||
}
|
||||
|
||||
/// #338: the third variant's wire form follows the same externally-tagged
|
||||
/// convention as `vol`/`vol_tf` — `fixed` snake_case tag, one named field
|
||||
/// (the `FixedStop` composite's own `distance` param).
|
||||
#[test]
|
||||
fn risk_regime_round_trips_as_externally_tagged_fixed() {
|
||||
let r = RiskRegime::Fixed { distance: 10.0 };
|
||||
let j = serde_json::to_string(&r).unwrap();
|
||||
assert_eq!(j, r#"{"fixed":{"distance":10.0}}"#);
|
||||
let back: RiskRegime = serde_json::from_str(&j).unwrap();
|
||||
assert_eq!(back, r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_absent_and_empty_risk_omit_from_canonical_bytes() {
|
||||
let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
|
||||
@@ -1611,6 +1745,26 @@ mod tests {
|
||||
assert!(!faults.contains(&DocFault::BadRegime { index: 0 }), "valid regime not flagged: {faults:?}");
|
||||
}
|
||||
|
||||
/// #338 (harvest audit item 11 extends the NaN branch): a non-positive OR
|
||||
/// NaN fixed-stop distance must be refused as a graceful
|
||||
/// `DocFault::BadRegime` at the doc tier — otherwise it reaches
|
||||
/// `FixedStop::new`'s `distance > 0.0` assert and panics instead of a
|
||||
/// validation error (the `validate_campaign_flags_non_positive_period_minutes_regime`
|
||||
/// precedent above, `Fixed` edition).
|
||||
#[test]
|
||||
fn validate_campaign_flags_non_positive_fixed_distance_regime() {
|
||||
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
|
||||
doc.risk = vec![
|
||||
RiskRegime::Fixed { distance: 10.0 }, // 0: valid
|
||||
RiskRegime::Fixed { distance: 0.0 }, // 1: distance <= 0
|
||||
RiskRegime::Fixed { distance: f64::NAN }, // 2: NaN (unreachable via JSON; defensive)
|
||||
];
|
||||
let faults = validate_campaign(&doc);
|
||||
assert!(faults.contains(&DocFault::BadRegime { index: 1 }), "{faults:?}");
|
||||
assert!(faults.contains(&DocFault::BadRegime { index: 2 }), "{faults:?}");
|
||||
assert!(!faults.contains(&DocFault::BadRegime { index: 0 }), "valid regime not flagged: {faults:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_campaign_accepts_a_valid_risk_section() {
|
||||
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
|
||||
@@ -1828,6 +1982,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();
|
||||
@@ -2019,7 +2223,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
|
||||
@@ -2039,7 +2244,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());
|
||||
}
|
||||
|
||||
@@ -2104,7 +2309,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),
|
||||
@@ -2135,7 +2343,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!(
|
||||
@@ -2144,6 +2352,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #319 fieldtest bug 1: a present-but-empty `presentation: {}` must drill
|
||||
/// down into its two required sub-slots exactly as a present-but-empty
|
||||
/// `data: {}` drills into `instruments`/`windows` — the walker must not
|
||||
/// stop at "the section exists" without checking what's inside it.
|
||||
#[test]
|
||||
fn presentation_drills_into_an_empty_section_like_data_does() {
|
||||
let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft",
|
||||
"data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
|
||||
"strategies": [ { "ref": { "content_id": "9f3a" },
|
||||
"axes": { "slow": { "kind": "I64", "values": [10] } } } ],
|
||||
"process": { "ref": { "content_id": "4e2d" } },
|
||||
"seed": 1,
|
||||
"presentation": {} }"#;
|
||||
let slots = open_slots_campaign(draft).unwrap();
|
||||
assert!(
|
||||
!slots.iter().any(|s| s.path == "presentation"),
|
||||
"an empty-but-present presentation is not the same open slot as an absent one: {slots:?}"
|
||||
);
|
||||
assert!(
|
||||
slots.iter().any(|s| s.path == "presentation.persist_taps"
|
||||
&& s.hint == "required, list of: equity | exposure | r_equity | net_r_equity"),
|
||||
"presentation.persist_taps must be named as its own open slot: {slots:?}"
|
||||
);
|
||||
assert!(
|
||||
slots.iter().any(|s| s.path == "presentation.emit"
|
||||
&& s.hint == "required, list of: family_table | selection_report"),
|
||||
"presentation.emit must be named as its own open slot: {slots:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #256 fork B: the fieldless enumerate-only stage round-trips through
|
||||
/// the schema-strict parser, and any slot on it is refused (its slot
|
||||
/// list is empty, so the generic unknown-slot check covers every key).
|
||||
@@ -2226,6 +2464,14 @@ mod tests {
|
||||
label.contains("content"),
|
||||
"process_ref describe must still name the content-id form: {label}"
|
||||
);
|
||||
// #329: the label must state the TAGGED shape ({ content_id: ... }) the
|
||||
// parser actually requires, not read like a bare id string — mirroring
|
||||
// the `{ content_id } | { identity_id }` bracket notation std::strategy
|
||||
// already uses, narrowed to the one key process.ref accepts.
|
||||
assert!(
|
||||
label.contains("{ content_id }"),
|
||||
"process_ref describe must state the tagged {{ content_id }} shape, not a bare id: {label}"
|
||||
);
|
||||
|
||||
let strategy = describe_block("std::strategy").expect("strategy describable");
|
||||
let strat_ref =
|
||||
@@ -2282,7 +2528,7 @@ mod tests {
|
||||
"presentation": { "persist_taps": [], "emit": [] } }"#;
|
||||
let slots = open_slots_campaign(draft).unwrap();
|
||||
assert!(slots.iter().any(|s| s.path == "process.ref"
|
||||
&& s.hint == "required, content id of a process document"));
|
||||
&& s.hint == "required, { content_id }: content id of a process document"));
|
||||
assert!(slots.iter().any(|s| s.path == "strategies[0].axes.slow"
|
||||
&& s.hint == "axis declared empty — a P1 axis is a non-empty finite set"));
|
||||
|
||||
@@ -2318,7 +2564,7 @@ mod tests {
|
||||
// ...and the `{}` process ref reports the narrower content-id-only hint.
|
||||
assert!(
|
||||
slots.iter().any(|s| s.path == "process.ref"
|
||||
&& s.hint == "required, content id of a process document"),
|
||||
&& s.hint == "required, { content_id }: content id of a process document"),
|
||||
"process `ref: {{}}` placeholder not reported as an open slot: {slots:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,76 @@ pub fn raw_matches_wrapped(raw: &str, wrapped: &str) -> bool {
|
||||
wrapped == raw || wrapped_to_raw_axis(wrapped) == raw
|
||||
}
|
||||
|
||||
/// (a) #328: the explicit `--axis` acceptance predicate, shared by BOTH
|
||||
/// intake routes (`validate_and_register_axes`'s real route and the synthetic
|
||||
/// sweep-verb intake ahead of `run_blueprint_sweep`). RAW is checked FIRST and
|
||||
/// independently — a legal RAW name (an open param's [`wrapped_to_raw_axis`]
|
||||
/// suffix, or a bound param's own already-raw name, #203) is accepted
|
||||
/// outright, so a pathological name that is both raw-legal and wrapped-exact
|
||||
/// (e.g. an unwrapped param) resolves as raw, never as a translation refusal.
|
||||
/// Only once that fails is a WRAPPED hit considered (an exact `param_space()`
|
||||
/// name, or one leading segment stripped off `name` landing on a bound
|
||||
/// param): that is a translation refusal naming the raw candidate, never a
|
||||
/// silent alias. Anything matching neither namespace is [`AxisIntake::Unknown`]
|
||||
/// — the caller's own unmatched-axis prose applies, unchanged.
|
||||
///
|
||||
/// Deliberately NOT built on [`raw_matches_wrapped`]: that predicate's own
|
||||
/// equality branch also matches an EXACT wrapped name (see its doc comment),
|
||||
/// so using it as the acceptance gate would silently accept the retired form
|
||||
/// instead of refusing it.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum AxisIntake {
|
||||
/// A legal RAW name (`aura graph introspect --params`'s own namespace,
|
||||
/// #328) — accept as-is.
|
||||
Raw,
|
||||
/// A retired WRAPPED name; the carried string is its translated RAW
|
||||
/// candidate — never applied silently, only ever surfaced in a refusal.
|
||||
WrappedRetired(String),
|
||||
/// Neither namespace: the caller's own unknown-axis prose applies.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Classify one `--axis` name against `wrapped_open` (the WRAPPED open-param
|
||||
/// probe, `blueprint_axis_probe(..).param_space()`) and `raw_bound` (the
|
||||
/// strategy's own `bound_param_space()` names — already RAW, #203). See
|
||||
/// [`AxisIntake`] for the precedence.
|
||||
pub fn classify_axis_intake(
|
||||
name: &str,
|
||||
wrapped_open: &[ParamSpec],
|
||||
raw_bound: &HashSet<String>,
|
||||
) -> AxisIntake {
|
||||
let is_raw = wrapped_open.iter().any(|p| wrapped_to_raw_axis(&p.name) == name)
|
||||
|| raw_bound.contains(name);
|
||||
if is_raw {
|
||||
return AxisIntake::Raw;
|
||||
}
|
||||
let is_wrapped = wrapped_open.iter().any(|p| p.name == name)
|
||||
|| raw_bound.iter().any(|b| name.split_once('.').map(|(_, rest)| rest) == Some(b.as_str()));
|
||||
if is_wrapped {
|
||||
AxisIntake::WrappedRetired(wrapped_to_raw_axis(name).to_string())
|
||||
} else {
|
||||
AxisIntake::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate one RAW axis name onto the ONE wrapped `space` slot it
|
||||
/// suffix-matches ([`raw_matches_wrapped`]), falling back to `raw` unchanged
|
||||
/// when no wrapped slot matches — the caller's own downstream name
|
||||
/// resolution (e.g. the sweep terminal's `UnknownKnob`) surfaces that case,
|
||||
/// never panicked here. #328: the shared translation `blueprint_sweep_family`
|
||||
/// established for its `SweepBinder::axis` call, extracted so
|
||||
/// `blueprint_sweep_over`/`validate_axis_grid` (the walkforward synthetic
|
||||
/// route's own binder calls, which key by the same exact wrapped
|
||||
/// `param_space()` name) can translate a RAW `--axis` name too instead of
|
||||
/// requiring the retired wrapped form.
|
||||
pub fn wrapped_name_of(raw: &str, space: &[ParamSpec]) -> String {
|
||||
space
|
||||
.iter()
|
||||
.find(|p| raw_matches_wrapped(raw, &p.name))
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| raw.to_string())
|
||||
}
|
||||
|
||||
/// Suffix-join each raw campaign axis onto exactly one wrapped param
|
||||
/// ([`raw_matches_wrapped`] — wrapped == raw, or stripping the wrapper's one
|
||||
/// node segment yields raw), then require every wrapped slot to be covered.
|
||||
@@ -139,6 +209,53 @@ mod tests {
|
||||
ParamSpec { name: name.to_string(), kind: ScalarKind::I64 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #328: a legal RAW name — either an open param's raw suffix or a bound
|
||||
/// param's own (already-raw) name — classifies `Raw`, accepted as-is.
|
||||
fn classify_axis_intake_accepts_a_raw_open_or_bound_name() {
|
||||
let wrapped_open = vec![spec("sma_signal.fast.length")];
|
||||
let raw_bound: HashSet<String> = ["bias.scale".to_string()].into_iter().collect();
|
||||
assert_eq!(classify_axis_intake("fast.length", &wrapped_open, &raw_bound), AxisIntake::Raw);
|
||||
assert_eq!(classify_axis_intake("bias.scale", &wrapped_open, &raw_bound), AxisIntake::Raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #328: a WRAPPED name — the exact `param_space()` string, or a bound
|
||||
/// param prefixed by one wrap segment — classifies `WrappedRetired`,
|
||||
/// carrying its raw candidate, never accepted silently.
|
||||
fn classify_axis_intake_flags_a_wrapped_name_with_its_raw_candidate() {
|
||||
let wrapped_open = vec![spec("sma_signal.fast.length")];
|
||||
let raw_bound: HashSet<String> = ["bias.scale".to_string()].into_iter().collect();
|
||||
assert_eq!(
|
||||
classify_axis_intake("sma_signal.fast.length", &wrapped_open, &raw_bound),
|
||||
AxisIntake::WrappedRetired("fast.length".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
classify_axis_intake("sma_signal.bias.scale", &wrapped_open, &raw_bound),
|
||||
AxisIntake::WrappedRetired("bias.scale".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #328: a name matching neither namespace is `Unknown` — the caller's own
|
||||
/// unmatched-axis prose applies, unchanged by this predicate.
|
||||
fn classify_axis_intake_reports_unknown_for_neither_space() {
|
||||
let wrapped_open = vec![spec("sma_signal.fast.length")];
|
||||
let raw_bound: HashSet<String> = ["bias.scale".to_string()].into_iter().collect();
|
||||
assert_eq!(classify_axis_intake("nope", &wrapped_open, &raw_bound), AxisIntake::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #328: raw is checked FIRST — an unwrapped (no-dot) param whose raw name
|
||||
/// happens to equal its own wrapped `param_space()` string (a root-level
|
||||
/// knob with no node-path prefix) resolves as `Raw`, never misclassified
|
||||
/// as a wrapped-exact hit needing translation.
|
||||
fn classify_axis_intake_prefers_raw_when_a_name_is_both() {
|
||||
let wrapped_open = vec![spec("length")]; // no dot: raw == wrapped
|
||||
let raw_bound: HashSet<String> = HashSet::new();
|
||||
assert_eq!(classify_axis_intake("length", &wrapped_open, &raw_bound), AxisIntake::Raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The only shape treated as a direct store address is a bare 64-char
|
||||
/// lowercase-hex token; anything else (wrong length, uppercase, non-hex)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Coverage reporting: the single interior-gap-month walk, shared by a
|
||||
//! campaign cell's coverage annotation
|
||||
//! (`DefaultMemberRunner::window_coverage`) and `aura data coverage`'s
|
||||
//! archive-wide report — two independent walk implementations before this
|
||||
//! module existed (#295 dedup).
|
||||
//! The interior-gap-month walk behind a campaign cell's coverage annotation
|
||||
//! (`DefaultMemberRunner::window_coverage`) — originally shared with `aura
|
||||
//! data coverage`'s archive-wide report (#295 dedup: two independent walk
|
||||
//! implementations before this module existed) before that verb retired in
|
||||
//! favor of per-cell fault isolation (#272, #273).
|
||||
|
||||
/// `"YYYY-MM"` rendering of a `(year, month)` pair.
|
||||
pub fn fmt_year_month((y, m): (u16, u8)) -> String {
|
||||
@@ -18,11 +18,11 @@ pub fn next_year_month((y, m): (u16, u8)) -> (u16, u8) {
|
||||
/// fall inside `window_ms` (Unix-ms) — one `"YYYY-MM"` entry per missing
|
||||
/// month, never a collapsed range: the registry's `CellCoverage::gap_months`
|
||||
/// is a flat list an aggregate counts directly. `months` is assumed sorted
|
||||
/// ([`aura_ingest::list_m1_months`]'s own contract). The single gap-walk
|
||||
/// implementation (#295): both `DefaultMemberRunner::window_coverage`
|
||||
/// (a swept cell's own window) and [`data_coverage_report`] (the full
|
||||
/// archive span, via [`FULL_ARCHIVE_WINDOW_MS`]) call this one walk instead
|
||||
/// of maintaining independent copies.
|
||||
/// ([`aura_ingest::list_m1_months`]'s own contract). `DefaultMemberRunner::
|
||||
/// window_coverage` (a swept cell's own window) is the sole caller since
|
||||
/// `aura data coverage`'s archive-wide report retired (#273); the walk stays
|
||||
/// its own function because it was single-sourced with that verb before then
|
||||
/// (#295).
|
||||
pub fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec<String> {
|
||||
let from_ym = data_server::records::unix_ms_to_year_month(window_ms.0);
|
||||
let to_ym = data_server::records::unix_ms_to_year_month(window_ms.1);
|
||||
@@ -40,68 +40,6 @@ pub fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec<S
|
||||
out
|
||||
}
|
||||
|
||||
/// A Unix-ms window that decodes (via `unix_ms_to_year_month`) to year/month
|
||||
/// bounds — 0001-01 and 9999-01, exact epoch-ms for those UTC instants, not
|
||||
/// an approximation — comfortably outside any realistic archive month, so an
|
||||
/// unbounded, archive-wide [`interior_gap_months`] walk never ms-filters out
|
||||
/// a real gap. Safely inside `chrono`'s valid calendar range, so the
|
||||
/// conversion inside `interior_gap_months` never panics.
|
||||
pub const FULL_ARCHIVE_WINDOW_MS: (i64, i64) = (-62_135_596_800_000, 253_370_764_800_000);
|
||||
|
||||
/// `aura data coverage <SYMBOL>`'s pure report body (#264): render `symbol`'s
|
||||
/// coverage report from its sorted `(year, month)` file list — one `span:`
|
||||
/// line framing the first/last present month, then either a `no gaps` line
|
||||
/// or one `missing: YYYY-MM..YYYY-MM` line per interior contiguous gap (a
|
||||
/// ten-month hole is one line, not ten). `Err` exactly when `months` is
|
||||
/// empty — no archive file exists for `symbol` at all (the caller's "unknown
|
||||
/// symbol" refusal, stderr + exit 1). The gap detection itself is
|
||||
/// [`interior_gap_months`] over the full archive span
|
||||
/// ([`FULL_ARCHIVE_WINDOW_MS`]); collapsing its flat per-month list into
|
||||
/// contiguous ranges is presentation-only regrouping, done here since it
|
||||
/// stays byte-identical to the pre-dedup report.
|
||||
pub fn data_coverage_report(symbol: &str, months: &[(u16, u8)]) -> Result<Vec<String>, String> {
|
||||
let Some(&first) = months.first() else {
|
||||
return Err(format!("no archive files found for symbol \"{symbol}\""));
|
||||
};
|
||||
let last = *months.last().expect("non-empty checked above");
|
||||
let mut lines =
|
||||
vec![format!("{symbol} span: {}..{}", fmt_year_month(first), fmt_year_month(last))];
|
||||
let gap_months = interior_gap_months(months, FULL_ARCHIVE_WINDOW_MS);
|
||||
if gap_months.is_empty() {
|
||||
lines.push(format!("{symbol} no gaps"));
|
||||
} else {
|
||||
for (from, to) in collapse_contiguous_year_months(&gap_months) {
|
||||
lines.push(format!("{symbol} missing: {from}..{to}"));
|
||||
}
|
||||
}
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// Collapse [`interior_gap_months`]' flat, ascending `"YYYY-MM"` list into
|
||||
/// contiguous inclusive `(from, to)` ranges — a ten-month hole collapses to
|
||||
/// one pair, not ten. Presentation-only regrouping: the gap WALK itself
|
||||
/// already ran in `interior_gap_months`.
|
||||
fn collapse_contiguous_year_months(months: &[String]) -> Vec<(String, String)> {
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
for m in months {
|
||||
let ym = parse_year_month(m);
|
||||
match out.last_mut() {
|
||||
Some((_, to)) if next_year_month(parse_year_month(to)) == ym => {
|
||||
*to = m.clone();
|
||||
}
|
||||
_ => out.push((m.clone(), m.clone())),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Inverse of [`fmt_year_month`] — parses back the `"YYYY-MM"` shape
|
||||
/// [`interior_gap_months`] always emits.
|
||||
fn parse_year_month(s: &str) -> (u16, u8) {
|
||||
let (y, m) = s.split_once('-').expect("interior_gap_months emits YYYY-MM");
|
||||
(y.parse().expect("YYYY digits"), m.parse().expect("MM digits"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -125,37 +63,4 @@ mod tests {
|
||||
"both interior gap months must be named, in order"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unknown symbol (no archive files at all — an empty month list)
|
||||
/// refuses rather than reporting a bogus empty-span coverage (#264): the
|
||||
/// caller eprintln's the message and exits 1.
|
||||
#[test]
|
||||
fn data_coverage_report_refuses_an_unknown_symbol() {
|
||||
let err = data_coverage_report("GHOST", &[]).unwrap_err();
|
||||
assert!(err.contains("GHOST"), "names the unknown symbol: {err}");
|
||||
}
|
||||
|
||||
/// A symbol with a fully contiguous file index reports its span plus an
|
||||
/// explicit `no gaps` line — never a bare span with no gap-status line at
|
||||
/// all, which would leave "no gaps" indistinguishable from "gaps not yet
|
||||
/// checked" (#264).
|
||||
#[test]
|
||||
fn data_coverage_report_of_a_gapless_symbol_is_span_plus_no_gaps() {
|
||||
let months = [(2024, 1), (2024, 2), (2024, 3)];
|
||||
let lines = data_coverage_report("SYMA", &months).expect("known symbol");
|
||||
assert_eq!(lines, vec!["SYMA span: 2024-01..2024-03", "SYMA no gaps"]);
|
||||
}
|
||||
|
||||
/// The Copper failure shape itself: one interior gap collapses to a single
|
||||
/// `missing: YYYY-MM..YYYY-MM` line naming the whole contiguous hole, not
|
||||
/// one line per missing month (#264).
|
||||
#[test]
|
||||
fn data_coverage_report_collapses_an_interior_gap_to_one_range_line() {
|
||||
let months = [(2024, 1), (2024, 2), (2024, 5), (2024, 6)];
|
||||
let lines = data_coverage_report("GAPSYM", &months).expect("known symbol");
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec!["GAPSYM span: 2024-01..2024-06", "GAPSYM missing: 2024-03..2024-04"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,23 @@
|
||||
//! Family assembly and orchestration (#295).
|
||||
//!
|
||||
//! The blueprint sweep / walk-forward / Monte-Carlo family builders — pure
|
||||
//! member-run recipes driven off a [`DataSource`] (the shared synthetic/real
|
||||
//! data provider every family builder threads, plus the `--select` objective
|
||||
//! [`Selection`] walk-forward resolves its winner under) — together with the
|
||||
//! winner-selection (`select_winner`) and axis-grid validation
|
||||
//! (`validate_axis_grid`) machinery they share. No `aura` binary is needed to
|
||||
//! drive a family: the shell (`aura-cli`) wraps these builders with
|
||||
//! persistence + stdout rendering (`run_blueprint_sweep`,
|
||||
//! `run_blueprint_walkforward`, `run_blueprint_mc`), which stay in the shell,
|
||||
//! along with the `--select`/`--real` argv grammar (`parse_select`,
|
||||
//! `select_rule_of`) and the `topology_hash`/`content_id` naming primitive
|
||||
//! (inlined here instead, mirroring `member::run_signal_r`'s own copy — the
|
||||
//! CLI shell still needs its own for call sites outside any family builder).
|
||||
//! The blueprint sweep / walk-forward / Monte-Carlo family builders that used
|
||||
//! to live here are retired (#319 — the research-verb quintet's own
|
||||
//! machinery; the campaign document path, `aura-campaign::exec`, is the
|
||||
//! surviving family-execution surface, with its own independent axis/window/
|
||||
//! winner-selection machinery). What remains is the shared [`DataSource`]
|
||||
//! data provider — the synthetic/real price-source abstraction
|
||||
//! `aura_runner::reproduce` drives its own bit-identical re-derivation
|
||||
//! over — and its synthetic stream primitives (`showcase_prices`,
|
||||
//! `walkforward_prices`, `walkforward_window_source`, `synthetic_walk_sources`).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use aura_composites::StopRule;
|
||||
use aura_core::{Cell, ParamSpec, Scalar, Timestamp};
|
||||
use aura_engine::{
|
||||
blueprint_from_json, walk_forward, window_of, BindError, Composite, FamilySelection,
|
||||
RollMode, RunManifest, SyntheticSpec, VecSource, WindowBounds, WindowRoller,
|
||||
};
|
||||
use aura_registry::{
|
||||
optimize_deflated, optimize_plateau, PlateauMode, DEFLATION_BLOCK_LEN, DEFLATION_N_RESAMPLES,
|
||||
};
|
||||
use aura_backtest::{
|
||||
monte_carlo, McFamily, RunMetrics, RunReport, SweepFamily, SweepPoint, WalkForwardResult,
|
||||
WindowRun, WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS,
|
||||
};
|
||||
use aura_core::{Scalar, Timestamp};
|
||||
use aura_engine::{window_of, SyntheticSpec, VecSource};
|
||||
use aura_backtest::{WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS};
|
||||
|
||||
use crate::binding::ResolvedBinding;
|
||||
use crate::member::{
|
||||
blueprint_axis_probe, blueprint_axis_probe_reopened, no_real_data, override_paths,
|
||||
pip_or_refuse, probe_window, reopen_all, run_blueprint_member, wrapped_bound_overrides_of,
|
||||
SYNTHETIC_PIP_SIZE,
|
||||
};
|
||||
use crate::member::{no_real_data, pip_or_refuse, probe_window, SYNTHETIC_PIP_SIZE};
|
||||
use crate::project::Env;
|
||||
use crate::translate::{R_SMA_STOP_K, R_SMA_STOP_LENGTH};
|
||||
|
||||
/// The in-sample winner-selection objective for walk-forward's per-window IS
|
||||
/// refit (cycle 0077). `Argmax` is the bare-best pick deflated for trials
|
||||
/// (#144, the default); `Plateau` argmaxes the neighbourhood-smoothed surface
|
||||
/// instead (opt-in via `--select`). The CLI shell's `--select` grammar
|
||||
/// (`parse_select`) and campaign-rule mapping (`select_rule_of`) build this
|
||||
/// value; they stay in the shell since only this type crosses the boundary.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Selection {
|
||||
Argmax,
|
||||
Plateau(PlateauMode),
|
||||
}
|
||||
|
||||
/// A warm-up-adequate synthetic stream (~18 ticks rising, falling, then rising
|
||||
/// again) used as `DataSource::Synthetic`'s full-window stream for the built-in
|
||||
@@ -99,19 +66,18 @@ pub enum DataChoice {
|
||||
Real { symbol: String, from_ms: Option<i64>, to_ms: Option<i64> },
|
||||
}
|
||||
|
||||
/// The source provider threaded into the family builders: synthetic built-in
|
||||
/// streams, or real M1 close bars from the data-server archive. Replaces the
|
||||
/// hardcoded `VecSource` so a member's source, pip, window, and roller sizes come
|
||||
/// from one place (Fork B/D/F).
|
||||
/// The source provider `aura_runner::reproduce` threads through its
|
||||
/// re-derivation: synthetic built-in streams, or real M1 close bars from the
|
||||
/// data-server archive. Replaces the hardcoded `VecSource` so a member's
|
||||
/// source, pip, window, and roller sizes come from one place (Fork B/D/F).
|
||||
///
|
||||
/// `Synthetic` denotes a *consumer-dependent* built-in stream, not one fixed
|
||||
/// series: the full-window consumers (`full_window` / `run_sources`, used by
|
||||
/// sweep / MC) draw the 18-bar `showcase_prices()`, while the windowed consumers
|
||||
/// (`windowed_sources` / `wf_window_sizes`, used by walk-forward) draw the 60-bar
|
||||
/// series: the full-window consumers (`full_window` / `run_sources`) draw the
|
||||
/// 18-bar `showcase_prices()`, while the windowed consumers
|
||||
/// (`windowed_sources` / `wf_window_sizes`) draw the 60-bar
|
||||
/// `walkforward_prices()` so the `(24,12,12)`-bar roller fits its span. The two
|
||||
/// faces never reach one consumer (a family is either full-window or windowed), so
|
||||
/// the split is invisible per call site but real across the type — read both
|
||||
/// family builders to see it whole.
|
||||
/// faces never reach one consumer (full-window vs. windowed reproduction), so
|
||||
/// the split is invisible per call site but real across the type.
|
||||
pub enum DataSource {
|
||||
Synthetic,
|
||||
Real {
|
||||
@@ -219,451 +185,14 @@ impl DataSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// #260: the r-sma sugar/MC paths below run with either no cost model (empty
|
||||
/// `cost` slice) or CLI-flag cost specs (scalar-only — `cost_specs_from_params`
|
||||
/// only ever wraps `CostValue::Scalar`), so an instrument-keyed map can never
|
||||
/// originate on these paths and the instrument context is genuinely inert.
|
||||
/// Named once so every such call site states its intent by reference instead
|
||||
/// of repeating the justifying comment.
|
||||
const NO_INSTRUMENT_CONTEXT: &str = "";
|
||||
|
||||
/// The winner-selection objective for walk-forward's per-window IS refit — the
|
||||
/// deflation-aware SQN variant (#144 default). Named once so `select_winner`
|
||||
/// and `blueprint_walkforward_family` (the only two family-builder-side call
|
||||
/// sites) cannot drift apart on the token; the CLI shell's campaign-sugar
|
||||
/// bridge keeps its own copy of this token (main.rs `WINNER_SELECTION_METRIC`)
|
||||
/// since it is not itself a family builder.
|
||||
const WINNER_SELECTION_METRIC: &str = "sqn_normalized";
|
||||
|
||||
/// Fixed RNG seed for the trials-deflation reality-check bootstrap in walk-forward
|
||||
/// winner selection. Recorded on each winner's manifest (so `overfit_probability`
|
||||
/// is reproducible by re-run); a CLI flag for it is a deferred refinement. The
|
||||
/// resample count and block length are the shared `aura_registry::DEFLATION_*`.
|
||||
const DEFLATION_SEED: u64 = 0xDEF1_A7ED;
|
||||
|
||||
/// Renders a [`BindError`] as one-line prose in `member::override_paths`' sibling
|
||||
/// register above (#247) — never the raw Rust `Debug` struct name
|
||||
/// (`KindMismatch { .. }` / `MissingKnob("..")`), the two variants the sweep
|
||||
/// terminal actually raises past `override_paths`' own pre-flight (which
|
||||
/// already rejects an unresolvable axis name as prose before either call
|
||||
/// site below ever reaches the terminal). `UnknownKnob` already carries a
|
||||
/// fully-prosed message string (wrapped from `override_paths`' own
|
||||
/// `Result<_, String>`) — unwrapped here rather than Debug-framed (#269), so
|
||||
/// the walkforward path's rejection reaches stderr as bare prose too.
|
||||
pub fn render_bind_error(e: &BindError) -> String {
|
||||
match e {
|
||||
BindError::MissingKnob(name) => format!(
|
||||
"axis {name}: an open param with no axis and no bound default — \
|
||||
bind it with `--axis {name}=<value>` — see `aura sweep <bp> --list-axes`"
|
||||
),
|
||||
BindError::KindMismatch { knob, expected, got } => format!(
|
||||
"axis {knob}: expected {expected:?}, supplied {got:?} — \
|
||||
see `aura sweep <bp> --list-axes`"
|
||||
),
|
||||
BindError::UnknownKnob(msg) => msg.clone(),
|
||||
BindError::DuplicateBinding(name) => format!(
|
||||
"axis {name}: bound twice — each param takes exactly one axis — \
|
||||
see `aura sweep <bp> --list-axes`"
|
||||
),
|
||||
BindError::EmptyAxis(name) => format!(
|
||||
"axis {name}: supplies no values — give at least one, e.g. `--axis {name}=2,4`"
|
||||
),
|
||||
BindError::EmptyRange(name) => format!(
|
||||
"axis {name}: the named range is empty — give it at least one value"
|
||||
),
|
||||
// A blueprint defect, not an axis usage error: the point passed name
|
||||
// resolution but its bootstrap failed. Prose frame with the compile
|
||||
// detail explicitly labelled as internal — per-variant prose for
|
||||
// CompileError belongs to the graph-build surface, not this boundary.
|
||||
BindError::Compile(e) => format!(
|
||||
"the resolved axis point failed to bootstrap — the blueprint is \
|
||||
defective at this point, re-validate it with `aura graph build` \
|
||||
(internal detail: {e:?})"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the in-sample winner under the chosen selection objective. `Argmax`
|
||||
/// defers to the trials-deflation pick (#144). `Plateau` argmaxes the smoothed grid
|
||||
/// surface — it needs the grid lattice, so a sweep with no lattice (a future random
|
||||
/// walk-forward producer) is refused rather than silently argmaxed. The metric is
|
||||
/// always known at the call sites, so a metric error is unreachable (`expect`); the
|
||||
/// only fallible outcome is the plateau-without-lattice refusal, returned as
|
||||
/// `Err(message)` for the caller to print and exit 2.
|
||||
pub fn select_winner(
|
||||
family: &SweepFamily, metric: &str, select: Selection, lattice: Option<&[usize]>,
|
||||
) -> Result<(SweepPoint, FamilySelection), String> {
|
||||
match select {
|
||||
Selection::Argmax => Ok(optimize_deflated(
|
||||
family, metric, DEFLATION_N_RESAMPLES, DEFLATION_BLOCK_LEN, DEFLATION_SEED,
|
||||
).expect("walk-forward metrics are known")),
|
||||
Selection::Plateau(mode) => match lattice {
|
||||
Some(lens) => Ok(optimize_plateau(family, lens, metric, mode)
|
||||
.expect("walk-forward metrics are known")),
|
||||
None => Err(
|
||||
"--select plateau requires a grid sweep; a random sweep has no parameter lattice"
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A constant, zero-compute `RunReport` for [`validate_axis_grid`]'s sweep-terminal
|
||||
/// 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.
|
||||
fn axis_grid_probe_report() -> RunReport {
|
||||
RunReport {
|
||||
manifest: RunManifest {
|
||||
commit: String::new(),
|
||||
params: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
window: (Timestamp(0), Timestamp(0)),
|
||||
seed: 0,
|
||||
broker: "wf-axis-preflight-placeholder".to_string(),
|
||||
selection: None,
|
||||
instrument: None,
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
},
|
||||
metrics: RunMetrics { total_pips: 0.0, max_drawdown: 0.0, bias_sign_flips: 0, r: None },
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// paths cannot drift to differently-worded rejections) to derive the #246 override
|
||||
/// set, then drives the sweep terminal's own `resolve_axes`/arity/kind checks
|
||||
/// (`SweepBinder::sweep_with_lattice`) with [`axis_grid_probe_report`] standing in for
|
||||
/// the run closure — no data access, no sim engine tick. Axis resolution is
|
||||
/// window-agnostic, so the caller derives or passes no window at all.
|
||||
fn validate_axis_grid(
|
||||
doc: &str, axes: &[(String, Vec<Scalar>)], raw_space: &[ParamSpec], probe_signal: &Composite,
|
||||
env: &Env,
|
||||
) -> Result<(), BindError> {
|
||||
let overrides = override_paths(axes, raw_space, probe_signal).map_err(BindError::UnknownKnob)?;
|
||||
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
|
||||
let mut iter = axes.iter();
|
||||
let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis");
|
||||
let mut binder = probe.axis(first_name, first_vals.clone());
|
||||
for (n, vals) in iter {
|
||||
binder = binder.axis(n, vals.clone());
|
||||
}
|
||||
binder.sweep_with_lattice(|_| axis_grid_probe_report()).map(|_| ())
|
||||
}
|
||||
|
||||
/// Sweep a serialized signal `doc` over user-named param-space axes. Structurally it
|
||||
/// keeps the shape of the retired `r_sma_sweep_family` demo builder (#159), with three
|
||||
/// deviations. (1) The signal source is
|
||||
/// `wrap_r(blueprint_from_json(doc))` — a loaded blueprint, not the Rust-built
|
||||
/// r-sma graph. (2) The signal is RE-loaded from `doc` per member (a `Composite` is
|
||||
/// `!Clone`, so the throwaway param-space probe and each grid point each reload). (3)
|
||||
/// The axes are taken verbatim BY NAME (not the four suffix-resolved r-sma knobs):
|
||||
/// each `(name, vals)` is fed straight to the `SweepBinder`, so an unknown name or a
|
||||
/// kind mismatch surfaces as the sweep terminal's [`BindError`], rendered to a message
|
||||
/// string — a named error, never a panic. An axis naming a bound param re-opens it
|
||||
/// (#246: bound value = default); an axis matching neither space is refused by
|
||||
/// `override_paths` before any run. Every member manifest carries the shared
|
||||
/// `topology_hash` of the loaded signal; reduce-mode fold, identical to the retired
|
||||
/// mirror's default (no-trace) arm.
|
||||
pub fn blueprint_sweep_family(
|
||||
doc: &str,
|
||||
axes: &[(String, Vec<Scalar>)],
|
||||
data: &DataSource,
|
||||
env: &Env,
|
||||
) -> Result<SweepFamily, String> {
|
||||
// Identity + binding read the AUTHORED doc, raw (no override re-open):
|
||||
// topology and the resolved role plan are properties of the document, not
|
||||
// of any one sweep's axis choices.
|
||||
let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
|
||||
// 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
|
||||
// `topology_hash` helper is the same primitive, kept single-sourced at
|
||||
// `aura_research`.
|
||||
let topo = aura_research::content_id_of(
|
||||
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
|
||||
);
|
||||
// Strict binding resolution (name defaults — the verb path carries no
|
||||
// campaign overrides): the family's open plan and wrap plan in one value.
|
||||
let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?;
|
||||
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
|
||||
return Err(crate::binding::synthetic_refusal(probe_signal.name(), &binding));
|
||||
}
|
||||
let pip = data.pip_size();
|
||||
let window = data.full_window(env);
|
||||
// The un-reopened wrapped OPEN space (#246): derives the override set (which
|
||||
// named axes re-open a bound param) before the real, reopened probe is built —
|
||||
// probe and per-member reloads must re-open identically so points resolve
|
||||
// against one space. A name matching neither space is the error here.
|
||||
let raw_space = blueprint_axis_probe(doc, env).param_space();
|
||||
let overrides = override_paths(axes, &raw_space, &probe_signal)?;
|
||||
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
|
||||
let space = probe.param_space();
|
||||
// The doc is parse-validated at the dispatch boundary (with file-path context),
|
||||
// so every reload here is infallible: the builder has a single error contract —
|
||||
// the `BindError` returned by the sweep terminal — and no hidden process exit.
|
||||
// Member reloads re-open the SAME override set derived above, so every member
|
||||
// resolves its axes against the identical (reopened) param space the probe used.
|
||||
let reload = |d: &str| {
|
||||
reopen_all(
|
||||
blueprint_from_json(d, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary; reload is infallible"),
|
||||
&overrides,
|
||||
)
|
||||
};
|
||||
// seed the named axes verbatim: the first via Composite::axis (consumes the probe),
|
||||
// the rest via SweepBinder::axis. resolve_axes name- and kind-checks them at the
|
||||
// sweep terminal, so an UnknownKnob / KindMismatch is returned, not panicked.
|
||||
let mut iter = axes.iter();
|
||||
let (first_name, first_vals) = iter.next().expect("a blueprint sweep declares >= 1 axis");
|
||||
let mut binder = probe.axis(first_name, first_vals.clone());
|
||||
for (n, vals) in iter {
|
||||
binder = binder.axis(n, vals.clone());
|
||||
}
|
||||
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, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||
})
|
||||
// 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))
|
||||
}
|
||||
|
||||
/// Sweep the LOADED blueprint over the user `--axis` grid on an in-sample window
|
||||
/// `[from,to]` — the windowed, lattice-carrying twin of
|
||||
/// `blueprint_sweep_family`. `sweep_with_lattice` gives the grid lattice `--select
|
||||
/// plateau` needs. An unknown/kind-mismatched axis surfaces as `BindError` at the
|
||||
/// sweep terminal (no panic, no hidden exit) for the caller to render. An axis
|
||||
/// naming a bound param re-opens it (#246: bound value = default, same
|
||||
/// `override_paths`/`reopen_all` recipe as `blueprint_sweep_family` — this is
|
||||
/// 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.
|
||||
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> {
|
||||
let reload = |d: &str| {
|
||||
blueprint_from_json(d, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary; reload is infallible")
|
||||
};
|
||||
let pip = data.pip_size();
|
||||
let probe_signal = reload(doc);
|
||||
// topology_hash's own two-line body, inlined (see `blueprint_sweep_family`).
|
||||
let topo = aura_research::content_id_of(
|
||||
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
|
||||
);
|
||||
// The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy
|
||||
// load: derives the override set (which named axes re-open a bound param) before
|
||||
// the reopened probe is built — probe and per-member reloads must re-open
|
||||
// identically so points resolve against one space, exactly like
|
||||
// `blueprint_sweep_family`.
|
||||
let raw_space = blueprint_axis_probe(doc, env).param_space();
|
||||
let overrides = override_paths(axes, &raw_space, &probe_signal).map_err(BindError::UnknownKnob)?;
|
||||
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
|
||||
let space = probe.param_space();
|
||||
let mut iter = axes.iter();
|
||||
let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis");
|
||||
let mut binder = probe.axis(first_name, first_vals.clone());
|
||||
for (n, vals) in iter {
|
||||
binder = binder.axis(n, vals.clone());
|
||||
}
|
||||
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, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the winner params over an out-of-sample window `[from,to]` on the loaded
|
||||
/// blueprint. The reduce-mode member
|
||||
/// (`run_blueprint_member`) retains R-metrics, not a raw pip curve, so the stitching
|
||||
/// segment is empty (an empty segment leaves the stitched curve unbroken). `overrides`
|
||||
/// (#246) is the SAME family-wide set `blueprint_walkforward_family` derived once and
|
||||
/// resolved `space`/`params` against — the OOS reload must re-open it too, or a
|
||||
/// bound-param axis's winner point (kind-checked against the REOPENED space) fails
|
||||
/// `bootstrap_with_cells`'s arity check against this still-closed reload.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run_oos_blueprint(
|
||||
doc: &str, params: &[Cell], space: &[ParamSpec], from: Timestamp, to: Timestamp,
|
||||
topo: &str, data: &DataSource, env: &Env, binding: &ResolvedBinding,
|
||||
overrides: &[String],
|
||||
) -> (Vec<(Timestamp, f64)>, RunReport) {
|
||||
let reload = reopen_all(
|
||||
blueprint_from_json(doc, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary; reload is infallible"),
|
||||
overrides,
|
||||
);
|
||||
let pip = data.pip_size();
|
||||
let sources = data.windowed_sources(from, to, env, &binding.columns());
|
||||
let window = window_of(&sources).expect("non-empty out-of-sample window");
|
||||
let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[], NO_INSTRUMENT_CONTEXT);
|
||||
(Vec::new(), report)
|
||||
}
|
||||
|
||||
/// 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`;
|
||||
/// only the per-window sweep/OOS source the loaded blueprint. In-closure errors
|
||||
/// (a bad `--axis`) `exit(2)` with the sweep terminal's message.
|
||||
pub fn blueprint_walkforward_family(
|
||||
doc: &str, axes: &[(String, Vec<Scalar>)], data: &DataSource, select: Selection,
|
||||
env: &Env,
|
||||
) -> WalkForwardResult {
|
||||
let span = data.wf_full_span(env);
|
||||
let (is_len, oos_len, step) = data.wf_window_sizes();
|
||||
let roller = match WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("aura: walk-forward window too short for one IS+OOS span: {e:?}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
|
||||
// topology_hash's own two-line body, inlined (see `blueprint_sweep_family`).
|
||||
let topo = aura_research::content_id_of(
|
||||
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
|
||||
);
|
||||
// The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy
|
||||
// load: derives the override set ONCE for the whole family — every per-window
|
||||
// sweep AND the OOS reload re-open the SAME set, mirroring
|
||||
// `blueprint_sweep_family`/`blueprint_sweep_over`. The SILENT variant
|
||||
// (`wrapped_bound_overrides_of`, not the validating `override_paths`): an axis
|
||||
// matching neither space is simply not an override here — the strict check + its
|
||||
// established error message stay single-sourced in `blueprint_sweep_over`'s own
|
||||
// pre-flight call below, so this derivation cannot double-validate with a
|
||||
// differently-worded rejection.
|
||||
let axis_names: Vec<String> = axes.iter().map(|(n, _)| n.clone()).collect();
|
||||
let raw_space = blueprint_axis_probe(doc, env).param_space();
|
||||
let overrides = wrapped_bound_overrides_of(&axis_names, &raw_space, &probe_signal);
|
||||
let space = blueprint_axis_probe_reopened(doc, env, &overrides).param_space();
|
||||
// Strict binding resolution, once per family; refusal is the established
|
||||
// `aura: ` + exit-1 register (the roller's usage refusals stay exit 2).
|
||||
let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())
|
||||
.unwrap_or_else(|m| {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
|
||||
eprintln!("aura: {}", crate::binding::synthetic_refusal(probe_signal.name(), &binding));
|
||||
std::process::exit(1);
|
||||
}
|
||||
// Validate the `--axis` grid ONCE at the dispatch boundary, mirroring `aura sweep`
|
||||
// (which resolves its axes a single time before any member runs). `walk_forward` fans
|
||||
// the per-window closure out across the windows in parallel, so a `BindError` raised
|
||||
// *inside* the closure would `eprintln!`+`exit(2)` from several windows before any one
|
||||
// exit lands — a racy, duplicated rejection (#177). Axis resolution is window-agnostic
|
||||
// (#253): `validate_axis_grid` resolves the SAME grid the sweep terminal would, without
|
||||
// running a single member — no IS window (or a second roller) is needed here at all.
|
||||
if let Err(e) = validate_axis_grid(doc, axes, &raw_space, &probe_signal, env) {
|
||||
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)
|
||||
.expect("axes validated in the dispatch-boundary pre-flight");
|
||||
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 }
|
||||
})
|
||||
}
|
||||
|
||||
/// A fresh seeded synthetic price walk for one Monte-Carlo draw — `blueprint_mc_family`'s
|
||||
/// pattern (a distinct realization per seed). A FIXED `SyntheticSpec` shared by the
|
||||
/// `aura mc <blueprint.json>` persist path AND the reproduce MonteCarlo branch, so the
|
||||
/// seed->walk reconstruction is bit-exact (C1). Length 60 comfortably warms the loaded
|
||||
/// r-sma graph (SMA slow=4 + the len-3 vol stop) so draws produce differing trades.
|
||||
/// A fresh seeded synthetic price walk for one Monte-Carlo draw (a distinct
|
||||
/// realization per seed). A FIXED `SyntheticSpec` shared by the reproduce
|
||||
/// MonteCarlo branch (`aura_runner::reproduce`, its only remaining
|
||||
/// production caller — #319 retired the family builder that used to mint
|
||||
/// such a family), so the seed->walk reconstruction is bit-exact (C1).
|
||||
/// Length 60 comfortably warms the loaded r-sma graph (SMA slow=4 + the
|
||||
/// len-3 vol stop) so draws produce differing trades.
|
||||
pub fn synthetic_walk_sources(seed: u64) -> Vec<Box<dyn aura_engine::Source>> {
|
||||
let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 };
|
||||
vec![Box::new(spec.source(seed))]
|
||||
}
|
||||
|
||||
/// Build a Monte-Carlo family from a loaded CLOSED signal blueprint: run the fixed
|
||||
/// blueprint across `n_seeds` seeds, each seed drawing a distinct synthetic walk. The
|
||||
/// blueprint must be CLOSED (empty wrapped `param_space`) — MC binds no axis, so a free
|
||||
/// knob has no binder; an OPEN blueprint yields a named `Err` (exit-free like the sibling
|
||||
/// [`blueprint_sweep_family`]: the IO wrapper `run_blueprint_mc` renders it to stderr +
|
||||
/// exit 2) before any run, pre-empting the `compile_with_params` arity panic. Each draw
|
||||
/// runs the shared reduce-mode member path (`run_blueprint_member`, the same fn reproduce
|
||||
/// re-runs), so reproduction is bit-identical (C1); every member carries the shared
|
||||
/// `topology_hash`.
|
||||
pub fn blueprint_mc_family(
|
||||
doc: &str, n_seeds: u64, data: &DataSource, env: &Env,
|
||||
) -> Result<McFamily, String> {
|
||||
let reload = |d: &str| {
|
||||
blueprint_from_json(d, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary; reload is infallible")
|
||||
};
|
||||
let probe_signal = reload(doc);
|
||||
// topology_hash's own two-line body, inlined (see `blueprint_sweep_family`).
|
||||
let topo = aura_research::content_id_of(
|
||||
&aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"),
|
||||
);
|
||||
// Strict binding resolution (name defaults — mc's synthetic family binds
|
||||
// no campaign overrides); the exit-free Err contract of this builder.
|
||||
let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?;
|
||||
if !binding.close_only() {
|
||||
// MC draws ALWAYS run the seeded synthetic close walk (real-data mc
|
||||
// routes through the campaign sugar and never reaches this builder).
|
||||
return Err(crate::binding::synthetic_refusal(probe_signal.name(), &binding));
|
||||
}
|
||||
let pip = data.pip_size();
|
||||
// probe the wrapped param_space (the same probe the sweep resolves against);
|
||||
// MC needs it empty. `blueprint_axis_probe` is the single source of that wrap.
|
||||
let space = blueprint_axis_probe(doc, env).param_space();
|
||||
if !space.is_empty() {
|
||||
// Exit-free like blueprint_sweep_family: the builder's single error contract is this
|
||||
// returned message (no hidden process exit), so the rejection is unit-testable; the IO
|
||||
// wrapper run_blueprint_mc renders it to stderr + exit 2 at the boundary.
|
||||
return Err(format!(
|
||||
"mc requires a closed blueprint (no free parameters); {} free knob(s) — \
|
||||
bind them or use `aura sweep --axis`",
|
||||
space.len()
|
||||
));
|
||||
}
|
||||
// Closed blueprint -> an empty base point (as `aura run <blueprint.json>`); the MC
|
||||
// draws vary the SEED, not a tuning param (C12 axis 4). Delegate the disjoint C1 draws
|
||||
// to the shared `monte_carlo` helper — it runs them in parallel across sims (invariant 1),
|
||||
// deterministic in seed-input order. Each draw
|
||||
// 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();
|
||||
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, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||
});
|
||||
// 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
|
||||
// walk is long), so the "distribution" is a single point masquerading as a family: a wrong
|
||||
// result with no error. Compare `metrics`, not the whole `RunReport` — the manifest's
|
||||
// `seed` differs per draw by construction, so a whole-report compare could never detect the
|
||||
// collapse; the metrics are the realization the seed is meant to move. A single-draw MC
|
||||
// (n == 1) is trivially "all identical" and is NOT this cross-seed condition, so it passes.
|
||||
if family.draws.len() >= 2
|
||||
&& family
|
||||
.draws
|
||||
.iter()
|
||||
.all(|d| d.report.metrics == family.draws[0].report.metrics)
|
||||
{
|
||||
return Err(
|
||||
"mc is vacuous: every seed produced an identical result — the strategy never warmed \
|
||||
over the synthetic walk, so no seed reached a distinguishable realization; use a \
|
||||
shallower-lookback blueprint or a longer walk"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(family)
|
||||
}
|
||||
|
||||
@@ -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::{CompileError, Composite, Harness, MeasurementReport, RunManifest};
|
||||
|
||||
use crate::member::{key_supply, resolve_run_data, wrapped_bound_defaults, RunData};
|
||||
use crate::member::{key_supply, raw_bound_defaults, resolve_run_data, 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`
|
||||
@@ -52,14 +51,40 @@ pub fn measurement_manifest(
|
||||
}
|
||||
}
|
||||
|
||||
/// #339 item 3 (a #317 follow-up): `CompileError::UnboundRootRole { role }`
|
||||
/// carries a flat root-role index — meaningless to a caller who authored a
|
||||
/// NAMED open role (`{"op":"input","role":"price"}`). `role_names` is
|
||||
/// `signal.input_roles()`'s own names, read before `compile_with_params`
|
||||
/// consumes `signal` (mirrors `member::compile_error_prose`'s pre-consumption
|
||||
/// `names` capture for its `ParamKindMismatch` prose). Every other
|
||||
/// `CompileError` variant keeps the existing Debug fallback deliberately —
|
||||
/// they ARE reachable on this direct-compile path (a hand-authored
|
||||
/// measurement envelope with an out-of-range declared-tap wire reaches
|
||||
/// `TapWireOutOfRange` here exactly as `run_signal_r`'s own compile call
|
||||
/// does, see `run_refuses_unrunnable_blueprint.rs`), so the fallback stays
|
||||
/// total rather than partial; only `UnboundRootRole` gets dedicated prose
|
||||
/// above.
|
||||
fn compile_error_prose(e: &CompileError, role_names: &[String]) -> String {
|
||||
let CompileError::UnboundRootRole { role } = e else {
|
||||
return format!("this blueprint does not compile to a runnable harness: {e:?}");
|
||||
};
|
||||
let name = role_names.get(*role).map(String::as_str).unwrap_or("<unknown>");
|
||||
format!(
|
||||
"this blueprint does not compile to a runnable harness: root role \"{name}\" is \
|
||||
unbound — it is declared open (an `input` role) but there is no enclosing graph \
|
||||
to wire it when run standalone"
|
||||
)
|
||||
}
|
||||
|
||||
/// The bare measurement run (C28 phase 3): `run_signal_r` MINUS `wrap_r` and the
|
||||
/// 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
|
||||
@@ -79,31 +104,28 @@ pub fn run_measurement(
|
||||
std::process::exit(1);
|
||||
}
|
||||
let names: Vec<String> = signal.param_space().iter().map(|p| p.name.clone()).collect();
|
||||
let defaults = wrapped_bound_defaults(&signal);
|
||||
// #339 item 3: `signal`'s own root-role names, captured before
|
||||
// `compile_with_params` consumes it below — the bare-tap path compiles
|
||||
// the signal DIRECTLY (unlike the bias/strategy arm's `wrap_r` nesting),
|
||||
// so an unbound open root role surfaces here as `CompileError::
|
||||
// UnboundRootRole { role }`, a flat index with no name attached at the
|
||||
// engine boundary. `role_names` lets `compile_error_prose` resolve it.
|
||||
let role_names: Vec<String> = signal.input_roles().iter().map(|r| r.name.clone()).collect();
|
||||
let defaults = raw_bound_defaults(&signal);
|
||||
let (sources, window, _pip_size) = resolve_run_data(&data, env, &binding);
|
||||
|
||||
// Compile the signal DIRECTLY — no wrap_r, no broker/executor/eq-ex-r sinks.
|
||||
let mut flat = signal.compile_with_params(params).unwrap_or_else(|e| {
|
||||
eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}");
|
||||
eprintln!("aura: {}", compile_error_prose(&e, &role_names));
|
||||
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 +138,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,13 +9,13 @@
|
||||
//! `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,
|
||||
blueprint_from_json, window_of, BlueprintNode, CompileError, Composite, GraphBuilder, Harness,
|
||||
RunManifest, VecSource,
|
||||
};
|
||||
use aura_backtest::{
|
||||
@@ -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")`,
|
||||
@@ -81,22 +82,20 @@ pub fn sim_optimal_manifest(
|
||||
}
|
||||
}
|
||||
|
||||
/// The wrap-prefixed BOUND-param defaults of `signal` (#249): every
|
||||
/// `bound_param_space()` entry as a `(<signal.name()>.<param>, value)` pair, in
|
||||
/// `bound_param_space()` order — the `RunManifest.defaults` field. Must be read
|
||||
/// off `signal` BEFORE it is consumed by `wrap_r`/reopening: an axis-reopened
|
||||
/// bound param has already left `bound_param_space()` by construction
|
||||
/// (`Composite::reopen` forgets the bound value), so a caller that reopens
|
||||
/// overrides before calling this naturally excludes them — no separate filter
|
||||
/// needed. Same prefixing rule as `wrapped_bound_names`, kept separate because
|
||||
/// that helper discards the value this one needs.
|
||||
pub fn wrapped_bound_defaults(signal: &Composite) -> Vec<(String, Scalar)> {
|
||||
let prefix = format!("{}.", signal.name());
|
||||
signal
|
||||
.bound_param_space()
|
||||
.into_iter()
|
||||
.map(|b| (format!("{prefix}{}", b.name), b.value))
|
||||
.collect()
|
||||
/// The RAW-namespace BOUND-param defaults of `signal` (#249/#328): every
|
||||
/// `bound_param_space()` entry as a `(<param>, value)` pair, already RAW
|
||||
/// (`bound_param_space()`'s own path-qualified name, #203 — no wrap-prefix
|
||||
/// concatenation), in `bound_param_space()` order — the `RunManifest.defaults`
|
||||
/// field. Must be read off `signal` BEFORE it is consumed by
|
||||
/// `wrap_r`/reopening: an axis-reopened bound param has already left
|
||||
/// `bound_param_space()` by construction (`Composite::reopen` forgets the
|
||||
/// bound value), so a caller that reopens overrides before calling this
|
||||
/// naturally excludes them — no separate filter needed. Contrast
|
||||
/// `wrapped_bound_names`, which DOES wrap-prefix (it matches against the
|
||||
/// WRAPPED open `param_space()`, a different coordinate system than this
|
||||
/// manifest-recording helper needs).
|
||||
pub fn raw_bound_defaults(signal: &Composite) -> Vec<(String, Scalar)> {
|
||||
signal.bound_param_space().into_iter().map(|b| (b.name, b.value)).collect()
|
||||
}
|
||||
|
||||
/// The honest broker label for the dual-tap r-sma harness: it runs a RiskExecutor
|
||||
@@ -326,7 +325,7 @@ pub fn wrap_r(
|
||||
if !reduce {
|
||||
// r_equity = cum_realized_r + unrealized_r — one tapped series for charting.
|
||||
let r_equity = g.add(
|
||||
LinComb::builder(2)
|
||||
LinComb::configured(2)
|
||||
.bind("weights[0]", Scalar::f64(1.0))
|
||||
.bind("weights[1]", Scalar::f64(1.0)),
|
||||
);
|
||||
@@ -410,7 +409,7 @@ pub fn wrap_r(
|
||||
// net_r_equity = cum_realized_r + unrealized_r − Σcum_cost_in_r
|
||||
// − Σopen_cost_in_r (the #221-deleted LinComb(4), weights 1,1,-1,-1).
|
||||
let net_eq = g.add(
|
||||
LinComb::builder(4)
|
||||
LinComb::configured(4)
|
||||
.bind("weights[0]", Scalar::f64(1.0))
|
||||
.bind("weights[1]", Scalar::f64(1.0))
|
||||
.bind("weights[2]", Scalar::f64(-1.0))
|
||||
@@ -472,21 +471,81 @@ pub fn resolve_run_data(
|
||||
}
|
||||
}
|
||||
|
||||
/// The variant-name string for a `ScalarKind`, matching the wire/CLI spelling
|
||||
/// used across the codebase (`F64`/`I64`/`Bool`/`Timestamp`).
|
||||
fn scalar_kind_name(kind: ScalarKind) -> &'static str {
|
||||
match kind {
|
||||
ScalarKind::F64 => "F64",
|
||||
ScalarKind::I64 => "I64",
|
||||
ScalarKind::Bool => "Bool",
|
||||
ScalarKind::Timestamp => "Timestamp",
|
||||
}
|
||||
}
|
||||
|
||||
/// Bug 3 (#319 fieldtest cycle 2): an `--override` value whose lexed kind
|
||||
/// does not match the param's declared kind (e.g. the bare literal `2` for
|
||||
/// an F64 param) reaches this compile boundary as a raw
|
||||
/// `CompileError::ParamKindMismatch { slot, expected, got }` — `slot` is a
|
||||
/// flat param-space index, meaningless to a caller. `names`/`params` are the
|
||||
/// SAME order `compile_with_params` was called with (built from `signal`
|
||||
/// before it was consumed by `wrap_r`, `C11`: composites inline, preserving
|
||||
/// order), so `slot` indexes both. This names the param path and both kinds,
|
||||
/// mirroring the campaign leg's own kind-fault prose
|
||||
/// (`ref_fault_prose`'s `AxisKindMismatch` arm, `research_docs.rs`). Every
|
||||
/// other `CompileError` variant keeps the existing Debug fallback — out of
|
||||
/// this bug's scope, and `names`/`params` only reconstruct the one variant
|
||||
/// that carries an injected value.
|
||||
fn compile_error_prose(e: &CompileError, names: &[String], params: &[Scalar]) -> String {
|
||||
let CompileError::ParamKindMismatch { slot, expected, got } = e else {
|
||||
return format!("this blueprint does not compile to a runnable harness: {e:?}");
|
||||
};
|
||||
let path = names.get(*slot).map(String::as_str).unwrap_or("<unknown>");
|
||||
let example = match (expected, params.get(*slot)) {
|
||||
(ScalarKind::F64, Some(Scalar::I64(v))) => format!("{v}.0"),
|
||||
(ScalarKind::F64, _) => "a decimal literal, e.g. 2.0".to_string(),
|
||||
(ScalarKind::I64, _) => "a bare integer literal, e.g. 2".to_string(),
|
||||
(ScalarKind::Bool, _) => "true or false".to_string(),
|
||||
(ScalarKind::Timestamp, _) => "an integer epoch-ns literal".to_string(),
|
||||
};
|
||||
format!(
|
||||
"--override {path}: expects {}, got {} — write {example}",
|
||||
scalar_kind_name(*expected),
|
||||
scalar_kind_name(*got)
|
||||
)
|
||||
}
|
||||
|
||||
/// Run a signal blueprint through the R scaffolding: hash the signal,
|
||||
/// wrap it (broker + equity/exposure/R sinks), compile with `params`, bootstrap,
|
||||
/// run over `data`, and build the RunReport (manifest carries topology_hash).
|
||||
/// The single construction+run path shared by the `aura run <blueprint.json>` CLI
|
||||
/// arm and its bit-identical test.
|
||||
///
|
||||
/// `topo`: `None` computes `topology_hash` inline from `signal` as always
|
||||
/// (every existing caller); `Some(hash)` overrides it with the caller's own
|
||||
/// **reference-semantics** hash (#343, revised) — the exec blueprint leg's
|
||||
/// `--override` branch passes the loaded base document's content id, computed
|
||||
/// BEFORE `reopen_all`, so the manifest reaching every consumer inside this
|
||||
/// function (the record line AND the trace-store persistence below) carries
|
||||
/// the base document's id, never the reopened topology's own, exactly as the
|
||||
/// campaign leg's `run_blueprint_member` has always taken its `topo` as a
|
||||
/// caller-supplied reference (`runner.rs`'s `&cell.strategy_id`).
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn run_signal_r(
|
||||
signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env,
|
||||
plan: TapPlan, topo: Option<&str>,
|
||||
) -> RunReport {
|
||||
// topology_hash's own two-line body, inlined: `content_id_of` over the
|
||||
// canonical (#164) blueprint JSON — the CLI shell's `topology_hash`
|
||||
// helper is the same primitive, kept single-sourced at `aura_research`.
|
||||
let topo = aura_research::content_id_of(
|
||||
&aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"),
|
||||
); // before signal is consumed
|
||||
// Skipped when the caller already supplies a reference-semantics hash
|
||||
// (`topo: Some(..)`) — no need to hash a signal whose own topology_hash
|
||||
// will be overridden anyway.
|
||||
let topo: String = match topo {
|
||||
Some(t) => t.to_string(),
|
||||
None => aura_research::content_id_of(
|
||||
&aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"),
|
||||
), // before signal is consumed
|
||||
};
|
||||
let run_name = signal.name().to_string(); // before signal is consumed by `wrap_r`
|
||||
// The default binding (name defaults; `aura run` carries no campaign
|
||||
// overrides). Refusals are the established `aura: ` + exit-1 register.
|
||||
@@ -504,7 +563,7 @@ pub fn run_signal_r(
|
||||
.iter()
|
||||
.map(|p| p.name.clone())
|
||||
.collect();
|
||||
let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below
|
||||
let defaults = raw_bound_defaults(&signal); // read before `signal` is consumed below
|
||||
let (tx_eq, rx_eq) = mpsc::channel();
|
||||
let (tx_ex, rx_ex) = mpsc::channel();
|
||||
let (tx_r, rx_r) = mpsc::channel();
|
||||
@@ -514,28 +573,17 @@ pub fn run_signal_r(
|
||||
let (sources, window, pip_size) = resolve_run_data(&data, env, &binding);
|
||||
let wrapped = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, false, pip_size, &binding, None);
|
||||
let mut flat = wrapped.compile_with_params(params).unwrap_or_else(|e| {
|
||||
eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}");
|
||||
eprintln!("aura: {}", compile_error_prose(&e, &names, params));
|
||||
std::process::exit(1);
|
||||
});
|
||||
// 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);
|
||||
});
|
||||
// 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));
|
||||
}
|
||||
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 +605,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 }
|
||||
}
|
||||
|
||||
@@ -599,7 +637,7 @@ pub fn run_blueprint_member(
|
||||
cost: &[aura_research::CostSpec],
|
||||
instrument: &str,
|
||||
) -> RunReport {
|
||||
let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below
|
||||
let defaults = raw_bound_defaults(&signal); // read before `signal` is consumed below
|
||||
let (tx_eq, rx_eq) = mpsc::channel();
|
||||
let (tx_ex, rx_ex) = mpsc::channel();
|
||||
let (tx_r, rx_r) = mpsc::channel();
|
||||
@@ -627,9 +665,10 @@ pub fn run_blueprint_member(
|
||||
h.run_bound(key_supply(binding, sources))
|
||||
.expect("sources opened against `binding` key-match that binding's own roles by construction");
|
||||
let mut named = zip_params(space, point); // by-name params for the manifest record
|
||||
// `match` (not an irrefutable `let`): `StopRule` also has a `Fixed` variant,
|
||||
// which stamps no vol knobs. The campaign/single-run paths only pass `Vol`
|
||||
// or `VolTf`, so the `Fixed` arm is inert here.
|
||||
// One arm's knobs per member (#233 Vol/VolTf, #338 Fixed) — the manifest
|
||||
// stamp `stop_rule_from_params` reads back (`translate.rs`), so a
|
||||
// campaign's `RiskRegime::Fixed` cell reproduces its own distance, not
|
||||
// the baked default vol-stop.
|
||||
match stop {
|
||||
StopRule::Vol { length, k } => {
|
||||
named.push(("stop_length".to_string(), Scalar::i64(length)));
|
||||
@@ -640,7 +679,9 @@ pub fn run_blueprint_member(
|
||||
named.push(("stop_length".to_string(), Scalar::i64(length)));
|
||||
named.push(("stop_k".to_string(), Scalar::f64(k)));
|
||||
}
|
||||
StopRule::Fixed(_) => {}
|
||||
StopRule::Fixed(distance) => {
|
||||
named.push(("stop_distance".to_string(), Scalar::f64(distance)));
|
||||
}
|
||||
}
|
||||
// Stamp the cost model the member ran under, beside the stop knobs (#234,
|
||||
// the #233 pattern): one `cost[k].<knob>` param per component, in
|
||||
@@ -680,8 +721,9 @@ pub fn run_blueprint_member(
|
||||
/// against: the loaded signal wrapped in the r-sma scaffolding (stop bound,
|
||||
/// reduce, no cost), taps discarded. `param_space()` on it is the axis
|
||||
/// namespace `--axis` binds; `.axis()` consumes it to seed a sweep. Single
|
||||
/// source for the sweep terminal, the MC closed-check, AND `--list-axes`, so
|
||||
/// the listed names track the swept names by construction (incl. across #159's
|
||||
/// source for the sweep terminal, the MC closed-check, AND
|
||||
/// `aura graph introspect --params`, so the listed names track the swept
|
||||
/// names by construction (incl. across #159's
|
||||
/// harness retirement). The reload is infallible under the SAME
|
||||
/// dispatch-boundary contract the callers already rely on: the doc is
|
||||
/// `blueprint_from_json`-validated at the `["sweep", ..]` / `["mc", ..]`
|
||||
@@ -694,7 +736,8 @@ pub fn blueprint_axis_probe(doc: &str, env: &Env) -> Composite {
|
||||
/// The axis probe with a #246 override set re-opened on the strategy BEFORE
|
||||
/// wrapping — probe and per-member reloads must re-open identically so points
|
||||
/// resolve against one space. The empty-set form is byte-equal to the old
|
||||
/// probe (mc/run closed-checks and the open `--list-axes` lines read that).
|
||||
/// probe (mc/run closed-checks and the open `aura graph introspect --params`
|
||||
/// lines read that).
|
||||
pub fn blueprint_axis_probe_reopened(doc: &str, env: &Env, overrides: &[String]) -> Composite {
|
||||
let signal = blueprint_from_json(doc, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
|
||||
@@ -725,55 +768,72 @@ pub fn wrapped_bound_names(signal: &Composite) -> HashSet<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The override subset of `names` (#246): every WRAPPED-coordinate name
|
||||
/// missing the un-reopened wrapped OPEN space but naming a BOUND param of the
|
||||
/// strategy — returned in STRATEGY coordinates (the wrap prefix
|
||||
/// `<signal.name()>.` stripped) for `Composite::reopen`. Names matching
|
||||
/// neither space are skipped here; the caller decides whether that is an
|
||||
/// error (sweep boundary) or falls through to its existing resolution
|
||||
/// errors. The silent variant `reproduce_family_in` uses over the RECORDED
|
||||
/// manifest param names (the sweep boundary uses the stricter
|
||||
/// `override_paths`, which errors on an unmatched name instead). Contrast
|
||||
/// `axes::raw_bound_overrides_of`, whose `names` are already RAW (a campaign
|
||||
/// document's own namespace, #203) and needs no such stripping.
|
||||
/// The override subset of `names` (#246): every name missing the un-reopened
|
||||
/// wrapped OPEN space but naming a BOUND param of the strategy — returned in
|
||||
/// STRATEGY coordinates (the wrap segment stripped) for `Composite::reopen`.
|
||||
/// Names matching neither space are skipped here; the caller decides whether
|
||||
/// that is an error (sweep boundary) or falls through to its existing
|
||||
/// resolution errors. The silent variant `reproduce_family_in`/`runner.rs`
|
||||
/// use over the RECORDED manifest param names (the sweep boundary uses the
|
||||
/// stricter `override_paths`, which errors on an unmatched name instead).
|
||||
///
|
||||
/// #328: matches via [`crate::axes::raw_matches_wrapped`], so `names` may be
|
||||
/// WRAPPED (every route this cycle leaves untouched) OR RAW (a `Sweep` family
|
||||
/// minted by `blueprint_sweep_family`, whose manifest now records the raw
|
||||
/// campaign namespace, #328) — `reproduce_family_in` reproduces families from
|
||||
/// either route generically, so this helper must tolerate both shapes rather
|
||||
/// than assume one. Contrast `axes::raw_bound_overrides_of`, whose `names` are
|
||||
/// ALWAYS RAW (a campaign document's own namespace, #203) by contract.
|
||||
pub fn wrapped_bound_overrides_of(
|
||||
names: &[String],
|
||||
open_space: &[ParamSpec],
|
||||
signal: &Composite,
|
||||
) -> Vec<String> {
|
||||
let open: HashSet<&str> = open_space.iter().map(|p| p.name.as_str()).collect();
|
||||
let prefix = format!("{}.", signal.name());
|
||||
let bound = wrapped_bound_names(signal);
|
||||
names
|
||||
.iter()
|
||||
.filter(|n| !open.contains(n.as_str()) && bound.contains(*n))
|
||||
.map(|n| n[prefix.len()..].to_string())
|
||||
.filter(|n| !open_space.iter().any(|p| crate::axes::raw_matches_wrapped(n, &p.name)))
|
||||
.filter_map(|n| {
|
||||
bound
|
||||
.iter()
|
||||
.find(|b| crate::axes::raw_matches_wrapped(n, b))
|
||||
.map(|b| crate::axes::wrapped_to_raw_axis(b).to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The sweep-boundary variant (#246): like `wrapped_bound_overrides_of`, but
|
||||
/// an axis matching NEITHER the open nor the bound space is the error — the
|
||||
/// honest replacement of the retired "fully bound; nothing to sweep" refusal.
|
||||
///
|
||||
/// #328: matches via [`crate::axes::raw_matches_wrapped`] rather than exact
|
||||
/// wrapped-name equality, so a RAW incoming name (`fast.length`) resolves
|
||||
/// against the WRAPPED open/bound space exactly like an exact wrapped hit
|
||||
/// (`sma_signal.fast.length`) would — the one shared suffix convention
|
||||
/// `axes.rs` defines, reused rather than re-derived here. This is the
|
||||
/// resolution mechanism, not an acceptance gate (contrast the CLI's own
|
||||
/// `--axis` intake, which refuses a wrapped name before this ever runs on the
|
||||
/// sweep-verb route); callers whose axes are not intake-filtered this cycle
|
||||
/// (the walk-forward routes) keep accepting either form unchanged.
|
||||
pub fn override_paths(
|
||||
axes: &[(String, Vec<Scalar>)],
|
||||
open_space: &[ParamSpec],
|
||||
signal: &Composite,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let open: HashSet<&str> = open_space.iter().map(|p| p.name.as_str()).collect();
|
||||
let prefix = format!("{}.", signal.name());
|
||||
let bound = wrapped_bound_names(signal);
|
||||
let mut overrides = Vec::new();
|
||||
for (name, _) in axes {
|
||||
if open.contains(name.as_str()) {
|
||||
if open_space.iter().any(|p| crate::axes::raw_matches_wrapped(name, &p.name)) {
|
||||
continue;
|
||||
}
|
||||
if bound.contains(name) {
|
||||
overrides.push(name[prefix.len()..].to_string());
|
||||
} else {
|
||||
return Err(format!(
|
||||
"axis {name}: names no param of this blueprint (open or bound) — \
|
||||
see `aura sweep <bp> --list-axes`"
|
||||
));
|
||||
match bound.iter().find(|b| crate::axes::raw_matches_wrapped(name, b)) {
|
||||
Some(b) => overrides.push(crate::axes::wrapped_to_raw_axis(b).to_string()),
|
||||
None => {
|
||||
return Err(format!(
|
||||
"axis {name}: names no param of this blueprint (open or bound) — \
|
||||
see `aura graph introspect --params <bp>`"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(overrides)
|
||||
@@ -801,6 +861,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 +907,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 +972,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 +1017,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 +1224,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(), None);
|
||||
let via_carve = run_signal_r(r_breakout_signal(Some(R_BREAKOUT_CHANNEL)), &[], RunData::Synthetic, 0, &env, TapPlan::record_all(), None);
|
||||
assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve");
|
||||
}
|
||||
|
||||
@@ -1311,13 +1377,15 @@ 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(), None);
|
||||
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(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve");
|
||||
}
|
||||
@@ -1646,4 +1714,181 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #338 write-side: `run_blueprint_member` given `StopRule::Fixed` actually
|
||||
/// bootstraps and runs the member (the `risk_executor`/`FixedStop` arm,
|
||||
/// end-to-end over a real synthetic realization) and stamps `stop_distance`
|
||||
/// into the manifest under the key `stop_rule_from_params`'s round-trip
|
||||
/// reads back — the `run_blueprint_member_stamps_the_vol_tf_stop_knobs`
|
||||
/// precedent above, `Fixed` edition.
|
||||
#[test]
|
||||
fn run_blueprint_member_stamps_the_fixed_stop_distance() {
|
||||
let env = Env::std();
|
||||
let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes");
|
||||
let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads");
|
||||
let space = blueprint_axis_probe(&doc, &env).param_space();
|
||||
let binding = crate::binding::resolve_binding("fixedstamp", reload().input_roles(), &BTreeMap::new())
|
||||
.expect("the price role resolves");
|
||||
let stop = StopRule::Fixed(10.0);
|
||||
let (sources, window, pip) = resolve_run_data(&RunData::Synthetic, &env, &binding);
|
||||
let run = run_blueprint_member(
|
||||
reload(),
|
||||
&[],
|
||||
&space,
|
||||
sources,
|
||||
window,
|
||||
0,
|
||||
pip,
|
||||
"topo",
|
||||
&env,
|
||||
stop,
|
||||
&binding,
|
||||
&[],
|
||||
"GER40",
|
||||
);
|
||||
assert!(
|
||||
run.manifest.params.contains(&("stop_distance".to_string(), Scalar::f64(10.0))),
|
||||
"manifest must stamp stop_distance: {:?}",
|
||||
run.manifest.params
|
||||
);
|
||||
assert!(
|
||||
!run.manifest.params.iter().any(|(k, _)| k == "stop_length" || k == "stop_k"),
|
||||
"a Fixed stop stamps no vol knobs: {:?}",
|
||||
run.manifest.params
|
||||
);
|
||||
}
|
||||
|
||||
/// 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(), None);
|
||||
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, None);
|
||||
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, None);
|
||||
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:?}"
|
||||
);
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("fast_tap"),
|
||||
"unknown-tap refusal enumerates the declared taps (fixture declares only fast_tap): {msg}"
|
||||
);
|
||||
|
||||
// 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(), None);
|
||||
run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, TapPlan::record_all(), None);
|
||||
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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use aura_core::Scalar;
|
||||
use aura_engine::{blueprint_from_json, window_of};
|
||||
use aura_registry::{group_families, Family, FamilyKind, Registry};
|
||||
use aura_backtest::point_from_params;
|
||||
@@ -142,7 +143,31 @@ pub fn reproduce_family_in(
|
||||
});
|
||||
}
|
||||
let space = crate::member::wrap_r(reload()?, tx_eq, tx_ex, tx_r, tx_req, stop, true, SYNTHETIC_PIP_SIZE, &binding, None).param_space();
|
||||
let point = point_from_params(&space, &stored.manifest.params)
|
||||
// #328: `stored.manifest.params` is RAW on every mint route now (the
|
||||
// synthetic sweep family and, since this cycle's tidy fix, the
|
||||
// real/campaign route too) — but a family minted before this fix may
|
||||
// still carry WRAPPED names on disk (C29: no retroactive rewrite of a
|
||||
// registered artifact), so `reproduce_family_in` reproduces either
|
||||
// shape generically: each recorded name is translated onto its
|
||||
// matching WRAPPED `space` slot (`raw_matches_wrapped`, tolerant of
|
||||
// both shapes) before `point_from_params`, which still keys by the
|
||||
// exact wrapped name. Non-axis stamps (`stop_length`, `cost[k].<knob>`)
|
||||
// carry no wrap segment either way and translate to themselves (no
|
||||
// `space` hit).
|
||||
let wrapped_params: Vec<(String, Scalar)> = stored
|
||||
.manifest
|
||||
.params
|
||||
.iter()
|
||||
.map(|(n, v)| {
|
||||
let wrapped = space
|
||||
.iter()
|
||||
.find(|p| crate::axes::raw_matches_wrapped(n, &p.name))
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| n.clone());
|
||||
(wrapped, *v)
|
||||
})
|
||||
.collect();
|
||||
let point = point_from_params(&space, &wrapped_params)
|
||||
.map_err(|m| RunnerError { exit_code: 1, message: m })?;
|
||||
// A MonteCarlo member carries no tuning params (the params-join is empty), so its
|
||||
// reproduce line would print a BLANK member label; the seed IS its realization
|
||||
|
||||
@@ -116,6 +116,21 @@ impl MemberRunner for DefaultMemberRunner<'_> {
|
||||
.param_space();
|
||||
let point = bind_axes(&space, &cell.strategy_id, params)?;
|
||||
let signal = reopen_all(signal, &overrides);
|
||||
// manifest.params records RAW names (#328: the real/campaign route was
|
||||
// the one mint left wrapped after the synthetic route's own switch) —
|
||||
// a name-only reshaping of `space` (order/kind untouched) fed to
|
||||
// `run_blueprint_member`, which only zips it against `point` BY
|
||||
// POSITION (`zip_params`) — never re-resolves by name — so renaming
|
||||
// here is purely the manifest's own namespace, with no effect on
|
||||
// member resolution (`space` itself stays wrapped for `bind_axes`
|
||||
// above, which still keys by the exact wrapped `param_space()` name).
|
||||
let manifest_space: Vec<aura_core::ParamSpec> = space
|
||||
.iter()
|
||||
.map(|p| aura_core::ParamSpec {
|
||||
name: crate::axes::wrapped_to_raw_axis(&p.name).to_string(),
|
||||
kind: p.kind,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// The member's resolved input binding (campaign data.bindings
|
||||
// overrides win over name defaults). A refusal is a member fault,
|
||||
@@ -173,7 +188,7 @@ impl MemberRunner for DefaultMemberRunner<'_> {
|
||||
let mut report = run_blueprint_member(
|
||||
signal,
|
||||
&point,
|
||||
&space,
|
||||
&manifest_space,
|
||||
sources,
|
||||
(from, to),
|
||||
0,
|
||||
@@ -247,7 +262,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 +274,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 +405,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 +425,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;
|
||||
}
|
||||
@@ -468,7 +505,28 @@ pub fn persist_campaign_traces(
|
||||
// is the member report's own `manifest.window` — already
|
||||
// epoch-ns (`run_blueprint_member` stamped the post-seam
|
||||
// bounds), so no second ms->ns crossing here.
|
||||
let point = point_from_params(&space, &member_report.manifest.params)?;
|
||||
//
|
||||
// #328: `member_report.manifest.params` is RAW (this cycle's tidy
|
||||
// fix put the real/campaign mint on the raw frame too), but
|
||||
// `point_from_params` still keys by the exact WRAPPED `space`
|
||||
// name — translate each recorded name onto its matching wrapped
|
||||
// slot first (`raw_matches_wrapped`, tolerant of either shape, the
|
||||
// same `reproduce_family_in` recipe), never re-resolving by a
|
||||
// renamed identity.
|
||||
let wrapped_params: Vec<(String, Scalar)> = member_report
|
||||
.manifest
|
||||
.params
|
||||
.iter()
|
||||
.map(|(n, v)| {
|
||||
let wrapped = space
|
||||
.iter()
|
||||
.find(|p| crate::axes::raw_matches_wrapped(n, &p.name))
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| n.clone());
|
||||
(wrapped, *v)
|
||||
})
|
||||
.collect();
|
||||
let point = point_from_params(&space, &wrapped_params)?;
|
||||
let (from, to) = member_report.manifest.window;
|
||||
let no_data = || {
|
||||
format!(
|
||||
@@ -753,4 +811,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,720 @@
|
||||
//! 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); one row at the last warm ts", FoldKind::Count),
|
||||
("sum", "sum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Sum),
|
||||
(
|
||||
"mean",
|
||||
"arithmetic mean of the series (f64 taps; f64 row); one row at the last warm ts",
|
||||
FoldKind::Mean,
|
||||
),
|
||||
("min", "minimum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Min),
|
||||
("max", "maximum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Max),
|
||||
(
|
||||
"first",
|
||||
"first warm value, at its own timestamp (any kind; kind-preserving row)",
|
||||
FoldKind::First,
|
||||
),
|
||||
("last", "last warm value, at its own timestamp (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, declared: Vec<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, declared } => {
|
||||
write!(
|
||||
f,
|
||||
"the tap plan names '{name}', but the blueprint declares no such tap — declared taps: {}",
|
||||
declared.join(", ")
|
||||
)
|
||||
}
|
||||
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(),
|
||||
declared: declared_taps.iter().map(|t| t.name.clone()).collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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() }
|
||||
}
|
||||
// Unbound, inert (C27) — but only reachable when `plan` carries
|
||||
// no default (an EXPLICIT plan, e.g. from `--tap`): record-all
|
||||
// (`default_named` = `Some(("record", …))`) always resolves the
|
||||
// Some arm above, so this arm never fires under record-all and
|
||||
// the note is exactly the C14 benign skipped-tap class (#334).
|
||||
// Emitted here (aura-runner), beside the pre-existing
|
||||
// runner-side `eprintln!` registers in this module/`member.rs`/
|
||||
// `measure.rs` — the runner→CLI print migration is #297.
|
||||
None => {
|
||||
eprintln!("aura: note: declared tap \"{}\" unbound this run", tap.name);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
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");
|
||||
}
|
||||
|
||||
/// C29 entry seam for the registry roster: every entry ships a gate-clean
|
||||
/// one-line meaning, and the bind/output prose inside it matches the
|
||||
/// entry's executable rules — the drift-pin the retired aura-std
|
||||
/// `fold_vocabulary` table carried, moved here with the surface (#332).
|
||||
#[test]
|
||||
fn roster_docs_pass_the_doc_gate_and_match_the_executable_rules() {
|
||||
let r = FoldRegistry::core();
|
||||
for entry in r.entries.values() {
|
||||
aura_core::doc_gate(entry.label, entry.doc)
|
||||
.unwrap_or_else(|f| panic!("fold {} doc fails the gate: {f:?}", entry.label));
|
||||
if entry.doc.contains("f64 taps") {
|
||||
assert!(
|
||||
(entry.binds_at)(ScalarKind::F64) && !(entry.binds_at)(ScalarKind::I64),
|
||||
"{} claims f64-only but binds wider",
|
||||
entry.label
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
entry.doc.contains("any kind"),
|
||||
"{}: bind prose must be 'f64 taps' or 'any kind': {}",
|
||||
entry.label,
|
||||
entry.doc
|
||||
);
|
||||
assert!(
|
||||
(entry.binds_at)(ScalarKind::I64) && (entry.binds_at)(ScalarKind::Bool),
|
||||
"{} claims any-kind but refuses a kind",
|
||||
entry.label
|
||||
);
|
||||
}
|
||||
if entry.doc.contains("i64 row") {
|
||||
assert!(
|
||||
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::I64)),
|
||||
"{} claims an i64 row but outputs otherwise",
|
||||
entry.label
|
||||
);
|
||||
} else if entry.doc.contains("f64 row") {
|
||||
assert!(
|
||||
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::F64)),
|
||||
"{} claims an f64 row but outputs otherwise",
|
||||
entry.label
|
||||
);
|
||||
} else if entry.doc.contains("kind-preserving row") {
|
||||
assert!(
|
||||
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::F64))
|
||||
&& matches!((entry.output)(ScalarKind::I64), FoldOutput::Row(ScalarKind::I64)),
|
||||
"{} claims kind-preserving but outputs otherwise",
|
||||
entry.label
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
matches!((entry.output)(ScalarKind::F64), FoldOutput::Series),
|
||||
"{}: doc names no row kind, so it must be the series entry",
|
||||
entry.label
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,11 @@
|
||||
//! the cost model) must still round-trip through a manifest, so a stored
|
||||
//! member (family, reproduce) re-derives bit-identically. These translators
|
||||
//! are the single source for each such write<->read pair — the manifest
|
||||
//! `stop_length`/`stop_k`/`stop_period_minutes` <-> [`StopRule`] binding, and
|
||||
//! the `cost[k].<knob>` <-> [`aura_research::CostSpec`] binding — so the two
|
||||
//! halves of each pair cannot drift out of sync between the run/campaign
|
||||
//! paths and reproduce/persist.
|
||||
//! `stop_length`/`stop_k`/`stop_period_minutes`/`stop_distance` <->
|
||||
//! [`StopRule`] binding, and the `cost[k].<knob>` <->
|
||||
//! [`aura_research::CostSpec`] binding — so the two halves of each pair
|
||||
//! cannot drift out of sync between the run/campaign paths and
|
||||
//! reproduce/persist.
|
||||
|
||||
use aura_composites::StopRule;
|
||||
use aura_core::{PrimitiveBuilder, Scalar};
|
||||
@@ -23,11 +24,14 @@ pub const R_SMA_STOP_LENGTH: i64 = 3;
|
||||
pub const R_SMA_STOP_K: f64 = 2.0;
|
||||
|
||||
/// Re-derive the `StopRule` a member was minted under from its manifest params
|
||||
/// (`stop_length`/`stop_k`/`stop_period_minutes`, stamped by `run_blueprint_member`):
|
||||
/// if `stop_period_minutes` is present alongside `stop_length`/`stop_k`, this
|
||||
/// re-derives `VolTf`; otherwise falls back to `Vol`, and to the default
|
||||
/// vol-stop regime when the manifest carries no stop knobs at all (pre-#233
|
||||
/// members), mirroring [`stop_rule_for_regime`]'s `None` arm for the same
|
||||
/// (`stop_length`/`stop_k`/`stop_period_minutes`/`stop_distance`, stamped by
|
||||
/// `run_blueprint_member`): if `stop_period_minutes` is present alongside
|
||||
/// `stop_length`/`stop_k`, this re-derives `VolTf`; `stop_length`/`stop_k`
|
||||
/// alone re-derives `Vol`; `stop_distance` alone (#338) re-derives `Fixed` —
|
||||
/// the three stamps are mutually exclusive (`run_blueprint_member` stamps
|
||||
/// exactly one arm's knobs per member). Falls back to the default vol-stop
|
||||
/// regime when the manifest carries no stop knobs at all (pre-#233 members),
|
||||
/// mirroring [`stop_rule_for_regime`]'s `None` arm for the same
|
||||
/// one-directional widening `point_from_params` already applies to missing
|
||||
/// manifest params.
|
||||
pub fn stop_rule_from_params(params: &[(String, Scalar)]) -> StopRule {
|
||||
@@ -35,11 +39,13 @@ pub fn stop_rule_from_params(params: &[(String, Scalar)]) -> StopRule {
|
||||
params.iter().find(|(n, _)| n == "stop_period_minutes").map(|(_, s)| s.as_i64());
|
||||
let length = params.iter().find(|(n, _)| n == "stop_length").map(|(_, s)| s.as_i64());
|
||||
let k = params.iter().find(|(n, _)| n == "stop_k").map(|(_, s)| s.as_f64());
|
||||
match (period_minutes, length, k) {
|
||||
(Some(period_minutes), Some(length), Some(k)) => {
|
||||
let distance = params.iter().find(|(n, _)| n == "stop_distance").map(|(_, s)| s.as_f64());
|
||||
match (period_minutes, length, k, distance) {
|
||||
(Some(period_minutes), Some(length), Some(k), _) => {
|
||||
StopRule::VolTf { period_minutes, length, k }
|
||||
}
|
||||
(None, Some(length), Some(k)) => StopRule::Vol { length, k },
|
||||
(None, Some(length), Some(k), _) => StopRule::Vol { length, k },
|
||||
(None, None, None, Some(distance)) => StopRule::Fixed(distance),
|
||||
_ => StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
|
||||
}
|
||||
}
|
||||
@@ -94,6 +100,7 @@ pub fn cost_specs_from_params(
|
||||
/// default vol-stop, `Some(RiskRegime::Vol { .. })` binds that regime's own
|
||||
/// params. Single-sourced so the persist-side re-run structurally cannot
|
||||
/// diverge from the run-side binding again (the #219 divergence class).
|
||||
/// `Fixed { distance }` (#338) binds the shipped `FixedStop` composite.
|
||||
pub fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> aura_composites::StopRule {
|
||||
match regime {
|
||||
None => aura_composites::StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
|
||||
@@ -103,6 +110,7 @@ pub fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> aura_c
|
||||
Some(aura_research::RiskRegime::VolTf { period_minutes, length, k }) => {
|
||||
aura_composites::StopRule::VolTf { period_minutes, length, k }
|
||||
}
|
||||
Some(aura_research::RiskRegime::Fixed { distance }) => aura_composites::StopRule::Fixed(distance),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +186,15 @@ mod tests {
|
||||
assert_eq!(stop_rule_from_params(&vol), StopRule::Vol { length: 3, k: 2.0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #338 round-trip: a manifest carrying ONLY `stop_distance` (no vol
|
||||
/// knobs) re-derives `StopRule::Fixed` — the `stop_rule_from_params_round_trips_the_vol_tf_stamp`
|
||||
/// precedent above, `Fixed` edition.
|
||||
fn stop_rule_from_params_round_trips_the_fixed_distance_stamp() {
|
||||
let fixed = vec![("stop_distance".to_string(), Scalar::f64(10.0))];
|
||||
assert_eq!(stop_rule_from_params(&fixed), StopRule::Fixed(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #262: `stop_rule_for_regime` binds `RiskRegime::VolTf` to
|
||||
/// `StopRule::VolTf` field-for-field — the resolve-side half of the
|
||||
@@ -193,6 +210,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #338: `stop_rule_for_regime` binds `RiskRegime::Fixed` to
|
||||
/// `StopRule::Fixed` — the resolve-side half of the write/resolve pair the
|
||||
/// manifest round-trip test above covers from the stamp side (the
|
||||
/// `stop_rule_for_regime_binds_vol_tf_field_for_field` precedent, `Fixed`
|
||||
/// edition).
|
||||
fn stop_rule_for_regime_binds_fixed_distance() {
|
||||
let regime = Some(aura_research::RiskRegime::Fixed { distance: 10.0 });
|
||||
assert_eq!(stop_rule_for_regime(regime), aura_composites::StopRule::Fixed(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #234: the one CostSpec -> builder binding maps each component to its
|
||||
/// shipped cost node with the knob BOUND — a bound component adds no open
|
||||
|
||||
@@ -7,13 +7,6 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
aura-core = { path = "../aura-core" }
|
||||
# DST-correct wall-clock math for the `Session` node. A vetted
|
||||
# standard crate for timezone/DST — never hand-rolled (C16 per-case policy).
|
||||
# `chrono` is already a transitive workspace dep (0.4 via aura-ingest's
|
||||
# data-server); aligned to that major. `chrono-tz` brings the IANA `Europe/Berlin`
|
||||
# zone + DST transitions.
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
chrono-tz = { version = "0.10", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
aura-strategy = { path = "../aura-strategy" }
|
||||
|
||||
@@ -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())),
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user