Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4301d87e75 | |||
| a3cd6a5b68 | |||
| 3596c3cdc4 | |||
| 7c27c070f4 | |||
| acd3d2c341 | |||
| 2add97e52d | |||
| 0d6d5b1324 | |||
| b18a695531 | |||
| e759b89b0e | |||
| a09aaadcbd | |||
| ede26bb231 | |||
| 9f87e5a583 | |||
| 9221bcd167 | |||
| 9636b00314 | |||
| 99e93000c5 | |||
| 32f6be8552 | |||
| 58d5345a75 | |||
| fd303de94d | |||
| 57c56ee4e2 | |||
| d87f534e85 | |||
| 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 |
Generated
+3
-2
@@ -160,6 +160,7 @@ dependencies = [
|
|||||||
"aura-core",
|
"aura-core",
|
||||||
"aura-engine",
|
"aura-engine",
|
||||||
"aura-ingest",
|
"aura-ingest",
|
||||||
|
"aura-market",
|
||||||
"aura-measurement",
|
"aura-measurement",
|
||||||
"aura-registry",
|
"aura-registry",
|
||||||
"aura-research",
|
"aura-research",
|
||||||
@@ -167,6 +168,7 @@ dependencies = [
|
|||||||
"aura-std",
|
"aura-std",
|
||||||
"aura-strategy",
|
"aura-strategy",
|
||||||
"aura-vocabulary",
|
"aura-vocabulary",
|
||||||
|
"chrono-tz",
|
||||||
"clap",
|
"clap",
|
||||||
"data-server",
|
"data-server",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -192,6 +194,7 @@ dependencies = [
|
|||||||
name = "aura-core"
|
name = "aura-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono-tz",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
@@ -307,8 +310,6 @@ dependencies = [
|
|||||||
"aura-backtest",
|
"aura-backtest",
|
||||||
"aura-core",
|
"aura-core",
|
||||||
"aura-strategy",
|
"aura-strategy",
|
||||||
"chrono",
|
|
||||||
"chrono-tz",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[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
|
- **Blueprint** — a serialized signal graph as data: a param-generic
|
||||||
`price → bias` node graph. A blueprint file (`blueprint.json`) is the unit a
|
`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*;
|
- **Open vs. closed** — a blueprint with unbound (free) numeric knobs is *open*;
|
||||||
one with every knob bound is *closed*. Different verbs want different states
|
one with every knob bound is *closed*. A plain `exec` wants closed; naming a
|
||||||
(see each verb below).
|
knob as a campaign axis (or an `exec --override`) is what reopens it.
|
||||||
- **Family** — the set of runs one verb produces over a blueprint (a sweep grid,
|
- **Family** — the set of runs a campaign document's process pipeline produces
|
||||||
a Monte-Carlo seed set, a walk-forward window sequence). Families are persisted
|
over a blueprint (a sweep grid, a Monte-Carlo seed set, a walk-forward window
|
||||||
in a content-addressed store and can be listed, ranked, and reproduced.
|
sequence). Families are persisted in a content-addressed store and can be
|
||||||
- **Axis** — one named, sweepable knob of a blueprint (e.g. `graph.fast.length`),
|
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
|
bound with a comma-separated value list. A **gang** fuses several sibling
|
||||||
knobs into one axis (one value drives all members).
|
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**.
|
`aura exec <target>` (#319) is the one executor verb: `<target>` is either a
|
||||||
Most drive a family of runs over it; `graph` renders its structure so a mis-wire
|
loaded **blueprint** file (a single synthetic run) or a **campaign document**
|
||||||
is visible before a run. `walkforward`, `mc`, and `generalize` share
|
(a `.json` file or its registered 64-hex content id — instruments × windows ×
|
||||||
`sweep`'s generic grammar — the blueprint positional plus `--real` and a
|
strategy × param axes × process, see `docs/authoring-guide.md` §3). `graph`
|
||||||
repeatable `--axis <name>=<csv>` (`generalize` takes `--real <SYM1,SYM2,…>`, at
|
renders a blueprint's structure so a mis-wire is visible before a run.
|
||||||
least two instruments, with a single value per axis) — see `--help`.
|
|
||||||
|
|
||||||
| Command | Purpose |
|
| 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 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 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 sweep <bp.json> --axis <name>=<csv> [--axis …]` | Run a **grid family** over the named axes and persist it. |
|
| `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 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 runs families` | List every persisted family (id, kind, member count). |
|
| `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 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). |
|
| `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
|
**Important contract — every open knob is mandatory on a campaign axis.** A
|
||||||
`walkforward`, the knobs enumerated by `--list-axes` are *all* required: you must
|
campaign document's `strategies[].axes` must name every open knob
|
||||||
supply an `--axis` for each open knob. There is no default value — pin a knob you
|
`graph introspect --params` lists: 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
|
don't want to vary with a single-value axis (`{"kind":"I64","values":[4]}`).
|
||||||
clean error naming the missing knob, not a silent default.
|
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`),
|
Use `aura exec --help` for the exact flag grammar, `docs/authoring-guide.md`
|
||||||
naming (`--name`), and selection (`--select`) flags.
|
for the campaign document shape (data window, naming, presentation/emit).
|
||||||
|
|
||||||
## Authoring & introspecting topology
|
## Authoring & introspecting topology
|
||||||
|
|
||||||
@@ -111,17 +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
|
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
|
```sh
|
||||||
aura graph build < crossover.ops.json > crossover.bp.json
|
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`,
|
The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, `tap`,
|
||||||
`gang`, and `doc` (`tap` declares a recorded measurement point on an interior
|
`gang`, `doc`, and `use` (`tap` declares a recorded measurement point on an
|
||||||
wire; `doc` declares the composite's one-line meaning, required at register —
|
interior wire; `doc` declares the composite's one-line meaning, required at
|
||||||
C29; see the authoring guide). See
|
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
|
`aura graph introspect --node <T>` for a type's exact ports and the op-script
|
||||||
grammar in the design ledger for the full semantics.
|
grammar in the design ledger for the full semantics.
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"surface": "cli_fixed_cost",
|
"surface": "cli_fixed_cost",
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"help_ms": 1.5046899999999999,
|
"help_ms": 1.487171,
|
||||||
"run_ms": 3.63305
|
"run_ms": 3.376192
|
||||||
},
|
},
|
||||||
"fingerprint": "run_line_fnv=6bb0d796f760d140",
|
"fingerprint": "run_line_fnv=9bdbc3acf7b2926a",
|
||||||
"reps": 3,
|
"reps": 3,
|
||||||
"host": {
|
"host": {
|
||||||
"hostname": "Raki",
|
"hostname": "Raki",
|
||||||
"nproc": 24
|
"nproc": 24
|
||||||
},
|
},
|
||||||
"profile": "release",
|
"profile": "release",
|
||||||
"commit": "9c7f60b",
|
"commit": "10570b7",
|
||||||
"date": "2026-07-23"
|
"date": "2026-07-25"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,31 +95,18 @@ fn blueprint_stems(dir: &Path) -> Vec<String> {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seed one blueprint into the scratch store via a tiny synthetic sweep (the
|
/// Seed one blueprint into the scratch store via `graph register` (#319: the
|
||||||
/// E2E `seed_blueprint` pattern) and return its content id.
|
/// surviving registration surface) and return its content id.
|
||||||
fn seed_blueprint(bin: &Path, dir: &Path, file: &str, name: &str) -> Result<String, String> {
|
fn seed_blueprint(bin: &Path, dir: &Path, file: &str, name: &str) -> Result<String, String> {
|
||||||
let before = blueprint_stems(dir);
|
let before = blueprint_stems(dir);
|
||||||
let (out, code) = run_in(
|
let (out, code) = run_in(bin, dir, &["graph", "register", file, "--name", name])?;
|
||||||
bin,
|
|
||||||
dir,
|
|
||||||
&[
|
|
||||||
"sweep",
|
|
||||||
file,
|
|
||||||
"--axis",
|
|
||||||
"sma_signal.fast.length=2,4",
|
|
||||||
"--axis",
|
|
||||||
"sma_signal.slow.length=8,16",
|
|
||||||
"--name",
|
|
||||||
name,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
if code != Some(0) {
|
if code != Some(0) {
|
||||||
return Err(format!("seed sweep failed ({code:?}): {out}"));
|
return Err(format!("seed register failed ({code:?}): {out}"));
|
||||||
}
|
}
|
||||||
blueprint_stems(dir)
|
blueprint_stems(dir)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|s| !before.contains(s))
|
.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> {
|
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> {
|
fn campaign_rep(bin: &Path, sizing: Sizing, process_doc: &str) -> Result<RepOutcome, String> {
|
||||||
let (scratch, doc) = build_scratch(bin, sizing, process_doc)?;
|
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) {
|
if timed.exit != Some(0) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"campaign run exited {:?}\nstdout: {}\nstderr: {}",
|
"aura exec exited {:?}\nstdout: {}\nstderr: {}",
|
||||||
timed.exit, timed.stdout, timed.stderr
|
timed.exit, timed.stdout, timed.stderr
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! CLI fixed cost: the spawn floor (`aura --help`, no fingerprint — nothing is
|
//! CLI fixed cost: the spawn floor (`aura --help`, no fingerprint — nothing is
|
||||||
//! computed) and a minimal data-only project run whose fingerprint is the
|
//! computed) and a minimal data-only project exec whose fingerprint is the
|
||||||
//! FNV-1a hash of the run's single stdout JSON line. Fresh scratch project per
|
//! FNV-1a hash of the exec's single stdout JSON line. Fresh scratch project per
|
||||||
//! repetition, so store counters start identical.
|
//! repetition, so store counters start identical.
|
||||||
|
|
||||||
use super::{median, RepOutcome};
|
use super::{median, RepOutcome};
|
||||||
@@ -11,7 +11,7 @@ use std::collections::BTreeMap;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Instant;
|
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
|
/// 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
|
/// (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
|
/// 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 run_walls = Vec::new();
|
||||||
let mut first_line = None;
|
let mut first_line = None;
|
||||||
for _ in 0..RUN_SAMPLES {
|
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) {
|
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);
|
run_walls.push(timed.wall_s * 1000.0);
|
||||||
if first_line.is_none() {
|
if first_line.is_none() {
|
||||||
@@ -70,7 +70,7 @@ pub fn rep(bin: &Path) -> Result<RepOutcome, String> {
|
|||||||
.stdout
|
.stdout
|
||||||
.lines()
|
.lines()
|
||||||
.next()
|
.next()
|
||||||
.ok_or("aura run must print its JSON record line")?
|
.ok_or("aura exec must print its JSON record line")?
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ mod tests {
|
|||||||
|
|
||||||
/// `run_reps` composed with the real campaign sweep surface — a fresh
|
/// `run_reps` composed with the real campaign sweep surface — a fresh
|
||||||
/// scratch project, a freshly seeded blueprint, and a genuine
|
/// 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
|
/// in-process shortcut). Protects that two independently built-and-run
|
||||||
/// scratch campaigns agree on the winner-ordinal fingerprint extracted
|
/// scratch campaigns agree on the winner-ordinal fingerprint extracted
|
||||||
/// from the child's stdout — C1's determinism carried through the
|
/// 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
|
/// `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 --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
|
/// Protects that two independent scratch runs agree on the run-line FNV
|
||||||
/// fingerprint extracted from the child's stdout, and that the metrics
|
/// fingerprint extracted from the child's stdout, and that the metrics
|
||||||
/// map carries the wall-clock keys the fixed_cost `BaselineDoc` needs.
|
/// map carries the wall-clock keys the fixed_cost `BaselineDoc` needs.
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ zip = "2"
|
|||||||
# (#[cfg(test)] use aura_composites::StopRule in main.rs's ic_tests module),
|
# (#[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.
|
# so this rides the same dev-only-edge idiom as `aura-analysis`/`sha2` below.
|
||||||
aura-composites = { path = "../aura-composites" }
|
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
|
# 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.
|
# (mod ic_tests in main.rs) — a test-only edge, not a production one.
|
||||||
aura-analysis = { path = "../aura-analysis" }
|
aura-analysis = { path = "../aura-analysis" }
|
||||||
|
|||||||
@@ -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,
|
//! a realized run-set (#198). The execution *semantics* (preflight, cell loop,
|
||||||
//! stage sequencing, selection, registry writes) live in `aura-campaign`; the
|
//! stage sequencing, selection, registry writes) live in `aura-campaign`; the
|
||||||
//! [`MemberRunner`](aura_campaign::MemberRunner) implementation over the shipped loaded-blueprint machinery
|
//! [`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_registry::CampaignRunRecord;
|
||||||
use aura_research::{
|
use aura_research::{
|
||||||
campaign_to_json, content_id_of, parse_campaign, parse_process, validate_campaign,
|
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;
|
use aura_runner::axes::is_content_id;
|
||||||
@@ -108,27 +109,17 @@ struct CampaignRunLine<'a> {
|
|||||||
campaign_run: &'a CampaignRunRecord,
|
campaign_run: &'a CampaignRunRecord,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stdout shape of a campaign run. `Full` is `aura campaign run` (emit-gated
|
/// `aura exec <target>` (campaign legs): resolve, gate, execute, emit. Every `Err`
|
||||||
/// 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`
|
|
||||||
/// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1; an `Ok`
|
/// 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
|
/// 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(
|
pub(crate) fn run_campaign(
|
||||||
target: &str,
|
target: &str,
|
||||||
env: &Env,
|
env: &Env,
|
||||||
parallel_instruments: NonZeroUsize,
|
parallel_instruments: NonZeroUsize,
|
||||||
|
overrides: &[(String, Scalar)],
|
||||||
) -> Result<usize, String> {
|
) -> Result<usize, String> {
|
||||||
// Project gate FIRST: nothing (not even the file-sugar registration)
|
// Project gate FIRST: nothing (not even the file-sugar registration)
|
||||||
// touches a store outside a project.
|
// 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
|
/// 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 one campaign executor path from a resolved content id onward: fetch
|
||||||
/// the stored canonical bytes by id (so file addressing and id addressing
|
/// the stored canonical bytes by id (so file addressing and id addressing
|
||||||
/// produce the same realization by construction) and re-run the intrinsic
|
/// produce the same realization by construction) and re-run the intrinsic
|
||||||
/// tier on them, execute, and emit per `presentation`. Shared by
|
/// tier on them, execute, and emit. Called by `run_campaign`.
|
||||||
/// `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).
|
|
||||||
pub(crate) fn run_campaign_by_id(
|
pub(crate) fn run_campaign_by_id(
|
||||||
campaign_id: &str,
|
campaign_id: &str,
|
||||||
env: &Env,
|
env: &Env,
|
||||||
presentation: RunPresentation,
|
|
||||||
parallel_instruments: NonZeroUsize,
|
parallel_instruments: NonZeroUsize,
|
||||||
|
overrides: &[(String, Scalar)],
|
||||||
) -> Result<usize, String> {
|
) -> Result<usize, String> {
|
||||||
let run = run_campaign_returning(campaign_id, env, parallel_instruments)?;
|
let run = run_campaign_returning(campaign_id, env, parallel_instruments, overrides)?;
|
||||||
present_campaign(run, presentation, env)
|
present_campaign(run, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one campaign executor path from a resolved content id up to (not
|
/// 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
|
/// outcome bundled with the context the presenter needs. Shared by
|
||||||
/// `run_campaign_by_id` (which then presents) and a forthcoming dissolved-verb
|
/// `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,
|
/// 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(
|
pub(crate) fn run_campaign_returning(
|
||||||
campaign_id: &str,
|
campaign_id: &str,
|
||||||
env: &Env,
|
env: &Env,
|
||||||
parallel_instruments: NonZeroUsize,
|
parallel_instruments: NonZeroUsize,
|
||||||
|
overrides: &[(String, Scalar)],
|
||||||
) -> Result<CampaignRun, String> {
|
) -> Result<CampaignRun, String> {
|
||||||
let registry = env.registry();
|
let registry = env.registry();
|
||||||
let campaign_text = registry
|
let campaign_text = registry
|
||||||
.get_campaign(campaign_id)
|
.get_campaign(campaign_id)
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.ok_or_else(|| format!("no campaign {campaign_id} in the project store"))?;
|
.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))?;
|
.map_err(|e| doc_error_prose("stored campaign document", &e))?;
|
||||||
let faults = validate_campaign(&campaign);
|
let faults = validate_campaign(&campaign);
|
||||||
if !faults.is_empty() {
|
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).
|
// Referential gate: zero faults or refuse (the campaign-validate seam).
|
||||||
let resolve = |t: &str| env.resolve(t);
|
let resolve = |t: &str| env.resolve(t);
|
||||||
let ref_faults = registry
|
let ref_faults = registry
|
||||||
@@ -295,15 +314,11 @@ pub(crate) fn run_campaign_returning(
|
|||||||
Ok(CampaignRun { outcome, campaign, strategies, server: runner.server() })
|
Ok(CampaignRun { outcome, campaign, strategies, server: runner.server() })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit an executed campaign's results per `presentation`: the zero-survivor
|
/// Emit an executed campaign's results: the zero-survivor stderr notes, the
|
||||||
/// stderr notes, the emit-gated family/selection lines, the always-on record
|
/// emit-gated family/selection lines, the always-on record line, then trace
|
||||||
/// line (Full only), then trace persistence. Split out of `run_campaign_by_id`
|
/// persistence. Split out of `run_campaign_by_id` so a forthcoming caller
|
||||||
/// so the dissolved-verb sugar can consume the outcome without this stdout tail.
|
/// could consume the outcome without this stdout tail.
|
||||||
fn present_campaign(
|
fn present_campaign(run: CampaignRun, env: &Env) -> Result<usize, String> {
|
||||||
run: CampaignRun,
|
|
||||||
presentation: RunPresentation,
|
|
||||||
env: &Env,
|
|
||||||
) -> Result<usize, String> {
|
|
||||||
let CampaignRun { outcome, campaign, strategies, server } = run;
|
let CampaignRun { outcome, campaign, strategies, server } = run;
|
||||||
|
|
||||||
// Zero-survivor stderr notes (exit stays 0 — a null result is a valid
|
// Zero-survivor stderr notes (exit stays 0 — a null result is a valid
|
||||||
@@ -355,6 +370,32 @@ fn present_campaign(
|
|||||||
let emit_family = campaign.presentation.emit.iter().any(|e| e == "family_table");
|
let emit_family = campaign.presentation.emit.iter().any(|e| e == "family_table");
|
||||||
let emit_selection = campaign.presentation.emit.iter().any(|e| e == "selection_report");
|
let emit_selection = campaign.presentation.emit.iter().any(|e| e == "selection_report");
|
||||||
for cell_out in &outcome.cells {
|
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 {
|
if emit_family {
|
||||||
for fam in &cell_out.families {
|
for fam in &cell_out.families {
|
||||||
for report in &fam.reports {
|
for report in &fam.reports {
|
||||||
@@ -381,23 +422,21 @@ fn present_campaign(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if presentation == RunPresentation::Full {
|
println!(
|
||||||
println!(
|
"{}",
|
||||||
"{}",
|
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
|
||||||
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
|
.expect("campaign run record serializes")
|
||||||
.expect("campaign run record serializes")
|
);
|
||||||
);
|
eprintln!(
|
||||||
eprintln!(
|
"aura: campaign run {} recorded: {} cells{}",
|
||||||
"aura: campaign run {} recorded: {} cells{}",
|
outcome.record.run,
|
||||||
outcome.record.run,
|
outcome.record.cells.len(),
|
||||||
outcome.record.cells.len(),
|
if failed == 0 {
|
||||||
if failed == 0 {
|
String::new()
|
||||||
String::new()
|
} else {
|
||||||
} else {
|
format!(", {failed} failed ({})", fail_labels.join(", "))
|
||||||
format!(", {failed} failed ({})", fail_labels.join(", "))
|
}
|
||||||
}
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trace persistence runs LAST, after the always-on record line (0109):
|
// Trace persistence runs LAST, after the always-on record line (0109):
|
||||||
// stdout stays data-pure (the record line is the wire contract) and the
|
// stdout stays data-pure (the record line is the wire contract) and the
|
||||||
@@ -417,6 +456,13 @@ fn present_campaign(
|
|||||||
Ok(failed)
|
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
|
/// #272: the fault-kind label the failed-cell summary line names — the same
|
||||||
/// closed `CellFaultKind` vocabulary an aggregate over `campaign_runs.jsonl`
|
/// closed `CellFaultKind` vocabulary an aggregate over `campaign_runs.jsonl`
|
||||||
/// counts by. Debug-leak-free (a fixed lowercase-snake label, not a Debug
|
/// counts by. Debug-leak-free (a fixed lowercase-snake label, not a Debug
|
||||||
|
|||||||
+70
-11
@@ -19,11 +19,9 @@ macro_rules! warning {
|
|||||||
|
|
||||||
pub(crate) use {note, warning};
|
pub(crate) use {note, warning};
|
||||||
|
|
||||||
/// The zero-trade note's message text (#313), pluralization-aware — the
|
/// The zero-trade note's message text (#313), pluralization-aware — a field
|
||||||
/// milestone fieldtest tripped over "all 1 walk-forward windows"
|
/// test caught the ungrammatical "all 1 walk-forward windows" on a run whose
|
||||||
/// (captured: `fieldtests/milestone-stderr-honesty/captured/
|
/// window count was one. Pure over the count so the wording is unit-pinned.
|
||||||
/// example2_singular.err`). Pure over the count so the wording is
|
|
||||||
/// unit-pinned.
|
|
||||||
fn zero_trade_note_text(n_windows: usize) -> String {
|
fn zero_trade_note_text(n_windows: usize) -> String {
|
||||||
if n_windows == 1 {
|
if n_windows == 1 {
|
||||||
"the single walk-forward window recorded zero trades".to_string()
|
"the single walk-forward window recorded zero trades".to_string()
|
||||||
@@ -32,11 +30,11 @@ fn zero_trade_note_text(n_windows: usize) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The #313 zero-trade note, shared by both walk-forward paths (the
|
/// The #313 zero-trade note (#319 Task 5: migrated onto the campaign
|
||||||
/// synthetic-blueprint path in `main.rs` and the `--real` sugar path in
|
/// walk-forward leg in `campaign_run.rs`, the surviving executor path) so
|
||||||
/// `verb_sugar.rs`) so the wording AND the condition live in exactly one
|
/// the wording AND the condition live in exactly one place. Takes the
|
||||||
/// place. Takes the per-window trade counts; a no-op unless there is at
|
/// per-window trade counts; a no-op unless there is at least one window and
|
||||||
/// least one window and every one of them traded zero times.
|
/// every one of them traded zero times.
|
||||||
pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator<Item = u64>) {
|
pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator<Item = u64>) {
|
||||||
let n_windows = window_trades.len();
|
let n_windows = window_trades.len();
|
||||||
if n_windows > 0 && window_trades.all(|n| n == 0) {
|
if n_windows > 0 && window_trades.all(|n| n == 0) {
|
||||||
@@ -44,9 +42,54 @@ pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::zero_trade_note_text;
|
use super::{zero_trade_cell_note_text, zero_trade_note_text};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
/// #278 fieldtest friction: the single-window form must read as
|
/// #278 fieldtest friction: the single-window form must read as
|
||||||
@@ -62,4 +105,20 @@ mod tests {
|
|||||||
"all 3 walk-forward windows recorded zero trades"
|
"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
+1262
-2005
File diff suppressed because it is too large
Load Diff
@@ -65,7 +65,7 @@ impl DocIntrospectCmd {
|
|||||||
/// Parse `--parallel-instruments` in domain terms: clap's built-in
|
/// Parse `--parallel-instruments` in domain terms: clap's built-in
|
||||||
/// `NonZeroUsize` parser would leak the Rust type name ("number would be
|
/// `NonZeroUsize` parser would leak the Rust type name ("number would be
|
||||||
/// zero for non-zero type") at exactly the moment a user needs guidance.
|
/// 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(|| {
|
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 \
|
"must be a whole number of at least 1 — it bounds how many distinct \
|
||||||
instruments are resident in parallel"
|
instruments are resident in parallel"
|
||||||
@@ -107,10 +107,12 @@ fn print_metric_roster() {
|
|||||||
(true, false) => "rankable",
|
(true, false) => "rankable",
|
||||||
(false, false) => "annotation",
|
(false, false) => "annotation",
|
||||||
};
|
};
|
||||||
|
// C29 (#315): the roster carries each metric's one-line meaning, not
|
||||||
|
// only where it may be used.
|
||||||
if generalize {
|
if generalize {
|
||||||
println!("{name:<24} {base} | generalize");
|
println!("{name:<24} {base} | generalize — {}", m.doc);
|
||||||
} else {
|
} else {
|
||||||
println!("{name:<24} {base}");
|
println!("{name:<24} {base} — {}", m.doc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +161,7 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
|
|||||||
format!("data.windows[{index}]: from_ms must be earlier than to_ms")
|
format!("data.windows[{index}]: from_ms must be earlier than to_ms")
|
||||||
}
|
}
|
||||||
DocFault::BadRegime { index } => {
|
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 } => {
|
DocFault::BadCost { index } => {
|
||||||
format!("cost[{index}]: the component's price-unit knob must be finite and >= 0")
|
format!("cost[{index}]: the component's price-unit knob must be finite and >= 0")
|
||||||
@@ -292,7 +294,7 @@ fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
|
|||||||
/// caller's existing not-found prose applies unchanged. `Err`: two or more
|
/// caller's existing not-found prose applies unchanged. `Err`: two or more
|
||||||
/// candidates share the prefix — refused by name, listing every candidate,
|
/// candidates share the prefix — refused by name, listing every candidate,
|
||||||
/// rather than guessed.
|
/// rather than guessed.
|
||||||
fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, String> {
|
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();
|
let matches: Vec<&String> = candidates.iter().filter(|c| c.starts_with(prefix)).collect();
|
||||||
match matches.as_slice() {
|
match matches.as_slice() {
|
||||||
[] => Ok(None),
|
[] => Ok(None),
|
||||||
@@ -372,6 +374,13 @@ fn describe_one_block(id: &str) -> Result<(), String> {
|
|||||||
for s in schema.slots {
|
for s in schema.slots {
|
||||||
let req = if s.required { "required" } else { "optional" };
|
let req = if s.required { "required" } else { "optional" };
|
||||||
println!(" {:<20} {req}, {}", s.name, slot_kind_label(s.kind));
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -402,19 +411,6 @@ pub enum CampaignSub {
|
|||||||
Introspect(DocIntrospectCmd),
|
Introspect(DocIntrospectCmd),
|
||||||
/// Register a valid campaign document into the store under the runs root.
|
/// Register a valid campaign document into the store under the runs root.
|
||||||
Register { file: PathBuf },
|
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
|
/// List stored campaign realizations, or dump one campaign's records
|
||||||
/// (the bare store lines, not the run-emit wrapper).
|
/// (the bare store lines, not the run-emit wrapper).
|
||||||
Runs { campaign: Option<String>, run: Option<usize> },
|
Runs { campaign: Option<String>, run: Option<usize> },
|
||||||
@@ -453,8 +449,14 @@ pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
|
|||||||
RefFault::StrategyUnloadable { id, error } => {
|
RefFault::StrategyUnloadable { id, error } => {
|
||||||
format!("strategy {id} cannot be loaded: {error}")
|
format!("strategy {id} cannot be loaded: {error}")
|
||||||
}
|
}
|
||||||
RefFault::AxisNotInParamSpace { strategy, axis } => {
|
RefFault::AxisNotInParamSpace { strategy, axis, raw_candidate } => {
|
||||||
format!("strategy {strategy}: axis \"{axis}\" is not in the param space")
|
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 } => {
|
RefFault::AxisKindMismatch { strategy, axis } => {
|
||||||
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
|
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
|
||||||
@@ -527,18 +529,7 @@ fn campaign_runs(campaign: Option<&str>, run: Option<usize>, env: &Env) -> Resul
|
|||||||
Ok(())
|
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) {
|
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 {
|
let result = match &cmd.sub {
|
||||||
CampaignSub::Validate { file } => validate_campaign_file(file, env),
|
CampaignSub::Validate { file } => validate_campaign_file(file, env),
|
||||||
CampaignSub::Introspect(i) => {
|
CampaignSub::Introspect(i) => {
|
||||||
@@ -548,7 +539,6 @@ pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
|
|||||||
CampaignSub::Register { file } => register_campaign(file, env),
|
CampaignSub::Register { file } => register_campaign(file, env),
|
||||||
CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env),
|
CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env),
|
||||||
CampaignSub::Show { id } => show_campaign(id, env),
|
CampaignSub::Show { id } => show_campaign(id, env),
|
||||||
CampaignSub::Run { .. } => unreachable!("handled above"),
|
|
||||||
};
|
};
|
||||||
if let Err(m) = result {
|
if let Err(m) = result {
|
||||||
eprintln!("aura: {m}");
|
eprintln!("aura: {m}");
|
||||||
@@ -752,6 +742,7 @@ mod tests {
|
|||||||
ref_fault_prose(&RefFault::AxisNotInParamSpace {
|
ref_fault_prose(&RefFault::AxisNotInParamSpace {
|
||||||
strategy: "9f3a".into(),
|
strategy: "9f3a".into(),
|
||||||
axis: "nope".into(),
|
axis: "nope".into(),
|
||||||
|
raw_candidate: None,
|
||||||
}),
|
}),
|
||||||
ref_fault_prose(&RefFault::AxisKindMismatch {
|
ref_fault_prose(&RefFault::AxisKindMismatch {
|
||||||
strategy: "9f3a".into(),
|
strategy: "9f3a".into(),
|
||||||
@@ -772,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]
|
#[test]
|
||||||
/// #194 (the prefix trap): a doc ref that carries the display `content:`
|
/// #194 (the prefix trap): a doc ref that carries the display `content:`
|
||||||
/// prefix (a copy-paste from register/introspect output) is bare-only in
|
/// prefix (a copy-paste from register/introspect output) is bare-only in
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ impl Scale {
|
|||||||
}],
|
}],
|
||||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||||
params: vec![ParamSpec { name: "factor".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",
|
doc: "scalar gain: emits the input times the factor param",
|
||||||
},
|
},
|
||||||
|p| Box::new(Scale::new(p[0].f64())),
|
|p| Box::new(Scale::new(p[0].f64())),
|
||||||
@@ -229,15 +231,25 @@ const CLAUDE_MD_PROJECT: &str = r#"# __NAME__ — an aura research project (data
|
|||||||
This directory is an aura project: blueprints + research documents over the
|
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.
|
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
|
- Run: `aura exec blueprints/signal.json` (single smoke run, synthetic stream
|
||||||
params bound; bound values are defaults — any `--axis` may override them
|
— the starter is closed; all params bound; bound values are defaults —
|
||||||
(#246))
|
`--override NODE.PARAM=VALUE` may override one for this run (#246))
|
||||||
- Sweep: `aura sweep blueprints/signal.json --axis __NAME_SNAKE___signal.fast.length=2,4,8`
|
- Campaign: `aura exec <campaign.json>` executes a registered campaign
|
||||||
(`aura sweep <bp> --list-axes` lists the open + bound-overridable axes)
|
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,
|
- Native nodes: when the project needs its first project-specific node,
|
||||||
`aura nodes new <name>` scaffolds a node crate beside this project and
|
`aura nodes new <name>` scaffolds a node crate beside this project and
|
||||||
attaches it via `[nodes]` in `Aura.toml` (build it with `cargo build`).
|
attaches it via `[nodes]` in `Aura.toml` (build it with `cargo build`).
|
||||||
- Topology is data (`blueprints/*.json`); results land in `runs/`.
|
- 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__`
|
/// Render a data-only project template: only `__NAME__`/`__NAME_SNAKE__`
|
||||||
@@ -448,11 +460,47 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
|
let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
|
||||||
assert!(claude.contains("demo-lab"));
|
assert!(claude.contains("demo-lab"));
|
||||||
// The CLAUDE.md sweep quickstart targets the closed starter itself
|
// The CLAUDE.md quickstart targets the closed starter itself through
|
||||||
// with the rendered blueprint-name axis prefix (#246: a bound param
|
// the surviving surface (#319): exec for both document classes, the
|
||||||
// is a default an axis overrides).
|
// #246 override residue, and raw-namespace axis discovery (#328).
|
||||||
assert!(claude.contains("blueprints/signal.json"));
|
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
|
/// 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.
|
//! handled cleanly, never a panic.
|
||||||
|
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
|
use std::path::Path;
|
||||||
use std::process::{Command, Stdio};
|
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
|
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the test
|
||||||
/// crate; the binary is named `aura` in `Cargo.toml`).
|
/// crate; the binary is named `aura` in `Cargo.toml`).
|
||||||
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||||||
|
|
||||||
/// A fresh, unique working directory so the spawned `aura sweep` writes its
|
fn run_code_in(dir: &Path, args: &[&str]) -> (String, Option<i32>) {
|
||||||
/// `./runs/runs.jsonl` into a throwaway dir, never dirtying the repo. Unique
|
let out = Command::new(BIN).args(args).current_dir(dir).output().expect("binary runs");
|
||||||
/// per test + per process; no external tempfile dependency (mirrors `cli_run.rs`).
|
let text = format!(
|
||||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
"{}{}",
|
||||||
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}"));
|
String::from_utf8_lossy(&out.stdout),
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
String::from_utf8_lossy(&out.stderr)
|
||||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
);
|
||||||
dir
|
(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
|
/// Property: a family-emitting command whose stdout reader closes early exits on
|
||||||
/// the EPIPE cleanly — it does NOT panic (`failed printing to stdout: Broken
|
/// 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
|
/// pipe`) and does NOT exit 101. `aura exec <campaign.json>` streams four
|
||||||
/// lines via `println!`; when the reader closes stdout after the first line, the
|
/// family-member JSON lines via `println!` (a 2×2 axis grid — #319: the
|
||||||
/// next write hits a closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at
|
/// surviving surface for what a bare `aura sweep` demo grid used to stream);
|
||||||
/// startup, so that write returns EPIPE and `println!` panics rather than the
|
/// when the reader closes stdout after the first line, the next write hits a
|
||||||
/// process terminating quietly — exactly the behaviour a CLI must not have. This
|
/// closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at startup, so that
|
||||||
/// is the contract: any aura subcommand can be piped into `| head` / `| less` /
|
/// 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.
|
/// 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]
|
#[test]
|
||||||
fn family_emitting_command_survives_early_reader_close() {
|
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)
|
let mut child = Command::new(BIN)
|
||||||
.arg("sweep")
|
.args(["exec", "broken-pipe.campaign.json"])
|
||||||
.current_dir(&cwd)
|
.current_dir(&dir)
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
.expect("spawn aura sweep");
|
.expect("spawn aura exec");
|
||||||
|
|
||||||
// Read just enough to guarantee the writer is mid-stream, then drop the read
|
// 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!`
|
// 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() {
|
if let Some(mut err) = child.stderr.take() {
|
||||||
let _ = err.read_to_string(&mut stderr);
|
let _ = err.read_to_string(&mut stderr);
|
||||||
}
|
}
|
||||||
let status = child.wait().expect("reap aura sweep");
|
let status = child.wait().expect("reap aura exec");
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&cwd);
|
|
||||||
|
|
||||||
// The defect: stderr carries the broken-pipe panic.
|
// The defect: stderr carries the broken-pipe panic.
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
+2665
-4870
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
|
//! 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
|
//! (`research_docs.rs`, `cli_run.rs`, `project_load.rs`, `exec.rs`, and —
|
||||||
//! and drive the `tests/fixtures/demo-project` fixture through the real
|
//! for [`synthetic_data`] alone — `project_new.rs`, `cli_broken_pipe.rs`,
|
||||||
//! `aura` binary (#223).
|
//! `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
|
//! **Per-test project directories, no shared store.** [`fresh_project`] mints
|
||||||
//! a unique tempdir per test, wired to the shared, once-built fixture crate
|
//! 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
|
//! no two tests ever race on the same `runs/` store — libtest's default
|
||||||
//! thread parallelism applies without a serializing lock (#250).
|
//! thread parallelism applies without a serializing lock (#250).
|
||||||
//!
|
//!
|
||||||
//! Each of the three test binaries compiles this file as its own `mod
|
//! Each consuming test binary compiles this file as its own `mod common`, so
|
||||||
//! common`, so an item unused by one binary trips that binary's `-D
|
//! an item unused by one binary trips that binary's `-D warnings` `dead_code`
|
||||||
//! warnings` `dead_code` lint; see the `#[allow(dead_code)]` markers below
|
//! lint; see the `#[allow(dead_code)]` markers below rather than deleting a
|
||||||
//! rather than deleting a helper just because one binary doesn't need it.
|
//! helper just because one binary doesn't need it.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
@@ -182,7 +184,7 @@ impl Drop for ScratchGuard {
|
|||||||
/// the two no-window `generalize` E2E tests get a real, tiny, hostless
|
/// the two no-window `generalize` E2E tests get a real, tiny, hostless
|
||||||
/// archive instead of streaming the 6.6 GB host archive (#250).
|
/// archive instead of streaming the 6.6 GB host archive (#250).
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
mod synthetic_data {
|
pub mod synthetic_data {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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);
|
||||||
|
}
|
||||||
@@ -31,15 +31,28 @@ fn two_tap_blueprint_json() -> String {
|
|||||||
serde_json::to_string(&v).expect("re-serialize measurement blueprint")
|
serde_json::to_string(&v).expect("re-serialize measurement blueprint")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_measurement(cwd: &Path) {
|
/// Run the two-tap measurement blueprint and return the trace handle it
|
||||||
|
/// printed. #311: the trace directory is keyed by the run's own identity, so
|
||||||
|
/// every `measure ic` argv below addresses the run by the handle it reported —
|
||||||
|
/// which is also this file's read-back pin for the analysis consumer.
|
||||||
|
fn run_measurement(cwd: &Path) -> String {
|
||||||
let bp = cwd.join("measurement.json");
|
let bp = cwd.join("measurement.json");
|
||||||
std::fs::write(&bp, two_tap_blueprint_json()).expect("write blueprint");
|
std::fs::write(&bp, two_tap_blueprint_json()).expect("write blueprint");
|
||||||
let out = Command::new(BIN)
|
let out = Command::new(BIN)
|
||||||
.args(["run", bp.to_str().unwrap()])
|
.args(["exec", bp.to_str().unwrap()])
|
||||||
.current_dir(cwd)
|
.current_dir(cwd)
|
||||||
.output()
|
.output()
|
||||||
.expect("spawn aura run");
|
.expect("spawn aura run");
|
||||||
assert!(out.status.success(), "aura run stderr: {}", String::from_utf8_lossy(&out.stderr));
|
assert!(out.status.success(), "aura run stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
let line = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let v: serde_json::Value =
|
||||||
|
serde_json::from_str(line.trim()).expect("stdout is one JSON record line");
|
||||||
|
let handle = v["trace_name"]
|
||||||
|
.as_str()
|
||||||
|
.expect("a recording run prints its trace_name")
|
||||||
|
.to_string();
|
||||||
|
assert!(handle.starts_with("sma_signal-"), "the render name stays the prefix: {handle}");
|
||||||
|
handle
|
||||||
}
|
}
|
||||||
|
|
||||||
fn measure_ic(cwd: &Path, run: &str, extra: &[&str]) -> std::process::Output {
|
fn measure_ic(cwd: &Path, run: &str, extra: &[&str]) -> std::process::Output {
|
||||||
@@ -51,11 +64,17 @@ fn measure_ic(cwd: &Path, run: &str, extra: &[&str]) -> std::process::Output {
|
|||||||
#[test]
|
#[test]
|
||||||
fn measure_ic_emits_a_well_formed_report() {
|
fn measure_ic_emits_a_well_formed_report() {
|
||||||
let cwd = temp_cwd("wellformed");
|
let cwd = temp_cwd("wellformed");
|
||||||
run_measurement(&cwd);
|
let handle = run_measurement(&cwd);
|
||||||
let out = measure_ic(&cwd, "sma_signal", &[]);
|
let out = measure_ic(&cwd, &handle, &[]);
|
||||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
|
let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
|
||||||
assert_eq!(r["run"], "sma_signal");
|
// #311: the handle the run printed reaches that run's data — the analysis
|
||||||
|
// consumer's own round trip through the identity-keyed store.
|
||||||
|
assert_eq!(
|
||||||
|
r["run"].as_str(),
|
||||||
|
Some(handle.as_str()),
|
||||||
|
"the report addresses the run by the handle the run itself printed"
|
||||||
|
);
|
||||||
assert_eq!(r["signal_tap"], "signal");
|
assert_eq!(r["signal_tap"], "signal");
|
||||||
assert_eq!(r["price_tap"], "price");
|
assert_eq!(r["price_tap"], "price");
|
||||||
assert_eq!(r["horizon"], 1);
|
assert_eq!(r["horizon"], 1);
|
||||||
@@ -71,9 +90,9 @@ fn measure_ic_emits_a_well_formed_report() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn measure_ic_is_deterministic() {
|
fn measure_ic_is_deterministic() {
|
||||||
let cwd = temp_cwd("determinism");
|
let cwd = temp_cwd("determinism");
|
||||||
run_measurement(&cwd);
|
let handle = run_measurement(&cwd);
|
||||||
let a = measure_ic(&cwd, "sma_signal", &["--seed", "9"]);
|
let a = measure_ic(&cwd, &handle, &["--seed", "9"]);
|
||||||
let b = measure_ic(&cwd, "sma_signal", &["--seed", "9"]);
|
let b = measure_ic(&cwd, &handle, &["--seed", "9"]);
|
||||||
assert!(a.status.success() && b.status.success());
|
assert!(a.status.success() && b.status.success());
|
||||||
assert_eq!(a.stdout, b.stdout, "same seed → byte-identical report");
|
assert_eq!(a.stdout, b.stdout, "same seed → byte-identical report");
|
||||||
}
|
}
|
||||||
@@ -97,8 +116,8 @@ fn measure_ic_unknown_run_errors() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn measure_ic_oversized_horizon_degenerates_through_the_cli() {
|
fn measure_ic_oversized_horizon_degenerates_through_the_cli() {
|
||||||
let cwd = temp_cwd("oversizedhorizon");
|
let cwd = temp_cwd("oversizedhorizon");
|
||||||
run_measurement(&cwd);
|
let handle = run_measurement(&cwd);
|
||||||
let out = measure_ic(&cwd, "sma_signal", &["--horizon", "100000000"]);
|
let out = measure_ic(&cwd, &handle, &["--horizon", "100000000"]);
|
||||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
|
let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
|
||||||
assert_eq!(r["horizon"], 100_000_000);
|
assert_eq!(r["horizon"], 100_000_000);
|
||||||
@@ -110,9 +129,9 @@ fn measure_ic_oversized_horizon_degenerates_through_the_cli() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn measure_ic_missing_tap_errors() {
|
fn measure_ic_missing_tap_errors() {
|
||||||
let cwd = temp_cwd("missingtap");
|
let cwd = temp_cwd("missingtap");
|
||||||
run_measurement(&cwd);
|
let handle = run_measurement(&cwd);
|
||||||
let out = Command::new(BIN)
|
let out = Command::new(BIN)
|
||||||
.args(["measure", "ic", "sma_signal", "--signal", "nope", "--price", "price"])
|
.args(["measure", "ic", &handle, "--signal", "nope", "--price", "price"])
|
||||||
.current_dir(&cwd)
|
.current_dir(&cwd)
|
||||||
.output()
|
.output()
|
||||||
.expect("spawn");
|
.expect("spawn");
|
||||||
@@ -123,3 +142,45 @@ fn measure_ic_missing_tap_errors() {
|
|||||||
String::from_utf8_lossy(&out.stderr)
|
String::from_utf8_lossy(&out.stderr)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #309 fieldtest: a campaign's trace handle is exactly what the run printed
|
||||||
|
/// and what `chart` accepts, so denying that it exists at all is false. A
|
||||||
|
/// family holds members, not one series — say so, and name the members, since
|
||||||
|
/// no other text surface lists them.
|
||||||
|
#[test]
|
||||||
|
fn measure_ic_on_a_family_handle_names_its_members_instead_of_denying_the_handle() {
|
||||||
|
let cwd = temp_cwd("measure-ic-family");
|
||||||
|
for member in ["c0/m0", "c0/m1"] {
|
||||||
|
let dir = cwd.join("runs/traces/fam1234-0").join(member);
|
||||||
|
std::fs::create_dir_all(&dir).expect("fabricate a family fan-out");
|
||||||
|
// A minimally valid member index: the manifest shape the store reads
|
||||||
|
// back, with no taps (the refusal needs the member KEY, not series).
|
||||||
|
std::fs::write(
|
||||||
|
dir.join("index.json"),
|
||||||
|
r#"{"manifest":{"commit":"fieldtest","params":[],"defaults":[],"window":[0,0],"seed":0,"broker":"sim"},"taps":[]}"#,
|
||||||
|
)
|
||||||
|
.expect("write member index");
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = std::process::Command::new(BIN)
|
||||||
|
.args(["measure", "ic", "fam1234-0", "--signal", "signal", "--price", "price"])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn measure ic");
|
||||||
|
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
!stderr.contains("no recorded run 'fam1234-0'"),
|
||||||
|
"the handle exists as a family; denying it outright is false: {stderr}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("family"),
|
||||||
|
"the refusal must say the handle names a family: {stderr}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("c0/m0") && stderr.contains("c0/m1"),
|
||||||
|
"the refusal must name the member keys, the only text route to them: {stderr}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
//! E2E: the project-as-crate load boundary (cycle 0102). Builds the fixture
|
//! E2E: the project-as-crate load boundary (cycle 0102). Builds the fixture
|
||||||
//! project once (cargo, same toolchain, path-dep on this workspace's
|
//! project once (cargo, same toolchain, path-dep on this workspace's
|
||||||
//! aura-core), then drives the aura binary from inside the fixture dir.
|
//! aura-core), then drives the aura binary from inside the fixture dir.
|
||||||
//! `aura run` persists nothing without --trace, so the tracked fixture stays
|
//! `aura exec` persists only the taps a blueprint declares (C27) and the
|
||||||
//! clean (target/, Cargo.lock, runs/ are fixture-gitignored).
|
//! fixture blueprints declare none, so the tracked fixture stays clean
|
||||||
|
//! (target/, Cargo.lock, runs/ are fixture-gitignored).
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Output};
|
use std::process::{Command, Output};
|
||||||
@@ -38,13 +39,13 @@ fn aura(args: &[&str], cwd: &Path) -> Output {
|
|||||||
#[test]
|
#[test]
|
||||||
fn project_run_resolves_demo_node_and_is_bit_identical() {
|
fn project_run_resolves_demo_node_and_is_bit_identical() {
|
||||||
let dir = built_project();
|
let dir = built_project();
|
||||||
let a = aura(&["run", "demo_signal.json"], dir);
|
let a = aura(&["exec", "demo_signal.json"], dir);
|
||||||
assert!(
|
assert!(
|
||||||
a.status.success(),
|
a.status.success(),
|
||||||
"run failed: {}",
|
"run failed: {}",
|
||||||
String::from_utf8_lossy(&a.stderr)
|
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!(b.status.success());
|
||||||
assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)");
|
assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)");
|
||||||
}
|
}
|
||||||
@@ -52,7 +53,7 @@ fn project_run_resolves_demo_node_and_is_bit_identical() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn project_run_manifest_carries_provenance() {
|
fn project_run_manifest_carries_provenance() {
|
||||||
let dir = built_project();
|
let dir = built_project();
|
||||||
let out = aura(&["run", "demo_signal.json"], dir);
|
let out = aura(&["exec", "demo_signal.json"], dir);
|
||||||
assert!(out.status.success());
|
assert!(out.status.success());
|
||||||
let v: serde_json::Value =
|
let v: serde_json::Value =
|
||||||
serde_json::from_slice(&out.stdout).expect("run report is JSON");
|
serde_json::from_slice(&out.stdout).expect("run report is JSON");
|
||||||
@@ -83,7 +84,7 @@ fn outside_a_project_the_demo_blueprint_is_unknown() {
|
|||||||
// cwd = the aura-cli crate dir: no Aura.toml anywhere up the tree.
|
// cwd = the aura-cli crate dir: no Aura.toml anywhere up the tree.
|
||||||
let cwd = Path::new(env!("CARGO_MANIFEST_DIR"));
|
let cwd = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||||
let bp = fixture_dir().join("demo_signal.json");
|
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());
|
assert!(!out.status.success());
|
||||||
let err = String::from_utf8_lossy(&out.stderr);
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -104,31 +105,91 @@ fn outside_a_project_the_demo_blueprint_is_unknown() {
|
|||||||
/// `<project-root>/runs/`, and no stray `runs/` must appear under the
|
/// `<project-root>/runs/`, and no stray `runs/` must appear under the
|
||||||
/// subdirectory. This is the property that makes `[paths]` project-relative
|
/// subdirectory. This is the property that makes `[paths]` project-relative
|
||||||
/// rather than shell-relative (C17).
|
/// 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]
|
#[test]
|
||||||
fn project_registry_anchors_at_discovered_root_not_invocation_cwd() {
|
fn project_registry_anchors_at_discovered_root_not_invocation_cwd() {
|
||||||
let dir = built_project();
|
let (dir, _fixture) = common::fresh_project_with_data();
|
||||||
let sub = dir.join("src");
|
let sub = dir.join("sub");
|
||||||
|
std::fs::create_dir_all(&sub).expect("create invocation subdirectory");
|
||||||
let runs_dir = dir.join("runs");
|
let runs_dir = dir.join("runs");
|
||||||
std::fs::remove_dir_all(&runs_dir).ok();
|
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"))
|
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
.args([
|
.args(["exec", "../anchor.campaign.json"])
|
||||||
"sweep",
|
|
||||||
&closed_bp,
|
|
||||||
"--axis",
|
|
||||||
"sma_signal.fast.length=2,4",
|
|
||||||
"--axis",
|
|
||||||
"sma_signal.slow.length=8,16",
|
|
||||||
"--name",
|
|
||||||
"proj-anchor",
|
|
||||||
])
|
|
||||||
.current_dir(&sub)
|
.current_dir(&sub)
|
||||||
.output()
|
.output()
|
||||||
.expect("spawn aura sweep");
|
.expect("spawn aura exec");
|
||||||
assert!(
|
assert!(
|
||||||
out.status.success(),
|
out.status.success(),
|
||||||
"sweep failed: {}",
|
"exec failed: {}",
|
||||||
String::from_utf8_lossy(&out.stderr)
|
String::from_utf8_lossy(&out.stderr)
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -162,7 +223,7 @@ fn vocabulary_charter_violation_refuses_end_to_end() {
|
|||||||
"badcharter fixture build failed:\n{}",
|
"badcharter fixture build failed:\n{}",
|
||||||
String::from_utf8_lossy(&build.stderr)
|
String::from_utf8_lossy(&build.stderr)
|
||||||
);
|
);
|
||||||
let run = aura(&["run", "x.json"], &dir);
|
let run = aura(&["exec", "x.json"], &dir);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
run.status.code(),
|
run.status.code(),
|
||||||
Some(1),
|
Some(1),
|
||||||
@@ -191,7 +252,7 @@ fn undescribed_vocabulary_entry_refuses_end_to_end() {
|
|||||||
"undescribed fixture build failed:\n{}",
|
"undescribed fixture build failed:\n{}",
|
||||||
String::from_utf8_lossy(&build.stderr)
|
String::from_utf8_lossy(&build.stderr)
|
||||||
);
|
);
|
||||||
let run = aura(&["run", "x.json"], &dir);
|
let run = aura(&["exec", "x.json"], &dir);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
run.status.code(),
|
run.status.code(),
|
||||||
Some(1),
|
Some(1),
|
||||||
@@ -229,7 +290,7 @@ fn restated_name_vocabulary_entry_refuses_end_to_end() {
|
|||||||
"restated fixture build failed:\n{}",
|
"restated fixture build failed:\n{}",
|
||||||
String::from_utf8_lossy(&build.stderr)
|
String::from_utf8_lossy(&build.stderr)
|
||||||
);
|
);
|
||||||
let run = aura(&["run", "x.json"], &dir);
|
let run = aura(&["exec", "x.json"], &dir);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
run.status.code(),
|
run.status.code(),
|
||||||
Some(1),
|
Some(1),
|
||||||
@@ -287,7 +348,7 @@ fn stale_dylib_warns_naming_both_timestamps_but_still_runs() {
|
|||||||
set_mtime(&dylib, DYLIB_MTIME);
|
set_mtime(&dylib, DYLIB_MTIME);
|
||||||
set_mtime(&src, SOURCE_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
|
// Restore sane mtimes before asserting, so a failed assertion does not
|
||||||
// leave the shared fixture wedged "stale" (or with a future mtime that
|
// leave the shared fixture wedged "stale" (or with a future mtime that
|
||||||
@@ -351,7 +412,7 @@ fn nested_nodes_dir() -> PathBuf {
|
|||||||
fn data_only_project_runs_and_stamps_commit_only_provenance() {
|
fn data_only_project_runs_and_stamps_commit_only_provenance() {
|
||||||
let dir = dataonly_dir();
|
let dir = dataonly_dir();
|
||||||
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
.args(["run", "signal.json"])
|
.args(["exec", "signal.json"])
|
||||||
.current_dir(&dir)
|
.current_dir(&dir)
|
||||||
.output()
|
.output()
|
||||||
.expect("spawn aura run");
|
.expect("spawn aura run");
|
||||||
@@ -387,7 +448,7 @@ fn data_only_project_runs_and_stamps_commit_only_provenance() {
|
|||||||
fn multi_crate_pointer_refuses_end_to_end() {
|
fn multi_crate_pointer_refuses_end_to_end() {
|
||||||
let dir = multicrate_dir();
|
let dir = multicrate_dir();
|
||||||
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
.args(["run", "x.json"])
|
.args(["exec", "x.json"])
|
||||||
.current_dir(&dir)
|
.current_dir(&dir)
|
||||||
.output()
|
.output()
|
||||||
.expect("spawn aura run");
|
.expect("spawn aura run");
|
||||||
@@ -427,7 +488,7 @@ fn nodes_pointer_crate_resolves_and_stamps_its_namespace() {
|
|||||||
String::from_utf8_lossy(&build.stderr)
|
String::from_utf8_lossy(&build.stderr)
|
||||||
);
|
);
|
||||||
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
.args(["run", "pointer_signal.json"])
|
.args(["exec", "pointer_signal.json"])
|
||||||
.current_dir(&dir)
|
.current_dir(&dir)
|
||||||
.output()
|
.output()
|
||||||
.expect("spawn aura run");
|
.expect("spawn aura run");
|
||||||
@@ -455,7 +516,7 @@ fn data_only_project_hints_attach_for_namespaced_ids() {
|
|||||||
let bp = tmp.join("bp.json");
|
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();
|
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"))
|
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)
|
.current_dir(&tmp)
|
||||||
.output()
|
.output()
|
||||||
.expect("spawn aura");
|
.expect("spawn aura");
|
||||||
@@ -479,7 +540,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",
|
"[package]\nname = \"nobuild\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[workspace]\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let out = aura(&["run", "x.json"], &tmp);
|
let out = aura(&["exec", "x.json"], &tmp);
|
||||||
assert_eq!(out.status.code(), Some(1), "runtime refusal");
|
assert_eq!(out.status.code(), Some(1), "runtime refusal");
|
||||||
let err = String::from_utf8_lossy(&out.stderr);
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
assert!(err.contains("cargo build"), "build hint present: {err}");
|
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
|
//! E2E: the `aura new` scaffolder (#241 T4). Proves the data-only authoring
|
||||||
//! loop — scaffold → `aura run`/`aura sweep`, zero Rust toolchain interaction
|
//! loop — scaffold → `aura exec`, zero Rust toolchain interaction — plus the
|
||||||
//! — plus the refusal contract. Scaffolded projects live in per-test temp
|
//! refusal contract. Scaffolded projects live in per-test temp dirs.
|
||||||
//! dirs.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Output};
|
use std::process::{Command, Output};
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
fn aura(args: &[&str], cwd: &Path) -> Output {
|
fn aura(args: &[&str], cwd: &Path) -> Output {
|
||||||
Command::new(env!("CARGO_BIN_EXE_aura"))
|
Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||||
.args(args)
|
.args(args)
|
||||||
@@ -61,17 +62,17 @@ fn init_and_commit(dir: &Path) {
|
|||||||
///
|
///
|
||||||
/// Documented deviation from the task text: the plan's wording asks this
|
/// 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/`".
|
/// headline to also assert that the run "writes a manifest under `x/runs/`".
|
||||||
/// Verified against `dispatch_run`/`run_signal_r` (main.rs, around the
|
/// Verified against `dispatch_exec`/`exec_blueprint_leg`/`run_signal_r`
|
||||||
/// `tx_req`/`rx_req` channel comment): plain `aura run` never touches the
|
/// (main.rs): a single-blueprint `aura exec` never touches the trace store —
|
||||||
/// trace store — it only prints the `RunReport` to stdout; only
|
/// it only prints the `RunReport` to stdout; only exec's campaign leg
|
||||||
/// `sweep`/`mc`/campaign verbs persist (`env.trace_store()`), a pre-#241
|
/// persists (`env.trace_store()`), a pre-#241 split this task's file list
|
||||||
/// split this task's file list does not touch (giving `run` a store write
|
/// does not touch (giving the blueprint leg a store write would widen scope
|
||||||
/// would widen scope beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new`
|
/// beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new` and this test
|
||||||
/// and this test file). There is thus no on-disk manifest for THIS verb to
|
/// file). There is thus no on-disk manifest for THIS leg to assert against;
|
||||||
/// assert against; the assertion below proves that absence directly instead
|
/// the assertion below proves that absence directly instead of only
|
||||||
/// of only asserting it in prose, and the persisted-store property the plan
|
/// asserting it in prose, and the persisted-store property the plan wanted
|
||||||
/// wanted is covered by `data_only_project_sweeps_without_any_build` below,
|
/// is covered by `data_only_project_execs_a_campaign_without_any_build`
|
||||||
/// which does assert `proj.join("runs").exists()`.
|
/// below, which does assert `proj.join("runs").exists()`.
|
||||||
#[test]
|
#[test]
|
||||||
fn new_scaffolds_and_runs_the_data_only_loop() {
|
fn new_scaffolds_and_runs_the_data_only_loop() {
|
||||||
let base = tmp("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("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");
|
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!(
|
assert!(
|
||||||
a.status.success(),
|
a.status.success(),
|
||||||
"run failed: {}",
|
"exec failed: {}",
|
||||||
String::from_utf8_lossy(&a.stderr)
|
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)");
|
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 v: serde_json::Value = serde_json::from_slice(&a.stdout).expect("report is JSON");
|
||||||
let p = &v["manifest"]["project"];
|
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!(p.get("namespace").is_none(), "no crate was loaded, namespace must be absent: {p}");
|
||||||
assert!(
|
assert!(
|
||||||
!proj.join("runs").exists(),
|
!proj.join("runs").exists(),
|
||||||
"plain `aura run` must not persist a trace store (only sweep/mc/campaign do) — \
|
"a plain single-blueprint `aura exec` must not persist a trace store (only exec's \
|
||||||
the task text's \"writes a manifest under x/runs/\" does not hold for this verb"
|
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);
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #241 headline: a fresh data-only project sweeps a dissolved verb (#218's
|
/// The minimal executable pipeline (one sweep stage) — copied verbatim from
|
||||||
/// gate) with zero build step — using the scaffolded (closed) starter
|
/// `research_docs.rs`'s constant of the same name.
|
||||||
/// blueprint and the exact axis the scaffolded CLAUDE.md advertises.
|
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
|
/// #246: the closed starter IS the campaign axis target — a bound param is a
|
||||||
/// an axis overrides.
|
/// default an axis overrides.
|
||||||
#[test]
|
#[test]
|
||||||
fn data_only_project_sweeps_without_any_build() {
|
fn data_only_project_execs_a_campaign_without_any_build() {
|
||||||
let base = tmp("sweep");
|
let base = tmp("sweep");
|
||||||
let new = aura(&["new", "scratch"], &base);
|
let new = aura(&["new", "scratch"], &base);
|
||||||
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
|
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
|
||||||
let proj = base.join("scratch");
|
let proj = base.join("scratch");
|
||||||
let out = aura(
|
seed_synthetic_archive(&proj);
|
||||||
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4"],
|
|
||||||
&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!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
assert!(proj.join("runs").exists(), "store must land inside the project");
|
assert!(proj.join("runs").exists(), "store must land inside the project");
|
||||||
let _ = std::fs::remove_dir_all(&base);
|
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
|
/// bound `fast.length` param — the persisted family has exactly one member
|
||||||
/// per axis value, not a single collapsed member. A silent regression where
|
/// per axis value, not a single collapsed member. A silent regression where
|
||||||
/// the bound-param override is dropped (member always built from the
|
/// the bound-param override is dropped (member always built from the
|
||||||
/// blueprint's own default) would still exit 0 and still create a `runs`
|
/// 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
|
/// store, so `data_only_project_execs_a_campaign_without_any_build` above
|
||||||
/// it; only the member count can.
|
/// cannot catch it; only the member count can.
|
||||||
#[test]
|
#[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 base = tmp("sweep-members");
|
||||||
let new = aura(&["new", "scratch"], &base);
|
let new = aura(&["new", "scratch"], &base);
|
||||||
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
|
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
|
||||||
let proj = base.join("scratch");
|
let proj = base.join("scratch");
|
||||||
let out = aura(
|
seed_synthetic_archive(&proj);
|
||||||
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4,8"],
|
|
||||||
&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));
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
let fams = aura(&["runs", "families"], &proj);
|
let fams = aura(&["runs", "families"], &proj);
|
||||||
assert!(fams.status.success(), "runs families exit: {:?}", fams.status);
|
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
|
// ...so the first quickstart run stamps a commit into the manifest, not
|
||||||
// an empty provenance block.
|
// an empty provenance block.
|
||||||
let run = aura(&["run", "blueprints/signal.json"], &proj);
|
let run = aura(&["exec", "blueprints/signal.json"], &proj);
|
||||||
assert!(
|
assert!(
|
||||||
run.status.success(),
|
run.status.success(),
|
||||||
"run failed: {}",
|
"exec failed: {}",
|
||||||
String::from_utf8_lossy(&run.stderr)
|
String::from_utf8_lossy(&run.stderr)
|
||||||
);
|
);
|
||||||
let v: serde_json::Value = serde_json::from_slice(&run.stdout).expect("report is JSON");
|
let v: serde_json::Value = serde_json::from_slice(&run.stdout).expect("report is JSON");
|
||||||
|
|||||||
@@ -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
|
/// 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,
|
/// discoverable as an axis, execs over real data (a campaign — #319: the
|
||||||
/// and that family reproduces bit-identically — the defining research loop of a
|
/// surviving surface for what `aura sweep --axis` used to do inline) into a
|
||||||
/// genuine external project ("sweep my OWN node's knob"), proven end to end.
|
/// 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]
|
#[test]
|
||||||
fn project_node_open_param_sweeps_and_reproduces() {
|
fn project_node_open_param_sweeps_and_reproduces() {
|
||||||
let (dir, _g) = fresh_project();
|
let (dir, _g) = fresh_project();
|
||||||
|
|
||||||
// (a) `--list-axes` discovers the PROJECT node's own open knob, followed by
|
// (a) `graph introspect --params` discovers the PROJECT node's own open
|
||||||
// the fixture's three BOUND params as `default=`-lines (#246: every bound
|
// knob, followed by the fixture's three BOUND params as `default=`-lines
|
||||||
// param is an equally re-openable `--axis`, listed regardless of how many
|
// (#246: every bound param is an equally re-openable axis, listed
|
||||||
// knobs happen to be open alongside it). NOT gated: enumerating axes needs
|
// regardless of how many knobs happen to be open alongside it). NOT
|
||||||
// no data, so the discoverability half runs on every host. The wrapped
|
// gated: enumerating axes needs no data, so the discoverability half runs
|
||||||
// name is `<blueprint>.<node>.<param>`.
|
// on every host. The name is RAW `<node>.<param>` (#328: the blueprint
|
||||||
let axes = aura_in(dir, &["sweep", "blueprints/scaled_open.json", "--list-axes"]);
|
// name stays out of axis paths).
|
||||||
|
let axes = aura_in(dir, &["graph", "introspect", "--params", "blueprints/scaled_open.json"]);
|
||||||
assert!(
|
assert!(
|
||||||
axes.status.success(),
|
axes.status.success(),
|
||||||
"--list-axes stderr: {}",
|
"graph introspect --params stderr: {}",
|
||||||
String::from_utf8_lossy(&axes.stderr)
|
String::from_utf8_lossy(&axes.stderr)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
String::from_utf8_lossy(&axes.stdout),
|
String::from_utf8_lossy(&axes.stdout),
|
||||||
"scaled_signal.gain.factor:F64\n\
|
"gain.factor:F64\n\
|
||||||
scaled_signal.fast.length:I64 default=2\n\
|
fast.length:I64 default=2\n\
|
||||||
scaled_signal.slow.length:I64 default=4\n\
|
slow.length:I64 default=4\n\
|
||||||
scaled_signal.bias.scale:F64 default=0.5\n",
|
bias.scale:F64 default=0.5\n",
|
||||||
"the project node's own OPEN param is the one open axis, alongside the \
|
"the project node's own OPEN param is the one open axis, alongside the \
|
||||||
fixture's bound defaults; stderr: {}",
|
fixture's bound defaults; stderr: {}",
|
||||||
String::from_utf8_lossy(&axes.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() {
|
if !local_data_present() {
|
||||||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// (b) sweep the project node's knob over real GER40 data -> two members.
|
// (b) exec a campaign over the project node's knob, real GER40 data ->
|
||||||
let sweep = aura_in(
|
// two members. Register the blueprint straight (not a sweep side-effect)
|
||||||
dir,
|
// and a minimal sweep-only process, then reference both by content id.
|
||||||
&[
|
let reg = aura_in(dir, &["graph", "register", "blueprints/scaled_open.json"]);
|
||||||
"sweep",
|
assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(®.stderr));
|
||||||
"blueprints/scaled_open.json",
|
let reg_out = String::from_utf8_lossy(®.stdout).into_owned();
|
||||||
"--real",
|
let bp_id = reg_out
|
||||||
"GER40",
|
.lines()
|
||||||
"--from",
|
.find(|l| l.starts_with("registered blueprint "))
|
||||||
GER40_FROM_MS,
|
.expect("register line")
|
||||||
"--to",
|
.trim_start_matches("registered blueprint ")
|
||||||
GER40_TO_MS,
|
.split(' ')
|
||||||
"--axis",
|
.next()
|
||||||
"scaled_signal.gain.factor=0.5,1.0",
|
.expect("blueprint id")
|
||||||
"--name",
|
.to_string();
|
||||||
"knob",
|
|
||||||
],
|
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!(
|
assert!(
|
||||||
sweep.status.success(),
|
exec.status.success(),
|
||||||
"sweep stderr: {}",
|
"exec stderr: {}",
|
||||||
String::from_utf8_lossy(&sweep.stderr)
|
String::from_utf8_lossy(&exec.stderr)
|
||||||
);
|
);
|
||||||
let stdout = String::from_utf8_lossy(&sweep.stdout).into_owned();
|
let stdout = String::from_utf8_lossy(&exec.stdout).into_owned();
|
||||||
let lines: Vec<&str> = stdout.lines().collect();
|
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}");
|
assert_eq!(lines.len(), 2, "one member line per factor point: {stdout}");
|
||||||
|
|
||||||
// Each member's manifest carries the swept PROJECT-node param binding under
|
// Each member's manifest carries the swept PROJECT-node param binding
|
||||||
// its wrapped name — the load-bearing proof that it is MY node's knob that
|
// under its RAW name (this cycle's tidy fix, #328 batch 2: the
|
||||||
// varied across the family, not some incidental std axis.
|
// 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]) {
|
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");
|
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -227,7 +265,7 @@ fn project_node_open_param_sweeps_and_reproduces() {
|
|||||||
.expect("manifest.params is an array");
|
.expect("manifest.params is an array");
|
||||||
let bound = params
|
let bound = params
|
||||||
.iter()
|
.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());
|
.and_then(|p| p[1]["F64"].as_f64());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bound,
|
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");
|
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!(
|
let out = format!(
|
||||||
"{}{}",
|
"{}{}",
|
||||||
String::from_utf8_lossy(&run.stdout),
|
String::from_utf8_lossy(&run.stdout),
|
||||||
|
|||||||
@@ -319,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
|
// Seed one real, content-addressed blueprint into the project's own
|
||||||
// store via a real sweep over its bound params, which the axes reopen
|
// store via `aura graph register` (#319 — the retired `sweep`
|
||||||
// as overridable defaults (#246) (mirrors `project_load.rs`'s anchor
|
// side-effect seeding this used to ride).
|
||||||
// test).
|
let bp_id = seed_blueprint(&dir, "campaign-ref-seed");
|
||||||
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();
|
|
||||||
|
|
||||||
// Register a valid AND executable process into the same project store:
|
// Register a valid AND executable process into the same project store:
|
||||||
// this test's OK campaign must pass all three validate tiers, and
|
// this test's OK campaign must pass all three validate tiers, and
|
||||||
@@ -377,10 +352,11 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
|
|||||||
.trim_start_matches("content:")
|
.trim_start_matches("content:")
|
||||||
.to_string();
|
.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
|
// `validate_campaign_refs` loads the stored blueprint bare, unlike the
|
||||||
// sweep's `wrap_r`-wrapped axis probe, so it does NOT carry the
|
// sweep's `wrap_r`-wrapped axis probe, so it never carries a wrap prefix
|
||||||
// "sma_signal." prefix `aura sweep --axis` binds against.
|
// to begin with.
|
||||||
let campaign = format!(
|
let campaign = format!(
|
||||||
r#"{{
|
r#"{{
|
||||||
"format_version": 1,
|
"format_version": 1,
|
||||||
@@ -440,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
|
// Seed one real, content-addressed blueprint (mirrors the content_id
|
||||||
// sibling test above).
|
// sibling test above; #319 — the retired `sweep` side-effect seeding).
|
||||||
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
let bp_id = seed_blueprint(&dir, "campaign-identity-seed");
|
||||||
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();
|
|
||||||
let bp_path = runs_dir.join("blueprints").join(format!("{bp_id}.json"));
|
let bp_path = runs_dir.join("blueprints").join(format!("{bp_id}.json"));
|
||||||
|
|
||||||
// The identity id of the SAME stored bytes — `--content-id FILE
|
// The identity id of the SAME stored bytes — `--content-id FILE
|
||||||
@@ -710,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"]);
|
let (out, code) = run_code_in(&dir, &["campaign", "validate", "risk-bad.campaign.json"]);
|
||||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
assert!(
|
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}"
|
"stdout/stderr: {out}"
|
||||||
);
|
);
|
||||||
assert!(!out.contains("BadRegime"), "Debug leak: {out}");
|
assert!(!out.contains("BadRegime"), "Debug leak: {out}");
|
||||||
@@ -871,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
|
/// #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]
|
#[test]
|
||||||
fn campaign_introspect_unwired_omits_bound_risk_and_cost_slots() {
|
fn campaign_introspect_unwired_omits_bound_risk_and_cost_slots() {
|
||||||
let dir = temp_cwd("campaign-introspect-unwired-risk-bound");
|
let dir = temp_cwd("campaign-introspect-unwired-risk-bound");
|
||||||
let with_both = CAMPAIGN_DOC.replacen(
|
let with_both = CAMPAIGN_DOC.replacen(
|
||||||
"\"seed\": 1,",
|
"\"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,
|
1,
|
||||||
);
|
);
|
||||||
assert_ne!(with_both, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
|
assert_ne!(with_both, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
|
||||||
@@ -995,7 +948,7 @@ fn campaign_introspect_unwired_reports_the_spec_example_slots() {
|
|||||||
write_doc(&dir, "draft.campaign.json", draft);
|
write_doc(&dir, "draft.campaign.json", draft);
|
||||||
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]);
|
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]);
|
||||||
assert_eq!(code, Some(0));
|
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)"));
|
assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,7 +974,7 @@ fn campaign_introspect_unwired_reports_placeholder_refs() {
|
|||||||
"strategy `ref: null` placeholder not enumerated: {out}"
|
"strategy `ref: null` placeholder not enumerated: {out}"
|
||||||
);
|
);
|
||||||
assert!(
|
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}"
|
"process `ref: {{}}` placeholder not enumerated: {out}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1239,7 +1192,7 @@ fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
|
|||||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
assert!(out.contains("refusing to register:"), "stdout/stderr: {out}");
|
assert!(out.contains("refusing to register:"), "stdout/stderr: {out}");
|
||||||
assert!(
|
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}"
|
"stdout/stderr: {out}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1276,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
|
/// Seed one blueprint into the built demo project's store via `aura graph
|
||||||
/// #246) into the built demo project's store via a real sweep and return
|
/// register` and return its content id (the referential test's recipe).
|
||||||
/// 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 {
|
fn seed_blueprint(dir: &Path, name: &str) -> String {
|
||||||
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 (out, code) = run_code_in(
|
let (out, code) = run_code_in(dir, &["graph", "register", &closed_bp, "--name", name]);
|
||||||
dir,
|
assert_eq!(code, Some(0), "seed register failed: {out}");
|
||||||
&[
|
out.lines()
|
||||||
"sweep",
|
.find(|l| l.starts_with("registered blueprint "))
|
||||||
&closed_bp,
|
.expect("register line")
|
||||||
"--axis",
|
.trim_start_matches("registered blueprint ")
|
||||||
"sma_signal.fast.length=2,4",
|
.split(' ')
|
||||||
"--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")
|
|
||||||
.next()
|
.next()
|
||||||
.expect("one stored blueprint")
|
.expect("id")
|
||||||
.expect("dir entry")
|
.trim_start_matches("content:")
|
||||||
.path()
|
.to_string()
|
||||||
.file_stem()
|
|
||||||
.expect("stem")
|
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register `doc` as a process document in the project store; returns its id.
|
/// Register `doc` as a process document in the project store; returns its id.
|
||||||
@@ -1492,7 +1436,7 @@ const WF_PROCESS_DOC: &str = r#"{
|
|||||||
fn campaign_run_outside_project_refuses() {
|
fn campaign_run_outside_project_refuses() {
|
||||||
let dir = temp_cwd("campaign-run-outside-project");
|
let dir = temp_cwd("campaign-run-outside-project");
|
||||||
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
|
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_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
assert!(out.contains("campaign run needs a project"), "stdout/stderr: {out}");
|
assert!(out.contains("campaign run needs a project"), "stdout/stderr: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1506,7 +1450,7 @@ fn campaign_run_outside_project_refuses() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn campaign_run_bogus_target_refuses() {
|
fn campaign_run_bogus_target_refuses() {
|
||||||
let (dir, _fixture) = fresh_project();
|
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_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
out.contains("'no-such-target' is neither a readable .json file nor a 64-hex content id"),
|
out.contains("'no-such-target' is neither a readable .json file nor a 64-hex content id"),
|
||||||
@@ -1519,7 +1463,7 @@ fn campaign_run_bogus_target_refuses() {
|
|||||||
fn campaign_run_unknown_id_refuses() {
|
fn campaign_run_unknown_id_refuses() {
|
||||||
let (dir, _fixture) = fresh_project();
|
let (dir, _fixture) = fresh_project();
|
||||||
let id = "0".repeat(64);
|
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_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
out.contains(&format!("no campaign {id} in the project store")),
|
out.contains(&format!("no campaign {id} in the project store")),
|
||||||
@@ -1637,7 +1581,7 @@ fn campaign_run_refuses_mc_before_walk_forward() {
|
|||||||
let bp_id = seed_blueprint(&dir, "campaign-run-mcwf-seed");
|
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);
|
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), "", ""));
|
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_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}");
|
assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1687,7 +1631,7 @@ fn campaign_run_refuses_a_non_terminal_selection_free_sweep() {
|
|||||||
let proc_id =
|
let proc_id =
|
||||||
register_process_doc(&dir, "selfree.process.json", SELECTION_FREE_SWEEP_THEN_GATE_PROCESS_DOC);
|
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), "", ""));
|
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_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
out.contains(
|
out.contains(
|
||||||
@@ -1801,7 +1745,7 @@ fn campaign_run_refuses_a_grid_stage_not_immediately_before_walk_forward() {
|
|||||||
"gridgate.campaign.json",
|
"gridgate.campaign.json",
|
||||||
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""),
|
&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_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}");
|
assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1854,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}");
|
assert_eq!(code, Some(0), "campaign run failed: {out}");
|
||||||
|
|
||||||
let line = out
|
let line = out
|
||||||
@@ -1916,7 +1860,7 @@ fn campaign_run_synthetic_e2e_cell_order_is_document_order_under_parallel_instru
|
|||||||
for k in ["1", "2"] {
|
for k in ["1", "2"] {
|
||||||
let (out, code) = run_code_in(
|
let (out, code) = run_code_in(
|
||||||
&dir,
|
&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}");
|
assert_eq!(code, Some(0), "K={k}: campaign run failed: {out}");
|
||||||
let line = out
|
let line = out
|
||||||
@@ -1977,7 +1921,7 @@ fn campaign_run_synthetic_e2e_parallel_instruments_contains_a_real_per_cell_faul
|
|||||||
|
|
||||||
let (out, code) = run_code_in(
|
let (out, code) = run_code_in(
|
||||||
&dir,
|
&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}");
|
assert_eq!(code, Some(3), "one contained fault, one success: {out}");
|
||||||
let line = out
|
let line = out
|
||||||
@@ -2032,7 +1976,7 @@ fn campaign_run_synthetic_e2e_gate_emptied_cell_carries_the_note_marker() {
|
|||||||
write_doc(&dir, "gateempty.campaign.json", &doc);
|
write_doc(&dir, "gateempty.campaign.json", &doc);
|
||||||
|
|
||||||
let (out, code) =
|
let (out, code) =
|
||||||
run_code_in(&dir, &["campaign", "run", "gateempty.campaign.json"]);
|
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_eq!(code, Some(0), "a zero-survivor cell is a valid result, not a fault: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
out.contains("aura: note: cell "),
|
out.contains("aura: note: cell "),
|
||||||
@@ -2064,7 +2008,7 @@ fn campaign_run_refuses_single_instrument_generalize() {
|
|||||||
let bp_id = seed_blueprint(&dir, "campaign-run-gen1-seed");
|
let bp_id = seed_blueprint(&dir, "campaign-run-gen1-seed");
|
||||||
let proc_id = register_process_doc(&dir, "gen1.process.json", GENERALIZE_PROCESS_DOC);
|
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), "", ""));
|
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_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
out.contains("std::generalize needs at least 2"),
|
out.contains("std::generalize needs at least 2"),
|
||||||
@@ -2125,7 +2069,7 @@ fn campaign_run_refuses_generalize_non_r_metric() {
|
|||||||
}}"#
|
}}"#
|
||||||
);
|
);
|
||||||
write_doc(&dir, "genpip.campaign.json", &campaign_doc);
|
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}");
|
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
// #207 (fieldtest 0108 F8): the refusal names the REAL rule (the rankable
|
// #207 (fieldtest 0108 F8): the refusal names the REAL rule (the rankable
|
||||||
// R-expectancy family), never the mislabel "pip metric" — max_r_drawdown is
|
// R-expectancy family), never the mislabel "pip metric" — max_r_drawdown is
|
||||||
@@ -2189,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 bp_id = seed_blueprint(&dir, "campaign-run-wfmc-seed");
|
||||||
let proc_id = register_process_doc(&dir, "wfmc.process.json", WF_AFTER_MC_PROCESS_DOC);
|
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), "", ""));
|
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_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
out.contains("std::walk_forward cannot follow std::monte_carlo"),
|
out.contains("std::walk_forward cannot follow std::monte_carlo"),
|
||||||
@@ -2398,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 bp_id = seed_blueprint(&dir, "campaign-run-zeromc-seed");
|
||||||
let proc_id = register_process_doc(&dir, "zeromc.process.json", ZERO_RESAMPLES_MC_PROCESS_DOC);
|
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), "", ""));
|
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_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
out.contains("process stage 1: monte_carlo resamples must be > 0"),
|
out.contains("process stage 1: monte_carlo resamples must be > 0"),
|
||||||
@@ -2433,7 +2377,7 @@ fn campaign_run_refuses_unknown_tap_at_validate() {
|
|||||||
"badtap.campaign.json",
|
"badtap.campaign.json",
|
||||||
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"r_record\"", ""),
|
&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_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
assert!(out.contains("campaign document invalid:"), "stdout/stderr: {out}");
|
assert!(out.contains("campaign document invalid:"), "stdout/stderr: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2476,7 +2420,7 @@ fn campaign_run_valid_tap_reaches_the_member_data_seam() {
|
|||||||
"taps.campaign.json",
|
"taps.campaign.json",
|
||||||
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"equity\"", ""),
|
&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_eq!(code, Some(3), "stdout/stderr: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
!out.contains("unknown tap"),
|
!out.contains("unknown tap"),
|
||||||
@@ -2546,7 +2490,7 @@ fn campaign_run_real_e2e_sweep_gate_walkforward() {
|
|||||||
"\"family_table\", \"selection_report\"",
|
"\"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.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(3)
|
if code == Some(3)
|
||||||
@@ -2723,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
|
// 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.
|
// line is read from each run's own stdout, so no isolation is needed.
|
||||||
let pooled = |doc: &str| -> serde_json::Value {
|
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}");
|
assert_eq!(code, Some(0), "campaign run failed: {out}");
|
||||||
let line = out
|
let line = out
|
||||||
.lines()
|
.lines()
|
||||||
@@ -2800,7 +2744,7 @@ fn campaign_run_synthetic_e2e_cost_block_nets_the_per_survivor_bootstrap() {
|
|||||||
write_doc(&dir, "net_ps.campaign.json", &with_cost);
|
write_doc(&dir, "net_ps.campaign.json", &with_cost);
|
||||||
|
|
||||||
let per_survivor = |doc: &str| -> Vec<(u64, serde_json::Value)> {
|
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}");
|
assert_eq!(code, Some(0), "campaign run failed: {out}");
|
||||||
let line = out
|
let line = out
|
||||||
.lines()
|
.lines()
|
||||||
@@ -2862,7 +2806,7 @@ fn campaign_run_synthetic_e2e_per_instrument_cost_resolves_per_cell() {
|
|||||||
(1709251200000, 1717199999999),
|
(1709251200000, 1717199999999),
|
||||||
);
|
);
|
||||||
write_doc(&dir, "percost.campaign.json", &doc);
|
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}");
|
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||||
|
|
||||||
// Member emit lines carry report.manifest; group the stamped resolved
|
// Member emit lines carry report.manifest; group the stamped resolved
|
||||||
@@ -2923,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");
|
assert_ne!(with_risk, base, "replacen must actually match the seed field");
|
||||||
write_doc(&dir, "voltf.campaign.json", &with_risk);
|
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}");
|
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||||
|
|
||||||
let record_line = out
|
let record_line = out
|
||||||
@@ -3048,7 +2992,7 @@ fn campaign_run_real_e2e_bindings_override_rebinds_price_to_open() {
|
|||||||
write_doc(&dir, "close.campaign.json", &base);
|
write_doc(&dir, "close.campaign.json", &base);
|
||||||
write_doc(&dir, "open.campaign.json", &rebound);
|
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)
|
if code_close == Some(3)
|
||||||
&& (out_close.contains("no recorded geometry") || out_close.contains("no data for instrument"))
|
&& (out_close.contains("no recorded geometry") || out_close.contains("no data for instrument"))
|
||||||
{
|
{
|
||||||
@@ -3056,7 +3000,7 @@ fn campaign_run_real_e2e_bindings_override_rebinds_price_to_open() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assert_eq!(code_close, Some(0), "stdout/stderr: {out_close}");
|
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}");
|
assert_eq!(code_open, Some(0), "stdout/stderr: {out_open}");
|
||||||
|
|
||||||
let first_member = |out: &str| -> serde_json::Value {
|
let first_member = |out: &str| -> serde_json::Value {
|
||||||
@@ -3111,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");
|
assert_ne!(with_regime, base, "replacen must actually match the fixture's seed field");
|
||||||
write_doc(&dir, "regime.campaign.json", &with_regime);
|
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)
|
if code == Some(3)
|
||||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||||
@@ -3142,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):
|
/// 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
|
/// 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
|
/// OWN stop into ITS OWN cell's members, never the other regime's values and
|
||||||
@@ -3181,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");
|
assert_ne!(with_regimes, base, "replacen must actually match the fixture's seed field");
|
||||||
write_doc(&dir, "tworegimes.campaign.json", &with_regimes);
|
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)
|
if code == Some(3)
|
||||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||||
@@ -3268,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");
|
assert_ne!(with_regime, base, "replacen must actually match the fixture's seed field");
|
||||||
write_doc(&dir, "regime-persist.campaign.json", &with_regime);
|
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.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(3)
|
if code == Some(3)
|
||||||
@@ -3314,9 +3320,9 @@ fn campaign_persist_non_default_regime_does_not_false_fail_c1() {
|
|||||||
/// trace-persist re-run must not false-fail the C1 drift alarm, exercised
|
/// trace-persist re-run must not false-fail the C1 drift alarm, exercised
|
||||||
/// through the REAL production path — `campaign run` -> `run_campaign_returning`
|
/// through the REAL production path — `campaign run` -> `run_campaign_returning`
|
||||||
/// -> `CliMemberRunner` (net-mode member run) -> `persist_campaign_traces`
|
/// -> `CliMemberRunner` (net-mode member run) -> `persist_campaign_traces`
|
||||||
/// (non-reduce re-run binding a `CostLeg` via `cost_nodes_for`) — not the
|
/// (retained-delivery re-run binding a `CostLeg` via `cost_nodes_for`) — not the
|
||||||
/// hand-mirrored arithmetic pinned by
|
/// hand-mirrored arithmetic pinned by
|
||||||
/// `persist_side_nonreduce_rerun_matches_reduce_mode_net_metrics_under_cost` in
|
/// `persist_side_retained_rerun_matches_folded_net_metrics_under_cost` in
|
||||||
/// `main.rs`'s unit tests. Mirrors the sibling non-default-regime e2e above
|
/// `main.rs`'s unit tests. Mirrors the sibling non-default-regime e2e above
|
||||||
/// (same #219-class divergence risk, cost edition): a costed campaign
|
/// (same #219-class divergence risk, cost edition): a costed campaign
|
||||||
/// document, `persist_taps` non-empty so the re-run actually executes, must
|
/// document, `persist_taps` non-empty so the re-run actually executes, must
|
||||||
@@ -3348,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");
|
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
|
||||||
write_doc(&dir, "cost-persist.campaign.json", &with_cost);
|
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.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(3)
|
if code == Some(3)
|
||||||
@@ -3443,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");
|
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
|
||||||
write_doc(&dir, "net-persist.campaign.json", &with_cost);
|
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.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(3)
|
if code == Some(3)
|
||||||
@@ -3524,7 +3530,7 @@ fn campaign_run_real_e2e_net_r_equity_tap_persists_the_cost_adjusted_curve() {
|
|||||||
/// its `constant` sibling
|
/// its `constant` sibling
|
||||||
/// (`campaign_run_real_e2e_costed_campaign_persists_without_drift_alarm`):
|
/// (`campaign_run_real_e2e_costed_campaign_persists_without_drift_alarm`):
|
||||||
/// `campaign run` -> `CliMemberRunner` (net-mode) -> `persist_campaign_traces`
|
/// `campaign run` -> `CliMemberRunner` (net-mode) -> `persist_campaign_traces`
|
||||||
/// (non-reduce re-run binding a `CostLeg`). The re-run must bind the identical
|
/// (retained-delivery re-run binding a `CostLeg`). The re-run must bind the identical
|
||||||
/// vol-slippage model the nominee ran under, so it must not false-fail the C1
|
/// vol-slippage model the nominee ran under, so it must not false-fail the C1
|
||||||
/// drift alarm; the emitted member must stamp `cost[0].slip_vol_mult`; and the
|
/// drift alarm; the emitted member must stamp `cost[0].slip_vol_mult`; and the
|
||||||
/// vol-driven charge must actually move net off gross (non-vacuity — a
|
/// vol-driven charge must actually move net off gross (non-vacuity — a
|
||||||
@@ -3557,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");
|
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
|
||||||
write_doc(&dir, "volcost-persist.campaign.json", &with_cost);
|
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.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(3)
|
if code == Some(3)
|
||||||
@@ -3646,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");
|
assert_ne!(with_regimes, base, "replacen must actually match the fixture's seed field");
|
||||||
write_doc(&dir, "tworegimes-persist.campaign.json", &with_regimes);
|
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.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(3)
|
if code == Some(3)
|
||||||
@@ -3724,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.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(3)
|
if code == Some(3)
|
||||||
@@ -3840,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
|
// (a) the cost-less baseline: net == gross, and net_r_equity skips with
|
||||||
// the remedy notice while equity still persists.
|
// 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)
|
if code_gross == Some(3)
|
||||||
&& (out_gross.contains("no recorded geometry") || out_gross.contains("no data for instrument"))
|
&& (out_gross.contains("no recorded geometry") || out_gross.contains("no data for instrument"))
|
||||||
{
|
{
|
||||||
@@ -3862,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
|
// (b) the costed run: gross identical member-for-member, net strictly
|
||||||
// below gross, no skip notice, net_r_equity persisted on disk.
|
// 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_eq!(code_net, Some(0), "costed run: {out_net}");
|
||||||
assert!(
|
assert!(
|
||||||
!out_net.contains("skipped"),
|
!out_net.contains("skipped"),
|
||||||
@@ -3927,7 +3933,7 @@ fn campaign_run_real_e2e_cost_block_nets_members_and_persists_net_r_equity() {
|
|||||||
/// charge or `vol_slippage`'s volatility-proxy-scaled charge) — nets a real
|
/// charge or `vol_slippage`'s volatility-proxy-scaled charge) — nets a real
|
||||||
/// campaign member through the SAME production path as its `constant`/
|
/// campaign member through the SAME production path as its `constant`/
|
||||||
/// `vol_slippage` siblings (`campaign run` -> `CliMemberRunner` (net-mode) ->
|
/// `vol_slippage` siblings (`campaign run` -> `CliMemberRunner` (net-mode) ->
|
||||||
/// `persist_campaign_traces`, non-reduce re-run binding a `CostLeg`). The
|
/// `persist_campaign_traces`, retained-delivery re-run binding a `CostLeg`). The
|
||||||
/// re-run must bind the identical carry model the nominee ran under, so it
|
/// re-run must bind the identical carry model the nominee ran under, so it
|
||||||
/// must not false-fail the C1 drift alarm; the emitted member must stamp
|
/// must not false-fail the C1 drift alarm; the emitted member must stamp
|
||||||
/// `cost[0].carry_per_cycle`; and the accrual must actually move at least one
|
/// `cost[0].carry_per_cycle`; and the accrual must actually move at least one
|
||||||
@@ -3961,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");
|
assert_ne!(with_cost, base, "replacen must actually match the fixture's seed field");
|
||||||
write_doc(&dir, "carrycost-persist.campaign.json", &with_cost);
|
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.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(3)
|
if code == Some(3)
|
||||||
@@ -4033,7 +4039,7 @@ fn campaign_run_real_e2e_sweep_monte_carlo_per_survivor() {
|
|||||||
"mc.campaign.json",
|
"mc.campaign.json",
|
||||||
&campaign_doc_json(&bp_id, &proc_id, (1725148800000, 1727740799999), "", ""),
|
&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.
|
// Skip on a data-less machine: the member-data refusal, never a panic.
|
||||||
if code == Some(3)
|
if code == Some(3)
|
||||||
@@ -4104,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);
|
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), "", ""));
|
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}");
|
assert_eq!(file_code, Some(3), "stdout/stderr: {file_out}");
|
||||||
|
|
||||||
let (reg_out, reg_code) = run_code_in(&dir, &["campaign", "register", "mc2.campaign.json"]);
|
let (reg_out, reg_code) = run_code_in(&dir, &["campaign", "register", "mc2.campaign.json"]);
|
||||||
@@ -4119,7 +4125,7 @@ fn campaign_run_by_content_id_matches_file_sugar_refusal() {
|
|||||||
.expect("id")
|
.expect("id")
|
||||||
.trim_start_matches("content:")
|
.trim_start_matches("content:")
|
||||||
.to_string();
|
.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}");
|
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");
|
let file_line = file_out.lines().find(|l| l.starts_with("aura: ")).expect("file refusal line");
|
||||||
@@ -4167,8 +4173,8 @@ fn campaign_run_tolerates_content_prefix_on_target() {
|
|||||||
.trim_start_matches("content:")
|
.trim_start_matches("content:")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let (bare_out, _) = run_code_in(&dir, &["campaign", "run", &id]);
|
let (bare_out, _) = run_code_in(&dir, &["exec", &id]);
|
||||||
let (pfx_out, _) = run_code_in(&dir, &["campaign", "run", &format!("content:{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");
|
let pfx_line = pfx_out.lines().find(|l| l.starts_with("aura: ")).expect("prefixed refusal line");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -4220,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);
|
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), "", ""));
|
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_eq!(code, Some(3), "stdout/stderr: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
out.contains("no recorded geometry") || out.contains("no data for instrument"),
|
out.contains("no recorded geometry") || out.contains("no data for instrument"),
|
||||||
@@ -4249,7 +4255,7 @@ fn campaign_run_invalid_file_refuses_before_touching_store() {
|
|||||||
let bad_path = write_doc(&dir, "bad.campaign.json", &bad);
|
let bad_path = write_doc(&dir, "bad.campaign.json", &bad);
|
||||||
let _cleanup = ScratchGuard(vec![ScratchPath::Dir(runs_dir.clone()), ScratchPath::File(bad_path)]);
|
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_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||||
assert!(
|
assert!(
|
||||||
out.contains("aura: campaign document invalid:"),
|
out.contains("aura: campaign document invalid:"),
|
||||||
@@ -4302,7 +4308,7 @@ fn campaign_over_a_gapped_archive_records_the_uncovered_cell_and_continues() {
|
|||||||
}}"#,
|
}}"#,
|
||||||
);
|
);
|
||||||
write_doc(&dir, "gap.campaign.json", &doc);
|
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}");
|
assert_eq!(code, Some(3), "stdout/stderr: {out}");
|
||||||
|
|
||||||
let record_line = out
|
let record_line = out
|
||||||
@@ -4344,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.
|
/// clap refuses it as a usage error (exit 2) before any target resolution.
|
||||||
#[test]
|
#[test]
|
||||||
fn campaign_run_rejects_a_zero_parallel_instruments_bound() {
|
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}");
|
assert_eq!(code, Some(2), "clap usage error expected, got: {out}");
|
||||||
// Domain prose, not clap's raw NonZeroUsize wording ("number would be
|
// Domain prose, not clap's raw NonZeroUsize wording ("number would be
|
||||||
// zero for non-zero type") — the fieldtest friction finding.
|
// zero for non-zero type") — the fieldtest friction finding.
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ fn temp_cwd(name: &str) -> std::path::PathBuf {
|
|||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `trace_name` handle a recording run printed on its stdout record line.
|
||||||
|
/// #311: the trace directory is `<render-name>-<id8>`, keyed by the run's own
|
||||||
|
/// identity, so the path is derived from this value rather than hard-coded.
|
||||||
|
fn trace_handle(stdout: &[u8]) -> String {
|
||||||
|
let line = String::from_utf8_lossy(stdout);
|
||||||
|
let v: serde_json::Value =
|
||||||
|
serde_json::from_str(line.trim()).expect("stdout is one JSON record line");
|
||||||
|
v["trace_name"]
|
||||||
|
.as_str()
|
||||||
|
.expect("a recording run prints its trace_name")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// The shipped `examples/r_sma.json`, turned MEASUREMENT-shaped: one declared tap
|
/// The shipped `examples/r_sma.json`, turned MEASUREMENT-shaped: one declared tap
|
||||||
/// on node 0 ("fast", SMA(2)) field 0, and its `bias` output removed. Same closed
|
/// on node 0 ("fast", SMA(2)) field 0, and its `bias` output removed. Same closed
|
||||||
/// topology (`sma_signal`), so it runs on the built-in synthetic stream.
|
/// topology (`sma_signal`), so it runs on the built-in synthetic stream.
|
||||||
@@ -34,7 +47,7 @@ fn measurement_blueprint_runs_bare_and_emits_its_tap() {
|
|||||||
std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
|
std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
|
||||||
|
|
||||||
let out = Command::new(BIN)
|
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)
|
.current_dir(&cwd)
|
||||||
.output()
|
.output()
|
||||||
.expect("spawn aura run");
|
.expect("spawn aura run");
|
||||||
@@ -49,10 +62,123 @@ fn measurement_blueprint_runs_bare_and_emits_its_tap() {
|
|||||||
assert_eq!(report["manifest"]["broker"], "measurement");
|
assert_eq!(report["manifest"]["broker"], "measurement");
|
||||||
|
|
||||||
// The tapped series is persisted through the trace store.
|
// The tapped series is persisted through the trace store.
|
||||||
let trace_path = cwd.join("runs/traces/sma_signal/fast_tap.json");
|
let trace_path =
|
||||||
|
cwd.join("runs/traces").join(trace_handle(&out.stdout)).join("fast_tap.json");
|
||||||
let trace_text = std::fs::read_to_string(&trace_path)
|
let trace_text = std::fs::read_to_string(&trace_path)
|
||||||
.unwrap_or_else(|e| panic!("expected a persisted tap trace at {}: {e}", trace_path.display()));
|
.unwrap_or_else(|e| panic!("expected a persisted tap trace at {}: {e}", trace_path.display()));
|
||||||
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
|
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
|
||||||
assert_eq!(trace["tap"], "fast_tap");
|
assert_eq!(trace["tap"], "fast_tap");
|
||||||
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
|
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").join(trace_handle(&out.stdout)).join("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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #309: the measurement leg reports its trace handle exactly as the strategy
|
||||||
|
/// leg does — both bind through one shared tap-plan pair, so a handle present
|
||||||
|
/// on one and absent on the other would mean the seam had drifted.
|
||||||
|
#[test]
|
||||||
|
fn measurement_run_reports_its_trace_handle_on_stdout() {
|
||||||
|
let cwd = temp_cwd("measurement-trace-handle");
|
||||||
|
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")])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn exec");
|
||||||
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let line = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let report: serde_json::Value =
|
||||||
|
serde_json::from_str(line.trim()).expect("stdout is one JSON object");
|
||||||
|
let handle = report["trace_name"].as_str().expect("trace_name is present and a string");
|
||||||
|
assert!(
|
||||||
|
cwd.join("runs/traces").join(handle).join("fast_tap.json").exists(),
|
||||||
|
"the reported handle must name the directory the tapped series landed in; got {handle}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! `aura run <blueprint.json>` must REFUSE a user-authorable blueprint it
|
//! `aura run <blueprint.json>` must REFUSE a user-authorable blueprint it
|
||||||
//! cannot run — a clean `aura:`-prefixed message on stderr and exit 1, the
|
//! cannot run — a clean `aura:`-prefixed message on stderr and exit 2, the
|
||||||
//! established binding-refusal register — never a process panic. Both cases
|
//! argv-named blueprint-content class (C14) — never a process panic. Both
|
||||||
//! are user mistakes on the hand-authored single-run path, so each deserves a
|
//! cases are user mistakes on the hand-authored single-run path, so each
|
||||||
//! diagnosis, not a stack trace.
|
//! deserves a diagnosis, not a stack trace.
|
||||||
//!
|
//!
|
||||||
//! Two independent unrunnable shapes, each pinned as its own minimal,
|
//! Two independent unrunnable shapes, each pinned as its own minimal,
|
||||||
//! self-contained blueprint (inlined here — no shared fixture file):
|
//! self-contained blueprint (inlined here — no shared fixture file):
|
||||||
@@ -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");
|
std::fs::write(&bp_path, bp_json).expect("write blueprint");
|
||||||
|
|
||||||
let out = Command::new(BIN)
|
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)
|
.current_dir(&cwd)
|
||||||
.output()
|
.output()
|
||||||
.expect("spawn aura run");
|
.expect("spawn aura run");
|
||||||
@@ -51,16 +51,18 @@ fn assert_clean_refusal(bp_json: &str, tag: &str, what: &str) {
|
|||||||
!stderr.to_lowercase().contains("panic"),
|
!stderr.to_lowercase().contains("panic"),
|
||||||
"{what}: expected a clean refusal, got a PANIC:\n{stderr}"
|
"{what}: expected a clean refusal, got a PANIC:\n{stderr}"
|
||||||
);
|
);
|
||||||
// The clean-refusal register: `aura: <msg>` on stderr, exit 1 — exactly
|
// The clean-refusal register: `aura: <msg>` on stderr — the prefix every
|
||||||
// what the binding refusals in `run_signal_r` already emit.
|
// refusal class (C14) shares regardless of exit code.
|
||||||
assert!(
|
assert!(
|
||||||
stderr.contains("aura:"),
|
stderr.contains("aura:"),
|
||||||
"{what}: refusal must carry the `aura:` prefix:\n{stderr}"
|
"{what}: refusal must carry the `aura:` prefix:\n{stderr}"
|
||||||
);
|
);
|
||||||
|
// C14 class 2: fault in argv-named content (#297) — both callers of this
|
||||||
|
// helper name a compile-time / exposed-output content fault.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
out.status.code(),
|
out.status.code(),
|
||||||
Some(1),
|
Some(2),
|
||||||
"{what}: a refusal exits 1 (not 101 panic, not 2 usage):\n{stderr}"
|
"{what}: a refusal exits 2 (blueprint-content fault, not 101 panic, not 1 env/data fault):\n{stderr}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,20 @@ fn tap_blueprint_json() -> String {
|
|||||||
serde_json::to_string(&v).expect("re-serialize tapped blueprint")
|
serde_json::to_string(&v).expect("re-serialize tapped blueprint")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `trace_name` handle a recording run printed on its stdout record line —
|
||||||
|
/// the address of the directory its taps landed in. #311: that directory is
|
||||||
|
/// `<render-name>-<id8>`, keyed by the run's own identity, so no test may
|
||||||
|
/// hard-code it; every trace path in this file is derived from this value.
|
||||||
|
fn trace_handle(stdout: &[u8]) -> String {
|
||||||
|
let line = String::from_utf8_lossy(stdout);
|
||||||
|
let v: serde_json::Value =
|
||||||
|
serde_json::from_str(line.trim()).expect("stdout is one JSON record line");
|
||||||
|
v["trace_name"]
|
||||||
|
.as_str()
|
||||||
|
.expect("a recording run prints its trace_name")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Property: on the single-run path (`aura run <blueprint.json>` over the
|
/// Property: on the single-run path (`aura run <blueprint.json>` over the
|
||||||
/// built-in synthetic stream, no `--real`), a declared tap is bound and its
|
/// built-in synthetic stream, no `--real`), a declared tap is bound and its
|
||||||
/// per-cycle series is persisted through the trace store — the persisted
|
/// per-cycle series is persisted through the trace store — the persisted
|
||||||
@@ -53,13 +67,14 @@ fn single_run_persists_a_declared_tap_series() {
|
|||||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||||
|
|
||||||
let out = Command::new(BIN)
|
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)
|
.current_dir(&cwd)
|
||||||
.output()
|
.output()
|
||||||
.expect("spawn aura run");
|
.expect("spawn aura run");
|
||||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
let trace_path = cwd.join("runs/traces/sma_signal/fast_tap.json");
|
let trace_path =
|
||||||
|
cwd.join("runs/traces").join(trace_handle(&out.stdout)).join("fast_tap.json");
|
||||||
let trace_text = std::fs::read_to_string(&trace_path).unwrap_or_else(|e| {
|
let trace_text = std::fs::read_to_string(&trace_path).unwrap_or_else(|e| {
|
||||||
panic!("expected a persisted tap trace at {}: {e}", trace_path.display())
|
panic!("expected a persisted tap trace at {}: {e}", trace_path.display())
|
||||||
});
|
});
|
||||||
@@ -100,7 +115,7 @@ fn tap_free_run_writes_no_trace_store() {
|
|||||||
let cwd = temp_cwd("tap-free-writes-nothing");
|
let cwd = temp_cwd("tap-free-writes-nothing");
|
||||||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
let out = Command::new(BIN)
|
let out = Command::new(BIN)
|
||||||
.args(["run", &bp])
|
.args(["exec", &bp])
|
||||||
.current_dir(&cwd)
|
.current_dir(&cwd)
|
||||||
.output()
|
.output()
|
||||||
.expect("spawn aura run");
|
.expect("spawn aura run");
|
||||||
@@ -124,6 +139,21 @@ fn duplicate_tap_blueprint_json() -> String {
|
|||||||
serde_json::to_string(&v).expect("re-serialize duplicate-tap blueprint")
|
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
|
/// 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
|
/// on the single-run path with a named error and exit code 1, before any
|
||||||
/// trace is persisted** — the CLI-owned `TapBindError::DuplicateBind` dedup
|
/// trace is persisted** — the CLI-owned `TapBindError::DuplicateBind` dedup
|
||||||
@@ -137,12 +167,14 @@ 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");
|
std::fs::write(&bp_path, duplicate_tap_blueprint_json()).expect("write dup-tap blueprint");
|
||||||
|
|
||||||
let out = Command::new(BIN)
|
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)
|
.current_dir(&cwd)
|
||||||
.output()
|
.output()
|
||||||
.expect("spawn aura run");
|
.expect("spawn aura run");
|
||||||
|
|
||||||
assert!(!out.status.success(), "a duplicate tap name must be refused, not silently bound");
|
assert!(!out.status.success(), "a duplicate tap name must be refused, not silently bound");
|
||||||
|
// C14 class 2: fault in argv-named content (#297)
|
||||||
|
assert_eq!(out.status.code(), Some(2));
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
assert!(
|
assert!(
|
||||||
stderr.contains("dup") && stderr.contains("more than once"),
|
stderr.contains("dup") && stderr.contains("more than once"),
|
||||||
@@ -163,12 +195,14 @@ fn an_unwritable_store_root_exits_through_the_tap_trace_register() {
|
|||||||
std::fs::write(cwd.join("runs"), b"not a directory").expect("occupy runs as a file");
|
std::fs::write(cwd.join("runs"), b"not a directory").expect("occupy runs as a file");
|
||||||
|
|
||||||
let out = Command::new(BIN)
|
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)
|
.current_dir(&cwd)
|
||||||
.output()
|
.output()
|
||||||
.expect("spawn aura run");
|
.expect("spawn aura run");
|
||||||
|
|
||||||
assert!(!out.status.success(), "an unwritable store must refuse, not succeed");
|
assert!(!out.status.success(), "an unwritable store must refuse, not succeed");
|
||||||
|
// C14 class 1: environment fault (#297)
|
||||||
|
assert_eq!(out.status.code(), Some(1));
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
assert!(
|
assert!(
|
||||||
stderr.contains("writing tap traces failed"),
|
stderr.contains("writing tap traces failed"),
|
||||||
@@ -181,6 +215,15 @@ fn an_unwritable_store_root_exits_through_the_tap_trace_register() {
|
|||||||
/// but the record consumer's deferred open in `initialize` fails; the run
|
/// but the record consumer's deferred open in `initialize` fails; the run
|
||||||
/// COMPLETES, the failure surfaces terminally through the register, and no
|
/// COMPLETES, the failure surfaces terminally through the register, and no
|
||||||
/// `index.json` is written (the store's treat-as-absent crash shape).
|
/// `index.json` is written (the store's treat-as-absent crash shape).
|
||||||
|
///
|
||||||
|
/// #311: the run directory's name is the run's own identity and cannot be known
|
||||||
|
/// in advance, so the premise is set up by RUNNING first — the identity property
|
||||||
|
/// itself guarantees that a second, byte-identical invocation targets the same
|
||||||
|
/// directory. Emptying it and dropping its write bit between the two runs
|
||||||
|
/// reproduces the original premise exactly (an existing, unwritable run dir)
|
||||||
|
/// without hard-coding a name. Making the store ROOT unwritable instead would
|
||||||
|
/// move the failure to `begin_run`, i.e. into the pre-run arm its sibling at
|
||||||
|
/// `an_unwritable_store_root_exits_through_the_tap_trace_register` already pins.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
|
fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
|
||||||
@@ -189,26 +232,505 @@ fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
|
|||||||
let cwd = temp_cwd("run-dir-read-only");
|
let cwd = temp_cwd("run-dir-read-only");
|
||||||
let bp_path = cwd.join("tapped_r_sma.json");
|
let bp_path = cwd.join("tapped_r_sma.json");
|
||||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
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");
|
let exec = || {
|
||||||
|
Command::new(BIN)
|
||||||
|
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura run")
|
||||||
|
};
|
||||||
|
|
||||||
|
let first = exec();
|
||||||
|
assert!(first.status.success(), "stderr: {}", String::from_utf8_lossy(&first.stderr));
|
||||||
|
let run_dir = cwd.join("runs/traces").join(trace_handle(&first.stdout));
|
||||||
|
for entry in std::fs::read_dir(&run_dir).expect("read the run dir") {
|
||||||
|
std::fs::remove_file(entry.expect("dir entry").path()).expect("empty the run dir");
|
||||||
|
}
|
||||||
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o555))
|
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o555))
|
||||||
.expect("make run dir read-only");
|
.expect("make run dir read-only");
|
||||||
|
|
||||||
let out = Command::new(BIN)
|
let out = exec();
|
||||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
|
||||||
.current_dir(&cwd)
|
|
||||||
.output()
|
|
||||||
.expect("spawn aura run");
|
|
||||||
|
|
||||||
// restore permissions FIRST so the next run's temp_cwd cleanup works.
|
// restore permissions FIRST so the next run's temp_cwd cleanup works.
|
||||||
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o755))
|
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o755))
|
||||||
.expect("restore run dir permissions");
|
.expect("restore run dir permissions");
|
||||||
|
|
||||||
assert!(!out.status.success(), "a failed writer must surface, not pass silently");
|
assert!(!out.status.success(), "a failed writer must surface, not pass silently");
|
||||||
|
// C14 class 1: environment fault (#297)
|
||||||
|
assert_eq!(out.status.code(), Some(1));
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
assert!(
|
assert!(
|
||||||
stderr.contains("writing tap traces failed"),
|
stderr.contains("writing tap traces failed"),
|
||||||
"the tap-trace register must fire terminally: {stderr}"
|
"the tap-trace register must fire terminally: {stderr}"
|
||||||
);
|
);
|
||||||
assert!(!run_dir.join("index.json").exists(), "no index — treat-as-absent crash shape");
|
assert!(!run_dir.join("index.json").exists(), "no index — treat-as-absent crash shape");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_dir(cwd.join("runs/traces")).expect("read the store").count(),
|
||||||
|
1,
|
||||||
|
"the identical re-run targeted the same directory rather than minting a second"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #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 run_dir = cwd.join("runs/traces").join(trace_handle(&out.stdout));
|
||||||
|
let index_text =
|
||||||
|
std::fs::read_to_string(run_dir.join("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(run_dir.join("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]| -> std::path::PathBuf {
|
||||||
|
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));
|
||||||
|
cwd.join("runs/traces").join(trace_handle(&out.stdout))
|
||||||
|
};
|
||||||
|
let dir_a = run(&cwd_a, &[]);
|
||||||
|
let dir_b = run(&cwd_b, &["--tap", "fast_tap=record", "--tap", "slow_tap=record"]);
|
||||||
|
// #311: the tap PLAN is not identity-bearing — it selects what is written,
|
||||||
|
// not what the run IS — so both invocations mint the same handle.
|
||||||
|
assert_eq!(
|
||||||
|
dir_a.file_name(),
|
||||||
|
dir_b.file_name(),
|
||||||
|
"the same blueprint under the same inputs is one identity either way"
|
||||||
|
);
|
||||||
|
|
||||||
|
for file in ["fast_tap.json", "slow_tap.json"] {
|
||||||
|
let a = std::fs::read(dir_a.join(file)).expect("default trace");
|
||||||
|
let b = std::fs::read(dir_b.join(file)).expect("explicit trace");
|
||||||
|
assert_eq!(a, b, "{file} must be byte-identical across default and explicit record plans");
|
||||||
|
}
|
||||||
|
let taps_of = |dir: &std::path::Path| -> serde_json::Value {
|
||||||
|
let text = std::fs::read_to_string(dir.join("index.json")).expect("read index.json");
|
||||||
|
serde_json::from_str::<serde_json::Value>(&text).expect("parse index.json")["taps"].clone()
|
||||||
|
};
|
||||||
|
assert_eq!(taps_of(&dir_a), taps_of(&dir_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");
|
||||||
|
// C14 class 2: fault in argv-named content (#297)
|
||||||
|
assert_eq!(out.status.code(), Some(2));
|
||||||
|
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");
|
||||||
|
// C14 class 2: fault in argv-named content (#297)
|
||||||
|
assert_eq!(out.status.code(), Some(2));
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #309: a run that records traces names the handle they landed under, on the
|
||||||
|
/// data plane — so a caller can chain `exec` into `chart` without knowing the
|
||||||
|
/// blueprint's render name. The tapped fixture's root composite is named
|
||||||
|
/// `sma_signal`, which is exactly the directory the existing
|
||||||
|
/// `single_run_persists_a_declared_tap_series` reads its trace back from.
|
||||||
|
#[test]
|
||||||
|
fn recording_run_reports_its_trace_handle_on_stdout() {
|
||||||
|
let cwd = temp_cwd("trace-handle-echo");
|
||||||
|
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")])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn exec");
|
||||||
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let line = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let v: serde_json::Value = serde_json::from_str(line.trim()).expect("stdout is one JSON object");
|
||||||
|
let handle = v["trace_name"].as_str().expect("trace_name is present and a string");
|
||||||
|
// #311: the handle is the run's own IDENTITY, not its render name alone —
|
||||||
|
// the render name is the readable prefix, the 8-hex tail the run identity.
|
||||||
|
// (The next assertion, that the handle names the directory the traces
|
||||||
|
// landed in, is name-agnostic and unchanged.)
|
||||||
|
assert!(
|
||||||
|
handle.starts_with("sma_signal-"),
|
||||||
|
"the handle carries the run's render name as its prefix; got {handle}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
handle.len(),
|
||||||
|
"sma_signal-".len() + 8,
|
||||||
|
"the identity suffix is exactly 8 hex chars; got {handle}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
cwd.join("runs/traces").join(handle).join("fast_tap.json").exists(),
|
||||||
|
"the reported handle must name the directory the traces landed in; got {handle}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #309: the handle is appended, never interleaved — the report's own key
|
||||||
|
/// order and bytes survive the flattening wrapper untouched. This is the first
|
||||||
|
/// pin of `serde(flatten)` byte behaviour in this workspace; assert bytes.
|
||||||
|
///
|
||||||
|
/// #311: the handle's own spelling (`<render-name>-<id8>`) is pinned elsewhere
|
||||||
|
/// (`recording_run_reports_its_trace_handle_on_stdout`); this test only pins the
|
||||||
|
/// BYTE POSITION — that whatever the handle is, it is the last key, appended
|
||||||
|
/// after every other field, never interleaved — so it reads the handle back off
|
||||||
|
/// the same line rather than hard-coding its value.
|
||||||
|
#[test]
|
||||||
|
fn trace_handle_is_appended_as_the_last_key() {
|
||||||
|
let cwd = temp_cwd("trace-handle-bytes");
|
||||||
|
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")])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn exec");
|
||||||
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
let handle = trace_handle(out.stdout.as_slice());
|
||||||
|
|
||||||
|
let expected_tail = format!(",\"trace_name\":\"{handle}\"}}");
|
||||||
|
assert!(
|
||||||
|
line.ends_with(&expected_tail),
|
||||||
|
"the handle must be the last key, appended before the closing brace: {line}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #309: a tap-free run records nothing, so it reports no handle — the
|
||||||
|
/// wrapper must not emit an empty key. (The line's key order and grouping are
|
||||||
|
/// pinned separately, in `exec.rs`; this asserts only the absent key.)
|
||||||
|
#[test]
|
||||||
|
fn tap_free_run_line_carries_no_trace_handle() {
|
||||||
|
let cwd = temp_cwd("trace-handle-absent");
|
||||||
|
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||||
|
|
||||||
|
let out = Command::new(BIN)
|
||||||
|
.args(["exec", &bp])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn exec");
|
||||||
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
assert!(
|
||||||
|
!line.contains("trace_name"),
|
||||||
|
"a run that recorded nothing must emit no trace_name key at all: {line}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #311 headline: two runs of ONE blueprint that differ in an identity-bearing
|
||||||
|
/// input (an `--override` value that actually changes the bound parameter,
|
||||||
|
/// `fast.length=2` vs `fast.length=3`) land in TWO trace directories — the
|
||||||
|
/// first run's tapped series is still on disk, byte-unchanged, after the
|
||||||
|
/// second run completes. Before #311, both runs minted `traces/sma_signal/`
|
||||||
|
/// and the second overwrote the first, losing the earlier series
|
||||||
|
/// unrecoverably.
|
||||||
|
#[test]
|
||||||
|
fn two_runs_of_one_blueprint_with_different_overrides_keep_both_traces() {
|
||||||
|
let cwd = temp_cwd("two-overrides-two-traces");
|
||||||
|
let bp_path = cwd.join("tapped_r_sma.json");
|
||||||
|
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||||
|
|
||||||
|
let run = |value: &str| -> String {
|
||||||
|
let ov = format!("fast.length={value}");
|
||||||
|
let out = Command::new(BIN)
|
||||||
|
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--override", &ov])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn exec");
|
||||||
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
trace_handle(&out.stdout)
|
||||||
|
};
|
||||||
|
|
||||||
|
let handle_a = run("2");
|
||||||
|
let series_a = std::fs::read(cwd.join("runs/traces").join(&handle_a).join("fast_tap.json"))
|
||||||
|
.expect("the first run's tapped series");
|
||||||
|
let handle_b = run("3");
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
handle_a, handle_b,
|
||||||
|
"two runs differing in an identity-bearing input must land in two directories"
|
||||||
|
);
|
||||||
|
let series_b = std::fs::read(cwd.join("runs/traces").join(&handle_b).join("fast_tap.json"))
|
||||||
|
.expect("the second run's tapped series");
|
||||||
|
assert_ne!(series_a, series_b, "SMA(2) and SMA(3) are different series");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(cwd.join("runs/traces").join(&handle_a).join("fast_tap.json"))
|
||||||
|
.expect("the first run's series survives the second run"),
|
||||||
|
series_a,
|
||||||
|
"the earlier run's trace must survive the later run untouched"
|
||||||
|
);
|
||||||
|
for handle in [&handle_a, &handle_b] {
|
||||||
|
assert!(
|
||||||
|
cwd.join("runs/traces").join(handle).join("index.json").is_file(),
|
||||||
|
"each run closes its own index: {handle}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #311 acceptance criterion 2: two runs identical in every identity-bearing
|
||||||
|
/// input write ONE directory and print ONE handle. Identity keying separates
|
||||||
|
/// runs that differ; it does not mint a directory per invocation.
|
||||||
|
#[test]
|
||||||
|
fn two_identical_runs_share_one_trace_directory() {
|
||||||
|
let cwd = temp_cwd("identical-runs-one-dir");
|
||||||
|
let bp_path = cwd.join("tapped_r_sma.json");
|
||||||
|
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||||
|
|
||||||
|
let run = || -> String {
|
||||||
|
let out = Command::new(BIN)
|
||||||
|
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn exec");
|
||||||
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
trace_handle(&out.stdout)
|
||||||
|
};
|
||||||
|
|
||||||
|
let first = run();
|
||||||
|
let second = run();
|
||||||
|
assert_eq!(first, second, "same inputs, same identity, same handle");
|
||||||
|
assert!(
|
||||||
|
first.starts_with("sma_signal-") && first.len() == "sma_signal-".len() + 8,
|
||||||
|
"the render name is the readable prefix, the 8-hex tail the identity: {first}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_dir(cwd.join("runs/traces")).expect("read the store").count(),
|
||||||
|
1,
|
||||||
|
"an unchanged re-run must not grow the store"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #311 acceptance criterion 3 — and the pin for the two source-read properties
|
||||||
|
/// the `project.commit` exclusion rests on: the field carries a `-dirty` marker,
|
||||||
|
/// and it is re-derived on EVERY invocation (`aura-runner::project::project_commit`).
|
||||||
|
/// A run whose project worktree is dirty must still mint the handle it minted
|
||||||
|
/// with a clean worktree; otherwise editing any file in the project — including
|
||||||
|
/// the blueprint being worked on — would turn the store into an unbounded
|
||||||
|
/// directory generator.
|
||||||
|
#[test]
|
||||||
|
fn a_dirty_project_worktree_does_not_change_the_run_handle() {
|
||||||
|
let cwd = temp_cwd("provenance-does-not-churn");
|
||||||
|
std::fs::write(cwd.join("Aura.toml"), "# identity fixture: data-only project\n")
|
||||||
|
.expect("write Aura.toml");
|
||||||
|
let bp_path = cwd.join("tapped_r_sma.json");
|
||||||
|
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||||
|
// Ignore the trace store the first `exec()` below creates — otherwise
|
||||||
|
// `runs/` itself would be an untracked path and `git status --porcelain`
|
||||||
|
// would already report the worktree dirty on the SECOND invocation
|
||||||
|
// regardless of `scratch.txt`, making that write inert rather than the
|
||||||
|
// thing that actually flips the marker.
|
||||||
|
std::fs::write(cwd.join(".gitignore"), "runs/\n").expect("write .gitignore");
|
||||||
|
|
||||||
|
let git = |args: &[&str]| {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.args(args)
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("run git");
|
||||||
|
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
};
|
||||||
|
git(&["init"]);
|
||||||
|
git(&["config", "user.email", "test@example.com"]);
|
||||||
|
git(&["config", "user.name", "test"]);
|
||||||
|
git(&["add", "-A"]);
|
||||||
|
git(&["commit", "-m", "fixture"]);
|
||||||
|
|
||||||
|
let exec = || -> (String, String) {
|
||||||
|
let out = Command::new(BIN)
|
||||||
|
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn exec");
|
||||||
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
let line = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let v: serde_json::Value =
|
||||||
|
serde_json::from_str(line.trim()).expect("stdout is one JSON record line");
|
||||||
|
let commit = v["manifest"]["project"]["commit"]
|
||||||
|
.as_str()
|
||||||
|
.expect("a run inside a git-backed project stamps its repo HEAD")
|
||||||
|
.to_string();
|
||||||
|
(trace_handle(&out.stdout), commit)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (clean_handle, clean_commit) = exec();
|
||||||
|
// `runs/` is gitignored (above), so this is the only untracked change —
|
||||||
|
// it, and nothing else, is what flips `git status --porcelain` for the
|
||||||
|
// second invocation below.
|
||||||
|
std::fs::write(cwd.join("scratch.txt"), "an uncommitted edit\n").expect("dirty the worktree");
|
||||||
|
let (dirty_handle, dirty_commit) = exec();
|
||||||
|
|
||||||
|
// The two source-read properties, pinned: the marker exists, and the field
|
||||||
|
// is re-derived per invocation rather than captured once.
|
||||||
|
assert!(
|
||||||
|
!clean_commit.ends_with("-dirty"),
|
||||||
|
"a committed worktree stamps a bare HEAD: {clean_commit}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
dirty_commit.ends_with("-dirty"),
|
||||||
|
"an uncommitted file stamps the -dirty marker: {dirty_commit}"
|
||||||
|
);
|
||||||
|
assert_ne!(clean_commit, dirty_commit, "project.commit is re-derived on every invocation");
|
||||||
|
|
||||||
|
// ...and yet it is the same run.
|
||||||
|
assert_eq!(
|
||||||
|
clean_handle, dirty_handle,
|
||||||
|
"the project repo's HEAD/dirty state is provenance, not identity"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_dir(cwd.join("runs/traces")).expect("read the store").count(),
|
||||||
|
1,
|
||||||
|
"one identity, one directory"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
|
|||||||
let sqrt = g.add(Sqrt::builder()); // → σ (price units)
|
let sqrt = g.add(Sqrt::builder()); // → σ (price units)
|
||||||
// k·σ: weights[0] bound (Some) or open and named `stop_k` (None).
|
// k·σ: weights[0] bound (Some) or open and named `stop_k` (None).
|
||||||
let scale = g.add(match knobs {
|
let scale = g.add(match knobs {
|
||||||
Some((_, k)) => LinComb::builder(1).bind("weights[0]", Scalar::f64(k)),
|
Some((_, k)) => LinComb::configured(1).bind("weights[0]", Scalar::f64(k)),
|
||||||
None => LinComb::builder(1).named("stop_k"),
|
None => LinComb::configured(1).named("stop_k"),
|
||||||
});
|
});
|
||||||
g.feed(price, [delay.input("series"), sub.input("lhs")]);
|
g.feed(price, [delay.input("series"), sub.input("lhs")]);
|
||||||
g.connect(delay.output("value"), sub.input("rhs"));
|
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 entry = g.input_role("entry_price");
|
||||||
let stop = g.input_role("stop_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.
|
// Per-role geometry fan targets, collected across all nodes, fed once per role.
|
||||||
let mut closed_t = Vec::with_capacity(n);
|
let mut closed_t = Vec::with_capacity(n);
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
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]
|
[dev-dependencies]
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -1,7 +1,37 @@
|
|||||||
//! Bakes the compiling toolchain's version into `AURA_RUSTC_VERSION` so the
|
//! Bakes the compiling toolchain's version into `AURA_RUSTC_VERSION`, and a
|
||||||
//! project descriptor (src/project.rs) can stamp it. Runs per consuming build:
|
//! deterministic fingerprint of aura-core's own sources into
|
||||||
//! the host binary and a project cdylib each recompile aura-core under their
|
//! `AURA_CORE_FINGERPRINT`, so the project descriptor (src/project.rs) can
|
||||||
//! own toolchain, which is exactly what makes the two stamps comparable.
|
//! stamp both. Runs per consuming build: the host binary and a project
|
||||||
|
//! cdylib each recompile aura-core under their own toolchain / from their own
|
||||||
|
//! checkout, which is exactly what makes the two stamps comparable (C30: the
|
||||||
|
//! load seam commits to a build-identity fingerprint, never the frozen crate
|
||||||
|
//! version).
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
|
const FNV_PRIME: u64 = 0x0100_0000_01b3;
|
||||||
|
|
||||||
|
fn fnv1a(bytes: &[u8], mut hash: u64) -> u64 {
|
||||||
|
for &b in bytes {
|
||||||
|
hash ^= b as u64;
|
||||||
|
hash = hash.wrapping_mul(FNV_PRIME);
|
||||||
|
}
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively collect every `*.rs` file under `dir`.
|
||||||
|
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_rs_files(&path, out);
|
||||||
|
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into());
|
let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into());
|
||||||
@@ -13,4 +43,34 @@ fn main() {
|
|||||||
"cargo:rustc-env=AURA_RUSTC_VERSION={}",
|
"cargo:rustc-env=AURA_RUSTC_VERSION={}",
|
||||||
String::from_utf8_lossy(&out.stdout).trim()
|
String::from_utf8_lossy(&out.stdout).trim()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Deterministic staleness fingerprint: hash (relative path + contents) of
|
||||||
|
// every source file under src/, in sorted order. Not a security hash —
|
||||||
|
// FNV-1a is dependency-free and plenty for detecting a stale build. This
|
||||||
|
// is *source-level* identity only, an accepted C30 limit: the consuming
|
||||||
|
// build resolves its own lockfile and features, so shared-dependency
|
||||||
|
// types riding the Rust tier (e.g. chrono-tz's) are NOT guarded by this
|
||||||
|
// stamp — the seam refuses staleness of aura-core itself, not the full
|
||||||
|
// link graph.
|
||||||
|
let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_rs_files(&src_dir, &mut files);
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
let mut hash = FNV_OFFSET_BASIS;
|
||||||
|
for path in &files {
|
||||||
|
// The walk starts at src_dir, so every path strips; a silent fallback
|
||||||
|
// here would make the fingerprint checkout-dependent.
|
||||||
|
let rel = path.strip_prefix(&src_dir).expect("walked file lies outside src/");
|
||||||
|
let contents = std::fs::read(path).expect("read aura-core source file");
|
||||||
|
// Prefix-free record: path NUL length-prefixed contents (paths never
|
||||||
|
// contain NUL, the length pins the content boundary).
|
||||||
|
hash = fnv1a(rel.to_string_lossy().as_bytes(), hash);
|
||||||
|
hash = fnv1a(&[0], hash);
|
||||||
|
hash = fnv1a(&(contents.len() as u64).to_le_bytes(), hash);
|
||||||
|
hash = fnv1a(&contents, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("cargo:rustc-env=AURA_CORE_FINGERPRINT={hash:016x}");
|
||||||
|
println!("cargo:rerun-if-changed=src");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ pub use column::{Column, Window};
|
|||||||
pub use ctx::Ctx;
|
pub use ctx::Ctx;
|
||||||
pub use error::KindMismatch;
|
pub use error::KindMismatch;
|
||||||
pub use node::{
|
pub use node::{
|
||||||
doc_gate, zip_params, BindOpError, BoundParam, DocGateFault, FieldSpec, Firing, Node,
|
doc_gate, zip_params, ArgKind, ArgOpError, ArgSpec, ArgValue, BindOpError, BoundParam,
|
||||||
NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
ConstructionArg, DocGateFault, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
|
||||||
|
PrimitiveBuilder,
|
||||||
};
|
};
|
||||||
pub use scalar::{Scalar, ScalarKind, Timestamp};
|
pub use scalar::{Scalar, ScalarKind, Timestamp};
|
||||||
pub use series_fold::SeriesFold;
|
pub use series_fold::SeriesFold;
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ pub struct PrimitiveBuilder {
|
|||||||
instance_name: Option<String>,
|
instance_name: Option<String>,
|
||||||
schema: NodeSchema,
|
schema: NodeSchema,
|
||||||
bound: Vec<BoundParam>,
|
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
|
// 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.
|
// boxed node out); a type alias would not clarify it.
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
@@ -127,7 +128,108 @@ impl PrimitiveBuilder {
|
|||||||
schema: NodeSchema,
|
schema: NodeSchema,
|
||||||
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
|
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
|
||||||
) -> Self {
|
) -> 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
|
/// 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
|
/// 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
|
/// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the
|
||||||
/// data-level construction surface (`GraphSession`, #157). `bind` keeps its
|
/// data-level construction surface (`GraphSession`, #157). `bind` keeps its
|
||||||
/// panic contract (and its pinned messages); `try_bind` reports the same three
|
/// 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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum BindOpError {
|
pub enum BindOpError {
|
||||||
/// No still-open param has this name.
|
/// No still-open param has this name.
|
||||||
@@ -333,6 +438,141 @@ pub enum BindOpError {
|
|||||||
AmbiguousParam(String),
|
AmbiguousParam(String),
|
||||||
/// The value's kind does not equal the param's declared kind.
|
/// The value's kind does not equal the param's declared kind.
|
||||||
KindMismatch { param: String, expected: ScalarKind, got: ScalarKind },
|
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
|
/// A node's declared interface: its inputs (in order) and its output record — an
|
||||||
@@ -835,6 +1075,132 @@ mod tests {
|
|||||||
fn doc_gate_accepts_a_meaning_line() {
|
fn doc_gate_accepts_a_meaning_line() {
|
||||||
assert_eq!(doc_gate("EMA", "exponential moving average over the input series"), Ok(()));
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! A research project compiles to a cdylib exporting ONE symbol,
|
//! A research project compiles to a cdylib exporting ONE symbol,
|
||||||
//! `AURA_PROJECT`, a [`ProjectDescriptor`]. The descriptor has two ABI tiers:
|
//! `AURA_PROJECT`, a [`ProjectDescriptor`]. The descriptor has two ABI tiers:
|
||||||
//! a **C tier** (`magic`, `descriptor_version`, the version stamps, the
|
//! a **C tier** (`magic`, `descriptor_version`, the build stamps, the
|
||||||
//! namespace — all C-compatible field types) the host validates BEFORE
|
//! namespace — all C-compatible field types) the host validates BEFORE
|
||||||
//! trusting anything, and a **Rust tier** (the vocabulary resolver and the
|
//! trusting anything, and a **Rust tier** (the vocabulary resolver and the
|
||||||
//! enumerable type-id list) the host touches only after both stamps match.
|
//! enumerable type-id list) the host touches only after both stamps match.
|
||||||
@@ -23,8 +23,11 @@ pub const AURA_PROJECT_SYMBOL: &[u8] = b"AURA_PROJECT\0";
|
|||||||
|
|
||||||
/// The compiling rustc's `--version` line (via build.rs).
|
/// The compiling rustc's `--version` line (via build.rs).
|
||||||
pub const RUSTC_VERSION: &str = env!("AURA_RUSTC_VERSION");
|
pub const RUSTC_VERSION: &str = env!("AURA_RUSTC_VERSION");
|
||||||
/// This aura-core's crate version.
|
/// Build-identity fingerprint of aura-core's own sources (via build.rs),
|
||||||
pub const CORE_VERSION: &str = env!("CARGO_PKG_VERSION");
|
/// per ledger contract C30: the load seam commits to a source-derived
|
||||||
|
/// fingerprint, never the frozen crate version (a fixed 0.1.0 under the
|
||||||
|
/// no-semver discipline would disarm the handshake it's meant to guard).
|
||||||
|
pub const CORE_FINGERPRINT: &str = env!("AURA_CORE_FINGERPRINT");
|
||||||
|
|
||||||
/// C-ABI string slice: pointer + length over `'static` UTF-8 bytes.
|
/// C-ABI string slice: pointer + length over `'static` UTF-8 bytes.
|
||||||
/// No NUL convention needed; readable without trusting the Rust ABI.
|
/// No NUL convention needed; readable without trusting the Rust ABI.
|
||||||
@@ -63,7 +66,7 @@ pub struct ProjectDescriptor {
|
|||||||
pub magic: u64,
|
pub magic: u64,
|
||||||
pub descriptor_version: u32,
|
pub descriptor_version: u32,
|
||||||
pub rustc_version: StrSlice,
|
pub rustc_version: StrSlice,
|
||||||
pub aura_core_version: StrSlice,
|
pub aura_core_fingerprint: StrSlice,
|
||||||
pub namespace: StrSlice,
|
pub namespace: StrSlice,
|
||||||
// ---- Rust tier: only after both stamps match ----
|
// ---- Rust tier: only after both stamps match ----
|
||||||
pub vocabulary: fn(&str) -> Option<PrimitiveBuilder>,
|
pub vocabulary: fn(&str) -> Option<PrimitiveBuilder>,
|
||||||
@@ -86,8 +89,8 @@ macro_rules! aura_project {
|
|||||||
rustc_version: $crate::project::StrSlice::new(
|
rustc_version: $crate::project::StrSlice::new(
|
||||||
$crate::project::RUSTC_VERSION,
|
$crate::project::RUSTC_VERSION,
|
||||||
),
|
),
|
||||||
aura_core_version: $crate::project::StrSlice::new(
|
aura_core_fingerprint: $crate::project::StrSlice::new(
|
||||||
$crate::project::CORE_VERSION,
|
$crate::project::CORE_FINGERPRINT,
|
||||||
),
|
),
|
||||||
namespace: $crate::project::StrSlice::new($ns),
|
namespace: $crate::project::StrSlice::new($ns),
|
||||||
vocabulary: $vocab,
|
vocabulary: $vocab,
|
||||||
@@ -117,7 +120,17 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn stamps_are_baked_and_nonempty() {
|
fn stamps_are_baked_and_nonempty() {
|
||||||
assert!(RUSTC_VERSION.starts_with("rustc "));
|
assert!(RUSTC_VERSION.starts_with("rustc "));
|
||||||
assert!(!CORE_VERSION.is_empty());
|
assert!(!CORE_FINGERPRINT.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C30/#348: the stamp is a source-derived hash, never a constant that
|
||||||
|
/// would disarm the load-seam refusal — pin its mechanical shape (the
|
||||||
|
/// 16-hex FNV-1a rendering) and that it is not the crate version.
|
||||||
|
#[test]
|
||||||
|
fn core_fingerprint_is_a_source_hash_not_the_crate_version() {
|
||||||
|
assert_eq!(CORE_FINGERPRINT.len(), 16);
|
||||||
|
assert!(CORE_FINGERPRINT.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
assert_ne!(CORE_FINGERPRINT, env!("CARGO_PKG_VERSION"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -152,7 +165,7 @@ mod tests {
|
|||||||
assert_eq!(d.descriptor_version, AURA_DESCRIPTOR_VERSION);
|
assert_eq!(d.descriptor_version, AURA_DESCRIPTOR_VERSION);
|
||||||
assert_eq!(unsafe { d.namespace.as_str() }, Some("t"));
|
assert_eq!(unsafe { d.namespace.as_str() }, Some("t"));
|
||||||
assert_eq!(unsafe { d.rustc_version.as_str() }, Some(RUSTC_VERSION));
|
assert_eq!(unsafe { d.rustc_version.as_str() }, Some(RUSTC_VERSION));
|
||||||
assert_eq!(unsafe { d.aura_core_version.as_str() }, Some(CORE_VERSION));
|
assert_eq!(unsafe { d.aura_core_fingerprint.as_str() }, Some(CORE_FINGERPRINT));
|
||||||
assert!((d.vocabulary)("nope").is_none());
|
assert!((d.vocabulary)("nope").is_none());
|
||||||
assert_eq!((d.type_ids)(), &["t::A"]);
|
assert_eq!((d.type_ids)(), &["t::A"]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
//! C23) and no external dependency (C16).
|
//! C23) and no external dependency (C16).
|
||||||
|
|
||||||
use aura_core::{
|
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};
|
use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target};
|
||||||
@@ -194,6 +195,40 @@ pub struct GangMember {
|
|||||||
pub name: String,
|
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 has an operational run-time role: it prefixes a
|
||||||
|
/// run's identity-keyed trace directory on disk unsanitized
|
||||||
|
/// (`traces/<name>-<id8>/`, #311; `crates/aura-registry/src/trace_store.rs`),
|
||||||
|
/// not the whole directory component as before #311. 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 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),
|
/// 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
|
/// interior edges (local indices), input roles (role `r` fans into the interior
|
||||||
@@ -240,6 +275,37 @@ impl Composite {
|
|||||||
pub fn doc(&self) -> Option<&str> {
|
pub fn doc(&self) -> Option<&str> {
|
||||||
self.doc.as_deref()
|
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`).
|
/// Install declared measurement taps (the output-side twin of `input_roles`).
|
||||||
/// Fluent, mirroring `with_doc`. Empty by default.
|
/// Fluent, mirroring `with_doc`. Empty by default.
|
||||||
pub fn with_taps(mut self, taps: Vec<Tap>) -> Self {
|
pub fn with_taps(mut self, taps: Vec<Tap>) -> Self {
|
||||||
@@ -297,6 +363,24 @@ impl Composite {
|
|||||||
out
|
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
|
/// 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
|
/// param returns to the open surface at the slot `collect_params` order
|
||||||
/// dictates; its bound value is forgotten by this value (the authored
|
/// dictates; its bound value is forgotten by this value (the authored
|
||||||
@@ -510,7 +594,8 @@ pub enum ReopenError {
|
|||||||
|
|
||||||
/// One entry of the aggregated BOUND param surface (#246): the path-qualified
|
/// One entry of the aggregated BOUND param surface (#246): the path-qualified
|
||||||
/// twin of [`ParamSpec`] carrying the bound value — the default a sweep axis
|
/// 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)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct BoundSpec {
|
pub struct BoundSpec {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -1066,6 +1151,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
|
/// Recursive mutable walk for `Composite::reopen` (#246): mirrors
|
||||||
/// `collect_params`' prefix rules (lockstep with `expansion_map`) — a leaf owns
|
/// `collect_params`' prefix rules (lockstep with `expansion_map`) — a leaf owns
|
||||||
/// `<node>.<param>`, a composite prefixes its `name()` and recurses. Returns
|
/// `<node>.<param>`, a composite prefixes its `name()` and recurses. Returns
|
||||||
@@ -1097,6 +1207,92 @@ fn reopen_in(items: &mut [BlueprintNode], path: &str) -> usize {
|
|||||||
hits
|
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
|
/// Read-only twin of `collect_params` over the BOUND surface (#246): same
|
||||||
/// recursion shape and prefix rules, but enumerating `bound_params()` (with
|
/// recursion shape and prefix rules, but enumerating `bound_params()` (with
|
||||||
/// values) instead of the open `params()`. Gangs are irrelevant here — a gang
|
/// values) instead of the open `params()`. Gangs are irrelevant here — a gang
|
||||||
@@ -1401,6 +1597,19 @@ mod tests {
|
|||||||
use aura_strategy::Bias;
|
use aura_strategy::Bias;
|
||||||
use std::sync::mpsc;
|
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
|
/// One knob fanning into two sibling open params passes the gate; the
|
||||||
/// value carries the gang table.
|
/// value carries the gang table.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1703,7 +1912,7 @@ mod tests {
|
|||||||
Composite::new(
|
Composite::new(
|
||||||
"sig3",
|
"sig3",
|
||||||
vec![
|
vec![
|
||||||
LinComb::builder(2).into(),
|
LinComb::configured(2).into(),
|
||||||
Bias::builder().into(),
|
Bias::builder().into(),
|
||||||
Sma::builder().named("c").into(),
|
Sma::builder().named("c").into(),
|
||||||
],
|
],
|
||||||
@@ -1742,7 +1951,7 @@ mod tests {
|
|||||||
Composite::new(
|
Composite::new(
|
||||||
"sig3",
|
"sig3",
|
||||||
vec![
|
vec![
|
||||||
LinComb::builder(2).into(),
|
LinComb::configured(2).into(),
|
||||||
Bias::builder().into(),
|
Bias::builder().into(),
|
||||||
Sma::builder().named("c").into(),
|
Sma::builder().named("c").into(),
|
||||||
],
|
],
|
||||||
@@ -3115,7 +3324,7 @@ mod tests {
|
|||||||
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
||||||
let strategy = Composite::new(
|
let strategy = Composite::new(
|
||||||
"strategy",
|
"strategy",
|
||||||
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
|
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
|
||||||
vec![],
|
vec![],
|
||||||
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
||||||
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
||||||
@@ -3213,7 +3422,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// #246: bound_param_space mirrors param_space's path qualification and
|
/// #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]
|
#[test]
|
||||||
fn bound_param_space_is_path_qualified_with_values() {
|
fn bound_param_space_is_path_qualified_with_values() {
|
||||||
use aura_strategy::Bias;
|
use aura_strategy::Bias;
|
||||||
@@ -3323,7 +3533,7 @@ mod tests {
|
|||||||
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
||||||
let strategy = Composite::new(
|
let strategy = Composite::new(
|
||||||
"strategy",
|
"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
|
// fan fast_slow's output into both LinComb terms so every interior slot
|
||||||
// is wired (the totality check, cycle 0040); param order is unaffected.
|
// is wired (the totality check, cycle 0040); param order is unaffected.
|
||||||
vec![
|
vec![
|
||||||
@@ -3383,7 +3593,7 @@ mod tests {
|
|||||||
use aura_std::{LinComb, Sma};
|
use aura_std::{LinComb, Sma};
|
||||||
let bp = Composite::new(
|
let bp = Composite::new(
|
||||||
"root",
|
"root",
|
||||||
vec![Sma::builder().into(), LinComb::builder(2).into()],
|
vec![Sma::builder().into(), LinComb::configured(2).into()],
|
||||||
vec![],
|
vec![],
|
||||||
vec![],
|
vec![],
|
||||||
vec![], // output
|
vec![], // output
|
||||||
@@ -3789,4 +3999,60 @@ mod tests {
|
|||||||
// the inner Sub lowered to flat node 0; the hoisted tap points at it
|
// 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 }]);
|
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
|
//! 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
|
//! 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).
|
//! 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::blueprint::{BlueprintNode, Composite, Gang, OutField, Role};
|
||||||
use crate::harness::Edge;
|
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
|
/// The CEILING format version this build's loader understands (and the
|
||||||
/// (Tier-2) change; additive optional fields do not bump it (#156). Pre-ship
|
/// writer may ever emit) — bumped only by a load-bearing (Tier-2) change;
|
||||||
/// dormancy (#61, 2026-07-10): while the project is unshipped every document
|
/// additive optional fields do not bump it (#156). Pre-ship dormancy (#61,
|
||||||
/// lives in-repo and reader/writer change atomically, so the Tier-2 bump
|
/// 2026-07-10): while the project is unshipped every document lives in-repo
|
||||||
/// discipline activates at the first external ship — which consciously
|
/// and reader/writer change atomically, so the Tier-2 bump discipline
|
||||||
/// freezes v1 (gangs included).
|
/// activates at the first external ship.
|
||||||
pub const BLUEPRINT_FORMAT_VERSION: u32 = 1;
|
///
|
||||||
|
/// #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.
|
/// Top-level envelope: the version is read before the payload is interpreted.
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
@@ -61,24 +72,64 @@ pub enum NodeData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A primitive node as data: its compiled-in type identity, optional instance
|
/// 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)]
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
pub struct PrimitiveData {
|
pub struct PrimitiveData {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub type_id: String,
|
pub type_id: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub name: Option<String>,
|
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")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub bound: Vec<BoundParam>,
|
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).
|
/// Serializer failure (typed, named).
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum SerializeError {
|
pub enum SerializeError {
|
||||||
Json(serde_json::Error),
|
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
|
// Canonical gang order (bind-order-independent, mirroring `project_node`'s
|
||||||
// bound-param canonicalization below): each gang's members ascending
|
// bound-param canonicalization below): each gang's members ascending
|
||||||
// `(node, pos)`, then the gangs themselves ordered by their (now-sorted)
|
// `(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));
|
gangs.sort_by_key(|g| (g.members[0].node, g.members[0].pos));
|
||||||
|
|
||||||
CompositeData {
|
Ok(CompositeData {
|
||||||
name: c.name().to_string(),
|
name: c.name().to_string(),
|
||||||
doc: c.doc().map(str::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(),
|
edges: c.edges().to_vec(),
|
||||||
input_roles: c.input_roles().to_vec(),
|
input_roles: c.input_roles().to_vec(),
|
||||||
output: c.output().to_vec(),
|
output: c.output().to_vec(),
|
||||||
taps: c.taps().to_vec(),
|
taps: c.taps().to_vec(),
|
||||||
gangs,
|
gangs,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn project_node(n: &BlueprintNode) -> NodeData {
|
fn project_node(n: &BlueprintNode) -> Result<NodeData, SerializeError> {
|
||||||
match n {
|
match n {
|
||||||
BlueprintNode::Primitive(b) => {
|
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
|
// Canonical: bound params in ascending original-slot order, mirroring
|
||||||
// the loader's re-bind canonicalization, so serialization is
|
// the loader's re-bind canonicalization, so serialization is
|
||||||
// bind-order-independent (a precondition for content-addressing, #158).
|
// 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.
|
// construction once a multi-param node enters the vocabulary.
|
||||||
let mut bound = b.bound_params().to_vec();
|
let mut bound = b.bound_params().to_vec();
|
||||||
bound.sort_by_key(|bp| bp.pos);
|
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(),
|
type_id: b.label(),
|
||||||
name: b.instance_name().map(str::to_string),
|
name: b.instance_name().map(str::to_string),
|
||||||
|
args,
|
||||||
bound,
|
bound,
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
BlueprintNode::Composite(c) => NodeData::Composite(project(c)),
|
BlueprintNode::Composite(c) => Ok(NodeData::Composite(project(c)?)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_doc(c: &Composite) -> BlueprintDoc {
|
/// Any primitive, at any nesting depth, carries construction args (#271) —
|
||||||
BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) }
|
/// 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> {
|
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
|
/// field order, defaults omitted (`skip_serializing_if`). An absent optional is
|
||||||
/// byte-identical to the pre-extension form.
|
/// byte-identical to the pre-extension form.
|
||||||
pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
|
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
|
/// 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
|
/// 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).
|
/// (`reproduce` re-binds params by name, so instance names are load-bearing there).
|
||||||
pub fn blueprint_identity_json(c: &Composite) -> Result<String, SerializeError> {
|
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);
|
strip_debug_symbols(&mut doc.blueprint);
|
||||||
serialize_doc(&doc)
|
serialize_doc(&doc)
|
||||||
}
|
}
|
||||||
@@ -200,11 +294,16 @@ pub enum LoadError {
|
|||||||
/// addition needs finer granularity than a version bump.
|
/// addition needs finer granularity than a version bump.
|
||||||
UnsupportedVersion { found: u32, supported: u32 },
|
UnsupportedVersion { found: u32, supported: u32 },
|
||||||
/// `resolve` returned `None` for a serialized `type_id` (outside the injected
|
/// `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),
|
UnknownNodeType(String),
|
||||||
/// The document's gangs section fails structural validation against its
|
/// The document's gangs section fails structural validation against its
|
||||||
/// own nodes (a hand-edited or corrupted document).
|
/// own nodes (a hand-edited or corrupted document).
|
||||||
Gang(crate::blueprint::CompileError),
|
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(
|
fn reconstruct(
|
||||||
@@ -220,6 +319,15 @@ fn reconstruct(
|
|||||||
if let Some(n) = &p.name {
|
if let Some(n) = &p.name {
|
||||||
b = b.named(n);
|
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
|
// Re-apply bound params BY NAME. The effective param vector is
|
||||||
// order-independent (each `bind` computes its slot against the
|
// order-independent (each `bind` computes its slot against the
|
||||||
// shrunk schema); ascending original `pos` is the canonical order.
|
// shrunk schema); ascending original `pos` is the canonical order.
|
||||||
@@ -251,7 +359,9 @@ pub fn blueprint_from_json(
|
|||||||
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
|
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
|
||||||
) -> Result<Composite, LoadError> {
|
) -> Result<Composite, LoadError> {
|
||||||
let doc: BlueprintDoc = serde_json::from_str(data).map_err(LoadError::Json)?;
|
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 {
|
return Err(LoadError::UnsupportedVersion {
|
||||||
found: doc.format_version,
|
found: doc.format_version,
|
||||||
supported: BLUEPRINT_FORMAT_VERSION,
|
supported: BLUEPRINT_FORMAT_VERSION,
|
||||||
@@ -266,7 +376,8 @@ mod tests {
|
|||||||
use crate::blueprint::{Composite, GangMember, OutField, Role};
|
use crate::blueprint::{Composite, GangMember, OutField, Role};
|
||||||
use crate::harness::{Edge, Target};
|
use crate::harness::{Edge, Target};
|
||||||
use crate::VecSource; // crate-root re-export (blueprint.rs tests import it the same way, e.g. :923)
|
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_std::{Recorder, Sma, Sub};
|
||||||
use aura_strategy::Bias;
|
use aura_strategy::Bias;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
@@ -436,11 +547,14 @@ mod tests {
|
|||||||
assert!(matches!(err, LoadError::UnknownNodeType(t) if t == "NoSuchNode"));
|
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]
|
#[test]
|
||||||
fn unsupported_version_fails_named() {
|
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();
|
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]
|
#[test]
|
||||||
@@ -813,4 +927,171 @@ mod tests {
|
|||||||
"the gang itself is identity-bearing"
|
"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
@@ -52,12 +52,13 @@ mod sweep;
|
|||||||
mod walkforward;
|
mod walkforward;
|
||||||
|
|
||||||
pub use blueprint::{
|
pub use blueprint::{
|
||||||
BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang, GangFault,
|
name_gate, BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang,
|
||||||
GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder, Tap, TapWire,
|
GangFault, GangMember, NameGateFault, OutField, RandomBinder, ReopenError, Role, SweepBinder,
|
||||||
|
Tap, TapWire,
|
||||||
};
|
};
|
||||||
pub use blueprint_serde::{
|
pub use blueprint_serde::{
|
||||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,
|
blueprint_from_json, blueprint_identity_json, blueprint_to_json, ArgData, BlueprintDoc,
|
||||||
LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
|
CompositeData, LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
|
||||||
};
|
};
|
||||||
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
|
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
|
||||||
pub use construction::{replay, GraphSession, Op, OpError};
|
pub use construction::{replay, GraphSession, Op, OpError};
|
||||||
@@ -88,7 +89,8 @@ pub use walkforward::{
|
|||||||
// (SourceSpec.kind is a ScalarKind; sources/Recorder columns are Scalar /
|
// (SourceSpec.kind is a ScalarKind; sources/Recorder columns are Scalar /
|
||||||
// Firing / Timestamp) so a graph builder has one import surface, not two.
|
// Firing / Timestamp) so a graph builder has one import surface, not two.
|
||||||
pub use aura_core::{
|
pub use aura_core::{
|
||||||
BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind, Timestamp,
|
ArgKind, ArgOpError, BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind,
|
||||||
|
Timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ fn params() -> [Scalar; 3] {
|
|||||||
fn signal_ops() -> Vec<Op> {
|
fn signal_ops() -> Vec<Op> {
|
||||||
vec![
|
vec![
|
||||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
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("fast".into()), args: vec![], bind: vec![] },
|
||||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), 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, bind: vec![] },
|
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
|
||||||
Op::Add { type_id: "Bias".into(), as_name: None, 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::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: "fast.value".into(), to: "sub.lhs".into() },
|
||||||
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
||||||
@@ -58,7 +58,7 @@ fn signal_ops() -> Vec<Op> {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replayed_construction_compiles_identically_to_graphbuilder() {
|
fn replayed_construction_compiles_identically_to_graphbuilder() {
|
||||||
// (a) op-script replay through the public construction surface.
|
// (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")
|
.expect("op-script resolves")
|
||||||
.compile_with_params(¶ms())
|
.compile_with_params(¶ms())
|
||||||
.expect("replay compiles");
|
.expect("replay compiles");
|
||||||
@@ -96,14 +96,14 @@ fn replayed_construction_compiles_identically_to_graphbuilder() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_attributes_eager_fault_to_its_op_index() {
|
fn replay_attributes_eager_fault_to_its_op_index() {
|
||||||
let ops = vec![
|
let ops = vec![
|
||||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 0 ok
|
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, bind: vec![] }, // 1 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, bind: vec![] }, // 2 fails
|
Op::Add { type_id: "Nope".into(), as_name: None, args: vec![], bind: vec![] }, // 2 fails
|
||||||
Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }, // 3 never runs
|
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
|
// `.err().unwrap()` (not `unwrap_err`): the Ok arm `Composite` holds build
|
||||||
// closures and is not `Debug`, which `unwrap_err`'s bound would require.
|
// 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())));
|
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() {
|
fn replay_attributes_holistic_fault_to_finalize_step() {
|
||||||
let ops = vec![
|
let ops = vec![
|
||||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 }, // 0
|
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("fast".into()), args: vec![], bind: vec![] }, // 1
|
||||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }, // 2
|
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, bind: vec![] }, // 3
|
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::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }, // 4
|
||||||
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, // 5
|
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, // 5
|
||||||
// sub.rhs deliberately left unwired -> only finalize can decide totality.
|
// sub.rhs deliberately left unwired -> only finalize can decide totality.
|
||||||
];
|
];
|
||||||
let finalize_index = ops.len(); // 6 — the implicit finalize step
|
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),
|
assert!(matches!(&err, (i, OpError::UnconnectedPort { .. }) if *i == finalize_index),
|
||||||
"the holistic fault is still attributed to the finalize step, got {err:?}");
|
"the holistic fault is still attributed to the finalize step, got {err:?}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ fn build_harness(
|
|||||||
Resample::builder().schema().clone(), // 0
|
Resample::builder().schema().clone(), // 0
|
||||||
Delay::builder().schema().clone(), // 1
|
Delay::builder().schema().clone(), // 1
|
||||||
Gt::builder().schema().clone(), // 2
|
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(), // 4
|
||||||
EqConst::builder().schema().clone(), // 5
|
EqConst::builder().schema().clone(), // 5
|
||||||
And::builder().schema().clone(), // 6
|
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 sq = g.add(Mul::builder()); // dev * dev
|
||||||
let var = g.add(Ema::builder().bind("length", Scalar::i64(n))); // EWMA variance
|
let var = g.add(Ema::builder().bind("length", Scalar::i64(n))); // EWMA variance
|
||||||
let sigma = g.add(Sqrt::builder());
|
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 upper = g.add(Add::builder()); // mean + k*sigma
|
||||||
let lower = g.add(Sub::builder()); // mean - k*sigma
|
let lower = g.add(Sub::builder()); // mean - k*sigma
|
||||||
let gt_hi = g.add(Gt::builder()); // price > upper
|
let gt_hi = g.add(Gt::builder()); // price > upper
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ pub fn build_harness() -> (Harness, Taps) {
|
|||||||
Resample::builder().schema().clone(), // 0
|
Resample::builder().schema().clone(), // 0
|
||||||
Delay::builder().schema().clone(), // 1
|
Delay::builder().schema().clone(), // 1
|
||||||
Gt::builder().schema().clone(), // 2
|
Gt::builder().schema().clone(), // 2
|
||||||
Session::builder(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
|
Session::configured(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
|
||||||
.schema()
|
.schema()
|
||||||
.clone(), // 3
|
.clone(), // 3
|
||||||
EqConst::builder().schema().clone(), // 4
|
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 delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
|
||||||
let gt = g.add(Gt::builder());
|
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
|
// The ONLY two params: the EqConst targets, named so the space reads
|
||||||
// `entry_bar.target` / `exit_bar.target`. Their `target` is left UNBOUND.
|
// `entry_bar.target` / `exit_bar.target`. Their `target` is left UNBOUND.
|
||||||
let entry_bar = g.add(EqConst::builder().named("entry_bar"));
|
let entry_bar = g.add(EqConst::builder().named("entry_bar"));
|
||||||
|
|||||||
@@ -33,10 +33,17 @@
|
|||||||
//! **Cold guard.** If the trigger column is empty (not yet fired) → `None`.
|
//! **Cold guard.** If the trigger column is empty (not yet fired) → `None`.
|
||||||
|
|
||||||
use aura_core::{
|
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};
|
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
|
/// Frankfurt-session bar indexer. Holds the open offset (minutes past local
|
||||||
/// midnight), the IANA timezone, and the bar period — baked at construction,
|
/// midnight), the IANA timezone, and the bar period — baked at construction,
|
||||||
/// never streamed and never a swept param (a timezone is not a scalar).
|
/// never streamed and never a swept param (a timezone is not a scalar).
|
||||||
@@ -60,23 +67,57 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The param-generic recipe for a blueprint primitive. The open time,
|
/// Roster factory: zero-arg, arg-bearing (spec §Concrete code shapes) — the
|
||||||
/// timezone, and period are **structural** config baked into the closure
|
/// timezone and open time come through the `args` channel (`try_args`),
|
||||||
/// (like `SimBroker::builder` bakes `pip_size`), NOT scalar params — the
|
/// declared by [`SESSION_ARGS`]. `period_minutes` becomes an ordinary
|
||||||
/// declared param list is empty and the `build_fn` ignores its slice (like
|
/// `ParamSpec` once `make` runs (a real scalar knob, unlike tz/open).
|
||||||
/// `Gt` / `Latch`).
|
pub fn builder() -> PrimitiveBuilder {
|
||||||
pub fn builder(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> 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(
|
PrimitiveBuilder::new(
|
||||||
"Session",
|
"Session",
|
||||||
NodeSchema {
|
NodeSchema {
|
||||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
|
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
|
||||||
output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }],
|
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",
|
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
|
/// The zero-arg `SessionFrankfurt` roster preset (#261): the Frankfurt open
|
||||||
@@ -244,4 +285,36 @@ mod tests {
|
|||||||
let inputs = trigger_input();
|
let inputs = trigger_input();
|
||||||
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+390
-14
@@ -173,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 —
|
/// The content-addressed document-store dir for one document kind —
|
||||||
/// a sibling of the runs store, like `blueprints/`.
|
/// a sibling of the runs store, like `blueprints/`.
|
||||||
fn doc_dir(&self, kind: &str) -> PathBuf {
|
fn doc_dir(&self, kind: &str) -> PathBuf {
|
||||||
@@ -273,10 +362,42 @@ 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
|
/// C29 walk (#316): the root and every named nested composite must carry a
|
||||||
/// gate-passing doc. `doc: None` refuses exactly like `Some("")` — both are
|
/// gate-passing doc. `doc: None` refuses exactly like `Some("")` — both are
|
||||||
/// the Empty fault ("carries no doc").
|
/// the Empty fault ("carries no doc"). `pub` (#317): the `use` construction
|
||||||
fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> {
|
/// 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() {
|
let fault = match c.doc.as_deref() {
|
||||||
None => Some(aura_core::DocGateFault::Empty),
|
None => Some(aura_core::DocGateFault::Empty),
|
||||||
Some(d) => aura_core::doc_gate(&c.name, d).err(),
|
Some(d) => aura_core::doc_gate(&c.name, d).err(),
|
||||||
@@ -300,7 +421,18 @@ pub enum RefFault {
|
|||||||
StrategyNotFound(String),
|
StrategyNotFound(String),
|
||||||
IdentityUnmatched(String),
|
IdentityUnmatched(String),
|
||||||
StrategyUnloadable { id: String, error: 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 },
|
AxisKindMismatch { strategy: String, axis: String },
|
||||||
/// An open param of the resolved strategy is bound by no campaign axis —
|
/// An open param of the resolved strategy is bound by no campaign axis —
|
||||||
/// the executor's every-open-knob-required rule, mirrored at validate
|
/// the executor's every-open-knob-required rule, mirrored at validate
|
||||||
@@ -402,10 +534,22 @@ impl Registry {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => faults.push(RefFault::AxisNotInParamSpace {
|
None => {
|
||||||
strategy: label.clone(),
|
// #328 did-you-mean: strip one leading segment off the
|
||||||
axis: axis.clone(),
|
// 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
|
// The reverse direction: every open param must be bound by some
|
||||||
@@ -938,6 +1082,14 @@ pub enum RegistryError {
|
|||||||
/// the store without a gate-passing doc. Registered artifacts are never
|
/// the store without a gate-passing doc. Registered artifacts are never
|
||||||
/// retroactively invalidated — the gate guards the write path only.
|
/// retroactively invalidated — the gate guards the write path only.
|
||||||
UndescribedComposite { name: String, fault: aura_core::DocGateFault },
|
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 {
|
impl fmt::Display for RegistryError {
|
||||||
@@ -978,6 +1130,12 @@ impl fmt::Display for RegistryError {
|
|||||||
its name — a registered composite describes itself (C29)"
|
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}\"")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1172,6 +1330,134 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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]
|
#[test]
|
||||||
fn document_stores_round_trip_by_content_id() {
|
fn document_stores_round_trip_by_content_id() {
|
||||||
let reg = Registry::open(temp_family_dir("document_store_round_trip"));
|
let reg = Registry::open(temp_family_dir("document_store_round_trip"));
|
||||||
@@ -1891,14 +2177,12 @@ mod tests {
|
|||||||
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
|
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
|
||||||
|
|
||||||
// A minimal fixture composite with one OPEN param, built from a
|
// 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,
|
// resolve it on load. `aura-composites`' shipped composites (vol_stop,
|
||||||
// risk_executor*) all route through `LinComb`, whose builder needs a
|
// risk_executor*) route through `LinComb`, an arg-bearing type (#271,
|
||||||
// structural arity argument and is therefore deliberately absent from
|
// `aura-vocabulary/src/lib.rs`) — using `Bias` here keeps this fixture
|
||||||
// the zero-arg `std_vocabulary` roster (see aura-std/src/vocabulary.rs)
|
// independent of that construction channel, so it stands in as the
|
||||||
// — they cannot round-trip through `blueprint_from_json` with this
|
// "real, open-param, vocabulary-loadable composite" the test needs.
|
||||||
// resolver, so this fixture stands in as the "real, open-param,
|
|
||||||
// vocabulary-loadable composite" the test needs.
|
|
||||||
let composite = Composite::new(
|
let composite = Composite::new(
|
||||||
"fixture",
|
"fixture",
|
||||||
vec![aura_strategy::Bias::builder().named("b").into()],
|
vec![aura_strategy::Bias::builder().named("b").into()],
|
||||||
@@ -1990,6 +2274,98 @@ mod tests {
|
|||||||
assert_eq!(reg.validate_campaign_refs(&by_identity, &resolve).expect("io ok"), Vec::new());
|
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
|
/// Property (#191): the identity-index sidecar is a last-line-wins cache
|
||||||
/// over identity id → content id — a missing file reads as empty (no
|
/// 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
|
/// error, since the scan is the truth path), a later append for the same
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ pub enum FamilyKind {
|
|||||||
Sweep,
|
Sweep,
|
||||||
MonteCarlo,
|
MonteCarlo,
|
||||||
WalkForward,
|
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,
|
CrossInstrument,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,25 @@ impl TraceStore {
|
|||||||
NameKind::NotFound
|
NameKind::NotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every top-level directory entry in the store, sorted. This enumerates,
|
||||||
|
/// it does not classify: an interrupted run leaves a directory with no
|
||||||
|
/// `index.json`, which appears here but is `NotFound` to
|
||||||
|
/// [`TraceStore::name_kind`] — a caller that needs chartable names must
|
||||||
|
/// filter. A store directory that does not exist or cannot be read yields
|
||||||
|
/// none rather than an error: enumeration is a diagnostic aid, never a
|
||||||
|
/// run-blocking step, so callers must not read emptiness as "nothing
|
||||||
|
/// recorded".
|
||||||
|
pub fn names(&self) -> Vec<String> {
|
||||||
|
let Ok(entries) = fs::read_dir(&self.dir) else { return Vec::new() };
|
||||||
|
let mut out: Vec<String> = entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.path().is_dir())
|
||||||
|
.filter_map(|e| e.file_name().into_string().ok())
|
||||||
|
.collect();
|
||||||
|
out.sort();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Read every member of a family. Two on-disk shapes are resolved: the
|
/// Read every member of a family. Two on-disk shapes are resolved: the
|
||||||
/// depth-1 campaign layout (`<name>/<key>/index.json`, `key` = the immediate
|
/// depth-1 campaign layout (`<name>/<key>/index.json`, `key` = the immediate
|
||||||
/// subdir name) and the depth-2 #224 sweep/walk-forward fan-out
|
/// subdir name) and the depth-2 #224 sweep/walk-forward fan-out
|
||||||
@@ -476,7 +495,16 @@ impl fmt::Display for TraceStoreError {
|
|||||||
NameKind::Family => "family",
|
NameKind::Family => "family",
|
||||||
NameKind::NotFound => "name",
|
NameKind::NotFound => "name",
|
||||||
};
|
};
|
||||||
write!(f, "'{name}' already used as a {kind}; pick another --trace name")
|
// #311: no verb has carried a trace-naming flag since #319 —
|
||||||
|
// a single run's handle is its own identity digest and a
|
||||||
|
// campaign's is `derive_trace_name`'s, so the only remedy left
|
||||||
|
// is freeing the directory.
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"'{name}' already used as a {kind}; both a single run's handle and a \
|
||||||
|
campaign's are machine-minted, so free the name by moving or deleting \
|
||||||
|
runs/traces/{name}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -575,6 +603,37 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `names()` enumerates top-level directories, sorted, and nothing else:
|
||||||
|
/// a loose file in the store root is not a handle, and an index-less
|
||||||
|
/// directory (an interrupted run) IS listed even though `name_kind` calls
|
||||||
|
/// it absent — the split this method's callers must filter on.
|
||||||
|
#[test]
|
||||||
|
fn names_lists_sorted_directories_only() {
|
||||||
|
let root = temp_traces_root("names");
|
||||||
|
let store = TraceStore::open(&root);
|
||||||
|
store.write("zeta", &sample_manifest(), &sample_taps()).expect("write zeta");
|
||||||
|
store.write("alpha", &sample_manifest(), &sample_taps()).expect("write alpha");
|
||||||
|
// An interrupted run: directory created, no index written.
|
||||||
|
store.begin_run("halfway").expect("begin halfway");
|
||||||
|
// A loose file beside the handles must not be listed.
|
||||||
|
fs::write(root.join("traces").join("stray.json"), "{}").expect("write stray file");
|
||||||
|
|
||||||
|
assert_eq!(store.names(), vec!["alpha", "halfway", "zeta"]);
|
||||||
|
assert_eq!(store.name_kind("halfway"), NameKind::NotFound, "listed, but not chartable");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An absent store root is not an error for enumeration — callers use it
|
||||||
|
/// on a diagnostic path where a refusal is already being printed.
|
||||||
|
#[test]
|
||||||
|
fn names_on_an_absent_store_is_empty() {
|
||||||
|
let root = temp_traces_root("names-absent");
|
||||||
|
let store = TraceStore::open(&root);
|
||||||
|
assert!(store.names().is_empty());
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn name_kind_classifies_run_family_and_absent() {
|
fn name_kind_classifies_run_family_and_absent() {
|
||||||
let root = temp_traces_root("namekind");
|
let root = temp_traces_root("namekind");
|
||||||
@@ -656,6 +715,27 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #311: the refusal's remedy must not send the reader to a flag that does
|
||||||
|
/// not exist — no verb has carried `--trace` since #319. Both handles are
|
||||||
|
/// machine-minted; freeing the directory is the only remedy left.
|
||||||
|
#[test]
|
||||||
|
fn name_taken_refusal_prescribes_freeing_the_directory_not_a_retired_flag() {
|
||||||
|
let root = temp_traces_root("nametaken-prose");
|
||||||
|
let store = TraceStore::open(&root);
|
||||||
|
store.write("asrun", &sample_manifest(), &sample_taps()).expect("write run");
|
||||||
|
let msg = store
|
||||||
|
.ensure_name_free("asrun", WriteKind::Family)
|
||||||
|
.expect_err("cross-kind reuse is refused")
|
||||||
|
.to_string();
|
||||||
|
assert!(msg.contains("already used as a run"), "the existing kind is named: {msg}");
|
||||||
|
assert!(!msg.contains("-trace"), "no verb carries a trace-naming flag since #319: {msg}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("runs/traces/asrun"),
|
||||||
|
"the remedy names the directory to free: {msg}"
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
/// The load-bearing compatibility pin: for the same rows, the streamed
|
/// The load-bearing compatibility pin: for the same rows, the streamed
|
||||||
/// writer's file bytes equal `TraceStore::write`'s — the legacy path's
|
/// writer's file bytes equal `TraceStore::write`'s — the legacy path's
|
||||||
/// bytes, whatever they are, define the shape. Covers all four
|
/// bytes, whatever they are, define the shape. Covers all four
|
||||||
|
|||||||
+120
-13
@@ -513,16 +513,17 @@ pub struct Axis {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A protective-stop regime: a serializable, content-addressable mirror of the
|
/// A protective-stop regime: a serializable, content-addressable mirror of the
|
||||||
/// runtime `StopRule` structural axis (C10/C20). The sole implemented variant is
|
/// runtime `StopRule` structural axis (C10/C20). `Fixed{distance}` (#338) makes
|
||||||
/// the vol-stop; the shipped fixed-stop rule is admitted as a future additive
|
/// the shipped `FixedStop` composite campaign-reachable, beside the vol-stop
|
||||||
/// variant (it runs as a composite today but is not yet campaign-reachable).
|
/// family — `distance` is the same price-unit knob `FixedStop`'s own `distance`
|
||||||
/// Externally tagged so adding `Fixed` is additive — no content-id churn on
|
/// param carries. Externally tagged so each variant was additive on arrival —
|
||||||
/// stored `Vol` regimes.
|
/// no content-id churn on stored `Vol`/`VolTf` regimes.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
#[serde(deny_unknown_fields, rename_all = "snake_case")]
|
||||||
pub enum RiskRegime {
|
pub enum RiskRegime {
|
||||||
Vol { length: i64, k: f64 },
|
Vol { length: i64, k: f64 },
|
||||||
VolTf { period_minutes: i64, 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
|
/// One component of the campaign's cost model (#234): a closed, additive
|
||||||
@@ -973,6 +974,14 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
|
|||||||
faults.push(DocFault::BadRegime { index: i });
|
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() {
|
for (i, c) in doc.cost.iter().enumerate() {
|
||||||
@@ -1102,7 +1111,7 @@ pub fn slot_kind_label(kind: SlotKind) -> &'static str {
|
|||||||
SlotKind::Strings => "list of strings",
|
SlotKind::Strings => "list of strings",
|
||||||
SlotKind::Windows => "list of { from_ms, to_ms }",
|
SlotKind::Windows => "list of { from_ms, to_ms }",
|
||||||
SlotKind::Ref => "one of { content_id } | { identity_id }",
|
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::Axes => "map: param name -> { kind, values }",
|
||||||
SlotKind::EmitKinds => "list of: family_table | selection_report",
|
SlotKind::EmitKinds => "list of: family_table | selection_report",
|
||||||
SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity",
|
SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity",
|
||||||
@@ -1301,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"))) {
|
if ref_slot_is_open(v.get("process").and_then(|p| p.get("ref"))) {
|
||||||
// deliberately narrower than the strategy-ref hint: validate_campaign
|
// deliberately narrower than the strategy-ref hint: validate_campaign
|
||||||
// refuses an identity_id process ref, so the guide must not offer it
|
// 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"));
|
// 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() {
|
if v.get("seed").and_then(|s| s.as_u64()).is_none() {
|
||||||
slots.push(open("seed", "required, non-negative integer"));
|
slots.push(open("seed", "required, non-negative integer"));
|
||||||
}
|
}
|
||||||
if v.get("presentation").is_none() {
|
match v.get("presentation") {
|
||||||
slots.push(open(
|
None => slots.push(open(
|
||||||
"presentation",
|
"presentation",
|
||||||
format!(
|
format!(
|
||||||
"required section: persist_taps ({}) + emit",
|
"required section: persist_taps ({}) + emit",
|
||||||
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().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)
|
Ok(slots)
|
||||||
}
|
}
|
||||||
@@ -1326,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) {
|
if v.get("kind").and_then(|k| k.as_str()) != Some(kind) {
|
||||||
slots.push(open("kind", format!("required, must be \"{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)]
|
#[cfg(test)]
|
||||||
@@ -1470,6 +1506,7 @@ mod tests {
|
|||||||
"format_version": 1,
|
"format_version": 1,
|
||||||
"kind": "campaign",
|
"kind": "campaign",
|
||||||
"name": "ger40-momentum-screen",
|
"name": "ger40-momentum-screen",
|
||||||
|
"description": "GER40 momentum screen over the fast/slow SMA grid.",
|
||||||
"data": {
|
"data": {
|
||||||
"instruments": ["GER40"],
|
"instruments": ["GER40"],
|
||||||
"windows": [ { "from_ms": 1704067200000, "to_ms": 1719792000000 } ]
|
"windows": [ { "from_ms": 1704067200000, "to_ms": 1719792000000 } ]
|
||||||
@@ -1638,6 +1675,18 @@ mod tests {
|
|||||||
assert_eq!(back, r);
|
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]
|
#[test]
|
||||||
fn campaign_absent_and_empty_risk_omit_from_canonical_bytes() {
|
fn campaign_absent_and_empty_risk_omit_from_canonical_bytes() {
|
||||||
let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
|
let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
|
||||||
@@ -1696,6 +1745,26 @@ mod tests {
|
|||||||
assert!(!faults.contains(&DocFault::BadRegime { index: 0 }), "valid regime not flagged: {faults:?}");
|
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]
|
#[test]
|
||||||
fn validate_campaign_accepts_a_valid_risk_section() {
|
fn validate_campaign_accepts_a_valid_risk_section() {
|
||||||
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
|
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
|
||||||
@@ -2283,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
|
/// #256 fork B: the fieldless enumerate-only stage round-trips through
|
||||||
/// the schema-strict parser, and any slot on it is refused (its slot
|
/// 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).
|
/// list is empty, so the generic unknown-slot check covers every key).
|
||||||
@@ -2365,6 +2464,14 @@ mod tests {
|
|||||||
label.contains("content"),
|
label.contains("content"),
|
||||||
"process_ref describe must still name the content-id form: {label}"
|
"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 strategy = describe_block("std::strategy").expect("strategy describable");
|
||||||
let strat_ref =
|
let strat_ref =
|
||||||
@@ -2421,7 +2528,7 @@ mod tests {
|
|||||||
"presentation": { "persist_taps": [], "emit": [] } }"#;
|
"presentation": { "persist_taps": [], "emit": [] } }"#;
|
||||||
let slots = open_slots_campaign(draft).unwrap();
|
let slots = open_slots_campaign(draft).unwrap();
|
||||||
assert!(slots.iter().any(|s| s.path == "process.ref"
|
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"
|
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"));
|
&& s.hint == "axis declared empty — a P1 axis is a non-empty finite set"));
|
||||||
|
|
||||||
@@ -2457,7 +2564,7 @@ mod tests {
|
|||||||
// ...and the `{}` process ref reports the narrower content-id-only hint.
|
// ...and the `{}` process ref reports the narrower content-id-only hint.
|
||||||
assert!(
|
assert!(
|
||||||
slots.iter().any(|s| s.path == "process.ref"
|
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:?}"
|
"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
|
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
|
/// Suffix-join each raw campaign axis onto exactly one wrapped param
|
||||||
/// ([`raw_matches_wrapped`] — wrapped == raw, or stripping the wrapper's one
|
/// ([`raw_matches_wrapped`] — wrapped == raw, or stripping the wrapper's one
|
||||||
/// node segment yields raw), then require every wrapped slot to be covered.
|
/// 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 }
|
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]
|
#[test]
|
||||||
/// The only shape treated as a direct store address is a bare 64-char
|
/// The only shape treated as a direct store address is a bare 64-char
|
||||||
/// lowercase-hex token; anything else (wrong length, uppercase, non-hex)
|
/// 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
|
//! The interior-gap-month walk behind a campaign cell's coverage annotation
|
||||||
//! campaign cell's coverage annotation
|
//! (`DefaultMemberRunner::window_coverage`) — originally shared with `aura
|
||||||
//! (`DefaultMemberRunner::window_coverage`) and `aura data coverage`'s
|
//! data coverage`'s archive-wide report (#295 dedup: two independent walk
|
||||||
//! archive-wide report — two independent walk implementations before this
|
//! implementations before this module existed) before that verb retired in
|
||||||
//! module existed (#295 dedup).
|
//! favor of per-cell fault isolation (#272, #273).
|
||||||
|
|
||||||
/// `"YYYY-MM"` rendering of a `(year, month)` pair.
|
/// `"YYYY-MM"` rendering of a `(year, month)` pair.
|
||||||
pub fn fmt_year_month((y, m): (u16, u8)) -> String {
|
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
|
/// fall inside `window_ms` (Unix-ms) — one `"YYYY-MM"` entry per missing
|
||||||
/// month, never a collapsed range: the registry's `CellCoverage::gap_months`
|
/// month, never a collapsed range: the registry's `CellCoverage::gap_months`
|
||||||
/// is a flat list an aggregate counts directly. `months` is assumed sorted
|
/// is a flat list an aggregate counts directly. `months` is assumed sorted
|
||||||
/// ([`aura_ingest::list_m1_months`]'s own contract). The single gap-walk
|
/// ([`aura_ingest::list_m1_months`]'s own contract). `DefaultMemberRunner::
|
||||||
/// implementation (#295): both `DefaultMemberRunner::window_coverage`
|
/// window_coverage` (a swept cell's own window) is the sole caller since
|
||||||
/// (a swept cell's own window) and [`data_coverage_report`] (the full
|
/// `aura data coverage`'s archive-wide report retired (#273); the walk stays
|
||||||
/// archive span, via [`FULL_ARCHIVE_WINDOW_MS`]) call this one walk instead
|
/// its own function because it was single-sourced with that verb before then
|
||||||
/// of maintaining independent copies.
|
/// (#295).
|
||||||
pub fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec<String> {
|
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 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);
|
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
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -125,37 +63,4 @@ mod tests {
|
|||||||
"both interior gap months must be named, in order"
|
"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,60 +1,24 @@
|
|||||||
//! Family assembly and orchestration (#295).
|
//! Family assembly and orchestration (#295).
|
||||||
//!
|
//!
|
||||||
//! The blueprint sweep / walk-forward / Monte-Carlo family builders — pure
|
//! The blueprint sweep / walk-forward / Monte-Carlo family builders that used
|
||||||
//! member-run recipes driven off a [`DataSource`] (the shared synthetic/real
|
//! to live here are retired (#319 — the research-verb quintet's own
|
||||||
//! data provider every family builder threads, plus the `--select` objective
|
//! machinery; the campaign document path, `aura-campaign::exec`, is the
|
||||||
//! [`Selection`] walk-forward resolves its winner under) — together with the
|
//! surviving family-execution surface, with its own independent axis/window/
|
||||||
//! winner-selection (`select_winner`) and axis-grid validation
|
//! winner-selection machinery). What remains is the shared [`DataSource`]
|
||||||
//! (`validate_axis_grid`) machinery they share. No `aura` binary is needed to
|
//! data provider — the synthetic/real price-source abstraction
|
||||||
//! drive a family: the shell (`aura-cli`) wraps these builders with
|
//! `aura_runner::reproduce` drives its own bit-identical re-derivation
|
||||||
//! persistence + stdout rendering (`run_blueprint_sweep`,
|
//! over — and its synthetic stream primitives (`showcase_prices`,
|
||||||
//! `run_blueprint_walkforward`, `run_blueprint_mc`), which stay in the shell,
|
//! `walkforward_prices`, `walkforward_window_source`, `synthetic_walk_sources`).
|
||||||
//! 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).
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::sync::Arc;
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use aura_campaign::{catch_member_panic, member_fault_prose, MemberFault};
|
use aura_core::{Scalar, Timestamp};
|
||||||
use aura_composites::StopRule;
|
use aura_engine::{window_of, SyntheticSpec, VecSource};
|
||||||
use aura_core::{Cell, ParamSpec, Scalar, Timestamp};
|
use aura_backtest::{WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS};
|
||||||
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 crate::binding::ResolvedBinding;
|
use crate::member::{no_real_data, pip_or_refuse, probe_window, SYNTHETIC_PIP_SIZE};
|
||||||
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::project::Env;
|
use crate::project::Env;
|
||||||
use crate::translate::{R_SMA_STOP_K, R_SMA_STOP_LENGTH};
|
use crate::RunnerError;
|
||||||
|
|
||||||
/// The demo default stop regime, shared by every family builder in this module.
|
|
||||||
const DEFAULT_STOP: StopRule = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K };
|
|
||||||
|
|
||||||
/// 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
|
/// 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
|
/// again) used as `DataSource::Synthetic`'s full-window stream for the built-in
|
||||||
@@ -103,19 +67,18 @@ pub enum DataChoice {
|
|||||||
Real { symbol: String, from_ms: Option<i64>, to_ms: Option<i64> },
|
Real { symbol: String, from_ms: Option<i64>, to_ms: Option<i64> },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The source provider threaded into the family builders: synthetic built-in
|
/// The source provider `aura_runner::reproduce` threads through its
|
||||||
/// streams, or real M1 close bars from the data-server archive. Replaces the
|
/// re-derivation: synthetic built-in streams, or real M1 close bars from the
|
||||||
/// hardcoded `VecSource` so a member's source, pip, window, and roller sizes come
|
/// data-server archive. Replaces the hardcoded `VecSource` so a member's
|
||||||
/// from one place (Fork B/D/F).
|
/// 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
|
/// `Synthetic` denotes a *consumer-dependent* built-in stream, not one fixed
|
||||||
/// series: the full-window consumers (`full_window` / `run_sources`, used by
|
/// series: the full-window consumers (`full_window` / `run_sources`) draw the
|
||||||
/// sweep / MC) draw the 18-bar `showcase_prices()`, while the windowed consumers
|
/// 18-bar `showcase_prices()`, while the windowed consumers
|
||||||
/// (`windowed_sources` / `wf_window_sizes`, used by walk-forward) draw the 60-bar
|
/// (`windowed_sources` / `wf_window_sizes`) draw the 60-bar
|
||||||
/// `walkforward_prices()` so the `(24,12,12)`-bar roller fits its span. The two
|
/// `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
|
/// faces never reach one consumer (full-window vs. windowed reproduction), so
|
||||||
/// the split is invisible per call site but real across the type — read both
|
/// the split is invisible per call site but real across the type.
|
||||||
/// family builders to see it whole.
|
|
||||||
pub enum DataSource {
|
pub enum DataSource {
|
||||||
Synthetic,
|
Synthetic,
|
||||||
Real {
|
Real {
|
||||||
@@ -128,19 +91,20 @@ pub enum DataSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DataSource {
|
impl DataSource {
|
||||||
/// Build a provider from a parsed choice, or refuse (stderr + exit 1) on a symbol
|
/// Build a provider from a parsed choice, or refuse (#297: returned, not
|
||||||
/// with no recorded geometry / absent data — both BEFORE any member runs (Fork C/G),
|
/// printed) on a symbol with no recorded geometry / absent data — both
|
||||||
/// via the same `pip_or_refuse` / `no_real_data` helpers `open_real_source` uses.
|
/// BEFORE any member runs (Fork C/G), via the same `pip_or_refuse` /
|
||||||
pub fn from_choice(choice: DataChoice, env: &Env) -> DataSource {
|
/// `no_real_data` helpers `open_real_source` uses.
|
||||||
|
pub fn from_choice(choice: DataChoice, env: &Env) -> Result<DataSource, RunnerError> {
|
||||||
match choice {
|
match choice {
|
||||||
DataChoice::Synthetic => DataSource::Synthetic,
|
DataChoice::Synthetic => Ok(DataSource::Synthetic),
|
||||||
DataChoice::Real { symbol, from_ms, to_ms } => {
|
DataChoice::Real { symbol, from_ms, to_ms } => {
|
||||||
let server = Arc::new(data_server::DataServer::new(env.data_path()));
|
let server = Arc::new(data_server::DataServer::new(env.data_path()));
|
||||||
let pip = pip_or_refuse(&server, &symbol, env);
|
let pip = pip_or_refuse(&server, &symbol, env)?;
|
||||||
if !server.has_symbol(&symbol) {
|
if !server.has_symbol(&symbol) {
|
||||||
no_real_data(&symbol, env);
|
return Err(no_real_data(&symbol, env));
|
||||||
}
|
}
|
||||||
DataSource::Real { server, symbol, from_ms, to_ms, pip }
|
Ok(DataSource::Real { server, symbol, from_ms, to_ms, pip })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,11 +119,11 @@ impl DataSource {
|
|||||||
/// The full run window, probed once. Synthetic: the showcase span. Real:
|
/// The full run window, probed once. Synthetic: the showcase span. Real:
|
||||||
/// `probe_window` drains a separate single-pass probe source for first/last ts
|
/// `probe_window` drains a separate single-pass probe source for first/last ts
|
||||||
/// (the same helper `open_real_source` uses for its manifest window).
|
/// (the same helper `open_real_source` uses for its manifest window).
|
||||||
pub fn full_window(&self, env: &Env) -> (Timestamp, Timestamp) {
|
pub fn full_window(&self, env: &Env) -> Result<(Timestamp, Timestamp), RunnerError> {
|
||||||
match self {
|
match self {
|
||||||
DataSource::Synthetic => {
|
DataSource::Synthetic => {
|
||||||
let s: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(showcase_prices()))];
|
let s: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(showcase_prices()))];
|
||||||
window_of(&s).expect("non-empty showcase stream")
|
Ok(window_of(&s).expect("non-empty showcase stream"))
|
||||||
}
|
}
|
||||||
DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
|
DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
|
||||||
probe_window(server, symbol, *from_ms, *to_ms, env)
|
probe_window(server, symbol, *from_ms, *to_ms, env)
|
||||||
@@ -173,11 +137,11 @@ impl DataSource {
|
|||||||
/// longer built-in stream (byte-unchanged from the retired pre-`DataSource`
|
/// longer built-in stream (byte-unchanged from the retired pre-`DataSource`
|
||||||
/// `walkforward_family`, which derived its span the same way). Real: the same
|
/// `walkforward_family`, which derived its span the same way). Real: the same
|
||||||
/// probed `--from..--to` window as `full_window`.
|
/// probed `--from..--to` window as `full_window`.
|
||||||
pub fn wf_full_span(&self, env: &Env) -> (Timestamp, Timestamp) {
|
pub fn wf_full_span(&self, env: &Env) -> Result<(Timestamp, Timestamp), RunnerError> {
|
||||||
match self {
|
match self {
|
||||||
DataSource::Synthetic => {
|
DataSource::Synthetic => {
|
||||||
let s: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(walkforward_prices()))];
|
let s: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(walkforward_prices()))];
|
||||||
window_of(&s).expect("non-empty walkforward stream")
|
Ok(window_of(&s).expect("non-empty walkforward stream"))
|
||||||
}
|
}
|
||||||
DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
|
DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
|
||||||
probe_window(server, symbol, *from_ms, *to_ms, env)
|
probe_window(server, symbol, *from_ms, *to_ms, env)
|
||||||
@@ -188,12 +152,14 @@ impl DataSource {
|
|||||||
/// A fresh full-window source set per member (single-pass): the synthetic
|
/// A fresh full-window source set per member (single-pass): the synthetic
|
||||||
/// showcase close stream, or one real source per resolved binding column
|
/// showcase close stream, or one real source per resolved binding column
|
||||||
/// in canonical order (callers guard the synthetic arm to `{close}`).
|
/// in canonical order (callers guard the synthetic arm to `{close}`).
|
||||||
pub fn run_sources(&self, env: &Env, fields: &[aura_ingest::M1Field]) -> Vec<Box<dyn aura_engine::Source>> {
|
pub fn run_sources(
|
||||||
|
&self, env: &Env, fields: &[aura_ingest::M1Field],
|
||||||
|
) -> Result<Vec<Box<dyn aura_engine::Source>>, RunnerError> {
|
||||||
match self {
|
match self {
|
||||||
DataSource::Synthetic => vec![Box::new(VecSource::new(showcase_prices()))],
|
DataSource::Synthetic => Ok(vec![Box::new(VecSource::new(showcase_prices()))]),
|
||||||
DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
|
DataSource::Real { server, symbol, from_ms, to_ms, .. } => {
|
||||||
aura_ingest::open_columns(server, symbol, *from_ms, *to_ms, fields)
|
aura_ingest::open_columns(server, symbol, *from_ms, *to_ms, fields)
|
||||||
.unwrap_or_else(|| no_real_data(symbol, env))
|
.ok_or_else(|| no_real_data(symbol, env))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,12 +169,12 @@ impl DataSource {
|
|||||||
/// binding column over the ns-native window.
|
/// binding column over the ns-native window.
|
||||||
pub fn windowed_sources(
|
pub fn windowed_sources(
|
||||||
&self, from: Timestamp, to: Timestamp, env: &Env, fields: &[aura_ingest::M1Field],
|
&self, from: Timestamp, to: Timestamp, env: &Env, fields: &[aura_ingest::M1Field],
|
||||||
) -> Vec<Box<dyn aura_engine::Source>> {
|
) -> Result<Vec<Box<dyn aura_engine::Source>>, RunnerError> {
|
||||||
match self {
|
match self {
|
||||||
DataSource::Synthetic => vec![Box::new(walkforward_window_source(from, to))],
|
DataSource::Synthetic => Ok(vec![Box::new(walkforward_window_source(from, to))]),
|
||||||
DataSource::Real { server, symbol, .. } => {
|
DataSource::Real { server, symbol, .. } => {
|
||||||
aura_ingest::open_columns_window(server, symbol, Some(from), Some(to), fields)
|
aura_ingest::open_columns_window(server, symbol, Some(from), Some(to), fields)
|
||||||
.unwrap_or_else(|| no_real_data(symbol, env))
|
.ok_or_else(|| no_real_data(symbol, env))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,618 +189,14 @@ impl DataSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #260: the r-sma sugar/MC paths below run with either no cost model (empty
|
/// A fresh seeded synthetic price walk for one Monte-Carlo draw (a distinct
|
||||||
/// `cost` slice) or CLI-flag cost specs (scalar-only — `cost_specs_from_params`
|
/// realization per seed). A FIXED `SyntheticSpec` shared by the reproduce
|
||||||
/// only ever wraps `CostValue::Scalar`), so an instrument-keyed map can never
|
/// MonteCarlo branch (`aura_runner::reproduce`, its only remaining
|
||||||
/// originate on these paths and the instrument context is genuinely inert.
|
/// production caller — #319 retired the family builder that used to mint
|
||||||
/// Named once so every such call site states its intent by reference instead
|
/// such a family), so the seed->walk reconstruction is bit-exact (C1).
|
||||||
/// of repeating the justifying comment.
|
/// Length 60 comfortably warms the loaded r-sma graph (SMA slow=4 + the
|
||||||
const NO_INSTRUMENT_CONTEXT: &str = "";
|
/// len-3 vol stop) so draws produce differing trades.
|
||||||
|
|
||||||
/// 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. Reused
|
|
||||||
/// (#278) as the discarded slot value for a member run [`catch_member_panic`]
|
|
||||||
/// contains: the caller always resolves the captured fault and exits before this
|
|
||||||
/// value is ever rendered or persisted, so its content is equally irrelevant there.
|
|
||||||
fn axis_grid_probe_report() -> RunReport {
|
|
||||||
RunReport {
|
|
||||||
manifest: RunManifest {
|
|
||||||
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 },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The LOWEST enumeration index's captured member-panic message, found by
|
|
||||||
/// walking `family.points` — guaranteed by `assemble_sweep`'s own
|
|
||||||
/// `collect()` to be in grid-enumeration order regardless of which worker
|
|
||||||
/// finished first (C1) — for the first point whose params match a captured
|
|
||||||
/// fault. Thread-order-independent by construction (mirrors the lowest-
|
|
||||||
/// index-fault convention `aura-campaign::exec::run_members` establishes for
|
|
||||||
/// the identical #272 seam), without needing a separate numeric-index
|
|
||||||
/// side-channel: the returned family already carries the points in order.
|
|
||||||
fn lowest_point_fault(family: &SweepFamily, faults: Mutex<Vec<(Vec<Cell>, String)>>) -> Option<String> {
|
|
||||||
let captured = faults.into_inner().expect("fault capture lock");
|
|
||||||
family
|
|
||||||
.points
|
|
||||||
.iter()
|
|
||||||
.find_map(|pt| captured.iter().find(|(p, _)| *p == pt.params).map(|(_, m)| m.clone()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The LOWEST seed's captured member-panic message (#278), found by walking
|
|
||||||
/// `family.draws` — `monte_carlo`'s own contract guarantees seed-**input**
|
|
||||||
/// order regardless of thread completion (C1) — for the first draw whose
|
|
||||||
/// seed matches a captured fault. Thread-order-independent by construction,
|
|
||||||
/// mirroring [`lowest_point_fault`]'s and `lowest_window_fault`'s identical
|
|
||||||
/// convention one level over (per-point sweep / per-window walk-forward).
|
|
||||||
fn lowest_seed_fault(family: &McFamily, faults: Mutex<Vec<(u64, String)>>) -> Option<String> {
|
|
||||||
let captured = faults.into_inner().expect("fault capture lock");
|
|
||||||
family
|
|
||||||
.draws
|
|
||||||
.iter()
|
|
||||||
.find_map(|d| captured.iter().find(|(s, _)| *s == d.seed).map(|(_, m)| m.clone()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render + exit(3) on a contained member panic (#278: the deliberate
|
|
||||||
/// failed-cells exit, C14, never the raw 101 an uncontained panic would
|
|
||||||
/// yield) — reuses `aura_campaign::member_fault_prose`'s established
|
|
||||||
/// "a member panicked: …" wording rather than re-wording it, and the
|
|
||||||
/// `aura: warning: ` class marker (#278 decision) the CLI's own `diag`
|
|
||||||
/// macro would emit, hand-written here since that macro is presentation-
|
|
||||||
/// crate-private (#295 boundary) and this fires from `aura-runner`.
|
|
||||||
fn exit_on_member_panic(msg: &str) -> ! {
|
|
||||||
eprintln!("aura: warning: {}", member_fault_prose(&MemberFault::Panic(msg.to_string())));
|
|
||||||
std::process::exit(3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate the `--axis` grid against `doc`'s wrapped param space WITHOUT running any
|
|
||||||
/// member (#253). Reuses the SAME strict, erroring axis-name check
|
|
||||||
/// `blueprint_sweep_over` performs (`override_paths` — single-sourced, so the two
|
|
||||||
/// 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());
|
|
||||||
}
|
|
||||||
// #278: `run_blueprint_member` runs bare here (no #272 fault boundary of its
|
|
||||||
// own), so a member-compile panic (e.g. an `Sma::new` length assert) would
|
|
||||||
// otherwise unwind straight through this sweep to an uncaught exit 101 —
|
|
||||||
// contained the same way the real/campaign path contains it, via
|
|
||||||
// `catch_member_panic` + a per-point capture, resolved to the lowest
|
|
||||||
// enumeration index's message after the sweep joins (thread-order-
|
|
||||||
// independent, C1).
|
|
||||||
let faults: Mutex<Vec<(Vec<Cell>, String)>> = Mutex::new(Vec::new());
|
|
||||||
let family = binder
|
|
||||||
.sweep(|point| {
|
|
||||||
// fresh per-member graph (Composite is !Clone, reload per member) run through
|
|
||||||
// the shared reduce-mode member path — the same fn reproduction re-runs.
|
|
||||||
match catch_member_panic(|| {
|
|
||||||
run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
|
|
||||||
}) {
|
|
||||||
Ok(report) => report,
|
|
||||||
Err(msg) => {
|
|
||||||
faults.lock().expect("fault capture lock").push((point.to_vec(), msg));
|
|
||||||
axis_grid_probe_report()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// render the sweep terminal's BindError to prose (#247), the fn's String error
|
|
||||||
// contract — never the raw Debug struct.
|
|
||||||
.map_err(|e| render_bind_error(&e))?;
|
|
||||||
if let Some(msg) = lowest_point_fault(&family, faults) {
|
|
||||||
exit_on_member_panic(&msg);
|
|
||||||
}
|
|
||||||
Ok(family)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sweep the LOADED blueprint over the user `--axis` grid on an in-sample window
|
|
||||||
/// `[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.
|
|
||||||
///
|
|
||||||
/// The returned `Option<String>` (#278) is this fn's OWN resolved member-panic
|
|
||||||
/// capture: this fn's nested sweep runs inside `blueprint_walkforward_family`'s
|
|
||||||
/// per-window parallel closure, so a contained member panic here must NOT
|
|
||||||
/// `eprintln!`+`exit` on the spot (that would race across windows, the exact
|
|
||||||
/// `#177` duplicated-rejection class the dispatch-boundary axis pre-flight
|
|
||||||
/// above already avoids) — captured internally, resolved to the lowest-index
|
|
||||||
/// message via [`lowest_point_fault`] before returning, for the caller to push
|
|
||||||
/// onto its own per-window fault list only after every window has joined.
|
|
||||||
pub fn blueprint_sweep_over(
|
|
||||||
doc: &str, axes: &[(String, Vec<Scalar>)], from: Timestamp, to: Timestamp, data: &DataSource,
|
|
||||||
env: &Env, binding: &ResolvedBinding,
|
|
||||||
) -> Result<(SweepFamily, Vec<usize>, Option<String>), BindError> {
|
|
||||||
let reload = |d: &str| {
|
|
||||||
blueprint_from_json(d, &|t| env.resolve(t))
|
|
||||||
.expect("doc parse-validated at the dispatch boundary; reload is infallible")
|
|
||||||
};
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
let faults: Mutex<Vec<(Vec<Cell>, String)>> = Mutex::new(Vec::new());
|
|
||||||
let (family, lattice) = binder.sweep_with_lattice(|point| {
|
|
||||||
let sources = data.windowed_sources(from, to, env, &binding.columns());
|
|
||||||
let window = window_of(&sources).expect("non-empty in-sample window");
|
|
||||||
match catch_member_panic(|| {
|
|
||||||
run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT)
|
|
||||||
}) {
|
|
||||||
Ok(report) => report,
|
|
||||||
Err(msg) => {
|
|
||||||
faults.lock().expect("fault capture lock").push((point.to_vec(), msg));
|
|
||||||
axis_grid_probe_report()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let fault = lowest_point_fault(&family, faults);
|
|
||||||
Ok((family, lattice, fault))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run the winner params over an out-of-sample window `[from,to]` on the loaded
|
|
||||||
/// 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, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT);
|
|
||||||
(Vec::new(), report)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A discarded placeholder [`WindowRun`] for a window whose IS refit or OOS
|
|
||||||
/// run was contained after a member panic (#278): `chosen_params` matches
|
|
||||||
/// `space`'s arity with inert zero cells (the `assemble_walk_forward` arity
|
|
||||||
/// invariant), `oos_equity` is empty (an empty segment leaves the stitched
|
|
||||||
/// curve unbroken, same convention as a genuinely equity-less window), and
|
|
||||||
/// the report reuses [`axis_grid_probe_report`]'s zero-cost placeholder.
|
|
||||||
/// Never rendered: the caller always resolves the captured window fault and
|
|
||||||
/// exits before touching a faulted window's run.
|
|
||||||
fn placeholder_window_run(space: &[ParamSpec]) -> WindowRun {
|
|
||||||
WindowRun {
|
|
||||||
chosen_params: space.iter().map(|_| Cell::from_i64(0)).collect(),
|
|
||||||
oos_equity: Vec::new(),
|
|
||||||
oos_report: axis_grid_probe_report(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The LOWEST roll-order window's captured member-panic message (#278),
|
|
||||||
/// found by walking `result.windows` — guaranteed by `assemble_walk_forward`
|
|
||||||
/// to be in roll order regardless of thread completion (C1) — for the first
|
|
||||||
/// window whose bounds match a captured fault. Thread-order-independent by
|
|
||||||
/// construction, mirroring [`lowest_point_fault`]'s identical convention one
|
|
||||||
/// level down (per-point within one window's IS sweep) and
|
|
||||||
/// `aura-campaign::exec`'s own lowest-index walk-forward fault attribution.
|
|
||||||
fn lowest_window_fault(result: &WalkForwardResult, faults: Mutex<Vec<(WindowBounds, String)>>) -> Option<String> {
|
|
||||||
let captured = faults.into_inner().expect("fault capture lock");
|
|
||||||
result
|
|
||||||
.windows
|
|
||||||
.iter()
|
|
||||||
.find_map(|w| captured.iter().find(|(b, _)| *b == w.bounds).map(|(_, m)| m.clone()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The loaded-blueprint IS-refit walk-forward: per IS window, re-optimize the
|
|
||||||
/// blueprint over the user `--axis` grid, select by `sqn_normalized`, run the
|
|
||||||
/// winner OOS, reusing the generic `walk_forward` driver + `select_winner`;
|
|
||||||
/// 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);
|
|
||||||
}
|
|
||||||
// #278: `blueprint_sweep_over`'s inner sweep and `run_oos_blueprint`'s single
|
|
||||||
// member run both drive `run_blueprint_member` bare, so either could
|
|
||||||
// otherwise unwind an uncaught member-compile panic straight through this
|
|
||||||
// window closure. Both are contained (`blueprint_sweep_over` resolves its
|
|
||||||
// own capture and returns it; `run_oos_blueprint` via `catch_member_panic`
|
|
||||||
// directly) and captured PER WINDOW here rather than printed on the spot —
|
|
||||||
// this closure runs across windows in parallel (`walk_forward`), so an
|
|
||||||
// inline `eprintln!`+`exit` would race the same way the axis pre-flight
|
|
||||||
// above was written to avoid (#177). The lowest roll-order window's fault
|
|
||||||
// is resolved once, after every window has joined.
|
|
||||||
let window_faults: Mutex<Vec<(WindowBounds, String)>> = Mutex::new(Vec::new());
|
|
||||||
let result = walk_forward(roller, space.clone(), |w: WindowBounds| {
|
|
||||||
let (is_family, lattice, is_fault) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env, &binding)
|
|
||||||
.expect("axes validated in the dispatch-boundary pre-flight");
|
|
||||||
if let Some(msg) = is_fault {
|
|
||||||
window_faults.lock().expect("fault capture lock").push((w, msg));
|
|
||||||
return placeholder_window_run(&space);
|
|
||||||
}
|
|
||||||
let (best, selection) = match select_winner(&is_family, WINNER_SELECTION_METRIC, select, Some(&lattice)) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); }
|
|
||||||
};
|
|
||||||
match catch_member_panic(|| {
|
|
||||||
run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env, &binding, &overrides)
|
|
||||||
}) {
|
|
||||||
Ok((oos_equity, mut oos_report)) => {
|
|
||||||
oos_report.manifest.selection = Some(selection);
|
|
||||||
WindowRun { chosen_params: best.params, oos_equity, oos_report }
|
|
||||||
}
|
|
||||||
Err(msg) => {
|
|
||||||
window_faults.lock().expect("fault capture lock").push((w, msg));
|
|
||||||
placeholder_window_run(&space)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if let Some(msg) = lowest_window_fault(&result, window_faults) {
|
|
||||||
exit_on_member_panic(&msg);
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A fresh seeded synthetic price walk for one Monte-Carlo draw — `blueprint_mc_family`'s
|
|
||||||
/// 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.
|
|
||||||
pub fn synthetic_walk_sources(seed: u64) -> Vec<Box<dyn aura_engine::Source>> {
|
pub fn synthetic_walk_sources(seed: u64) -> Vec<Box<dyn aura_engine::Source>> {
|
||||||
let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 };
|
let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 };
|
||||||
vec![Box::new(spec.source(seed))]
|
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();
|
|
||||||
// #278: `run_blueprint_member` ran bare here, the one family builder without
|
|
||||||
// the #272 fault boundary its sweep/walk-forward siblings gained in 51096a3
|
|
||||||
// — a member-compile panic (e.g. an `Sma::new` length assert) would otherwise
|
|
||||||
// unwind straight through `monte_carlo`'s `run_indexed` to an uncaught exit
|
|
||||||
// 101. Contained the same way: `catch_member_panic` + a per-seed capture,
|
|
||||||
// resolved to the LOWEST seed's message after the join (thread-order-
|
|
||||||
// independent, C1) via `lowest_seed_fault`.
|
|
||||||
let faults: Mutex<Vec<(u64, String)>> = Mutex::new(Vec::new());
|
|
||||||
let family = monte_carlo(&base_point, &seeds, |seed, _base| {
|
|
||||||
let sources = synthetic_walk_sources(seed);
|
|
||||||
let window = window_of(&sources).expect("non-empty synthetic walk");
|
|
||||||
match catch_member_panic(|| {
|
|
||||||
run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
|
|
||||||
}) {
|
|
||||||
Ok(report) => report,
|
|
||||||
Err(msg) => {
|
|
||||||
faults.lock().expect("fault capture lock").push((seed, msg));
|
|
||||||
axis_grid_probe_report()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// The captured fault must win BEFORE the vacuous-mc guard below: a faulted
|
|
||||||
// draw's placeholder report is metrics-identical across every faulted seed,
|
|
||||||
// so a run with >= 2 faulted seeds (or one faulted + one real draw sharing
|
|
||||||
// its placeholder's zero metrics) could otherwise trip the vacuous refusal
|
|
||||||
// instead of surfacing the real member fault.
|
|
||||||
if let Some(msg) = lowest_seed_fault(&family, faults) {
|
|
||||||
exit_on_member_panic(&msg);
|
|
||||||
}
|
|
||||||
// Silent-vacuous MC guard (refuse-don't-guess, C10): with >= 2 seeds, if every draw's
|
|
||||||
// metrics are bit-identical to the first, no seed reached a distinguishable realization —
|
|
||||||
// the strategy never warmed over the fixed synthetic walk (e.g. a lookback as deep as the
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -30,13 +30,326 @@ pub use tap_plan::{
|
|||||||
};
|
};
|
||||||
pub use tap_recorder::TapRecorder;
|
pub use tap_recorder::TapRecorder;
|
||||||
|
|
||||||
|
/// What a declared-tap entry point returns: the run's record plus the two
|
||||||
|
/// values that ride *beside* it rather than inside it. The report is the
|
||||||
|
/// durable C18 record; these are per-invocation facts the shell renders
|
||||||
|
/// (C27/#297 — the library never prints).
|
||||||
|
///
|
||||||
|
/// `Debug` is load-bearing, not decoration: a caller asserting a refusal with
|
||||||
|
/// `Result::unwrap_err` needs the `Ok` side to be `Debug`, which the
|
||||||
|
/// `(report, skipped)` tuple this type replaced satisfied implicitly.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RunOutcome<R> {
|
||||||
|
/// The run record itself.
|
||||||
|
pub report: R,
|
||||||
|
/// Declared taps the plan left unbound this run; the CLI prints the note.
|
||||||
|
pub skipped: Vec<String>,
|
||||||
|
/// The trace-store handle the run's taps landed under, when it recorded
|
||||||
|
/// any; `None` when the plan persisted nothing.
|
||||||
|
pub trace_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// A refusal a library function reports instead of exiting the process
|
/// A refusal a library function reports instead of exiting the process
|
||||||
/// itself. The shell (`aura-cli`'s `dispatch_reproduce`) is the single place
|
/// itself. The shell (`aura-cli`'s `exit_on_runner_error`, shared by the
|
||||||
/// that maps it back to the identical stderr bytes + exit code, so the
|
/// dispatch arms) maps it back to the stderr bytes + exit code; the class
|
||||||
/// binary's observable behaviour stays byte-unchanged (#295, spec §Error
|
/// follows the C14 partition — argv-named content 2, environment/data/IO 1
|
||||||
/// handling).
|
/// (#295/#297).
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct RunnerError {
|
pub struct RunnerError {
|
||||||
pub exit_code: i32,
|
pub exit_code: i32,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The 8-hex identity of a run: a digest over its manifest with the two
|
||||||
|
/// provenance fields removed and `params`/`defaults` merged.
|
||||||
|
///
|
||||||
|
/// Both removed fields record *who built or checked out the code*, not what the
|
||||||
|
/// run was. `commit` is the aura binary's own build sha (`crates/aura-cli/build.rs`),
|
||||||
|
/// so it moves whenever the engine's checkout moves. `project.commit` is the
|
||||||
|
/// project repository's HEAD plus a `-dirty` marker, and — unlike the former —
|
||||||
|
/// it is re-evaluated on every invocation (`project.rs::project_commit`), so any
|
||||||
|
/// uncommitted file in the project worktree, including the blueprint being
|
||||||
|
/// edited, would mint a fresh directory. Including either would make an
|
||||||
|
/// identity-keyed store an unbounded directory generator.
|
||||||
|
///
|
||||||
|
/// `project.dylib_sha256` is deliberately kept: it is the node crate's own
|
||||||
|
/// content hash, which is what the C13 hot-reload comparison varies.
|
||||||
|
///
|
||||||
|
/// **Removing the keys, not blanking them, is load-bearing.**
|
||||||
|
/// `ProjectProvenance.commit` is `skip_serializing_if = "Option::is_none"`, so
|
||||||
|
/// the key is *absent* — not null — whenever the project's HEAD is not
|
||||||
|
/// derivable. Blanking would canonicalise one run to `{"project":{"commit":null}}`
|
||||||
|
/// and an otherwise-identical run to `{"project":{}}`: two digests for two runs
|
||||||
|
/// that differ in nothing identity-bearing. Removal collapses both to the same
|
||||||
|
/// bytes.
|
||||||
|
///
|
||||||
|
/// **`params` and `defaults` are merged into one name-sorted sequence, not
|
||||||
|
/// hashed as the two separate vectors the manifest carries.** The partition
|
||||||
|
/// between them records *how a value was supplied* — reopened by an axis vs.
|
||||||
|
/// held at its bound default (`RunManifest.defaults`' own doc comment,
|
||||||
|
/// `crates/aura-engine/src/report.rs`) — which is exactly the same kind of
|
||||||
|
/// provenance-not-identity distinction the two removed fields above are
|
||||||
|
/// removed for. Two runs whose bound parameterisation is identical but whose
|
||||||
|
/// values happen to be partitioned differently between the vectors (e.g. one
|
||||||
|
/// run leaves `fast.length` at its default of `2`, another passes
|
||||||
|
/// `--override fast.length=2` — a no-op override) compute the exact same
|
||||||
|
/// signal and must land in the same trace directory. The merge is total and
|
||||||
|
/// lossless *because* the two vectors are disjoint by construction — a bound
|
||||||
|
/// param is either varied (`params`) or held (`defaults`), never both — so no
|
||||||
|
/// name collision can silently drop or shadow a value.
|
||||||
|
///
|
||||||
|
/// serde_json's map is sorted, so re-serialising a parsed value yields
|
||||||
|
/// deterministic bytes — the same technique aura-bench uses for its record-line
|
||||||
|
/// fingerprint (`crates/aura-bench/src/surfaces/fixed_cost.rs`).
|
||||||
|
///
|
||||||
|
/// **`topology_hash` is not hashed as-is — `identity_hash` stands in for it
|
||||||
|
/// (2026-07-27 audit fix, C29).** `manifest.topology_hash` is a **content** id
|
||||||
|
/// (`content_id_of(blueprint_to_json(..))`, #158/C24): the canonical bytes it
|
||||||
|
/// hashes include a blueprint's `doc` field — an authored one-line rationale,
|
||||||
|
/// C29 — and every other C23 debug symbol (render name, instance names, bound
|
||||||
|
/// param/role/output/tap/gang names). A description-only (or name-only) edit
|
||||||
|
/// to an otherwise bit-identical blueprint therefore changes `topology_hash`,
|
||||||
|
/// which would mint a fresh trace directory for what is, by C29's own Id
|
||||||
|
/// treatment ("description fields … are blanked for the identity id"), the
|
||||||
|
/// SAME run — a description influencing an identity id is exactly what C29
|
||||||
|
/// forbids. The caller passes `identity_hash` — the #171
|
||||||
|
/// `blueprint_identity_json` projection (debug-symbol-blind by construction)
|
||||||
|
/// — and this function substitutes it for whatever `manifest.topology_hash`
|
||||||
|
/// carries in the hashed value; `manifest.topology_hash` itself is untouched
|
||||||
|
/// (it keeps its own #343 reference-semantics content id — the reproduction
|
||||||
|
/// store still keys on it byte-exact). **The blanking is wholesale, not
|
||||||
|
/// description-only:** `identity_hash` is blind to the render name and every
|
||||||
|
/// node/role/output/tap/gang name too, because none of these carry run
|
||||||
|
/// semantics (a bound param's *openness* stays identity-bearing) — two
|
||||||
|
/// blueprints identical in every load-bearing respect but differing only in
|
||||||
|
/// such names correctly compute the same `identity_hash`, one directory, not
|
||||||
|
/// two. Both mint sites
|
||||||
|
/// (`aura-runner::member::run_signal_r`, `aura-runner::measure::run_measurement`)
|
||||||
|
/// compute `identity_hash` from the SAME blueprint reference they already hash
|
||||||
|
/// for `topology_hash`, so the two can never drift.
|
||||||
|
pub fn run_identity_digest(manifest: &aura_engine::RunManifest, identity_hash: &str) -> String {
|
||||||
|
use aura_engine::Scalar;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
let mut merged: Vec<(String, Scalar)> = manifest
|
||||||
|
.params
|
||||||
|
.iter()
|
||||||
|
.chain(manifest.defaults.iter())
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
merged.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
|
let mut v = serde_json::to_value(manifest).expect("a run manifest serializes");
|
||||||
|
if let Some(obj) = v.as_object_mut() {
|
||||||
|
obj.remove("commit");
|
||||||
|
if let Some(project) = obj.get_mut("project").and_then(|p| p.as_object_mut()) {
|
||||||
|
project.remove("commit");
|
||||||
|
}
|
||||||
|
obj.remove("defaults");
|
||||||
|
obj.insert(
|
||||||
|
"params".to_string(),
|
||||||
|
serde_json::to_value(&merged).expect("the merged param vec serializes"),
|
||||||
|
);
|
||||||
|
obj.insert(
|
||||||
|
"topology_hash".to_string(),
|
||||||
|
serde_json::Value::String(identity_hash.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let canonical = serde_json::to_string(&v).expect("re-serializing a parsed value cannot fail");
|
||||||
|
let hex = format!("{:x}", Sha256::digest(canonical.as_bytes()));
|
||||||
|
hex[..8].to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::run_identity_digest;
|
||||||
|
use aura_engine::{ProjectProvenance, RunManifest, Scalar, Timestamp};
|
||||||
|
|
||||||
|
/// A fixed `identity_hash` stand-in used by every case below that does not
|
||||||
|
/// itself vary the blueprint's identity projection — held equal across both
|
||||||
|
/// calls being compared so the case tests exactly the field it names, not an
|
||||||
|
/// incidental `identity_hash` difference. Shaped like a real SHA-256 hex
|
||||||
|
/// digest (64 lowercase hex chars) but not a real hash of anything.
|
||||||
|
fn identity_hash() -> String {
|
||||||
|
"beef".repeat(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A manifest with every field populated the way a real single run stamps
|
||||||
|
/// them — the base each case below perturbs by exactly one field.
|
||||||
|
fn manifest() -> RunManifest {
|
||||||
|
RunManifest {
|
||||||
|
commit: "aabbccdd".to_string(),
|
||||||
|
params: vec![("fast.length".to_string(), Scalar::I64(2))],
|
||||||
|
defaults: vec![("bias.scale".to_string(), Scalar::F64(0.5))],
|
||||||
|
window: (Timestamp(1), Timestamp(18)),
|
||||||
|
seed: 0,
|
||||||
|
broker: "sim-optimal(pip_size=0.0001)".to_string(),
|
||||||
|
selection: None,
|
||||||
|
instrument: None,
|
||||||
|
topology_hash: Some("0f1e2d3c".to_string()),
|
||||||
|
project: Some(ProjectProvenance {
|
||||||
|
namespace: None,
|
||||||
|
dylib_sha256: None,
|
||||||
|
commit: Some("deadbeef".to_string()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_identity_digest_is_eight_lowercase_hex() {
|
||||||
|
let d = run_identity_digest(&manifest(), &identity_hash());
|
||||||
|
assert_eq!(d.len(), 8, "the handle suffix is 8 hex chars: {d}");
|
||||||
|
assert!(
|
||||||
|
d.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()),
|
||||||
|
"lowercase hex only: {d}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_identity_digest_ignores_the_binary_build_sha() {
|
||||||
|
let a = manifest();
|
||||||
|
let mut b = manifest();
|
||||||
|
b.commit = "0123456789abcdef".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
run_identity_digest(&a, &identity_hash()),
|
||||||
|
run_identity_digest(&b, &identity_hash()),
|
||||||
|
"manifest.commit is the binary's build provenance, not the run's identity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_identity_digest_ignores_the_project_head_and_its_dirty_marker() {
|
||||||
|
let a = manifest();
|
||||||
|
let mut b = manifest();
|
||||||
|
b.project.as_mut().expect("base manifest carries provenance").commit =
|
||||||
|
Some("deadbeef-dirty".to_string());
|
||||||
|
assert_eq!(
|
||||||
|
run_identity_digest(&a, &identity_hash()),
|
||||||
|
run_identity_digest(&b, &identity_hash()),
|
||||||
|
"project.commit is re-derived per invocation; editing the worktree is not a new run"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key-presence case that removal (rather than blanking) exists for: an
|
||||||
|
/// absent `project.commit` is a MISSING key, not `null`
|
||||||
|
/// (`skip_serializing_if`), so blanking would split one identity in two.
|
||||||
|
#[test]
|
||||||
|
fn run_identity_digest_collapses_an_absent_project_commit_onto_a_present_one() {
|
||||||
|
let a = manifest();
|
||||||
|
let mut b = manifest();
|
||||||
|
b.project.as_mut().expect("base manifest carries provenance").commit = None;
|
||||||
|
assert_eq!(
|
||||||
|
run_identity_digest(&a, &identity_hash()),
|
||||||
|
run_identity_digest(&b, &identity_hash()),
|
||||||
|
"a derivable HEAD and an underivable one are the same run"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_identity_digest_separates_two_param_settings() {
|
||||||
|
let a = manifest();
|
||||||
|
let mut b = manifest();
|
||||||
|
b.params = vec![("fast.length".to_string(), Scalar::I64(3))];
|
||||||
|
assert_ne!(
|
||||||
|
run_identity_digest(&a, &identity_hash()),
|
||||||
|
run_identity_digest(&b, &identity_hash()),
|
||||||
|
"params are identity-bearing — this is the headline case"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A no-op `--override` that pins a param to the value it already defaults
|
||||||
|
/// to moves the pair from `defaults` to `params` without changing the
|
||||||
|
/// effective parameterisation — the digest must not tell the two runs
|
||||||
|
/// apart. This is the union-not-partition property: same name/value pairs,
|
||||||
|
/// different vector each rides in.
|
||||||
|
#[test]
|
||||||
|
fn run_identity_digest_ignores_which_vector_a_param_rides_in() {
|
||||||
|
let a = manifest(); // params: [fast.length=2], defaults: [bias.scale=0.5]
|
||||||
|
let mut b = manifest();
|
||||||
|
b.params = vec![];
|
||||||
|
b.defaults = vec![
|
||||||
|
("fast.length".to_string(), Scalar::I64(2)),
|
||||||
|
("bias.scale".to_string(), Scalar::F64(0.5)),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
run_identity_digest(&a, &identity_hash()),
|
||||||
|
run_identity_digest(&b, &identity_hash()),
|
||||||
|
"same effective params, different params/defaults partition — same run"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The C13 discriminator stays IN: a reloaded node crate with different
|
||||||
|
/// bytes is a different run.
|
||||||
|
#[test]
|
||||||
|
fn run_identity_digest_keeps_the_project_dylib_hash() {
|
||||||
|
let a = manifest();
|
||||||
|
let mut b = manifest();
|
||||||
|
b.project.as_mut().expect("base manifest carries provenance").dylib_sha256 =
|
||||||
|
Some("ff00".to_string());
|
||||||
|
assert_ne!(
|
||||||
|
run_identity_digest(&a, &identity_hash()),
|
||||||
|
run_identity_digest(&b, &identity_hash()),
|
||||||
|
"dylib_sha256 is exactly what C13 hot-reload varies"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C29 audit fix (2026-07-27): `topology_hash` is a CONTENT id — it hashes
|
||||||
|
/// a blueprint's `doc` field (and every other C23 debug symbol) along with
|
||||||
|
/// its structure — so a description-only edit changes `manifest.
|
||||||
|
/// topology_hash` even though the run is otherwise bit-identical. The
|
||||||
|
/// digest must not tell these two manifests apart: it hashes the caller's
|
||||||
|
/// `identity_hash`, not `manifest.topology_hash`, so two manifests whose
|
||||||
|
/// `topology_hash` fields differ (as a real doc-only edit would produce)
|
||||||
|
/// digest the same as long as the SAME `identity_hash` is supplied — which
|
||||||
|
/// is exactly what `blueprint_identity_json` (doc-blind) computes for both.
|
||||||
|
#[test]
|
||||||
|
fn run_identity_digest_ignores_topology_hash_content_id_when_identity_hash_agrees() {
|
||||||
|
let a = manifest(); // topology_hash: "0f1e2d3c"
|
||||||
|
let mut b = manifest();
|
||||||
|
b.topology_hash = Some("ffffffff".to_string()); // as if a doc-only edit changed the content id
|
||||||
|
assert_eq!(
|
||||||
|
run_identity_digest(&a, &identity_hash()),
|
||||||
|
run_identity_digest(&b, &identity_hash()),
|
||||||
|
"a description-only edit changes topology_hash (a content id, C29) but not \
|
||||||
|
the blueprint's identity projection — the digest must not tell these apart"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The absolute property every other case in this module only tests
|
||||||
|
/// relationally: ONE fully-populated manifest (every field set, including
|
||||||
|
/// a `project` with `namespace`, `dylib_sha256` AND `commit` all present)
|
||||||
|
/// plus one fixed `identity_hash` yields this ONE specific 8-hex value —
|
||||||
|
/// stably, across builds and processes, since the whole trace store's
|
||||||
|
/// addressing rests on it.
|
||||||
|
///
|
||||||
|
/// If this literal ever needs to change, that change is NOT a casual
|
||||||
|
/// update: it means `run_identity_digest`'s canonicalisation shifted
|
||||||
|
/// (e.g. a workspace-wide serde_json `preserve_order` feature unification
|
||||||
|
/// would turn `Map::remove` into a swap-remove and reorder keys), and
|
||||||
|
/// every previously recorded trace directory has been silently
|
||||||
|
/// re-addressed. Update it only with that consequence understood.
|
||||||
|
#[test]
|
||||||
|
fn run_identity_digest_is_stable_for_a_fixed_manifest() {
|
||||||
|
let m = RunManifest {
|
||||||
|
commit: "aabbccdd".to_string(),
|
||||||
|
params: vec![("fast.length".to_string(), Scalar::I64(2))],
|
||||||
|
defaults: vec![("bias.scale".to_string(), Scalar::F64(0.5))],
|
||||||
|
window: (Timestamp(1), Timestamp(18)),
|
||||||
|
seed: 0,
|
||||||
|
broker: "sim-optimal(pip_size=0.0001)".to_string(),
|
||||||
|
selection: None,
|
||||||
|
instrument: None,
|
||||||
|
topology_hash: Some("0f1e2d3c".to_string()),
|
||||||
|
project: Some(ProjectProvenance {
|
||||||
|
namespace: Some("quadriga".to_string()),
|
||||||
|
dylib_sha256: Some("ff00".to_string()),
|
||||||
|
commit: Some("deadbeef".to_string()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
run_identity_digest(&m, &"c0ffee00".repeat(8)),
|
||||||
|
"8cfcc5fc",
|
||||||
|
"the digest for this fixed manifest must be stable across builds and processes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use aura_core::{Scalar, Timestamp};
|
use aura_core::{Scalar, Timestamp};
|
||||||
use aura_engine::{Composite, Harness, MeasurementReport, RunManifest};
|
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::project::Env;
|
||||||
use crate::tap_plan::{bind_tap_plan, TapPlan};
|
use crate::tap_plan::{bind_tap_plan, TapPlan};
|
||||||
|
use crate::RunnerError;
|
||||||
|
|
||||||
/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`,
|
/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`,
|
||||||
/// falling back to `"unknown"`) — `measurement_manifest`'s `RunManifest.commit`
|
/// falling back to `"unknown"`) — `measurement_manifest`'s `RunManifest.commit`
|
||||||
@@ -51,68 +52,116 @@ 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
|
/// 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).
|
/// 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
|
/// No broker, no risk executor, no equity/exposure/r sinks at all — the R leg is
|
||||||
/// is where the measured O(cycles) retention is removed. The tap machinery is
|
/// absent here rather than merely folded, which is what distinguishes this path
|
||||||
|
/// now that `run_signal_r` retains per trade rather than per cycle. The tap
|
||||||
|
/// machinery is
|
||||||
/// the shared `bind_tap_plan`/`BoundTaps` pair — one wiring for both entry
|
/// the shared `bind_tap_plan`/`BoundTaps` pair — one wiring for both entry
|
||||||
/// points, so they cannot drift (#283).
|
/// points, so they cannot drift (#283).
|
||||||
pub fn run_measurement(
|
pub fn run_measurement(
|
||||||
signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env,
|
signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env,
|
||||||
plan: TapPlan,
|
plan: TapPlan,
|
||||||
) -> MeasurementReport {
|
) -> Result<crate::RunOutcome<MeasurementReport>, RunnerError> {
|
||||||
// topology_hash's own two-line body, inlined (mirrors member::run_signal_r):
|
// 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
|
// `content_id_of` over the canonical (#164) blueprint JSON — the CLI shell's
|
||||||
// `topology_hash` helper is the same primitive, kept single-sourced at
|
// `topology_hash` helper is the same primitive, kept single-sourced at
|
||||||
// `aura_research`.
|
// `aura_research`. `identity_hash` is the sibling #171 projection over the
|
||||||
|
// SAME `signal`, feeding `run_identity_digest` in place of `topology_hash`
|
||||||
|
// (2026-07-27, C29) — see that function's doc comment.
|
||||||
let topo = aura_research::content_id_of(
|
let topo = aura_research::content_id_of(
|
||||||
&aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"),
|
&aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"),
|
||||||
); // before signal is consumed
|
); // before signal is consumed
|
||||||
let run_name = signal.name().to_string();
|
let identity_hash = aura_research::content_id_of(
|
||||||
|
&aura_engine::blueprint_identity_json(&signal).expect("a buildable signal serializes"),
|
||||||
|
); // before signal is consumed
|
||||||
|
// #311 (mirrors run_signal_r): the render name is the readable prefix; the
|
||||||
|
// directory is minted from the manifest below.
|
||||||
|
let render_name = signal.name().to_string();
|
||||||
|
// C14 class 2: fault in argv-named content (#297)
|
||||||
let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
|
let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new())
|
||||||
.unwrap_or_else(|m| {
|
.map_err(|m| RunnerError { exit_code: 2, message: m })?;
|
||||||
eprintln!("aura: {m}");
|
// C14 class 2: fault in argv-named content (#297)
|
||||||
std::process::exit(1);
|
|
||||||
});
|
|
||||||
if matches!(data, RunData::Synthetic) && !binding.close_only() {
|
if matches!(data, RunData::Synthetic) && !binding.close_only() {
|
||||||
eprintln!("aura: {}", crate::binding::synthetic_refusal(signal.name(), &binding));
|
return Err(RunnerError { exit_code: 2, message: crate::binding::synthetic_refusal(signal.name(), &binding) });
|
||||||
std::process::exit(1);
|
|
||||||
}
|
}
|
||||||
let names: Vec<String> = signal.param_space().iter().map(|p| p.name.clone()).collect();
|
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
|
||||||
let (sources, window, _pip_size) = resolve_run_data(&data, env, &binding);
|
// `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.
|
// 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| {
|
// C14 class 2: fault in argv-named content (#297)
|
||||||
eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}");
|
let mut flat = signal.compile_with_params(params)
|
||||||
std::process::exit(1);
|
.map_err(|e| RunnerError { exit_code: 2, message: compile_error_prose(&e, &role_names) })?;
|
||||||
});
|
|
||||||
|
|
||||||
// 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))
|
|
||||||
.expect("sources opened against `binding` key-match that binding's own roles by construction");
|
|
||||||
|
|
||||||
|
// #311 (mirrors run_signal_r): assemble the manifest BEFORE the bind and
|
||||||
|
// key the trace directory by the run's own identity. Every input this
|
||||||
|
// needs — `topo` (above), `defaults` (above), `window` (from
|
||||||
|
// `resolve_run_data`) — is already resolved by this point; `names` is
|
||||||
|
// untouched by the compile call, which passes `role_names`.
|
||||||
let named_params: Vec<(String, Scalar)> =
|
let named_params: Vec<(String, Scalar)> =
|
||||||
names.into_iter().zip(params.iter().copied()).collect();
|
names.into_iter().zip(params.iter().copied()).collect();
|
||||||
let mut manifest = measurement_manifest(named_params, window, seed);
|
let mut manifest = measurement_manifest(named_params, window, seed);
|
||||||
manifest.defaults = defaults;
|
manifest.defaults = defaults;
|
||||||
manifest.topology_hash = Some(topo);
|
manifest.topology_hash = Some(topo);
|
||||||
manifest.project = env.provenance();
|
manifest.project = env.provenance();
|
||||||
|
let run_name =
|
||||||
|
format!("{}-{}", render_name, crate::run_identity_digest(&manifest, &identity_hash));
|
||||||
|
|
||||||
|
// Bind each declared tap per the plan's subscription (mirrors
|
||||||
|
// run_signal_r — the shared bind_tap_plan/BoundTaps pair IS the mirror).
|
||||||
|
// C14 class 2: fault in argv-named content (#297)
|
||||||
|
let bound = bind_tap_plan(&mut flat, plan, env, &run_name)
|
||||||
|
.map_err(|e| RunnerError { exit_code: e.exit_class(), message: e.to_string() })?;
|
||||||
|
let trace_name = bound.trace_name().map(str::to_string);
|
||||||
|
|
||||||
|
let mut h = Harness::bootstrap(flat).expect("valid measurement harness");
|
||||||
|
h.run_bound(key_supply(&binding, sources))
|
||||||
|
.expect("sources opened against `binding` key-match that binding's own roles by construction");
|
||||||
|
|
||||||
// Close the tap plan (mirrors run_signal_r; nothing buffered, #283).
|
// Close the tap plan (mirrors run_signal_r; nothing buffered, #283).
|
||||||
let tap_names: Vec<String> = bound.declared_names().to_vec();
|
let tap_names: Vec<String> = bound.declared_names().to_vec();
|
||||||
bound.finish(&manifest).unwrap_or_else(|e| {
|
let skipped = bound.skipped().to_vec();
|
||||||
eprintln!("aura: {e}");
|
bound.finish(&manifest)
|
||||||
std::process::exit(1);
|
.map_err(|e| RunnerError { exit_code: e.exit_class(), message: e.to_string() })?;
|
||||||
});
|
Ok(crate::RunOutcome {
|
||||||
MeasurementReport { manifest, taps: tap_names }
|
report: MeasurementReport { manifest, taps: tap_names },
|
||||||
|
skipped,
|
||||||
|
trace_name,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -165,11 +214,15 @@ mod tests {
|
|||||||
let (env, root) = temp_project_env("fold");
|
let (env, root) = temp_project_env("fold");
|
||||||
let mut plan = TapPlan::empty();
|
let mut plan = TapPlan::empty();
|
||||||
plan.subscribe("fast_tap", TapSubscription::named("mean"));
|
plan.subscribe("fast_tap", TapSubscription::named("mean"));
|
||||||
let report = run_measurement(tapped_r_sma(), &[], RunData::Synthetic, 0, &env, plan);
|
let outcome = run_measurement(tapped_r_sma(), &[], RunData::Synthetic, 0, &env, plan)
|
||||||
|
.expect("measurement run succeeds");
|
||||||
|
// #311: the run reports the identity-keyed directory it wrote into.
|
||||||
|
let handle = outcome.trace_name.clone().expect("a recording run reports its handle");
|
||||||
|
let report = outcome.report;
|
||||||
assert_eq!(report.taps, vec!["fast_tap".to_string()]);
|
assert_eq!(report.taps, vec!["fast_tap".to_string()]);
|
||||||
|
|
||||||
let text = std::fs::read_to_string(
|
let text = std::fs::read_to_string(
|
||||||
root.join("runs").join("traces").join("sma_signal").join("fast_tap.json"),
|
root.join("runs").join("traces").join(&handle).join("fast_tap.json"),
|
||||||
)
|
)
|
||||||
.expect("one-row fold trace persisted");
|
.expect("one-row fold trace persisted");
|
||||||
let v: serde_json::Value = serde_json::from_str(&text).expect("parse");
|
let v: serde_json::Value = serde_json::from_str(&text).expect("parse");
|
||||||
@@ -193,7 +246,8 @@ mod tests {
|
|||||||
let doc = include_str!("../../aura-cli/examples/r_sma.json");
|
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 signal = blueprint_from_json(doc, &|t| std_vocabulary(t)).expect("r_sma loads");
|
||||||
let report =
|
let report =
|
||||||
run_measurement(signal, &[], RunData::Synthetic, 0, &env, TapPlan::record_all());
|
run_measurement(signal, &[], RunData::Synthetic, 0, &env, TapPlan::record_all())
|
||||||
|
.expect("measurement run succeeds").report;
|
||||||
assert!(report.taps.is_empty(), "no declared taps");
|
assert!(report.taps.is_empty(), "no declared taps");
|
||||||
assert!(!root.join("runs").exists(), "a tap-free run writes no runs/ entry");
|
assert!(!root.join("runs").exists(), "a tap-free run writes no runs/ entry");
|
||||||
}
|
}
|
||||||
|
|||||||
+522
-202
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@
|
|||||||
use aura_core::{DocGateFault, PrimitiveBuilder, doc_gate};
|
use aura_core::{DocGateFault, PrimitiveBuilder, doc_gate};
|
||||||
use aura_core::project::{
|
use aura_core::project::{
|
||||||
AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_VERSION, AURA_PROJECT_SYMBOL,
|
AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_VERSION, AURA_PROJECT_SYMBOL,
|
||||||
CORE_VERSION, ProjectDescriptor, RUSTC_VERSION, StrSlice,
|
CORE_FINGERPRINT, ProjectDescriptor, RUSTC_VERSION, StrSlice,
|
||||||
};
|
};
|
||||||
use aura_engine::ProjectProvenance;
|
use aura_engine::ProjectProvenance;
|
||||||
use aura_registry::{Registry, TraceStore};
|
use aura_registry::{Registry, TraceStore};
|
||||||
@@ -378,7 +378,7 @@ fn validate_c_tier(
|
|||||||
magic: u64,
|
magic: u64,
|
||||||
descriptor_version: u32,
|
descriptor_version: u32,
|
||||||
rustc_version: StrSlice,
|
rustc_version: StrSlice,
|
||||||
aura_core_version: StrSlice,
|
aura_core_fingerprint: StrSlice,
|
||||||
namespace: StrSlice,
|
namespace: StrSlice,
|
||||||
dylib_path: &Path,
|
dylib_path: &Path,
|
||||||
) -> Result<String, ProjectError> {
|
) -> Result<String, ProjectError> {
|
||||||
@@ -401,13 +401,13 @@ fn validate_c_tier(
|
|||||||
host: RUSTC_VERSION.to_string(),
|
host: RUSTC_VERSION.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let dylib_core = unsafe { aura_core_version.as_str() }
|
let dylib_core = unsafe { aura_core_fingerprint.as_str() }
|
||||||
.ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))?;
|
.ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))?;
|
||||||
if dylib_core != CORE_VERSION {
|
if dylib_core != CORE_FINGERPRINT {
|
||||||
return Err(ProjectError::Incompatible {
|
return Err(ProjectError::Incompatible {
|
||||||
what: "aura-core version",
|
what: "aura-core build",
|
||||||
dylib: dylib_core.to_string(),
|
dylib: dylib_core.to_string(),
|
||||||
host: CORE_VERSION.to_string(),
|
host: CORE_FINGERPRINT.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
unsafe { namespace.as_str() }
|
unsafe { namespace.as_str() }
|
||||||
@@ -580,13 +580,13 @@ fn load_crate(crate_root: &Path, release: bool) -> Result<NativeEnv, ProjectErro
|
|||||||
let magic = unsafe { (*desc_ptr).magic };
|
let magic = unsafe { (*desc_ptr).magic };
|
||||||
let descriptor_version = unsafe { (*desc_ptr).descriptor_version };
|
let descriptor_version = unsafe { (*desc_ptr).descriptor_version };
|
||||||
let rustc_version = unsafe { (*desc_ptr).rustc_version };
|
let rustc_version = unsafe { (*desc_ptr).rustc_version };
|
||||||
let aura_core_version = unsafe { (*desc_ptr).aura_core_version };
|
let aura_core_fingerprint = unsafe { (*desc_ptr).aura_core_fingerprint };
|
||||||
let namespace_stamp = unsafe { (*desc_ptr).namespace };
|
let namespace_stamp = unsafe { (*desc_ptr).namespace };
|
||||||
let namespace = validate_c_tier(
|
let namespace = validate_c_tier(
|
||||||
magic,
|
magic,
|
||||||
descriptor_version,
|
descriptor_version,
|
||||||
rustc_version,
|
rustc_version,
|
||||||
aura_core_version,
|
aura_core_fingerprint,
|
||||||
namespace_stamp,
|
namespace_stamp,
|
||||||
&dylib_path,
|
&dylib_path,
|
||||||
)?;
|
)?;
|
||||||
@@ -806,7 +806,7 @@ mod tests {
|
|||||||
AURA_DESCRIPTOR_MAGIC,
|
AURA_DESCRIPTOR_MAGIC,
|
||||||
AURA_DESCRIPTOR_VERSION,
|
AURA_DESCRIPTOR_VERSION,
|
||||||
StrSlice::new(RUSTC_VERSION),
|
StrSlice::new(RUSTC_VERSION),
|
||||||
StrSlice::new(CORE_VERSION),
|
StrSlice::new(CORE_FINGERPRINT),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,7 +857,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_c_tier_rejects_aura_core_version_mismatch() {
|
fn validate_c_tier_rejects_aura_core_build_mismatch() {
|
||||||
let (magic, version, rustc, _) = matching_stamps();
|
let (magic, version, rustc, _) = matching_stamps();
|
||||||
let path = PathBuf::from("/tmp/x.so");
|
let path = PathBuf::from("/tmp/x.so");
|
||||||
let bad_core = StrSlice::new("9.9.9-fake");
|
let bad_core = StrSlice::new("9.9.9-fake");
|
||||||
@@ -865,10 +865,56 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
ProjectError::Incompatible { what: "aura-core version", .. }
|
ProjectError::Incompatible { what: "aura-core build", .. }
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #348 / C30: the aura-core stamp compared at the load seam is a
|
||||||
|
/// build-identity fingerprint of aura-core's *sources*, not the frozen
|
||||||
|
/// crate version. Under C30's no-semver discipline the workspace version
|
||||||
|
/// is pinned at "0.1.0", so every stale pre-transition dylib stamps
|
||||||
|
/// exactly that string — a host whose own stamp were still the crate
|
||||||
|
/// version would wave any of them through (the disarmed handshake this
|
||||||
|
/// pin re-arms). The host side of the comparison must be a
|
||||||
|
/// source-derived fingerprint (never the frozen version string), the
|
||||||
|
/// refusal must echo both stamps, and its prose must name the rebuild
|
||||||
|
/// fix. The accept side — identical stamps still load — stays pinned by
|
||||||
|
/// `validate_c_tier_accepts_matching_stamps` above.
|
||||||
|
#[test]
|
||||||
|
fn validate_c_tier_rejects_the_frozen_crate_version_as_aura_core_stamp() {
|
||||||
|
let path = PathBuf::from("/tmp/x.so");
|
||||||
|
// What every stale, pre-fingerprint dylib carries as its aura-core
|
||||||
|
// stamp: the frozen workspace crate version.
|
||||||
|
let stale_stamp = StrSlice::new("0.1.0");
|
||||||
|
let err = validate_c_tier(
|
||||||
|
AURA_DESCRIPTOR_MAGIC,
|
||||||
|
AURA_DESCRIPTOR_VERSION,
|
||||||
|
StrSlice::new(RUSTC_VERSION),
|
||||||
|
stale_stamp,
|
||||||
|
StrSlice::new("demo"),
|
||||||
|
&path,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
let ProjectError::Incompatible { dylib, host, .. } = &err else {
|
||||||
|
panic!("expected Incompatible, got: {err}");
|
||||||
|
};
|
||||||
|
assert_eq!(dylib.as_str(), "0.1.0", "the refusal echoes the dylib's stale stamp");
|
||||||
|
assert_ne!(
|
||||||
|
host.as_str(),
|
||||||
|
"0.1.0",
|
||||||
|
"the host's stamp is a source-derived fingerprint, not the frozen crate version"
|
||||||
|
);
|
||||||
|
// Version-bump-proof twin of the literal above: whatever the crate
|
||||||
|
// version becomes, it must never be the host stamp again.
|
||||||
|
assert_ne!(
|
||||||
|
host.as_str(),
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
"the host's stamp reverted to the crate version"
|
||||||
|
);
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("rebuild"), "the refusal names the fix: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
/// A null stamp pointer (e.g. a zeroed/corrupt descriptor) refuses rather
|
/// A null stamp pointer (e.g. a zeroed/corrupt descriptor) refuses rather
|
||||||
/// than dereferencing it, same as the standalone `StrSlice::as_str` test
|
/// than dereferencing it, same as the standalone `StrSlice::as_str` test
|
||||||
/// in aura-core — here exercised through the loader's own refusal path.
|
/// in aura-core — here exercised through the loader's own refusal path.
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
//! Bit-identical reproduction (`aura reproduce`) — #295.
|
//! Bit-identical reproduction (`aura reproduce`) — #295.
|
||||||
//!
|
//!
|
||||||
//! A library function here reports a refusal as a returned
|
//! Both outcomes here are values, not process control flow: success returns a
|
||||||
//! [`crate::RunnerError`] rather than ending the process. The shell dispatch
|
//! [`ReproduceReport`] the caller inspects (`ReproduceReport::all_identical`);
|
||||||
//! arm (`aura-cli`'s `dispatch_reproduce`) is the single place that prints
|
//! a refusal returns a [`crate::RunnerError`] — neither ends the process. The
|
||||||
//! the error's message to stderr and calls `std::process::exit` on its code,
|
//! shell dispatch arm (`aura-cli`'s `dispatch_reproduce`) is the single place
|
||||||
//! keeping the observable stderr/exit bytes unchanged (C18).
|
//! that renders the report and prints a refusal's message to stderr, calling
|
||||||
|
//! `std::process::exit` on its code, keeping the observable stderr/exit bytes
|
||||||
|
//! unchanged (C18); a library embedder (a World program) reads the returned
|
||||||
|
//! report directly instead.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
use aura_core::Scalar;
|
||||||
use aura_engine::{blueprint_from_json, window_of};
|
use aura_engine::{blueprint_from_json, window_of};
|
||||||
use aura_registry::{group_families, Family, FamilyKind, Registry};
|
use aura_registry::{group_families, Family, FamilyKind, Registry};
|
||||||
use aura_backtest::point_from_params;
|
use aura_backtest::point_from_params;
|
||||||
@@ -22,10 +26,18 @@ use crate::RunnerError;
|
|||||||
|
|
||||||
/// The outcome of reproducing one persisted family: per member, whether its re-run
|
/// The outcome of reproducing one persisted family: per member, whether its re-run
|
||||||
/// metrics are bit-identical to the stored metrics (C1).
|
/// metrics are bit-identical to the stored metrics (C1).
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct ReproduceReport {
|
pub struct ReproduceReport {
|
||||||
pub outcomes: Vec<(String, bool)>,
|
pub outcomes: Vec<(String, bool)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ReproduceReport {
|
||||||
|
/// True iff every member reproduced bit-identically (C1).
|
||||||
|
pub fn all_identical(&self) -> bool {
|
||||||
|
self.outcomes.iter().all(|(_, identical)| *identical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Look up a persisted family by id, or refuse (exit code 1: unknown id / registry
|
/// Look up a persisted family by id, or refuse (exit code 1: unknown id / registry
|
||||||
/// load failure) — the single place `reproduce_family` and `reproduce_family_in`
|
/// load failure) — the single place `reproduce_family` and `reproduce_family_in`
|
||||||
/// resolve a family, so the two exit-1 error phrasings can't drift out of sync
|
/// resolve a family, so the two exit-1 error phrasings can't drift out of sync
|
||||||
@@ -67,11 +79,35 @@ pub fn load_family(reg: &Registry, id: &str) -> Result<Family, RunnerError> {
|
|||||||
|
|
||||||
/// Re-derive every member of a persisted sweep family from the content-addressed store
|
/// Re-derive every member of a persisted sweep family from the content-addressed store
|
||||||
/// and compare to the stored result, against an explicit registry (testable seam).
|
/// and compare to the stored result, against an explicit registry (testable seam).
|
||||||
|
///
|
||||||
|
/// Refusal contract: an identity/pip mismatch (below) refuses class 2 — this is the
|
||||||
|
/// explicit-source seam, so a caller-given `data` that contradicts the member
|
||||||
|
/// manifests is argv-named content (C14). `reproduce_family`, the simple/derived
|
||||||
|
/// path, hits the same guard at class 1 instead (Fork 6, #299: on that path `data`
|
||||||
|
/// is derived FROM the family's own manifest, so a mismatch there is data drift, not
|
||||||
|
/// a caller error). A WINDOW guard is deliberately absent: no reproduce path takes a
|
||||||
|
/// caller window (WF and real-sweep re-runs load stored.manifest.window; MonteCarlo
|
||||||
|
/// derives its walk from manifest.seed; synthetic uses the full window).
|
||||||
pub fn reproduce_family_in(
|
pub fn reproduce_family_in(
|
||||||
reg: &Registry,
|
reg: &Registry,
|
||||||
id: &str,
|
id: &str,
|
||||||
data: &DataSource,
|
data: &DataSource,
|
||||||
env: &Env,
|
env: &Env,
|
||||||
|
) -> Result<ReproduceReport, RunnerError> {
|
||||||
|
reproduce_family_with_class(reg, id, data, env, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared reproduce loop (#299 Fork 6): `mismatch_class` is the exit code the
|
||||||
|
/// identity/pip guard refuses with — context-borne, not a property of the guard
|
||||||
|
/// itself. `reproduce_family_in` (explicit-source seam) passes 2; `reproduce_family`
|
||||||
|
/// (derived-source seam) passes 1. Guard messages are identical either way; only the
|
||||||
|
/// class varies.
|
||||||
|
fn reproduce_family_with_class(
|
||||||
|
reg: &Registry,
|
||||||
|
id: &str,
|
||||||
|
data: &DataSource,
|
||||||
|
env: &Env,
|
||||||
|
mismatch_class: i32,
|
||||||
) -> Result<ReproduceReport, RunnerError> {
|
) -> Result<ReproduceReport, RunnerError> {
|
||||||
let family = load_family(reg, id)?;
|
let family = load_family(reg, id)?;
|
||||||
let pip = data.pip_size();
|
let pip = data.pip_size();
|
||||||
@@ -82,6 +118,52 @@ pub fn reproduce_family_in(
|
|||||||
exit_code: 1,
|
exit_code: 1,
|
||||||
message: "family member has no topology_hash; not a generated run".to_string(),
|
message: "family member has no topology_hash; not a generated run".to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
// Identity/geometry guard (#299): a source whose identity or pip
|
||||||
|
// contradicts the member manifests makes divergence mechanical. The
|
||||||
|
// class is context-borne (Fork 6, see this fn's doc): caller error on
|
||||||
|
// the explicit-source seam, data drift on the derived seam.
|
||||||
|
if let Some(recorded) = stored.manifest.instrument.as_deref() {
|
||||||
|
match data {
|
||||||
|
DataSource::Synthetic => {
|
||||||
|
return Err(RunnerError {
|
||||||
|
exit_code: mismatch_class,
|
||||||
|
message: format!(
|
||||||
|
"reproduce source mismatch: family member was recorded over instrument '{recorded}', the source is synthetic"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
DataSource::Real { symbol, .. } if symbol != recorded => {
|
||||||
|
return Err(RunnerError {
|
||||||
|
exit_code: mismatch_class,
|
||||||
|
message: format!(
|
||||||
|
"reproduce source mismatch: family member was recorded over instrument '{recorded}', the source names '{symbol}'"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// pip has no manifest field of its own: forward-build the label the
|
||||||
|
// FAMILY MINT stamps for this pip (the r_sma/risk-executor variant —
|
||||||
|
// `run_blueprint_member` unconditionally stamps that label) and
|
||||||
|
// compare against the recorded one. The plain-arm branch below
|
||||||
|
// tolerates a stored record shaped like a single-run stamp (legacy or
|
||||||
|
// otherwise), not a variant any live re-run of THIS family would
|
||||||
|
// itself produce.
|
||||||
|
let expected = if stored.manifest.broker.contains("+risk-executor") {
|
||||||
|
crate::member::r_sma_broker_label(pip)
|
||||||
|
} else {
|
||||||
|
crate::member::sim_optimal_broker_label(pip)
|
||||||
|
};
|
||||||
|
if expected != stored.manifest.broker {
|
||||||
|
return Err(RunnerError {
|
||||||
|
exit_code: mismatch_class,
|
||||||
|
message: format!(
|
||||||
|
"reproduce broker/pip mismatch: manifest recorded '{}', the source's pip renders '{expected}'",
|
||||||
|
stored.manifest.broker
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
let doc = reg
|
let doc = reg
|
||||||
.get_blueprint(&hash)
|
.get_blueprint(&hash)
|
||||||
.map_err(|e| RunnerError { exit_code: 1, message: format!("{e}") })?
|
.map_err(|e| RunnerError { exit_code: 1, message: format!("{e}") })?
|
||||||
@@ -133,6 +215,9 @@ pub fn reproduce_family_in(
|
|||||||
.map_err(|m| RunnerError { exit_code: 1, message: m })?;
|
.map_err(|m| RunnerError { exit_code: 1, message: m })?;
|
||||||
// The member's binding, re-derived from the stored blueprint's own
|
// The member's binding, re-derived from the stored blueprint's own
|
||||||
// input roles (name defaults — family manifests carry no overrides).
|
// input roles (name defaults — family manifests carry no overrides).
|
||||||
|
// Deliberately class 1, unchanged by #297: prose identical to the
|
||||||
|
// now-class-2 `run_signal_r` twins, but a stored artifact being
|
||||||
|
// reproduced is not argv-named content (C14 partition).
|
||||||
let binding = crate::binding::resolve_binding(&hash, reload()?.input_roles(), &BTreeMap::new())
|
let binding = crate::binding::resolve_binding(&hash, reload()?.input_roles(), &BTreeMap::new())
|
||||||
.map_err(|m| RunnerError { exit_code: 1, message: m })?;
|
.map_err(|m| RunnerError { exit_code: 1, message: m })?;
|
||||||
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
|
if matches!(data, DataSource::Synthetic) && !binding.close_only() {
|
||||||
@@ -142,7 +227,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 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 })?;
|
.map_err(|m| RunnerError { exit_code: 1, message: m })?;
|
||||||
// A MonteCarlo member carries no tuning params (the params-join is empty), so its
|
// 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
|
// reproduce line would print a BLANK member label; the seed IS its realization
|
||||||
@@ -174,7 +283,7 @@ pub fn reproduce_family_in(
|
|||||||
// stored window bounds; the winner params come from the shared
|
// stored window bounds; the winner params come from the shared
|
||||||
// manifest->cells recovery below (as Sweep members do).
|
// manifest->cells recovery below (as Sweep members do).
|
||||||
let (from, to) = stored.manifest.window;
|
let (from, to) = stored.manifest.window;
|
||||||
let s = data.windowed_sources(from, to, env, &binding.columns());
|
let s = data.windowed_sources(from, to, env, &binding.columns())?;
|
||||||
let w = window_of(&s).expect("non-empty OOS window");
|
let w = window_of(&s).expect("non-empty OOS window");
|
||||||
(s, w)
|
(s, w)
|
||||||
}
|
}
|
||||||
@@ -187,9 +296,11 @@ pub fn reproduce_family_in(
|
|||||||
_ => match data {
|
_ => match data {
|
||||||
DataSource::Real { .. } => {
|
DataSource::Real { .. } => {
|
||||||
let (from, to) = stored.manifest.window;
|
let (from, to) = stored.manifest.window;
|
||||||
(data.windowed_sources(from, to, env, &binding.columns()), (from, to))
|
(data.windowed_sources(from, to, env, &binding.columns())?, (from, to))
|
||||||
|
}
|
||||||
|
DataSource::Synthetic => {
|
||||||
|
(data.run_sources(env, &binding.columns())?, data.full_window(env)?)
|
||||||
}
|
}
|
||||||
DataSource::Synthetic => (data.run_sources(env, &binding.columns()), data.full_window(env)),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let rerun = run_blueprint_member(
|
let rerun = run_blueprint_member(
|
||||||
@@ -209,7 +320,8 @@ pub fn reproduce_family_in(
|
|||||||
// construction (`cost_specs_from_params`), so the instrument is
|
// construction (`cost_specs_from_params`), so the instrument is
|
||||||
// inert; the fallback is never resolved against a map.
|
// inert; the fallback is never resolved against a map.
|
||||||
stored.manifest.instrument.as_deref().unwrap_or(""),
|
stored.manifest.instrument.as_deref().unwrap_or(""),
|
||||||
);
|
)
|
||||||
|
.expect("re-run cost specs are stamp-derived scalars; the instrument-inert fallback never misses");
|
||||||
outcomes.push((label, rerun.metrics == stored.metrics));
|
outcomes.push((label, rerun.metrics == stored.metrics));
|
||||||
}
|
}
|
||||||
Ok(ReproduceReport { outcomes })
|
Ok(ReproduceReport { outcomes })
|
||||||
@@ -222,13 +334,17 @@ pub fn reproduce_family_in(
|
|||||||
/// re-derive a real-data family over the wrong stream; see `reproduce_family_in`'s
|
/// re-derive a real-data family over the wrong stream; see `reproduce_family_in`'s
|
||||||
/// per-member window loader for how the reconstructed source is actually used.
|
/// per-member window loader for how the reconstructed source is actually used.
|
||||||
///
|
///
|
||||||
/// Prints its own per-member + summary lines to stdout (unchanged from the
|
/// Returns the report as a value (#299): divergence is data the caller inspects
|
||||||
/// pre-#295 shell function — this is the report's normal output, not an error
|
/// (`ReproduceReport::all_identical`), not a process exit — this library never
|
||||||
/// path); only the final "not every member reproduced" outcome is a refusal,
|
/// prints and never exits. The CLI's `dispatch_reproduce` renders the report and
|
||||||
/// returned with an EMPTY message (there is no accompanying stderr line today,
|
/// owns the exit; a library embedder (a World program) reads `report.outcomes`
|
||||||
/// so the shell dispatch arm must not synthesize one either — see
|
/// directly.
|
||||||
/// `dispatch_reproduce`).
|
///
|
||||||
pub fn reproduce_family(id: &str, env: &Env) -> Result<(), RunnerError> {
|
/// This is the derived-source seam (Fork 6, #299): `data` is reconstructed FROM
|
||||||
|
/// the family's own manifest, not caller-given, so the identity/pip guard inside
|
||||||
|
/// the shared loop refuses class 1 here (data drift within the stored family),
|
||||||
|
/// not class 2 (`reproduce_family_in`'s explicit-source class — see its doc).
|
||||||
|
pub fn reproduce_family(id: &str, env: &Env) -> Result<ReproduceReport, RunnerError> {
|
||||||
let reg = env.registry();
|
let reg = env.registry();
|
||||||
let family = load_family(®, id)?;
|
let family = load_family(®, id)?;
|
||||||
// Reconstruct the DataSource the family was minted over: `None` instrument
|
// Reconstruct the DataSource the family was minted over: `None` instrument
|
||||||
@@ -238,21 +354,10 @@ pub fn reproduce_family(id: &str, env: &Env) -> Result<(), RunnerError> {
|
|||||||
let data = match family.members.first().and_then(|m| m.report.manifest.instrument.clone()) {
|
let data = match family.members.first().and_then(|m| m.report.manifest.instrument.clone()) {
|
||||||
None => DataSource::Synthetic,
|
None => DataSource::Synthetic,
|
||||||
Some(symbol) => {
|
Some(symbol) => {
|
||||||
DataSource::from_choice(DataChoice::Real { symbol, from_ms: None, to_ms: None }, env)
|
DataSource::from_choice(DataChoice::Real { symbol, from_ms: None, to_ms: None }, env)?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let rep = reproduce_family_in(®, id, &data, env)?;
|
reproduce_family_with_class(®, id, &data, env, 1)
|
||||||
let total = rep.outcomes.len();
|
|
||||||
let ok = rep.outcomes.iter().filter(|(_, b)| *b).count();
|
|
||||||
for (label, identical) in &rep.outcomes {
|
|
||||||
let verdict = if *identical { "bit-identical" } else { "DIVERGED" };
|
|
||||||
println!("{id} member {label} reproduced: {verdict}");
|
|
||||||
}
|
|
||||||
println!("reproduced {ok}/{total} members bit-identically");
|
|
||||||
if ok != total {
|
|
||||||
return Err(RunnerError { exit_code: 1, message: String::new() });
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -262,7 +367,9 @@ mod tests {
|
|||||||
use aura_engine::RunManifest;
|
use aura_engine::RunManifest;
|
||||||
use aura_registry::{FamilyKind, Registry};
|
use aura_registry::{FamilyKind, Registry};
|
||||||
|
|
||||||
use super::load_family;
|
use super::{load_family, reproduce_family_in, ReproduceReport};
|
||||||
|
use crate::family::DataSource;
|
||||||
|
use crate::project::Env;
|
||||||
|
|
||||||
/// A registry over a fresh per-test directory (the #258 tag-keyed pattern:
|
/// A registry over a fresh per-test directory (the #258 tag-keyed pattern:
|
||||||
/// fixed name under the build-tree tmp anchor, pre-create wipe).
|
/// fixed name under the build-tree tmp anchor, pre-create wipe).
|
||||||
@@ -293,6 +400,116 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A persisted member carrying a `topology_hash` (#299 guard tests): the
|
||||||
|
/// identity/pip guards fire BEFORE the blueprint fetch, so a seeded record
|
||||||
|
/// needs a `topology_hash` to clear the earlier "not a generated run"
|
||||||
|
/// check, but the hash itself never resolves against the store.
|
||||||
|
fn stamped_report(instrument: Option<&str>, broker: &str) -> RunReport {
|
||||||
|
RunReport {
|
||||||
|
manifest: RunManifest {
|
||||||
|
topology_hash: Some("deadbeef".to_string()),
|
||||||
|
instrument: instrument.map(str::to_string),
|
||||||
|
broker: broker.to_string(),
|
||||||
|
..minimal_report().manifest
|
||||||
|
},
|
||||||
|
..minimal_report()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The instrument guard (#299): a family member recorded over a real
|
||||||
|
/// instrument refuses class 2 — before any blueprint fetch — when the
|
||||||
|
/// caller-given source is `Synthetic`, naming the recorded instrument in
|
||||||
|
/// the message.
|
||||||
|
#[test]
|
||||||
|
fn reproduce_family_in_refuses_synthetic_source_for_a_real_instrument_member() {
|
||||||
|
let reg = temp_registry("guard-synthetic-for-real");
|
||||||
|
reg.append_family(
|
||||||
|
"f",
|
||||||
|
FamilyKind::Sweep,
|
||||||
|
&[stamped_report(Some("GER40"), "sim-optimal(pip_size=0.0001)")],
|
||||||
|
)
|
||||||
|
.expect("append family");
|
||||||
|
|
||||||
|
let err = reproduce_family_in(®, "f", &DataSource::Synthetic, &Env::std())
|
||||||
|
.expect_err("a synthetic source must not silently reproduce a real-instrument member");
|
||||||
|
assert_eq!(err.exit_code, 2);
|
||||||
|
assert!(err.message.contains("GER40"), "message: {}", err.message);
|
||||||
|
assert!(err.message.contains("synthetic"), "message: {}", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The instrument guard's Real-vs-Real twin: a caller-given `Real` source
|
||||||
|
/// naming a DIFFERENT symbol than the member's recorded instrument refuses
|
||||||
|
/// class 2, naming both sides.
|
||||||
|
#[test]
|
||||||
|
fn reproduce_family_in_refuses_a_real_source_naming_the_wrong_symbol() {
|
||||||
|
let reg = temp_registry("guard-wrong-symbol");
|
||||||
|
reg.append_family(
|
||||||
|
"f",
|
||||||
|
FamilyKind::Sweep,
|
||||||
|
&[stamped_report(Some("GER40"), "sim-optimal(pip_size=0.0001)")],
|
||||||
|
)
|
||||||
|
.expect("append family");
|
||||||
|
|
||||||
|
// A hand-built `DataSource::Real` fixture: `DataServer::new` merely
|
||||||
|
// scans its base path (a no-op on a missing directory), so the guard
|
||||||
|
// — which never touches the server — is exercised without any real
|
||||||
|
// archive on disk.
|
||||||
|
let server = std::sync::Arc::new(data_server::DataServer::new("/nonexistent-aura-test-path"));
|
||||||
|
let data = DataSource::Real {
|
||||||
|
server,
|
||||||
|
symbol: "US500".to_string(),
|
||||||
|
from_ms: None,
|
||||||
|
to_ms: None,
|
||||||
|
pip: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = reproduce_family_in(®, "f", &data, &Env::std())
|
||||||
|
.expect_err("a real source naming the wrong symbol must not silently reproduce");
|
||||||
|
assert_eq!(err.exit_code, 2);
|
||||||
|
assert!(err.message.contains("GER40"), "message: {}", err.message);
|
||||||
|
assert!(err.message.contains("US500"), "message: {}", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The broker/pip guard (#299): the recorded manifest carries no pip
|
||||||
|
/// field of its own — the pip survives only inside the broker label — so
|
||||||
|
/// the guard forward-builds the label the family mint stamps for this pip
|
||||||
|
/// (tolerating a plain-shape stored record generically, not asserting a
|
||||||
|
/// live re-run would itself produce it — #299) and refuses
|
||||||
|
/// class 2 on a label mismatch, naming both labels.
|
||||||
|
#[test]
|
||||||
|
fn reproduce_family_in_refuses_a_pip_mismatch_via_the_broker_label() {
|
||||||
|
let reg = temp_registry("guard-pip-mismatch");
|
||||||
|
// Recorded under a pip the actual synthetic run (0.0001, `SYNTHETIC_PIP_SIZE`)
|
||||||
|
// does not match: the label the re-run would forward-build diverges from
|
||||||
|
// the stored one, so the guard must fire before any re-run.
|
||||||
|
reg.append_family(
|
||||||
|
"f",
|
||||||
|
FamilyKind::Sweep,
|
||||||
|
&[stamped_report(None, "sim-optimal(pip_size=0.0002)")],
|
||||||
|
)
|
||||||
|
.expect("append family");
|
||||||
|
|
||||||
|
let err = reproduce_family_in(®, "f", &DataSource::Synthetic, &Env::std())
|
||||||
|
.expect_err("a pip mismatch forward-built via the broker label must refuse");
|
||||||
|
assert_eq!(err.exit_code, 2);
|
||||||
|
assert!(err.message.contains("sim-optimal(pip_size=0.0002)"), "message: {}", err.message);
|
||||||
|
assert!(err.message.contains("sim-optimal(pip_size=0.0001)"), "message: {}", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `all_identical` is true iff every outcome reproduced bit-identically.
|
||||||
|
#[test]
|
||||||
|
fn all_identical_reflects_every_outcome() {
|
||||||
|
let all_ok = ReproduceReport {
|
||||||
|
outcomes: vec![("a".to_string(), true), ("b".to_string(), true)],
|
||||||
|
};
|
||||||
|
assert!(all_ok.all_identical());
|
||||||
|
|
||||||
|
let one_diverged = ReproduceReport {
|
||||||
|
outcomes: vec![("a".to_string(), true), ("b".to_string(), false)],
|
||||||
|
};
|
||||||
|
assert!(!one_diverged.all_identical());
|
||||||
|
}
|
||||||
|
|
||||||
/// One id vocabulary across enumeration and reproduction (C18, #298): the
|
/// One id vocabulary across enumeration and reproduction (C18, #298): the
|
||||||
/// family-identity string a consumer lifts off the registry's own member
|
/// family-identity string a consumer lifts off the registry's own member
|
||||||
/// enumeration (`FamilyRunRecord.family`) resolves through the reproduce
|
/// enumeration (`FamilyRunRecord.family`) resolves through the reproduce
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ pub fn render_value(v: &Scalar) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The shipped harness/data binding seam for `aura_campaign::execute`: members
|
/// The shipped harness/data binding seam for `aura_campaign::execute`: members
|
||||||
/// run through the shipped loaded-blueprint machinery (`wrap_r` reduce-mode
|
/// run through the shipped loaded-blueprint machinery (`wrap_r` folded-delivery
|
||||||
/// via `run_blueprint_member`) over windowed real M1 close bars
|
/// via `run_blueprint_member`) over windowed real M1 close bars
|
||||||
/// (`M1FieldSource::open_window` — the ms→ns crossing happens at exactly this
|
/// (`M1FieldSource::open_window` — the ms→ns crossing happens at exactly this
|
||||||
/// seam, via `unix_ms_to_epoch_ns`). All refusals are member faults for the
|
/// seam, via `unix_ms_to_epoch_ns`). All refusals are member faults for the
|
||||||
@@ -116,6 +116,21 @@ impl MemberRunner for DefaultMemberRunner<'_> {
|
|||||||
.param_space();
|
.param_space();
|
||||||
let point = bind_axes(&space, &cell.strategy_id, params)?;
|
let point = bind_axes(&space, &cell.strategy_id, params)?;
|
||||||
let signal = reopen_all(signal, &overrides);
|
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
|
// The member's resolved input binding (campaign data.bindings
|
||||||
// overrides win over name defaults). A refusal is a member fault,
|
// 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(
|
let mut report = run_blueprint_member(
|
||||||
signal,
|
signal,
|
||||||
&point,
|
&point,
|
||||||
&space,
|
&manifest_space,
|
||||||
sources,
|
sources,
|
||||||
(from, to),
|
(from, to),
|
||||||
0,
|
0,
|
||||||
@@ -184,7 +199,7 @@ impl MemberRunner for DefaultMemberRunner<'_> {
|
|||||||
&binding,
|
&binding,
|
||||||
&self.cost,
|
&self.cost,
|
||||||
&cell.instrument,
|
&cell.instrument,
|
||||||
);
|
)?;
|
||||||
report.manifest.instrument = Some(cell.instrument.clone());
|
report.manifest.instrument = Some(cell.instrument.clone());
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
@@ -354,7 +369,7 @@ pub fn cell_member_fanout(cell_out: &CellOutcome) -> Vec<(Option<String>, &RunRe
|
|||||||
/// that family under its own `<cell_key>/<member_key>/` subdirectory — never
|
/// that family under its own `<cell_key>/<member_key>/` subdirectory — never
|
||||||
/// narrowing to one nominated member, which would silently drop the others
|
/// narrowing to one nominated member, which would silently drop the others
|
||||||
/// the sweep actually produced. Each written member is independently re-run
|
/// the sweep actually produced. Each written member is independently re-run
|
||||||
/// once in non-reduce trace mode over its own recorded `manifest.window`,
|
/// once under retained delivery over its own recorded `manifest.window`,
|
||||||
/// asserting the re-run METRICS equal the recorded member metrics (the C1
|
/// asserting the re-run METRICS equal the recorded member metrics (the C1
|
||||||
/// drift alarm — manifest fields are fresh-context and not compared), and
|
/// drift alarm — manifest fields are fresh-context and not compared), and
|
||||||
/// writes the requested-AND-producible taps through the sweep verbs'
|
/// writes the requested-AND-producible taps through the sweep verbs'
|
||||||
@@ -483,14 +498,35 @@ pub fn persist_campaign_traces(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
for (member_subdir, member_report) in members.iter().cloned() {
|
for (member_subdir, member_report) in members.iter().cloned() {
|
||||||
// Re-run the member, non-reduce: the SAME member the executor ran
|
// Re-run the member under retained delivery: the SAME member the
|
||||||
// (same wrapped space, same params, same window, seed-free real
|
// executor ran (same wrapped space, same params, same window,
|
||||||
// data), mirroring `DefaultMemberRunner::run_member` with the
|
// seed-free real data), mirroring `DefaultMemberRunner::run_member`
|
||||||
// reduce fold off so the per-cycle tap streams exist. The window
|
// with the series fold off so the per-cycle tap streams exist. The window
|
||||||
// is the member report's own `manifest.window` — already
|
// is the member report's own `manifest.window` — already
|
||||||
// epoch-ns (`run_blueprint_member` stamped the post-seam
|
// epoch-ns (`run_blueprint_member` stamped the post-seam
|
||||||
// bounds), so no second ms->ns crossing here.
|
// 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 (from, to) = member_report.manifest.window;
|
||||||
let no_data = || {
|
let no_data = || {
|
||||||
format!(
|
format!(
|
||||||
@@ -543,11 +579,13 @@ pub fn persist_campaign_traces(
|
|||||||
// not just on a real divergence.
|
// not just on a real divergence.
|
||||||
let (tx_cost, rx_cost) = mpsc::channel();
|
let (tx_cost, rx_cost) = mpsc::channel();
|
||||||
let (tx_net, rx_net) = mpsc::channel();
|
let (tx_net, rx_net) = mpsc::channel();
|
||||||
let cost_leg = (!campaign.cost.is_empty()).then(|| CostLeg {
|
let cost_leg = if campaign.cost.is_empty() {
|
||||||
nodes: cost_nodes_for(&campaign.cost, &cell_rec.instrument),
|
None
|
||||||
tx_cost,
|
} else {
|
||||||
tx_net,
|
let nodes = cost_nodes_for(&campaign.cost, &cell_rec.instrument)
|
||||||
});
|
.map_err(|f| f.message())?;
|
||||||
|
Some(CostLeg { nodes, tx_cost, tx_net })
|
||||||
|
};
|
||||||
let mut h =
|
let mut h =
|
||||||
wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, false, geo.pip_size, &binding, cost_leg)
|
wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, false, geo.pip_size, &binding, cost_leg)
|
||||||
.bootstrap_with_cells(&point)
|
.bootstrap_with_cells(&point)
|
||||||
@@ -571,8 +609,8 @@ pub fn persist_campaign_traces(
|
|||||||
let net_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_net.try_iter().collect();
|
let net_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_net.try_iter().collect();
|
||||||
|
|
||||||
// The C1 drift alarm: metrics equality against the recorded
|
// The C1 drift alarm: metrics equality against the recorded
|
||||||
// member. The reduce-mode fold shares its arithmetic with this
|
// member. The folded delivery shares its arithmetic with this
|
||||||
// non-reduce reduction (SeriesFold via `summarize`; GatedRecorder
|
// retained-side reduction (SeriesFold via `summarize`; GatedRecorder
|
||||||
// emits exactly the rows `summarize_r`'s ledger reads), so equality
|
// emits exactly the rows `summarize_r`'s ledger reads), so equality
|
||||||
// is bit-exact.
|
// is bit-exact.
|
||||||
let mut rerun_metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
|
let mut rerun_metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
|
||||||
|
|||||||
@@ -161,17 +161,21 @@ impl FoldRegistry {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
for (label, doc, fold) in [
|
for (label, doc, fold) in [
|
||||||
("count", "number of warm rows (any kind; i64 row)", FoldKind::Count),
|
("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)", FoldKind::Sum),
|
("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)", FoldKind::Mean),
|
(
|
||||||
("min", "minimum of the series (f64 taps; f64 row)", FoldKind::Min),
|
"mean",
|
||||||
("max", "maximum of the series (f64 taps; f64 row)", FoldKind::Max),
|
"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",
|
||||||
"first warm value, at its own timestamp (any kind; kind-preserving row)",
|
"first warm value, at its own timestamp (any kind; kind-preserving row)",
|
||||||
FoldKind::First,
|
FoldKind::First,
|
||||||
),
|
),
|
||||||
("last", "last warm value (any kind; kind-preserving row)", FoldKind::Last),
|
("last", "last warm value, at its own timestamp (any kind; kind-preserving row)", FoldKind::Last),
|
||||||
] {
|
] {
|
||||||
r.register(FoldEntry {
|
r.register(FoldEntry {
|
||||||
label,
|
label,
|
||||||
@@ -213,11 +217,12 @@ impl FoldRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A typed tap-plan fault — the pre-bootstrap refusals plus the terminal
|
/// A typed tap-plan fault — the pre-bootstrap refusals plus the terminal
|
||||||
/// store fault. Entry points map every variant to the established
|
/// store fault. Entry points wrap every variant into a returned
|
||||||
/// `aura: ` + exit-1 refusal register via `Display`.
|
/// `RunnerError` (#297) — prose via `Display`, class via `exit_class`
|
||||||
|
/// (content faults 2, store I/O 1, the C14 partition).
|
||||||
pub enum TapPlanError {
|
pub enum TapPlanError {
|
||||||
/// The plan names a tap the blueprint does not declare.
|
/// The plan names a tap the blueprint does not declare.
|
||||||
UnknownTap { name: String },
|
UnknownTap { name: String, declared: Vec<String> },
|
||||||
/// The plan names a label the registry does not carry.
|
/// The plan names a label the registry does not carry.
|
||||||
UnknownLabel { label: String, roster: Vec<&'static str> },
|
UnknownLabel { label: String, roster: Vec<&'static str> },
|
||||||
/// The entry's bind rule rejects the tap's column kind.
|
/// The entry's bind rule rejects the tap's column kind.
|
||||||
@@ -237,8 +242,12 @@ pub enum TapPlanError {
|
|||||||
impl fmt::Display for TapPlanError {
|
impl fmt::Display for TapPlanError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
TapPlanError::UnknownTap { name } => {
|
TapPlanError::UnknownTap { name, declared } => {
|
||||||
write!(f, "the tap plan names '{name}', but the blueprint declares no such tap")
|
write!(
|
||||||
|
f,
|
||||||
|
"the tap plan names '{name}', but the blueprint declares no such tap — declared taps: {}",
|
||||||
|
declared.join(", ")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
TapPlanError::UnknownLabel { label, roster } => {
|
TapPlanError::UnknownLabel { label, roster } => {
|
||||||
write!(f, "unknown fold '{label}' — available: {}", roster.join(", "))
|
write!(f, "unknown fold '{label}' — available: {}", roster.join(", "))
|
||||||
@@ -277,6 +286,25 @@ impl From<TraceStoreError> for TapPlanError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TapPlanError {
|
||||||
|
/// C14 partition, wired into `run_signal_r`/`run_measurement`'s
|
||||||
|
/// `bind_tap_plan` `.map_err` (#297 Fork 1/2): faults in the content of
|
||||||
|
/// what argv named are class 2; environment faults (store I/O) are
|
||||||
|
/// class 1.
|
||||||
|
pub fn exit_class(&self) -> i32 {
|
||||||
|
match self {
|
||||||
|
TapPlanError::Store(_) => 1,
|
||||||
|
TapPlanError::UnknownTap { .. }
|
||||||
|
| TapPlanError::UnknownLabel { .. }
|
||||||
|
| TapPlanError::KindMismatch { .. }
|
||||||
|
| TapPlanError::UnknownParam { .. }
|
||||||
|
| TapPlanError::MissingParam { .. }
|
||||||
|
| TapPlanError::ParamKind { .. }
|
||||||
|
| TapPlanError::Bind(_) => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Validate one `Named` subscription's param bindings against the entry's
|
/// Validate one `Named` subscription's param bindings against the entry's
|
||||||
/// schema: every binding names a schema param of the right kind; every
|
/// schema: every binding names a schema param of the right kind; every
|
||||||
/// schema param is bound.
|
/// schema param is bound.
|
||||||
@@ -340,6 +368,15 @@ pub struct BoundTaps {
|
|||||||
persisted: Vec<String>,
|
persisted: Vec<String>,
|
||||||
rows: Vec<(String, ScalarKind, Receiver<(Timestamp, Vec<Scalar>)>)>,
|
rows: Vec<(String, ScalarKind, Receiver<(Timestamp, Vec<Scalar>)>)>,
|
||||||
outcomes: Vec<(String, Receiver<Result<(), TraceStoreError>>)>,
|
outcomes: Vec<(String, Receiver<Result<(), TraceStoreError>>)>,
|
||||||
|
/// Declared taps that resolved to no subscription this run (#297): the
|
||||||
|
/// caller-printed "unbound" note migrates to the CLI, this is the data
|
||||||
|
/// it prints from.
|
||||||
|
skipped: Vec<String>,
|
||||||
|
/// The trace-store handle this run's taps landed under — `Some` exactly
|
||||||
|
/// when the plan persisted something and `begin_run` was called. Rides
|
||||||
|
/// beside the report like `skipped` (#297): the CLI prints it, the
|
||||||
|
/// library hands it back as a value.
|
||||||
|
trace_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BoundTaps {
|
impl BoundTaps {
|
||||||
@@ -348,6 +385,17 @@ impl BoundTaps {
|
|||||||
&self.declared
|
&self.declared
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The recorded trace handle, or `None` when this run persisted nothing.
|
||||||
|
pub fn trace_name(&self) -> Option<&str> {
|
||||||
|
self.trace_name.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declared taps that resolved to no subscription this run (#297) — the
|
||||||
|
/// data the caller's "unbound" note prints from.
|
||||||
|
pub fn skipped(&self) -> &[String] {
|
||||||
|
&self.skipped
|
||||||
|
}
|
||||||
|
|
||||||
/// Drain the ≤1-message-per-tap channels and close the run: record
|
/// Drain the ≤1-message-per-tap channels and close the run: record
|
||||||
/// outcomes first (declared order), then fold rows written through the
|
/// outcomes first (declared order), then fold rows written through the
|
||||||
/// same streamer, then `index.json` last. Any fault returns before the
|
/// same streamer, then `index.json` last. Any fault returns before the
|
||||||
@@ -402,7 +450,10 @@ pub fn bind_tap_plan(
|
|||||||
// Unknown-tap guard: every plan name must be declared.
|
// Unknown-tap guard: every plan name must be declared.
|
||||||
for name in plan.by_name.keys() {
|
for name in plan.by_name.keys() {
|
||||||
if !declared_taps.iter().any(|t| &t.name == name) {
|
if !declared_taps.iter().any(|t| &t.name == name) {
|
||||||
return Err(TapPlanError::UnknownTap { name: name.clone() });
|
return Err(TapPlanError::UnknownTap {
|
||||||
|
name: name.clone(),
|
||||||
|
declared: declared_taps.iter().map(|t| t.name.clone()).collect(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +464,7 @@ pub fn bind_tap_plan(
|
|||||||
Live(Box<dyn FnMut(Timestamp, Cell) + Send>),
|
Live(Box<dyn FnMut(Timestamp, Cell) + Send>),
|
||||||
}
|
}
|
||||||
let mut resolved: Vec<(String, ScalarKind, Resolved)> = Vec::new();
|
let mut resolved: Vec<(String, ScalarKind, Resolved)> = Vec::new();
|
||||||
|
let mut skipped: Vec<String> = Vec::new();
|
||||||
for tap in &declared_taps {
|
for tap in &declared_taps {
|
||||||
let kind = flat.signatures[tap.node].output[tap.field].kind;
|
let kind = flat.signatures[tap.node].output[tap.field].kind;
|
||||||
let sub = match plan.by_name.remove(&tap.name) {
|
let sub = match plan.by_name.remove(&tap.name) {
|
||||||
@@ -422,7 +474,18 @@ pub fn bind_tap_plan(
|
|||||||
Some((label, params)) => {
|
Some((label, params)) => {
|
||||||
Resolved::Named { label: label.clone(), params: params.clone() }
|
Resolved::Named { label: label.clone(), params: params.clone() }
|
||||||
}
|
}
|
||||||
None => continue, // unbound, inert (C27)
|
// 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).
|
||||||
|
// The name is recorded here and the note is CLI-printed from
|
||||||
|
// the returned `skipped` names (the runner→CLI print
|
||||||
|
// migration, #297) — this module no longer emits it.
|
||||||
|
None => {
|
||||||
|
skipped.push(tap.name.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if let Resolved::Named { label, params } = &sub {
|
if let Resolved::Named { label, params } = &sub {
|
||||||
@@ -454,6 +517,8 @@ pub fn bind_tap_plan(
|
|||||||
persisted: Vec::new(),
|
persisted: Vec::new(),
|
||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
outcomes: Vec::new(),
|
outcomes: Vec::new(),
|
||||||
|
skipped,
|
||||||
|
trace_name: if persists { Some(run_name.to_string()) } else { None },
|
||||||
};
|
};
|
||||||
for (name, kind, sub) in resolved {
|
for (name, kind, sub) in resolved {
|
||||||
let node: Box<dyn Node> = match sub {
|
let node: Box<dyn Node> = match sub {
|
||||||
@@ -504,6 +569,64 @@ mod tests {
|
|||||||
assert!(r.roster().iter().all(|(_, doc)| !doc.is_empty()), "every entry documents itself");
|
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]
|
#[test]
|
||||||
fn unknown_label_refusal_enumerates_the_roster() {
|
fn unknown_label_refusal_enumerates_the_roster() {
|
||||||
let r = FoldRegistry::core();
|
let r = FoldRegistry::core();
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
//! the cost model) must still round-trip through a manifest, so a stored
|
//! the cost model) must still round-trip through a manifest, so a stored
|
||||||
//! member (family, reproduce) re-derives bit-identically. These translators
|
//! member (family, reproduce) re-derives bit-identically. These translators
|
||||||
//! are the single source for each such write<->read pair — the manifest
|
//! are the single source for each such write<->read pair — the manifest
|
||||||
//! `stop_length`/`stop_k`/`stop_period_minutes` <-> [`StopRule`] binding, and
|
//! `stop_length`/`stop_k`/`stop_period_minutes`/`stop_distance` <->
|
||||||
//! the `cost[k].<knob>` <-> [`aura_research::CostSpec`] binding — so the two
|
//! [`StopRule`] binding, and the `cost[k].<knob>` <->
|
||||||
//! halves of each pair cannot drift out of sync between the run/campaign
|
//! [`aura_research::CostSpec`] binding — so the two halves of each pair
|
||||||
//! paths and reproduce/persist.
|
//! cannot drift out of sync between the run/campaign paths and
|
||||||
|
//! reproduce/persist.
|
||||||
|
|
||||||
use aura_composites::StopRule;
|
use aura_composites::StopRule;
|
||||||
use aura_core::{PrimitiveBuilder, Scalar};
|
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;
|
pub const R_SMA_STOP_K: f64 = 2.0;
|
||||||
|
|
||||||
/// Re-derive the `StopRule` a member was minted under from its manifest params
|
/// 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`):
|
/// (`stop_length`/`stop_k`/`stop_period_minutes`/`stop_distance`, stamped by
|
||||||
/// if `stop_period_minutes` is present alongside `stop_length`/`stop_k`, this
|
/// `run_blueprint_member`): if `stop_period_minutes` is present alongside
|
||||||
/// re-derives `VolTf`; otherwise falls back to `Vol`, and to the default
|
/// `stop_length`/`stop_k`, this re-derives `VolTf`; `stop_length`/`stop_k`
|
||||||
/// vol-stop regime when the manifest carries no stop knobs at all (pre-#233
|
/// alone re-derives `Vol`; `stop_distance` alone (#338) re-derives `Fixed` —
|
||||||
/// members), mirroring [`stop_rule_for_regime`]'s `None` arm for the same
|
/// 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
|
/// one-directional widening `point_from_params` already applies to missing
|
||||||
/// manifest params.
|
/// manifest params.
|
||||||
pub fn stop_rule_from_params(params: &[(String, Scalar)]) -> StopRule {
|
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());
|
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 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());
|
let k = params.iter().find(|(n, _)| n == "stop_k").map(|(_, s)| s.as_f64());
|
||||||
match (period_minutes, length, k) {
|
let distance = params.iter().find(|(n, _)| n == "stop_distance").map(|(_, s)| s.as_f64());
|
||||||
(Some(period_minutes), Some(length), Some(k)) => {
|
match (period_minutes, length, k, distance) {
|
||||||
|
(Some(period_minutes), Some(length), Some(k), _) => {
|
||||||
StopRule::VolTf { period_minutes, length, 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 },
|
_ => 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
|
/// default vol-stop, `Some(RiskRegime::Vol { .. })` binds that regime's own
|
||||||
/// params. Single-sourced so the persist-side re-run structurally cannot
|
/// params. Single-sourced so the persist-side re-run structurally cannot
|
||||||
/// diverge from the run-side binding again (the #219 divergence class).
|
/// 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 {
|
pub fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> aura_composites::StopRule {
|
||||||
match regime {
|
match regime {
|
||||||
None => aura_composites::StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
|
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 }) => {
|
Some(aura_research::RiskRegime::VolTf { period_minutes, length, k }) => {
|
||||||
aura_composites::StopRule::VolTf { period_minutes, length, k }
|
aura_composites::StopRule::VolTf { period_minutes, length, k }
|
||||||
}
|
}
|
||||||
|
Some(aura_research::RiskRegime::Fixed { distance }) => aura_composites::StopRule::Fixed(distance),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,12 +121,15 @@ pub fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> aura_c
|
|||||||
/// cannot diverge from the run-side cost model (the #219 divergence class,
|
/// cannot diverge from the run-side cost model (the #219 divergence class,
|
||||||
/// cost edition). The bound knob names are the builders' own `ParamSpec` names
|
/// cost edition). The bound knob names are the builders' own `ParamSpec` names
|
||||||
/// — the `CostSpec` serde vocabulary conforms to them.
|
/// — the `CostSpec` serde vocabulary conforms to them.
|
||||||
pub fn cost_nodes_for(specs: &[aura_research::CostSpec], instrument: &str) -> Vec<PrimitiveBuilder> {
|
pub fn cost_nodes_for(
|
||||||
|
specs: &[aura_research::CostSpec],
|
||||||
|
instrument: &str,
|
||||||
|
) -> Result<Vec<PrimitiveBuilder>, CostKnobFault> {
|
||||||
specs
|
specs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| {
|
.map(|s| {
|
||||||
let (knob, v) = cost_knob(s, instrument);
|
let (knob, v) = cost_knob(s, instrument)?;
|
||||||
match s {
|
Ok(match s {
|
||||||
aura_research::CostSpec::Constant { .. } => {
|
aura_research::CostSpec::Constant { .. } => {
|
||||||
ConstantCost::builder().bind(knob, Scalar::f64(v))
|
ConstantCost::builder().bind(knob, Scalar::f64(v))
|
||||||
}
|
}
|
||||||
@@ -128,29 +139,47 @@ pub fn cost_nodes_for(specs: &[aura_research::CostSpec], instrument: &str) -> Ve
|
|||||||
aura_research::CostSpec::Carry { .. } => {
|
aura_research::CostSpec::Carry { .. } => {
|
||||||
CarryCost::builder().bind(knob, Scalar::f64(v))
|
CarryCost::builder().bind(knob, Scalar::f64(v))
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The fault `cost_knob` refuses with: the document named an instrument the
|
||||||
|
/// value map has no entry for (unreachable after intrinsic validation —
|
||||||
|
/// kept as a loud refusal, never a silent 0-charge).
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CostKnobFault {
|
||||||
|
pub knob: &'static str,
|
||||||
|
pub instrument: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CostKnobFault {
|
||||||
|
/// The exact prose the retired exit register printed (byte-identical).
|
||||||
|
pub fn message(&self) -> String {
|
||||||
|
format!("cost {}: no entry for instrument {}", self.knob, self.instrument)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The one CostSpec-variant -> (knob name, value) mapping, shared by
|
/// The one CostSpec-variant -> (knob name, value) mapping, shared by
|
||||||
/// `cost_nodes_for`'s bind above and `run_blueprint_member`'s manifest stamp:
|
/// `cost_nodes_for`'s bind above and `run_blueprint_member`'s manifest stamp:
|
||||||
/// the stamp key must equal the bind key for reproduce to re-derive a
|
/// the stamp key must equal the bind key for reproduce to re-derive a
|
||||||
/// `CostSpec` from a stored manifest, so both sites read the name off this
|
/// `CostSpec` from a stored manifest, so both sites read the name off this
|
||||||
/// single function rather than each carrying its own literal.
|
/// single function rather than each carrying its own literal.
|
||||||
pub fn cost_knob(spec: &aura_research::CostSpec, instrument: &str) -> (&'static str, f64) {
|
pub fn cost_knob(
|
||||||
|
spec: &aura_research::CostSpec,
|
||||||
|
instrument: &str,
|
||||||
|
) -> Result<(&'static str, f64), CostKnobFault> {
|
||||||
let (knob, value) = match spec {
|
let (knob, value) = match spec {
|
||||||
aura_research::CostSpec::Constant { cost_per_trade } => ("cost_per_trade", cost_per_trade),
|
aura_research::CostSpec::Constant { cost_per_trade } => ("cost_per_trade", cost_per_trade),
|
||||||
aura_research::CostSpec::VolSlippage { slip_vol_mult } => ("slip_vol_mult", slip_vol_mult),
|
aura_research::CostSpec::VolSlippage { slip_vol_mult } => ("slip_vol_mult", slip_vol_mult),
|
||||||
aura_research::CostSpec::Carry { carry_per_cycle } => ("carry_per_cycle", carry_per_cycle),
|
aura_research::CostSpec::Carry { carry_per_cycle } => ("carry_per_cycle", carry_per_cycle),
|
||||||
};
|
};
|
||||||
let v = value.resolve(instrument).unwrap_or_else(|| {
|
match value.resolve(instrument) {
|
||||||
|
Some(v) => Ok((knob, v)),
|
||||||
// Unreachable after intrinsic validation (map keys ≡ instruments);
|
// Unreachable after intrinsic validation (map keys ≡ instruments);
|
||||||
// refuse loudly rather than charging 0 silently if it ever surfaces.
|
// refuse loudly rather than charging 0 silently if it ever surfaces.
|
||||||
eprintln!("aura: cost {knob}: no entry for instrument {instrument}");
|
None => Err(CostKnobFault { knob, instrument: instrument.to_string() }),
|
||||||
std::process::exit(1);
|
}
|
||||||
});
|
|
||||||
(knob, v)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -178,6 +207,15 @@ mod tests {
|
|||||||
assert_eq!(stop_rule_from_params(&vol), StopRule::Vol { length: 3, k: 2.0 });
|
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]
|
#[test]
|
||||||
/// #262: `stop_rule_for_regime` binds `RiskRegime::VolTf` to
|
/// #262: `stop_rule_for_regime` binds `RiskRegime::VolTf` to
|
||||||
/// `StopRule::VolTf` field-for-field — the resolve-side half of the
|
/// `StopRule::VolTf` field-for-field — the resolve-side half of the
|
||||||
@@ -193,6 +231,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]
|
#[test]
|
||||||
/// #234: the one CostSpec -> builder binding maps each component to its
|
/// #234: the one CostSpec -> builder binding maps each component to its
|
||||||
/// shipped cost node with the knob BOUND — a bound component adds no open
|
/// shipped cost node with the knob BOUND — a bound component adds no open
|
||||||
@@ -212,14 +261,53 @@ mod tests {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"GER40",
|
"GER40",
|
||||||
);
|
)
|
||||||
|
.expect("every knob resolves against the GER40-keyed fixture");
|
||||||
let labels: Vec<String> = nodes.iter().map(|n| n.label()).collect();
|
let labels: Vec<String> = nodes.iter().map(|n| n.label()).collect();
|
||||||
assert_eq!(labels, ["ConstantCost", "VolSlippageCost", "CarryCost"]);
|
assert_eq!(labels, ["ConstantCost", "VolSlippageCost", "CarryCost"]);
|
||||||
assert!(
|
assert!(
|
||||||
nodes.iter().all(|n| n.params().is_empty()),
|
nodes.iter().all(|n| n.params().is_empty()),
|
||||||
"every component must be fully bound (no open param leaks into param_space)"
|
"every component must be fully bound (no open param leaks into param_space)"
|
||||||
);
|
);
|
||||||
assert!(cost_nodes_for(&[], "GER40").is_empty(), "an empty model binds no nodes");
|
assert!(
|
||||||
|
cost_nodes_for(&[], "GER40").expect("empty model resolves").is_empty(),
|
||||||
|
"an empty model binds no nodes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// Review fix (#297 fork 3): `CostKnobFault::message()` is the exact prose
|
||||||
|
/// the retired exit register printed byte-identically — pinned against a
|
||||||
|
/// concrete fixture so a future edit to the format string is caught here,
|
||||||
|
/// not only at the call sites that propagate it as `MemberFault::Bind`.
|
||||||
|
fn cost_knob_fault_message_is_byte_pinned() {
|
||||||
|
let fault = CostKnobFault { knob: "cost_per_trade", instrument: "XYZ".to_string() };
|
||||||
|
assert_eq!(fault.message(), "cost cost_per_trade: no entry for instrument XYZ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
/// Review fix (#297 fork 3): `cost_knob`/`cost_nodes_for` refuse (never
|
||||||
|
/// silently charge 0) when a `CostSpec`'s per-instrument map lacks the
|
||||||
|
/// requested instrument — the `PerInstrument` counterpart of
|
||||||
|
/// `cost_nodes_for_maps_each_component_to_its_bound_builder`'s all-resolve
|
||||||
|
/// case above, exercising the `None` arm of `CostValue::resolve`.
|
||||||
|
fn cost_knob_and_cost_nodes_for_refuse_an_uncovered_instrument() {
|
||||||
|
let spec = aura_research::CostSpec::Constant {
|
||||||
|
cost_per_trade: aura_research::CostValue::PerInstrument(std::collections::BTreeMap::from([(
|
||||||
|
"GER40".to_string(),
|
||||||
|
2.0,
|
||||||
|
)])),
|
||||||
|
};
|
||||||
|
let err = cost_knob(&spec, "EURUSD").expect_err("EURUSD is not in the map");
|
||||||
|
assert_eq!(err.knob, "cost_per_trade");
|
||||||
|
assert_eq!(err.instrument, "EURUSD");
|
||||||
|
|
||||||
|
let err = match cost_nodes_for(std::slice::from_ref(&spec), "EURUSD") {
|
||||||
|
Err(f) => f,
|
||||||
|
Ok(_) => panic!("cost_nodes_for must propagate the same fault"),
|
||||||
|
};
|
||||||
|
assert_eq!(err.knob, "cost_per_trade");
|
||||||
|
assert_eq!(err.instrument, "EURUSD");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! The folded wrap retains one record row per closed trade, not one per cycle,
|
||||||
|
//! and binds no sink whose rows nothing reads.
|
||||||
|
//!
|
||||||
|
//! Both assertions are identities that hold at any trade count, so this pin
|
||||||
|
//! depends on no property of the driven price path. Under retained delivery the
|
||||||
|
//! record sink keeps one row per warm cycle and the series sinks one row each,
|
||||||
|
//! so both identities fail loudly at any trade rate below one close per cycle —
|
||||||
|
//! that is where the discrimination comes from.
|
||||||
|
|
||||||
|
use aura_backtest::summarize_r;
|
||||||
|
use aura_composites::StopRule;
|
||||||
|
use aura_core::{Scalar, Timestamp};
|
||||||
|
use aura_engine::{blueprint_from_json, Harness, Source, VecSource};
|
||||||
|
use aura_runner::binding::probe_binding;
|
||||||
|
use aura_runner::member::{key_supply, wrap_r, SYNTHETIC_PIP_SIZE};
|
||||||
|
use aura_runner::translate::{R_SMA_STOP_K, R_SMA_STOP_LENGTH};
|
||||||
|
use aura_vocabulary::std_vocabulary;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
const CYCLES: usize = 50_000;
|
||||||
|
|
||||||
|
/// A deterministic triangular oscillation around 100.0. No RNG: the same series
|
||||||
|
/// every run. The period is long enough that the vol stop warms and positions
|
||||||
|
/// both open and close, so the gated sink is exercised rather than trivially
|
||||||
|
/// empty — but nothing in the assertions depends on how often that happens.
|
||||||
|
fn prices(n: usize) -> Vec<(Timestamp, Scalar)> {
|
||||||
|
(0..n)
|
||||||
|
.map(|i| {
|
||||||
|
let phase = (i % 400) as f64;
|
||||||
|
let leg = if phase < 200.0 { phase } else { 400.0 - phase };
|
||||||
|
(Timestamp(i as i64 + 1), Scalar::f64(100.0 + leg * 0.05))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn folded_wrap_retains_rows_per_trade_not_per_cycle() {
|
||||||
|
let signal = blueprint_from_json(
|
||||||
|
include_str!("../../aura-cli/examples/r_sma.json"),
|
||||||
|
&|t| std_vocabulary(t),
|
||||||
|
)
|
||||||
|
.expect("the shipped r_sma example parses");
|
||||||
|
let binding = probe_binding(signal.input_roles());
|
||||||
|
|
||||||
|
let (tx_eq, rx_eq) = mpsc::channel();
|
||||||
|
let (tx_ex, rx_ex) = mpsc::channel();
|
||||||
|
let (tx_r, rx_r) = mpsc::channel();
|
||||||
|
let (tx_req, rx_req) = mpsc::channel();
|
||||||
|
|
||||||
|
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 },
|
||||||
|
true,
|
||||||
|
SYNTHETIC_PIP_SIZE,
|
||||||
|
&binding,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let flat = wrapped.compile_with_params(&[]).expect("the wrap compiles bound");
|
||||||
|
let mut h = Harness::bootstrap(flat).expect("the wrap bootstraps");
|
||||||
|
let sources: Vec<Box<dyn Source>> = vec![Box::new(VecSource::new(prices(CYCLES)))];
|
||||||
|
h.run_bound(key_supply(&binding, sources)).expect("sources key-match the binding");
|
||||||
|
|
||||||
|
let record: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
|
||||||
|
let equity_rows = rx_eq.try_iter().count();
|
||||||
|
let exposure_rows = rx_ex.try_iter().count();
|
||||||
|
let req_rows = rx_req.try_iter().count();
|
||||||
|
|
||||||
|
// `n_trades` counts TRADES, not rows — closed rows plus one when a position
|
||||||
|
// is still open on the last retained row. Feeding it more rows does not make
|
||||||
|
// it larger, which is what keeps the identity below non-vacuous.
|
||||||
|
let n_trades = summarize_r(&record, &[]).n_trades;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
record.len() as u64 <= n_trades + 1,
|
||||||
|
"record retention exceeded one row per trade: {} rows for {n_trades} trades \
|
||||||
|
over {CYCLES} cycles",
|
||||||
|
record.len()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
equity_rows <= 1 && exposure_rows <= 1,
|
||||||
|
"a folded series sink retained more than its summary row: eq={equity_rows} ex={exposure_rows}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
req_rows, 0,
|
||||||
|
"the r-equity sink is bound under folded delivery, retaining {req_rows} unread rows"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A run with no warm cycle reports zeros — and reaches them by the folded
|
||||||
|
/// route: the series sink emits its summary row at finalize unconditionally,
|
||||||
|
/// carrying the fold's defaults. The gated record sink, having no last row to
|
||||||
|
/// flush, emits nothing at all.
|
||||||
|
#[test]
|
||||||
|
fn folded_wrap_reports_zeros_when_no_cycle_warms() {
|
||||||
|
let signal = blueprint_from_json(
|
||||||
|
include_str!("../../aura-cli/examples/r_sma.json"),
|
||||||
|
&|t| std_vocabulary(t),
|
||||||
|
)
|
||||||
|
.expect("the shipped r_sma example parses");
|
||||||
|
let binding = probe_binding(signal.input_roles());
|
||||||
|
|
||||||
|
let (tx_eq, rx_eq) = mpsc::channel();
|
||||||
|
let (tx_ex, rx_ex) = mpsc::channel();
|
||||||
|
let (tx_r, rx_r) = mpsc::channel();
|
||||||
|
let (tx_req, rx_req) = mpsc::channel();
|
||||||
|
|
||||||
|
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 },
|
||||||
|
true,
|
||||||
|
SYNTHETIC_PIP_SIZE,
|
||||||
|
&binding,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let flat = wrapped.compile_with_params(&[]).expect("the wrap compiles bound");
|
||||||
|
let mut h = Harness::bootstrap(flat).expect("the wrap bootstraps");
|
||||||
|
// No bars at all: nothing warms, nothing evaluates.
|
||||||
|
let sources: Vec<Box<dyn Source>> = vec![Box::new(VecSource::new(Vec::new()))];
|
||||||
|
h.run_bound(key_supply(&binding, sources)).expect("sources key-match the binding");
|
||||||
|
|
||||||
|
let eq: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
|
||||||
|
let ex: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
|
||||||
|
let record: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
|
||||||
|
|
||||||
|
assert_eq!(eq.len(), 1, "the series sink emits its summary row even unwarmed");
|
||||||
|
assert_eq!(ex.len(), 1, "the series sink emits its summary row even unwarmed");
|
||||||
|
assert_eq!(record.len(), 0, "the gated sink has no last row to flush");
|
||||||
|
assert_eq!(rx_req.try_iter().count(), 0, "the r-equity sink is not bound");
|
||||||
|
|
||||||
|
// The zeros the entry point would report, read exactly as it reads them.
|
||||||
|
assert_eq!(eq[0].1[0].as_f64(), 0.0, "total_pips");
|
||||||
|
assert_eq!(eq[0].1[1].as_f64(), 0.0, "max_drawdown");
|
||||||
|
assert_eq!(ex[0].1[2].as_i64(), 0, "bias_sign_flips");
|
||||||
|
}
|
||||||
@@ -7,13 +7,6 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
aura-core = { path = "../aura-core" }
|
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]
|
[dev-dependencies]
|
||||||
aura-strategy = { path = "../aura-strategy" }
|
aura-strategy = { path = "../aura-strategy" }
|
||||||
|
|||||||
@@ -7,10 +7,15 @@
|
|||||||
//! indexed `F64` knobs that `Composite::param_space` aggregates (C8/C12/C19).
|
//! indexed `F64` knobs that `Composite::param_space` aggregates (C8/C12/C19).
|
||||||
|
|
||||||
use aura_core::{
|
use aura_core::{
|
||||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
ArgKind, ArgSpec, ArgValue, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
|
||||||
ScalarKind,
|
PrimitiveBuilder, ScalarKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// The declared construction args of a `LinComb` recipe: `arity` fixes the
|
||||||
|
/// node's input/param count (topology, C19), taken through the `args`
|
||||||
|
/// channel instead of a Rust-side builder parameter.
|
||||||
|
const LINCOMB_ARGS: &[ArgSpec] = &[ArgSpec { name: "arity", kind: ArgKind::Count }];
|
||||||
|
|
||||||
/// Weighted sum of `N` f64 inputs: `Σ weights[i] · input[i]`. The `weights` are
|
/// Weighted sum of `N` f64 inputs: `Σ weights[i] · input[i]`. The `weights` are
|
||||||
/// construction parameters that configure the node and fix its arity
|
/// construction parameters that configure the node and fix its arity
|
||||||
/// (`weights.len()` inputs, in slot order). Emits `None` until *all* inputs
|
/// (`weights.len()` inputs, in slot order). Emits `None` until *all* inputs
|
||||||
@@ -40,10 +45,29 @@ impl LinComb {
|
|||||||
Self { weights, out: [Cell::from_f64(0.0)] }
|
Self { weights, out: [Cell::from_f64(0.0)] }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The param-generic recipe for a blueprint primitive. The `arity` is topology
|
/// Roster factory: zero-arg, arg-bearing. `arity` is topology (fixed per
|
||||||
/// (fixed per blueprint, C19), taken as a builder arg; only the weight *values*
|
/// blueprint, C19), now taken through the `args` channel instead of a
|
||||||
/// are injected, slot by slot, through `LinComb::new` (the single sizing gate).
|
/// Rust-side parameter.
|
||||||
pub fn builder(arity: usize) -> PrimitiveBuilder {
|
pub fn builder() -> PrimitiveBuilder {
|
||||||
|
PrimitiveBuilder::pending(
|
||||||
|
"LinComb",
|
||||||
|
"linear combination of its inputs with constant weights",
|
||||||
|
LINCOMB_ARGS,
|
||||||
|
Self::make,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn a validated `arity` into the real, arity-sized signature; only
|
||||||
|
/// the weight *values* are injected, slot by slot, through `LinComb::new`
|
||||||
|
/// (the single sizing gate) once params are bound/built.
|
||||||
|
fn make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
|
||||||
|
let arity = values
|
||||||
|
.iter()
|
||||||
|
.find_map(|(n, v)| match (n.as_str(), v) {
|
||||||
|
("arity", ArgValue::Count(n)) => Some(*n),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.expect("try_args validated `arity` as ArgKind::Count before calling make");
|
||||||
let inputs = (0..arity)
|
let inputs = (0..arity)
|
||||||
.map(|i| PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: format!("term[{i}]") })
|
.map(|i| PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: format!("term[{i}]") })
|
||||||
.collect();
|
.collect();
|
||||||
@@ -63,6 +87,13 @@ impl LinComb {
|
|||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rust-path convenience — same recipe as the data path (twin identity).
|
||||||
|
pub fn configured(arity: usize) -> PrimitiveBuilder {
|
||||||
|
Self::builder()
|
||||||
|
.try_args(&[("arity".to_string(), arity.to_string())])
|
||||||
|
.expect("configured: a positive arity is always the canonical strict-form Count string")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Node for LinComb {
|
impl Node for LinComb {
|
||||||
@@ -149,7 +180,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn input_slots_are_named_term_index() {
|
fn input_slots_are_named_term_index() {
|
||||||
let lc = LinComb::builder(3);
|
let lc = LinComb::configured(3);
|
||||||
let names: Vec<String> = lc.schema().inputs.iter().map(|p| p.name.clone()).collect();
|
let names: Vec<String> = lc.schema().inputs.iter().map(|p| p.name.clone()).collect();
|
||||||
assert_eq!(names, ["term[0]", "term[1]", "term[2]"]);
|
assert_eq!(names, ["term[0]", "term[1]", "term[2]"]);
|
||||||
}
|
}
|
||||||
@@ -157,7 +188,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn chained_bind_reconstructs_positional_vector() {
|
fn chained_bind_reconstructs_positional_vector() {
|
||||||
// bind BOTH weights, in reverse slot order, to DISTINCT values; build empty.
|
// bind BOTH weights, in reverse slot order, to DISTINCT values; build empty.
|
||||||
let builder = LinComb::builder(2)
|
let builder = LinComb::configured(2)
|
||||||
.bind("weights[1]", Scalar::f64(2.0))
|
.bind("weights[1]", Scalar::f64(2.0))
|
||||||
.bind("weights[0]", Scalar::f64(0.5));
|
.bind("weights[0]", Scalar::f64(0.5));
|
||||||
assert!(builder.params().is_empty());
|
assert!(builder.params().is_empty());
|
||||||
@@ -173,7 +204,7 @@ mod tests {
|
|||||||
assert_eq!(lc.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(11.0)].as_slice()));
|
assert_eq!(lc.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(11.0)].as_slice()));
|
||||||
|
|
||||||
// partial: bind weights[0], leave weights[1] open → inject it at build
|
// partial: bind weights[0], leave weights[1] open → inject it at build
|
||||||
let partial = LinComb::builder(2).bind("weights[0]", Scalar::f64(0.5));
|
let partial = LinComb::configured(2).bind("weights[0]", Scalar::f64(0.5));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
partial.params().iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
|
partial.params().iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
|
||||||
["weights[1]"],
|
["weights[1]"],
|
||||||
@@ -187,4 +218,14 @@ mod tests {
|
|||||||
inputs2[1].push(Scalar::f64(3.0)).unwrap();
|
inputs2[1].push(Scalar::f64(3.0)).unwrap();
|
||||||
assert_eq!(lc2.eval(Ctx::new(&inputs2, Timestamp(0))), Some([Cell::from_f64(11.0)].as_slice()));
|
assert_eq!(lc2.eval(Ctx::new(&inputs2, Timestamp(0))), Some([Cell::from_f64(11.0)].as_slice()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Twin identity (spec §aura-std): `configured(arity)` is exactly
|
||||||
|
/// `builder().try_args([("arity", arity)])`.
|
||||||
|
#[test]
|
||||||
|
fn lincomb_configured_twin_equals_builder_try_args() {
|
||||||
|
let via_configured = LinComb::configured(3);
|
||||||
|
let via_try_args = LinComb::builder().try_args(&[("arity".into(), "3".into())]).expect("valid arity configures");
|
||||||
|
assert_eq!(via_configured.construction_args(), via_try_args.construction_args());
|
||||||
|
assert_eq!(via_configured.schema(), via_try_args.schema());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ mod tests {
|
|||||||
vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
|
vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
|
||||||
);
|
);
|
||||||
// vector knob expands flat to N indexed F64 entries
|
// vector knob expands flat to N indexed F64 entries
|
||||||
let lc = LinComb::builder(2).schema().params.clone();
|
let lc = LinComb::configured(2).schema().params.clone();
|
||||||
assert_eq!(lc.len(), 2);
|
assert_eq!(lc.len(), 2);
|
||||||
assert_eq!(lc[0].name, "weights[0]");
|
assert_eq!(lc[0].name, "weights[0]");
|
||||||
assert_eq!(lc[1].name, "weights[1]");
|
assert_eq!(lc[1].name, "weights[1]");
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ impl CarryCost {
|
|||||||
"CarryCost",
|
"CarryCost",
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
vec![ParamSpec { name: "carry_per_cycle".into(), kind: ScalarKind::F64 }],
|
vec![ParamSpec { name: "carry_per_cycle".into(), kind: ScalarKind::F64 }],
|
||||||
|
"cost-model node: cost accrued per held cycle (param carry_per_cycle)",
|
||||||
|p| Box::new(CarryCost::new(p[0].f64())),
|
|p| Box::new(CarryCost::new(p[0].f64())),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ impl ConstantCost {
|
|||||||
"ConstantCost",
|
"ConstantCost",
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
vec![ParamSpec { name: "cost_per_trade".into(), kind: ScalarKind::F64 }],
|
vec![ParamSpec { name: "cost_per_trade".into(), kind: ScalarKind::F64 }],
|
||||||
|
"cost-model node: fixed cost per trade (param cost_per_trade), charged at close",
|
||||||
|p| Box::new(ConstantCost::new(p[0].f64())),
|
|p| Box::new(ConstantCost::new(p[0].f64())),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,26 +214,20 @@ impl<F: CostNode> Node for CostRunner<F> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Assemble a cost-node `PrimitiveBuilder`: the 4 geometry inputs ++ the factor's
|
/// Assemble a cost-node `PrimitiveBuilder`: the 4 geometry inputs ++ the factor's
|
||||||
/// extra inputs, the standard 3-field cost output, the given params, and a build
|
/// extra inputs, the standard 3-field cost output, the given params, a
|
||||||
/// closure. The single home for the cost-node schema shape.
|
/// factor-specific one-line `doc` (C29 — each cost node names its own charge
|
||||||
|
/// basis rather than sharing one generic sentence, #330), and a build closure.
|
||||||
|
/// The single home for the cost-node schema shape.
|
||||||
pub fn cost_node_builder(
|
pub fn cost_node_builder(
|
||||||
name: &'static str,
|
name: &'static str,
|
||||||
extra_inputs: Vec<PortSpec>,
|
extra_inputs: Vec<PortSpec>,
|
||||||
params: Vec<ParamSpec>,
|
params: Vec<ParamSpec>,
|
||||||
|
doc: &'static str,
|
||||||
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
|
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
|
||||||
) -> PrimitiveBuilder {
|
) -> PrimitiveBuilder {
|
||||||
let mut inputs = geometry_input_ports();
|
let mut inputs = geometry_input_ports();
|
||||||
inputs.extend(extra_inputs);
|
inputs.extend(extra_inputs);
|
||||||
PrimitiveBuilder::new(
|
PrimitiveBuilder::new(name, NodeSchema { inputs, output: cost_output_fields(), params, doc }, build)
|
||||||
name,
|
|
||||||
NodeSchema {
|
|
||||||
inputs,
|
|
||||||
output: cost_output_fields(),
|
|
||||||
params,
|
|
||||||
doc: "cost-model node: charges its cost in R from position geometry, at close or accrued per held cycle",
|
|
||||||
},
|
|
||||||
build,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -501,6 +495,7 @@ mod tests {
|
|||||||
"Probe",
|
"Probe",
|
||||||
extra.clone(),
|
extra.clone(),
|
||||||
params.clone(),
|
params.clone(),
|
||||||
|
"cost-model node: probe factor for the builder-assembly test",
|
||||||
|_p| -> Box<dyn Node> { Box::new(CostRunner::new(StubCost(0.0))) },
|
|_p| -> Box<dyn Node> { Box::new(CostRunner::new(StubCost(0.0))) },
|
||||||
);
|
);
|
||||||
let schema = builder.schema();
|
let schema = builder.schema();
|
||||||
@@ -537,6 +532,21 @@ mod tests {
|
|||||||
assert_eq!(a, "volatility");
|
assert_eq!(a, "volatility");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cost_node_descriptions_are_pairwise_distinct() {
|
||||||
|
// C29: `aura graph introspect --vocabulary` must be able to tell the
|
||||||
|
// three cost nodes apart by their charge basis (flat-per-trade vs
|
||||||
|
// per-held-cycle-accrual vs volatility-scaled), not read one
|
||||||
|
// identical generic sentence three times (#330).
|
||||||
|
use crate::{CarryCost, ConstantCost, VolSlippageCost};
|
||||||
|
let constant_doc = ConstantCost::builder().schema().doc;
|
||||||
|
let carry_doc = CarryCost::builder().schema().doc;
|
||||||
|
let vol_slippage_doc = VolSlippageCost::builder().schema().doc;
|
||||||
|
assert_ne!(constant_doc, carry_doc, "ConstantCost vs CarryCost doc must differ");
|
||||||
|
assert_ne!(constant_doc, vol_slippage_doc, "ConstantCost vs VolSlippageCost doc must differ");
|
||||||
|
assert_ne!(carry_doc, vol_slippage_doc, "CarryCost vs VolSlippageCost doc must differ");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn producer_and_aggregator_share_the_triple() {
|
fn producer_and_aggregator_share_the_triple() {
|
||||||
// The structural lockstep: producer output and aggregator output/inputs all
|
// The structural lockstep: producer output and aggregator output/inputs all
|
||||||
@@ -545,10 +555,10 @@ mod tests {
|
|||||||
ConstantCost::builder().schema().output.iter().map(|f| f.name.clone()).collect();
|
ConstantCost::builder().schema().output.iter().map(|f| f.name.clone()).collect();
|
||||||
assert_eq!(prod, COST_FIELD_NAMES.to_vec());
|
assert_eq!(prod, COST_FIELD_NAMES.to_vec());
|
||||||
let agg_out: Vec<String> =
|
let agg_out: Vec<String> =
|
||||||
CostSum::builder(1).schema().output.iter().map(|f| f.name.clone()).collect();
|
CostSum::configured(1).schema().output.iter().map(|f| f.name.clone()).collect();
|
||||||
assert_eq!(agg_out, COST_FIELD_NAMES.to_vec());
|
assert_eq!(agg_out, COST_FIELD_NAMES.to_vec());
|
||||||
let agg_in: Vec<String> =
|
let agg_in: Vec<String> =
|
||||||
CostSum::builder(1).schema().inputs.iter().map(|p| p.name.clone()).collect();
|
CostSum::configured(1).schema().inputs.iter().map(|p| p.name.clone()).collect();
|
||||||
let expected: Vec<String> =
|
let expected: Vec<String> =
|
||||||
COST_FIELD_NAMES.iter().map(|f| format!("cost[0].{f}")).collect();
|
COST_FIELD_NAMES.iter().map(|f| format!("cost[0].{f}")).collect();
|
||||||
assert_eq!(agg_in, expected);
|
assert_eq!(agg_in, expected);
|
||||||
|
|||||||
@@ -7,9 +7,15 @@
|
|||||||
//! the cost path is uniform whether one or several cost nodes are wired.
|
//! the cost path is uniform whether one or several cost nodes are wired.
|
||||||
|
|
||||||
use aura_core::{
|
use aura_core::{
|
||||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind,
|
ArgKind, ArgSpec, ArgValue, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec,
|
||||||
|
PrimitiveBuilder, ScalarKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// The declared construction args of a `CostSum` recipe: `n_costs` fixes the
|
||||||
|
/// node's input count (topology, C19), taken through the `args` channel
|
||||||
|
/// instead of a Rust-side builder parameter.
|
||||||
|
const COST_SUM_ARGS: &[ArgSpec] = &[ArgSpec { name: "n_costs", kind: ArgKind::Count }];
|
||||||
|
|
||||||
/// The cost-record field triple and its width come from the shared cost contract
|
/// The cost-record field triple and its width come from the shared cost contract
|
||||||
/// (`crate::cost::{COST_FIELD_NAMES, COST_WIDTH}`) — one source of truth, read by
|
/// (`crate::cost::{COST_FIELD_NAMES, COST_WIDTH}`) — one source of truth, read by
|
||||||
/// both the producer side (`cost_node_builder`) and this aggregator. The input-name
|
/// both the producer side (`cost_node_builder`) and this aggregator. The input-name
|
||||||
@@ -32,10 +38,29 @@ impl CostSum {
|
|||||||
Self { n_costs, out: [Cell::from_f64(0.0); COST_WIDTH] }
|
Self { n_costs, out: [Cell::from_f64(0.0); COST_WIDTH] }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The param-generic recipe. `n_costs` is topology (fixed per blueprint, C19),
|
/// Roster factory: zero-arg, arg-bearing. `n_costs` is topology (fixed per
|
||||||
/// captured by the build closure (no per-build params). The input names are a
|
/// blueprint, C19), now taken through the `args` channel instead of a
|
||||||
/// lockstep contract with the connect side (`cost[k].<field>`).
|
/// Rust-side parameter.
|
||||||
pub fn builder(n_costs: usize) -> PrimitiveBuilder {
|
pub fn builder() -> PrimitiveBuilder {
|
||||||
|
PrimitiveBuilder::pending(
|
||||||
|
"CostSum",
|
||||||
|
"sums cost-model contributions into one cost-in-R stream",
|
||||||
|
COST_SUM_ARGS,
|
||||||
|
Self::make,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn a validated `n_costs` into the real, arity-sized signature. The
|
||||||
|
/// input names are a lockstep contract with the connect side
|
||||||
|
/// (`cost[k].<field>`).
|
||||||
|
fn make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
|
||||||
|
let n_costs = values
|
||||||
|
.iter()
|
||||||
|
.find_map(|(n, v)| match (n.as_str(), v) {
|
||||||
|
("n_costs", ArgValue::Count(n)) => Some(*n),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.expect("try_args validated `n_costs` as ArgKind::Count before calling make");
|
||||||
let mut inputs = Vec::with_capacity(n_costs * COST_WIDTH);
|
let mut inputs = Vec::with_capacity(n_costs * COST_WIDTH);
|
||||||
for k in 0..n_costs {
|
for k in 0..n_costs {
|
||||||
for field in COST_FIELD_NAMES {
|
for field in COST_FIELD_NAMES {
|
||||||
@@ -63,6 +88,13 @@ impl CostSum {
|
|||||||
move |_| Box::new(CostSum::new(n_costs)),
|
move |_| Box::new(CostSum::new(n_costs)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rust-path convenience — same recipe as the data path (twin identity).
|
||||||
|
pub fn configured(n_costs: usize) -> PrimitiveBuilder {
|
||||||
|
Self::builder()
|
||||||
|
.try_args(&[("n_costs".to_string(), n_costs.to_string())])
|
||||||
|
.expect("configured: a positive n_costs is always the canonical strict-form Count string")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Node for CostSum {
|
impl Node for CostSum {
|
||||||
@@ -140,7 +172,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn input_slots_are_named_cost_index_field() {
|
fn input_slots_are_named_cost_index_field() {
|
||||||
let s = CostSum::builder(2);
|
let s = CostSum::configured(2);
|
||||||
let names: Vec<String> = s.schema().inputs.iter().map(|p| p.name.clone()).collect();
|
let names: Vec<String> = s.schema().inputs.iter().map(|p| p.name.clone()).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
names,
|
names,
|
||||||
@@ -161,4 +193,15 @@ mod tests {
|
|||||||
fn new_panics_on_zero() {
|
fn new_panics_on_zero() {
|
||||||
let _ = CostSum::new(0);
|
let _ = CostSum::new(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Twin identity (spec §aura-strategy): `configured(n_costs)` is exactly
|
||||||
|
/// `builder().try_args([("n_costs", n_costs)])`.
|
||||||
|
#[test]
|
||||||
|
fn cost_sum_configured_twin_equals_builder_try_args() {
|
||||||
|
let via_configured = CostSum::configured(2);
|
||||||
|
let via_try_args =
|
||||||
|
CostSum::builder().try_args(&[("n_costs".into(), "2".into())]).expect("valid n_costs configures");
|
||||||
|
assert_eq!(via_configured.construction_args(), via_try_args.construction_args());
|
||||||
|
assert_eq!(via_configured.schema(), via_try_args.schema());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ impl VolSlippageCost {
|
|||||||
"VolSlippageCost",
|
"VolSlippageCost",
|
||||||
volatility_input_ports(),
|
volatility_input_ports(),
|
||||||
vec![ParamSpec { name: "slip_vol_mult".into(), kind: ScalarKind::F64 }],
|
vec![ParamSpec { name: "slip_vol_mult".into(), kind: ScalarKind::F64 }],
|
||||||
|
"cost-model node: slippage proportional to volatility (slip_vol_mult × volatility input), charged at close",
|
||||||
|p| Box::new(VolSlippageCost::new(p[0].f64())),
|
|p| Box::new(VolSlippageCost::new(p[0].f64())),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,22 +8,27 @@
|
|||||||
//! takes it as an injected resolver, and a project `cdylib` later supplies its
|
//! takes it as an injected resolver, and a project `cdylib` later supplies its
|
||||||
//! own (C16) through the same seam.
|
//! own (C16) through the same seam.
|
||||||
//!
|
//!
|
||||||
//! Scope (#155): only the **zero-argument** `Type::builder()` factories are
|
//! Scope (#155, extended #271): the roster resolves any type whose factory is
|
||||||
//! resolvable. Builders that take structural construction arguments
|
//! `Type::builder()` with zero RUST-side arguments — this now includes
|
||||||
//! (`LinComb(arity)`, `CostSum(n_costs)`, `SimBroker(pip_size)`, `Session(..)`)
|
//! arg-bearing types (`Session`, `LinComb`, `CostSum`), whose `builder()` is
|
||||||
//! and the recording sinks (`Recorder`/`GatedRecorder`/`SeriesReducer`, which
|
//! itself zero-arg and returns a *pending* `PrimitiveBuilder`, configured
|
||||||
//! capture an `mpsc::Sender`) are deliberately absent — an absent `type_id`
|
//! through the typed `args` channel (`try_args`) at the `add` op, before any
|
||||||
|
//! bind. `SimBroker(pip_size)` stays out of scope: it is a plain scalar Rust
|
||||||
|
//! parameter, not a structural construction arg — a different (unaddressed)
|
||||||
|
//! gap. The recording sinks (`Recorder`/`GatedRecorder`/`SeriesReducer`, which
|
||||||
|
//! capture an `mpsc::Sender`) stay deliberately absent — an absent `type_id`
|
||||||
//! yields `None`, so the loader fails cleanly (`LoadError::UnknownNodeType`)
|
//! yields `None`, so the loader fails cleanly (`LoadError::UnknownNodeType`)
|
||||||
//! rather than guessing. Serialising structural-axis construction args is a
|
//! rather than guessing.
|
||||||
//! later, additive extension (#156/C20).
|
|
||||||
|
|
||||||
use aura_backtest::PositionManagement;
|
use aura_backtest::PositionManagement;
|
||||||
use aura_market::{Resample, SessionFrankfurt};
|
use aura_market::{Resample, Session, SessionFrankfurt};
|
||||||
use aura_std::{
|
use aura_std::{
|
||||||
Abs, Add, And, Const, CumSum, Delay, Div, Ema, EqConst, Gt, Latch, Max, Min, Mul, RollingMax,
|
Abs, Add, And, Const, CumSum, Delay, Div, Ema, EqConst, Gt, Latch, LinComb, Max, Min, Mul,
|
||||||
RollingMin, Scale, Select, Sign, Sma, Sqrt, Sub, When,
|
RollingMax, RollingMin, Scale, Select, Sign, Sma, Sqrt, Sub, When,
|
||||||
|
};
|
||||||
|
use aura_strategy::{
|
||||||
|
Bias, CarryCost, ConstantCost, CostSum, FixedStop, LongOnly, Sizer, VolSlippageCost,
|
||||||
};
|
};
|
||||||
use aura_strategy::{Bias, CarryCost, ConstantCost, FixedStop, LongOnly, Sizer, VolSlippageCost};
|
|
||||||
use aura_core::PrimitiveBuilder;
|
use aura_core::PrimitiveBuilder;
|
||||||
|
|
||||||
/// The single roster source (#160): one `"TypeId" => Type` line per zero-arg
|
/// The single roster source (#160): one `"TypeId" => Type` line per zero-arg
|
||||||
@@ -67,6 +72,7 @@ std_vocabulary_roster! {
|
|||||||
"CarryCost" => CarryCost,
|
"CarryCost" => CarryCost,
|
||||||
"Const" => Const,
|
"Const" => Const,
|
||||||
"ConstantCost" => ConstantCost,
|
"ConstantCost" => ConstantCost,
|
||||||
|
"CostSum" => CostSum,
|
||||||
"CumSum" => CumSum,
|
"CumSum" => CumSum,
|
||||||
"Delay" => Delay,
|
"Delay" => Delay,
|
||||||
"Div" => Div,
|
"Div" => Div,
|
||||||
@@ -75,6 +81,7 @@ std_vocabulary_roster! {
|
|||||||
"FixedStop" => FixedStop,
|
"FixedStop" => FixedStop,
|
||||||
"Gt" => Gt,
|
"Gt" => Gt,
|
||||||
"Latch" => Latch,
|
"Latch" => Latch,
|
||||||
|
"LinComb" => LinComb,
|
||||||
"LongOnly" => LongOnly,
|
"LongOnly" => LongOnly,
|
||||||
"Max" => Max,
|
"Max" => Max,
|
||||||
"Min" => Min,
|
"Min" => Min,
|
||||||
@@ -85,6 +92,7 @@ std_vocabulary_roster! {
|
|||||||
"RollingMin" => RollingMin,
|
"RollingMin" => RollingMin,
|
||||||
"Scale" => Scale,
|
"Scale" => Scale,
|
||||||
"Select" => Select,
|
"Select" => Select,
|
||||||
|
"Session" => Session,
|
||||||
"SessionFrankfurt" => SessionFrankfurt,
|
"SessionFrankfurt" => SessionFrankfurt,
|
||||||
"Sign" => Sign,
|
"Sign" => Sign,
|
||||||
"Sizer" => Sizer,
|
"Sizer" => Sizer,
|
||||||
@@ -103,8 +111,11 @@ mod tests {
|
|||||||
/// label back, and only the rostered keys do. The builder's own `label()`
|
/// label back, and only the rostered keys do. The builder's own `label()`
|
||||||
/// is the independent oracle: a typo'd roster key fails the lookup-label
|
/// is the independent oracle: a typo'd roster key fails the lookup-label
|
||||||
/// agreement even though the match arms and the list share the same
|
/// agreement even though the match arms and the list share the same
|
||||||
/// (mistyped) literal; construction-arg nodes and sinks stay absent so the
|
/// (mistyped) literal; an unresolvable id and a sink stay absent so the
|
||||||
/// loader fails cleanly rather than guessing.
|
/// loader fails cleanly rather than guessing. `LinComb` — an arg-bearing
|
||||||
|
/// type, #271 — now resolves to a PENDING builder (its own `.label()`
|
||||||
|
/// still round-trips; it just is not yet constructible without
|
||||||
|
/// `try_args`).
|
||||||
#[test]
|
#[test]
|
||||||
fn std_vocabulary_resolves_known_and_rejects_unknown() {
|
fn std_vocabulary_resolves_known_and_rejects_unknown() {
|
||||||
for &type_id in std_vocabulary_types() {
|
for &type_id in std_vocabulary_types() {
|
||||||
@@ -116,9 +127,9 @@ mod tests {
|
|||||||
"resolver key must round-trip to the same type label"
|
"resolver key must round-trip to the same type label"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// an unknown id, a construction-arg node, and a sink are all absent
|
// an unknown id and a sink are absent; LinComb is now resolved (#271)
|
||||||
assert!(std_vocabulary("nope").is_none());
|
assert!(std_vocabulary("nope").is_none());
|
||||||
assert!(std_vocabulary("LinComb").is_none());
|
assert!(std_vocabulary("LinComb").is_some());
|
||||||
assert!(std_vocabulary("Recorder").is_none());
|
assert!(std_vocabulary("Recorder").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,14 +140,16 @@ mod tests {
|
|||||||
/// act. The residual #160 drift — a new zero-arg node never rostered at
|
/// act. The residual #160 drift — a new zero-arg node never rostered at
|
||||||
/// all — is not catchable here (no enumeration of zero-arg builders
|
/// all — is not catchable here (no enumeration of zero-arg builders
|
||||||
/// exists); it fails safe (clean `UnknownNodeType` on load, merely absent
|
/// exists); it fails safe (clean `UnknownNodeType` on load, merely absent
|
||||||
/// from `--vocabulary`).
|
/// from `--vocabulary`). #271: `Session`/`LinComb`/`CostSum` moved IN
|
||||||
|
/// (arg-bearing types now have zero-arg `builder()` factories); the count
|
||||||
|
/// pin moves 33 -> 36.
|
||||||
#[test]
|
#[test]
|
||||||
fn std_vocabulary_types_lists_exactly_the_resolvable_keys() {
|
fn std_vocabulary_types_lists_exactly_the_resolvable_keys() {
|
||||||
// known non-members stay out of the list
|
// known non-members stay out of the list
|
||||||
assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node
|
assert!(std_vocabulary_types().contains(&"LinComb")); // now arg-bearing-reachable (#271)
|
||||||
assert!(!std_vocabulary_types().contains(&"Recorder")); // sink
|
assert!(!std_vocabulary_types().contains(&"Recorder")); // sink
|
||||||
assert!(!std_vocabulary_types().contains(&"nope"));
|
assert!(!std_vocabulary_types().contains(&"nope"));
|
||||||
// count guard: pins the roster at exactly 33 entries
|
// count guard: pins the roster at exactly 36 entries
|
||||||
assert_eq!(std_vocabulary_types().len(), 33);
|
assert_eq!(std_vocabulary_types().len(), 36);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,6 @@ fn the_shell_defines_no_domain_modules_and_no_lib_target() {
|
|||||||
"render.rs",
|
"render.rs",
|
||||||
"research_docs.rs",
|
"research_docs.rs",
|
||||||
"scaffold.rs",
|
"scaffold.rs",
|
||||||
"verb_sugar.rs",
|
|
||||||
],
|
],
|
||||||
"C28 (#295): the shell holds argv/dispatch, argv->document translation, \
|
"C28 (#295): the shell holds argv/dispatch, argv->document translation, \
|
||||||
and presentation only — a new module needs a library home"
|
and presentation only — a new module needs a library home"
|
||||||
|
|||||||
+287
-58
@@ -10,7 +10,7 @@ JSON artifact kinds you author headlessly along that arc:
|
|||||||
through `aura process validate|introspect|register|show`;
|
through `aura process validate|introspect|register|show`;
|
||||||
3. a **campaign document** (role 6b) — experiment intent (instruments ×
|
3. a **campaign document** (role 6b) — experiment intent (instruments ×
|
||||||
windows × strategy × axes × process) through `aura campaign
|
windows × strategy × axes × process) through `aura campaign
|
||||||
validate|introspect|register|show|run|runs`.
|
validate|introspect|register|runs|show`, executed by `aura exec` (#319).
|
||||||
|
|
||||||
Each section below is a worked, verified example — every command shown was
|
Each section below is a worked, verified example — every command shown was
|
||||||
run against this repo and the output is transcribed, not invented. The *why*
|
run against this repo and the output is transcribed, not invented. The *why*
|
||||||
@@ -39,9 +39,11 @@ the same shape every node in `aura-std` already follows.
|
|||||||
scaffolds a **data-only** project (`docs/project-layout.md`, "A project
|
scaffolds a **data-only** project (`docs/project-layout.md`, "A project
|
||||||
repo (two tiers)") — a strategy over the std vocabulary is a
|
repo (two tiers)") — a strategy over the std vocabulary is a
|
||||||
blueprint/campaign document, not Rust: the scaffold ships **one** closed
|
blueprint/campaign document, not Rust: the scaffold ships **one** closed
|
||||||
`signal.json` starter that serves both verbs — `aura run` uses its bound
|
`signal.json` starter that serves both legs of `exec` (#319) — `aura exec
|
||||||
values as-is, `aura sweep --axis <bp>.fast.length=2,4,8` overrides them
|
signal.json` uses its bound values as-is, `aura exec signal.json --override
|
||||||
(bound = default, not fixed); `--list-axes` lists the open knobs and the
|
fast.length=8` reopens one for that run, and a campaign document's
|
||||||
|
`strategies[].axes` reopens one across a family (bound = default, not
|
||||||
|
fixed); `aura graph introspect --params` lists the open knobs and the
|
||||||
bound defaults alike, so every override target is discoverable.
|
bound defaults alike, so every override target is discoverable.
|
||||||
- **A project-specific *native* node type** — the moment §0's three-part
|
- **A project-specific *native* node type** — the moment §0's three-part
|
||||||
pattern is actually needed — lives in an attached **node crate**:
|
pattern is actually needed — lives in an attached **node crate**:
|
||||||
@@ -49,8 +51,9 @@ the same shape every node in `aura-std` already follows.
|
|||||||
`src/lib.rs`, registered under the project's own `<namespace>::` prefix)
|
`src/lib.rs`, registered under the project's own `<namespace>::` prefix)
|
||||||
and appends its path to the project's `Aura.toml [nodes]` section.
|
and appends its path to the project's `Aura.toml [nodes]` section.
|
||||||
- **A block promoted to universal** — reused across projects and folded into
|
- **A block promoted to universal** — reused across projects and folded into
|
||||||
the engine itself — lives in `crates/aura-std/src/`, unprefixed, and is
|
the engine itself — lives in `crates/aura-std/` (or its sibling domain
|
||||||
rostered in `crates/aura-std/src/vocabulary.rs` instead of a node crate's
|
crates: `aura-market`, `aura-strategy`), unprefixed, and is rostered in
|
||||||
|
`crates/aura-vocabulary/src/lib.rs` instead of a node crate's
|
||||||
`vocabulary()` function. The pattern below is identical either way; only
|
`vocabulary()` function. The pattern below is identical either way; only
|
||||||
the rostering call site differs (see "Rostering the type" below).
|
the rostering call site differs (see "Rostering the type" below).
|
||||||
|
|
||||||
@@ -89,11 +92,16 @@ Every node type — std or project-local — is three things:
|
|||||||
`aura_core::aura_project!` macro wires up (every `aura new` scaffold
|
`aura_core::aura_project!` macro wires up (every `aura new` scaffold
|
||||||
emits a starter pair — see the worked example below). Add one match arm
|
emits a starter pair — see the worked example below). Add one match arm
|
||||||
to `vocabulary()` and one entry to `type_ids()`'s slice.
|
to `vocabulary()` and one entry to `type_ids()`'s slice.
|
||||||
- **Std-side** (only when promoting a node into `aura-std` itself): one
|
- **Std-side** (only when promoting a node into the shipped std
|
||||||
line in the `std_vocabulary_roster!` macro invocation in
|
vocabulary): one line in the `std_vocabulary_roster!` macro invocation in
|
||||||
[`crates/aura-std/src/vocabulary.rs`](../crates/aura-std/src/vocabulary.rs)
|
[`crates/aura-vocabulary/src/lib.rs`](../crates/aura-vocabulary/src/lib.rs)
|
||||||
— `"TypeId" => Type,` — which expands into both the resolver `match` and
|
— `"TypeId" => Type,` — which expands into both the resolver `match` and
|
||||||
the enumerable type-id list, so the two surfaces cannot drift apart.
|
the enumerable type-id list, so the two surfaces cannot drift apart. A
|
||||||
|
zero-arg `Type::builder()` rosters directly; an **arg-bearing** type
|
||||||
|
(structural, non-scalar construction — see "Session anchoring" below and
|
||||||
|
`docs/glossary.md`'s `construction arg` entry, #271) rosters the exact
|
||||||
|
same way — its `builder()` is itself zero-arg, returning a *pending*
|
||||||
|
recipe the `add` op's `args` configures before any `bind`.
|
||||||
An unrostered type fails safe either way: the loader refuses with a clean
|
An unrostered type fails safe either way: the loader refuses with a clean
|
||||||
`LoadError::UnknownNodeType` naming the missing id, and the type is simply
|
`LoadError::UnknownNodeType` naming the missing id, and the type is simply
|
||||||
absent from `aura graph introspect --vocabulary` — never a silent partial
|
absent from `aura graph introspect --vocabulary` — never a silent partial
|
||||||
@@ -122,7 +130,8 @@ impl Scale {
|
|||||||
pub fn new(factor: f64) -> Self {
|
pub fn new(factor: f64) -> Self {
|
||||||
Self { factor, out: [Cell::from_f64(0.0)] }
|
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 {
|
pub fn builder() -> PrimitiveBuilder {
|
||||||
PrimitiveBuilder::new(
|
PrimitiveBuilder::new(
|
||||||
"my_lab::Scale",
|
"my_lab::Scale",
|
||||||
@@ -220,20 +229,54 @@ $ aura graph build < smacross.json > blueprint.json
|
|||||||
Nodes are referenced by an **identifier** (given by `add`, see below); ports
|
Nodes are referenced by an **identifier** (given by `add`, see below); ports
|
||||||
are dotted `<identifier>.<port>` on both sides of a wire.
|
are dotted `<identifier>.<port>` on both sides of a wire.
|
||||||
|
|
||||||
### The nine ops
|
### The eleven ops
|
||||||
|
|
||||||
| op | JSON shape | does |
|
| op | JSON shape | does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| `name` | `{"op":"name","name":<str>}` | set the composite's **render name**, script-level and at-most-once (a second `name` op refuses: `a script names its blueprint at most once`). The name must be a single path segment (non-empty, no `/`, `\`, `.` or `..`) since it prefixes a trace directory (`traces/<name>-<id8>/`, #311) at run time. Omitting the op keeps the CLI's own default, `"graph"`. |
|
||||||
| `doc` | `{"op":"doc","text":<str>}` | declare the composite's one-line meaning (C29) — the op-script twin of the Rust builder's `.doc(...)`. Required before `aura graph register` accepts the product (the store refuses a doc-less composite); at most one per script (a second refuses: `a doc op may appear at most once`). The text is gated: empty or merely restating the composite's name refuses at register. |
|
| `doc` | `{"op":"doc","text":<str>}` | declare the composite's one-line meaning (C29) — the op-script twin of the Rust builder's `.doc(...)`. Required before `aura graph register` accepts the product (the store refuses a doc-less composite); at most one per script (a second refuses: `a doc op may appear at most once`). The text is gated: empty or merely restating the composite's name refuses at register. |
|
||||||
| `source` | `{"op":"source","role":<str>,"kind":<ScalarKind>}` | reserve a bound root **source** role of `kind` — a real input the harness feeds (e.g. `"price"`). |
|
| `source` | `{"op":"source","role":<str>,"kind":<ScalarKind>}` | reserve a bound root **source** role of `kind` — a real input the harness feeds (e.g. `"price"`). |
|
||||||
| `input` | `{"op":"input","role":<str>}` | reserve an open root **input** role (kind inferred from the slots it feeds) — for a fragment meant to be wired by an *enclosing* graph. A standalone document built with `aura graph build` finalizes as a closed root, so an `input` role that is never bound refuses at the end: `finalize: root input role <name> is unbound`. |
|
| `input` | `{"op":"input","role":<str>}` | reserve an open root **input** role (kind inferred from the slots it feeds) — the formal parameter of an **open pattern**, a fragment meant to be wired by an *enclosing* graph. An open pattern builds and registers like any blueprint. Running it standalone is governed by the ordinary run gates: the harness binds input roles to archive columns **by name** (C26), so a pattern whose roles match the data runs as-is; what refuses, by name, is a role the harness cannot bind — or the run surface's other gates (a signal without a bias output or tap; free knobs). |
|
||||||
| `add` | `{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}` | instantiate a node of a type in the closed vocabulary (`aura graph introspect --vocabulary`) — see §0 below for how a type gets into that vocabulary in the first place. `name` becomes the node's identifier for later ops (default: the type's own lowercase label — two unnamed nodes of the same type then collide). `bind` sets zero or more of its params. |
|
| `add` | `{"op":"add","type":<TypeId>,"name":<str>?,"args":{<arg>:<str>}?,"bind":{<param>:<Scalar>}?}` | instantiate a node of a type in the closed vocabulary (`aura graph introspect --vocabulary`) — see §0 below for how a type gets into that vocabulary in the first place. `name` becomes the node's identifier for later ops (default: the type's own lowercase label — two unnamed nodes of the same type then collide). `args` (#271) configures an **arg-bearing** type's structural, non-scalar construction (`Session`'s timezone/open, `LinComb`/`CostSum`'s arity) — a closed table of string values, applied BEFORE `bind`; `aura graph introspect --node <T>` lists a type's declared `arg` rows. `bind` sets zero or more of the (now real) params. |
|
||||||
|
| `use` | `{"op":"use","ref":{"content_id":<id-or-prefix>}\|{"name":<label>},"name":<str>?,"bind":{<path>:<Scalar>}?}` | splice a **registered blueprint** into the graph as a nested composite under an instance identifier (`name`; default: the stored composite's own name). The ref resolves by content id (full or unique prefix) or by a register-time label; `graph build` echoes every resolution (`aura: note: use "…": … -> <id>`) so the exact id can be pinned. The instance's open input roles wire as `<instance>.<role>`, its outputs as `<instance>.<field>`; its open params surface path-qualified (`<instance>.<node>.<param>`), sweepable (ganging an instance's params is not yet supported — the `gang` op refuses a composite instance's member path). A doc-less stored source refuses at the op (C29). The emitted blueprint contains the subgraph *inline* — no reference survives into the artifact. |
|
||||||
| `feed` | `{"op":"feed","role":<str>,"into":[<port>, …]}` | fan a previously-declared role into one or more interior input slots, all-or-nothing (a failing target leaves none of the batch wired). |
|
| `feed` | `{"op":"feed","role":<str>,"into":[<port>, …]}` | fan a previously-declared role into one or more interior input slots, all-or-nothing (a failing target leaves none of the batch wired). |
|
||||||
| `connect` | `{"op":"connect","from":<port>,"to":<port>}` | wire one interior output field to one interior input slot. A `connect` that would close a dataflow cycle is rejected immediately — the only legal feedback path is an explicit delay/state node (domain invariant 5). |
|
| `connect` | `{"op":"connect","from":<port>,"to":<port>}` | wire one interior output field to one interior input slot. A `connect` that would close a dataflow cycle is rejected immediately — the only legal feedback path is an explicit delay/state node (domain invariant 5). |
|
||||||
| `expose` | `{"op":"expose","from":<port>,"as":<str>}` | promote an interior output field to a boundary output under the alias `as` — a real *alias* (a terminal boundary name, not a referenceable identifier like `add`'s `name`). Together with `tap`, one of the two ops whose `as` key is a terminal name rather than an identifier. |
|
| `expose` | `{"op":"expose","from":<port>,"as":<str>}` | promote an interior output field to a boundary output under the alias `as` — a real *alias* (a terminal boundary name, not a referenceable identifier like `add`'s `name`). Together with `tap`, one of the two ops whose `as` key is a terminal name rather than an identifier. |
|
||||||
| `tap` | `{"op":"tap","from":<port>,"as":<str>}` | declare a **measurement tap** on an interior output field under the name `as` — the output-side twin of `expose` (a recorded observation point, not a boundary output; a `Composite.taps` entry, C27). A single `aura run` constructs a recorder at each declared tap and persists its per-cycle series as a `ColumnarTrace`; a sweep leaves it inert. Tap names are their own namespace and must be unique (a second `tap` under one name refuses: `duplicate tap name`). |
|
| `tap` | `{"op":"tap","from":<port>,"as":<str>}` | declare a **measurement tap** on an interior output field under the name `as` — the output-side twin of `expose` (a recorded observation point, not a boundary output; a `Composite.taps` entry, C27). A single `aura exec` constructs a recorder at each declared tap and persists its per-cycle series as a `ColumnarTrace`; a campaign member run leaves it inert. Tap names are their own namespace and must be unique (a second `tap` under one name refuses: `duplicate tap name`). |
|
||||||
| `gang` | `{"op":"gang","as":"channel_length","into":["channel_hi.length","channel_lo.length"]}` | Fuse two or more sibling params into ONE public knob: the member addresses leave the sweepable param space and `as` replaces them; the bound or swept value fans out to every member at bootstrap. Members must share one scalar kind and stay open (un-bound). |
|
| `gang` | `{"op":"gang","as":"channel_length","into":["channel_hi.length","channel_lo.length"]}` | Fuse two or more sibling params into ONE public knob: the member addresses leave the sweepable param space and `as` replaces them; the bound or swept value fans out to every member at bootstrap. Members must share one scalar kind and stay open (un-bound). |
|
||||||
|
|
||||||
|
Omitting `name` is fine for a single strategy, but it has two live
|
||||||
|
consequences once a project has more than one: every unnamed store document
|
||||||
|
is indistinguishable by name (`"graph"` again), and two `use`-splices of
|
||||||
|
unnamed blueprints collide on the same default instance identifier (`graph`)
|
||||||
|
inside the same composing script. A one-line `name` op dissolves both.
|
||||||
|
|
||||||
|
The third consequence that used to sit here — every unnamed strategy's tap
|
||||||
|
recording landing in one shared `traces/graph/` directory, where a later run
|
||||||
|
overwrote an earlier one's trace — is **resolved** (#311), and no longer
|
||||||
|
depends on naming at all: a run's trace directory is keyed by the run's own
|
||||||
|
identity (`traces/<name>-<id8>/`, a digest over its manifest), so two runs
|
||||||
|
that differ in any identity-bearing input — a different topology, a data
|
||||||
|
window, a seed, or an `--override` that changes a value — land in two
|
||||||
|
directories, and two runs that differ in nothing land in the same one.
|
||||||
|
|
||||||
|
What counts as differing is the run's *effective* parameterisation, not how
|
||||||
|
you supplied it: the digest merges the manifest's `params` and `defaults`
|
||||||
|
name-sorted, so pinning a parameter on the command line to the value it
|
||||||
|
already has gives you the same directory as not passing it at all, and the
|
||||||
|
order of several `--override` flags never matters. What the digest does not
|
||||||
|
see is provenance — the aura binary's build sha, and your project repo's
|
||||||
|
HEAD and dirty state — so rebuilding aura or leaving a file uncommitted
|
||||||
|
never re-addresses a run.
|
||||||
|
|
||||||
|
That directory's
|
||||||
|
*contents* are not wholesale-replaced on the second write, though: the
|
||||||
|
declared tap *plan* (which of a blueprint's taps this invocation subscribes)
|
||||||
|
is not itself identity-bearing, so a same-identity pair that persists a
|
||||||
|
different tap subset can strand an earlier tap file beside the later run's
|
||||||
|
own — tracked as #352. The render name survives as the directory's readable
|
||||||
|
prefix, and `aura chart <name>` lists the runs recorded under it.
|
||||||
|
|
||||||
Value forms are the typed-tag representations used everywhere in this
|
Value forms are the typed-tag representations used everywhere in this
|
||||||
family of artifacts:
|
family of artifacts:
|
||||||
|
|
||||||
@@ -260,15 +303,15 @@ family of artifacts:
|
|||||||
```
|
```
|
||||||
|
|
||||||
(`fast.length`, `slow.length`, and `bias.scale` are all left unbound here on
|
(`fast.length`, `slow.length`, and `bias.scale` are all left unbound here on
|
||||||
purpose — three open campaign axes, see §3. This is the milestone fieldtest
|
purpose — three open campaign axes, see §3. Every command shown below in this
|
||||||
corpus's own example, verified below; byte-identical to the on-disk
|
guide is run against exactly this document.)
|
||||||
`fieldtests/milestone-research-artifacts/mra_1_strategy_smacross.json`.)
|
|
||||||
|
|
||||||
A `bind` in an `add` op pins that param to a value and removes it from the
|
A `bind` in an `add` op pins that param to a value and removes it from the
|
||||||
**open** param space (`--params`): a bound param is a **default** (#246) — a
|
**open** param space (`--params`): a bound param is a **default** (#246) — a
|
||||||
run uses it as-is, while any campaign axis or `aura sweep --axis` naming it
|
plain `exec` uses it as-is, while a campaign axis naming it re-opens it for
|
||||||
re-opens it for that family and binds it per cell. `--list-axes` lists it
|
that family (bound per cell) and `exec`'s own `--override NODE.PARAM=VALUE`
|
||||||
after the open knobs as `<name>:<KIND> default=<value>`. `bind` is for a
|
(#319) re-opens it for that one execution. `graph introspect --params` lists
|
||||||
|
it after the open knobs as `<name>:<KIND> default=<value>`. `bind` is for a
|
||||||
value the strategy carries by default; leave a param unbound, as all three
|
value the strategy carries by default; leave a param unbound, as all three
|
||||||
are here, to make binding it mandatory for every sweep.
|
are here, to make binding it mandatory for every sweep.
|
||||||
|
|
||||||
@@ -276,7 +319,7 @@ A param therefore has three states: **open** (an axis every sweep MUST bind),
|
|||||||
**bound** (a default any axis MAY override, #246), and **ganged** (open, but
|
**bound** (a default any axis MAY override, #246), and **ganged** (open, but
|
||||||
fused with its siblings under ONE public knob declared by a `gang` op; the
|
fused with its siblings under ONE public knob declared by a `gang` op; the
|
||||||
member addresses are unbindable and only the gang's own single-segment name —
|
member addresses are unbindable and only the gang's own single-segment name —
|
||||||
wrapped like any knob, e.g. `graph.channel_length` — appears in `--list-axes`).
|
e.g. `channel_length` — appears in `graph introspect --params`).
|
||||||
|
|
||||||
### Worked example: declaring a measurement tap
|
### Worked example: declaring a measurement tap
|
||||||
|
|
||||||
@@ -305,13 +348,101 @@ bias scaling) under the name `spread`:
|
|||||||
|
|
||||||
The built blueprint carries a `taps` array naming the resolved wire
|
The built blueprint carries a `taps` array naming the resolved wire
|
||||||
(`{"name":"spread","from":{"node":<sub's index>,"field":0}}` — the name is
|
(`{"name":"spread","from":{"node":<sub's index>,"field":0}}` — the name is
|
||||||
addressed, the index is resolved *for* you). A single `aura run <blueprint>`
|
addressed, the index is resolved *for* you). A single `aura exec <blueprint>`
|
||||||
then constructs a recorder at that point and persists the `spread` series as a
|
then constructs a recorder at that point and persists the `spread` series as a
|
||||||
`ColumnarTrace` in the run's trace store, chartable by its name. A tap is inert
|
`ColumnarTrace` in the run's trace store. The run prints the handle it wrote
|
||||||
in a sweep (no per-cell recorder) — it is a single-run observation surface.
|
under as `trace_name` on its stdout line, so it need not be guessed; chart it
|
||||||
|
with `aura chart <trace_name>` (all of the run's taps) or
|
||||||
|
`aura chart <trace_name> --tap spread` (just this one). Note the two tokens
|
||||||
|
are different things: the handle addresses the RUN, `--tap` selects a tap
|
||||||
|
WITHIN it — `aura chart spread` refuses, because no run is named `spread`. A tap is inert
|
||||||
|
in a campaign member run (no per-cell recorder) — it is a single-run
|
||||||
|
observation surface (or, per campaign, what a nominee's `persist_taps`
|
||||||
|
re-run records, §3).
|
||||||
(`sub.value` is tapped here purely to illustrate; any interior output field is
|
(`sub.value` is tapped here purely to illustrate; any interior output field is
|
||||||
a legal tap source, and a producer needs no other consumer to be tapped.)
|
a legal tap source, and a producer needs no other consumer to be tapped.)
|
||||||
|
|
||||||
|
### Worked example: a `LinComb` op-script
|
||||||
|
|
||||||
|
`LinComb` is **arg-bearing** (#271): `graph introspect --node LinComb` shows
|
||||||
|
only the `arity` arg and the pending note until `args` supplies it — the
|
||||||
|
ports (`term[0]`, `term[1]`, …) and params (`weights[0]`, `weights[1]`, …)
|
||||||
|
form only once `arity` is real, one pair per term. A minimal weighted blend
|
||||||
|
of two sources:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"op": "source", "role": "a", "kind": "F64"},
|
||||||
|
{"op": "source", "role": "b", "kind": "F64"},
|
||||||
|
{"op": "add", "type": "LinComb", "name": "combo",
|
||||||
|
"args": {"arity": "2"}, "bind": {"weights[0]": {"F64": 0.7}}},
|
||||||
|
{"op": "feed", "role": "a", "into": ["combo.term[0]"]},
|
||||||
|
{"op": "feed", "role": "b", "into": ["combo.term[1]"]},
|
||||||
|
{"op": "expose", "from": "combo.value", "as": "blend"}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`args` fixes the arity BEFORE `bind`/`feed` can address `term[i]`/`weights[i]`
|
||||||
|
at all — the same closed-args-then-bind order every arg-bearing type follows
|
||||||
|
(§0/#271). `weights[0]` is bound to `0.7` here, so it becomes a default
|
||||||
|
(#246); the unbound `weights[1]` stays a required axis, appearing in `graph
|
||||||
|
introspect --params` as `combo.weights[1]:F64` (alongside
|
||||||
|
`combo.weights[0]:F64 default=0.7`).
|
||||||
|
|
||||||
|
### Worked example: wiring a cost model (`ConstantCost` → `CostSum`)
|
||||||
|
|
||||||
|
`CostSum`'s `cost[k].<field>` inputs (C10) are meaningless fed from a bare
|
||||||
|
source: they read the co-temporal record a real `PositionManagement` node
|
||||||
|
emits (`closed`, `open`, `entry_price`, `stop_price`). Every node in that
|
||||||
|
chain — `Bias`, `FixedStop`, `Sizer`, `PositionManagement`, `ConstantCost`,
|
||||||
|
`CostSum` — sits in the closed vocabulary, so the whole cost-model wiring is
|
||||||
|
honestly expressible by hand, one `add`/`connect` pair at a time, mirroring
|
||||||
|
the Rust `risk_executor` + `cost_graph` composite-builders (`aura-composites`)
|
||||||
|
field-for-field:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"op": "source", "role": "price", "kind": "F64"},
|
||||||
|
{"op": "source", "role": "signal", "kind": "F64"},
|
||||||
|
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 1.0}}},
|
||||||
|
{"op": "feed", "role": "signal", "into": ["bias.signal"]},
|
||||||
|
{"op": "add", "type": "FixedStop", "name": "stop", "bind": {"distance": {"F64": 0.01}}},
|
||||||
|
{"op": "add", "type": "PositionManagement", "name": "pm"},
|
||||||
|
{"op": "feed", "role": "price", "into": ["stop.price", "pm.price"]},
|
||||||
|
{"op": "add", "type": "Sizer", "name": "sizer", "bind": {"risk_budget": {"F64": 1.0}}},
|
||||||
|
{"op": "connect", "from": "bias.bias", "to": "sizer.bias"},
|
||||||
|
{"op": "connect", "from": "stop.stop_distance", "to": "sizer.stop_distance"},
|
||||||
|
{"op": "connect", "from": "bias.bias", "to": "pm.bias"},
|
||||||
|
{"op": "connect", "from": "stop.stop_distance", "to": "pm.stop_distance"},
|
||||||
|
{"op": "connect", "from": "sizer.size", "to": "pm.size"},
|
||||||
|
{"op": "add", "type": "ConstantCost", "name": "cc", "bind": {"cost_per_trade": {"F64": 0.5}}},
|
||||||
|
{"op": "connect", "from": "pm.closed_this_cycle", "to": "cc.closed"},
|
||||||
|
{"op": "connect", "from": "pm.open", "to": "cc.open"},
|
||||||
|
{"op": "connect", "from": "pm.entry_price", "to": "cc.entry_price"},
|
||||||
|
{"op": "connect", "from": "pm.stop_price", "to": "cc.stop_price"},
|
||||||
|
{"op": "add", "type": "CostSum", "name": "costsum", "args": {"n_costs": "1"}},
|
||||||
|
{"op": "connect", "from": "cc.cost_in_r", "to": "costsum.cost[0].cost_in_r"},
|
||||||
|
{"op": "connect", "from": "cc.cum_cost_in_r", "to": "costsum.cost[0].cum_cost_in_r"},
|
||||||
|
{"op": "connect", "from": "cc.open_cost_in_r", "to": "costsum.cost[0].open_cost_in_r"},
|
||||||
|
{"op": "expose", "from": "pm.realized_r", "as": "realized_r"},
|
||||||
|
{"op": "expose", "from": "costsum.cost_in_r", "as": "cost_in_r"}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`CostSum`'s port names ARE the `cost[k].<field>` shape already
|
||||||
|
(`cost[0].cost_in_r`, …) — `cost_port` is baked into `CostSum::make`'s schema
|
||||||
|
itself (#271), no composite-level renaming needed; a second factor is
|
||||||
|
`args: {"n_costs": "2"}` plus a second cost-node block wired into
|
||||||
|
`cost[1].*`, and `CostSum` per-field-sums whatever arity it was given (#341
|
||||||
|
item 4). This closes the "CostSum reachable but pathless" gap: the ports
|
||||||
|
genuinely connect at the op-script level, using only closed-vocabulary
|
||||||
|
nodes. It is a standalone wiring reference, though, not the production
|
||||||
|
attachment path — a campaign strategy's real cost model still comes from
|
||||||
|
the campaign document's `cost:` block (C10, `cost_nodes_for`), wrapped
|
||||||
|
around the user's bias-only blueprint entirely in Rust at run time; a
|
||||||
|
user's op-script-authored blueprint never embeds `PositionManagement` /
|
||||||
|
cost nodes itself.
|
||||||
|
|
||||||
### Session anchoring: the `SessionFrankfurt` preset
|
### Session anchoring: the `SessionFrankfurt` preset
|
||||||
|
|
||||||
To anchor logic to the trading session, the closed vocabulary ships a
|
To anchor logic to the trading session, the closed vocabulary ships a
|
||||||
@@ -336,10 +467,42 @@ separate in-session bool — `bars_since_open` alone is the contract. DST is
|
|||||||
handled by `chrono-tz`, so the same local minute reads the same index in
|
handled by `chrono-tz`, so the same local minute reads the same index in
|
||||||
summer (CEST) and winter (CET).
|
summer (CEST) and winter (CET).
|
||||||
|
|
||||||
|
The index is derived from the clock (`ctx.now()`), never counted from trigger
|
||||||
|
firings — so the trigger's cadence only sets how *often* the node emits, not
|
||||||
|
what it emits. Feeding a denser stream (e.g. a raw m1 `price`) is equally
|
||||||
|
valid: the node fires once per input tick and reports the same
|
||||||
|
`period_minutes`-bar index throughout that bar. The once-per-bar wiring above
|
||||||
|
is the convention when you want exactly one emission per completed bar; it is
|
||||||
|
not a correctness requirement.
|
||||||
|
|
||||||
Wire it like any node —
|
Wire it like any node —
|
||||||
`{"op":"add","type":"SessionFrankfurt","bind":{"period_minutes":{"I64":15}}}`,
|
`{"op":"add","type":"SessionFrankfurt","bind":{"period_minutes":{"I64":15}}}`,
|
||||||
feed its `trigger` from a once-per-bar stream, and read `bars_since_open`.
|
feed its `trigger` from a once-per-bar stream, and read `bars_since_open`.
|
||||||
|
|
||||||
|
**Any other timezone/open: `Session` + `args` (#271).** `SessionFrankfurt`
|
||||||
|
stays baked sugar for the one Frankfurt open; the canonical path for ANY IANA
|
||||||
|
timezone and local open time is the base `Session` type through the typed
|
||||||
|
construction-args channel — `args` configures it BEFORE `bind`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"op":"add","type":"Session","name":"ny",
|
||||||
|
"args":{"tz":"America/New_York","open":"09:30"},
|
||||||
|
"bind":{"period_minutes":{"I64":15}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
`tz` is `chrono_tz`'s own exact, case-sensitive IANA name (`"berlin"` refuses;
|
||||||
|
`"Europe/Berlin"` accepts); `open` is strict zero-padded local `HH:MM`
|
||||||
|
(`"9:30"` refuses; `"09:30"` accepts); a `Count` arg (`LinComb`/`CostSum`'s
|
||||||
|
arity) is a plain positive decimal — no sign, no leading zeros (`"03"`
|
||||||
|
refuses; `"3"` accepts). The accepted string IS the canonical form, no
|
||||||
|
normalization — and note the deliberate asymmetry: each kind's canonical
|
||||||
|
form is its domain's conventional notation, fixed-width for wall-clock
|
||||||
|
times, minimal for numbers. Giving `Session` no `args` at all refuses the `add`
|
||||||
|
op (`node <name> is missing required arg "tz"`) — an arg-bearing type never
|
||||||
|
silently enters the graph half-configured. `aura graph introspect --node
|
||||||
|
Session` lists both declared `arg` rows with their kind and hint text before
|
||||||
|
you write the JSON.
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -362,6 +525,9 @@ SMA
|
|||||||
param length:I64 (bind {"I64": <v>})
|
param length:I64 (bind {"I64": <v>})
|
||||||
$ aura graph introspect --unwired < partial.json # open slots of a partial op-script
|
$ aura graph introspect --unwired < partial.json # open slots of a partial op-script
|
||||||
sub.rhs:F64
|
sub.rhs:F64
|
||||||
|
$ aura graph introspect --taps tapped.json # one row per declared tap: name, source wire, kind
|
||||||
|
fast_ma fast.value F64
|
||||||
|
spread sub.value F64
|
||||||
$ aura graph introspect --content-id smacross.json # SHA-256 of the canonical form
|
$ aura graph introspect --content-id smacross.json # SHA-256 of the canonical form
|
||||||
597d719b7ac607158cda3e68cd497387620397a5e93087e23da512876dafba9a
|
597d719b7ac607158cda3e68cd497387620397a5e93087e23da512876dafba9a
|
||||||
$ aura graph introspect --content-id smacross.json --identity-id # + debug-name-blind identity id, combinable
|
$ aura graph introspect --content-id smacross.json --identity-id # + debug-name-blind identity id, combinable
|
||||||
@@ -373,23 +539,42 @@ slow.length:I64
|
|||||||
bias.scale:F64
|
bias.scale:F64
|
||||||
```
|
```
|
||||||
|
|
||||||
These printed names are the **raw param-space namespace**: op-script params
|
These printed names are the **one** axis namespace (#328): op-script params
|
||||||
and a campaign document's `strategies[].axes` keys (§3) share this one raw
|
and a campaign document's `strategies[].axes` keys (§3) speak the same raw
|
||||||
form. There is a third, *wrapped* surface: the dissolved `aura sweep
|
`<node>.<param>` form as `graph introspect --params` prints (open params
|
||||||
<blueprint> --axis` CLI (glossary `sweep`) accepts only the names `aura
|
bare, bound params trailing `default=<value>`) — every discovered name is
|
||||||
sweep <blueprint> --list-axes` prints — the raw name prefixed with the
|
verbatim legal as a document axis key, bound params included (#246's
|
||||||
root-composite instance name, `graph.<param>` — never the raw form; the
|
re-open contract), and as the token `exec --override NODE.PARAM=VALUE` (#319)
|
||||||
campaign document the sweep sugar generates still stores the raw form
|
takes on the single-run leg:
|
||||||
(#210):
|
|
||||||
|
```json
|
||||||
|
"strategies": [ { "ref": { "content_id": "…" },
|
||||||
|
"axes": { "fast.length": { "kind": "I64", "values": [3, 5, 7, 9] },
|
||||||
|
"bias.scale": { "kind": "F64", "values": [0.25, 0.5] } } } ]
|
||||||
|
```
|
||||||
|
|
||||||
|
A single execution reopens the same raw name directly, with no document at all:
|
||||||
|
|
||||||
```
|
```
|
||||||
raw (--params, campaign axes): fast.length
|
$ aura exec smacross.json --override fast.length=5 # single run: raw works
|
||||||
wrapped (--list-axes, --axis): graph.fast.length
|
```
|
||||||
|
|
||||||
|
The older `<blueprint>.<node>.<param>` wrapped form (e.g. `graph.fast.length`,
|
||||||
|
what pre-#328 transcripts and the retired `--list-axes` output used to print)
|
||||||
|
is retired from the surface; naming it in a campaign document refuses with a
|
||||||
|
translation pointer to the raw candidate, the one remaining intake seam:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ aura campaign validate wrapped.json # a document quoting an old transcript
|
||||||
|
aura: campaign references do not resolve:
|
||||||
|
strategy f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240:
|
||||||
|
axis "graph.fast.length" is not in the param space; axis names are raw
|
||||||
|
node.param paths — did you mean "fast.length"?
|
||||||
```
|
```
|
||||||
|
|
||||||
A ganged knob's raw address has one path segment less than a member address
|
A ganged knob's raw address has one path segment less than a member address
|
||||||
would — it sits at the composite's own level, like a role name (e.g.
|
would — it sits at the composite's own level, like a role name (e.g.
|
||||||
`channel_length`, not `channel_hi.length`) — and wraps identically (`graph.channel_length`).
|
`channel_length`, not `channel_hi.length`).
|
||||||
|
|
||||||
`--content-id`, `--identity-id`, `--params`, and `graph register` all accept
|
`--content-id`, `--identity-id`, `--params`, and `graph register` all accept
|
||||||
**either** shape: the raw op-script array or an already-built `#155`
|
**either** shape: the raw op-script array or an already-built `#155`
|
||||||
@@ -404,6 +589,13 @@ registered blueprint 597d719b7ac607158cda3e68cd497387620397a5e93087e23da512876da
|
|||||||
The printed content id is the address a campaign document's `strategies[].ref`
|
The printed content id is the address a campaign document's `strategies[].ref`
|
||||||
points at (§3).
|
points at (§3).
|
||||||
|
|
||||||
|
`graph register <file> --name <label>` additionally records a **blueprint
|
||||||
|
label** — a legible, re-pointable handle for the `use` op's `{"name":…}`
|
||||||
|
ref form (re-registering under the same label repoints it; the echo names
|
||||||
|
the previous target). `aura graph introspect --registered` lists the
|
||||||
|
labels with their id prefix and root doc line — the discovery surface for
|
||||||
|
what `use` can reference.
|
||||||
|
|
||||||
## 2. Process documents — a validation methodology (role 5)
|
## 2. Process documents — a validation methodology (role 5)
|
||||||
|
|
||||||
A process document is a named, versionable pipeline of **std stage blocks**
|
A process document is a named, versionable pipeline of **std stage blocks**
|
||||||
@@ -632,9 +824,9 @@ campaign document valid (executable): pipeline shape and static guards pass
|
|||||||
```
|
```
|
||||||
$ aura campaign register mra_3_campaign_full_v2.json
|
$ aura campaign register mra_3_campaign_full_v2.json
|
||||||
registered campaign 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b677bd (…/runs/campaigns/42ed…json)
|
registered campaign 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b677bd (…/runs/campaigns/42ed…json)
|
||||||
$ aura campaign run 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b677bd
|
$ aura exec 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b677bd
|
||||||
{"family_id":"42edebd2-0-GER40-w0-s0-0","report":{...}}
|
{"family_id":"42edebd2-0-GER40-w0-r0-s0-0","report":{...}}
|
||||||
{"family_id":"42edebd2-0-GER40-w0-s0-0-r1","report":{...}}
|
{"family_id":"42edebd2-0-GER40-w0-r1-s0-0","report":{...}}
|
||||||
…
|
…
|
||||||
{"campaign_run":{"campaign":"42edebd2…","process":"cd9127…","run":0,"seed":42,
|
{"campaign_run":{"campaign":"42edebd2…","process":"cd9127…","run":0,"seed":42,
|
||||||
"cells":[{"strategy":"597d719b…","instrument":"GER40","window_ms":[...],
|
"cells":[{"strategy":"597d719b…","instrument":"GER40","window_ms":[...],
|
||||||
@@ -653,32 +845,69 @@ $ aura campaign run 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b6
|
|||||||
```
|
```
|
||||||
|
|
||||||
With two stop regimes every (instrument, window) cell runs twice — the four
|
With two stop regimes every (instrument, window) cell runs twice — the four
|
||||||
cells above are the "4 cell(s)" the validate summary counted. The second
|
cells above are the "4 cell(s)" the validate summary counted. The regime
|
||||||
regime's family ids and trace dirs carry the `-r1` ordinal suffix (the
|
ordinal is an INFIX segment, `-r{k}`, right after the window segment
|
||||||
default/first regime stays unsuffixed), each cell record names its regime,
|
(`{id8}-{ordinal}-{instrument}-w{k}-r{k}-s{stage}-{run}`) — the
|
||||||
and generalization is keyed per regime — regimes are compared, never pooled.
|
default/first regime carries `-r0` explicitly, never left unmarked — each
|
||||||
|
cell record names its regime, and generalization is keyed per regime —
|
||||||
|
regimes are compared, never pooled.
|
||||||
|
|
||||||
`aura campaign run` is register-then-run sugar for a `.json` file, but the
|
`aura exec` (#319) is register-then-run sugar for a `.json` file, but the
|
||||||
canonical address is always the content id — running a bare file the first
|
canonical address is always the content id — running a bare file the first
|
||||||
time registers it implicitly. `aura campaign runs` lists stored
|
time registers it implicitly. `aura campaign runs` lists stored
|
||||||
realizations; `aura campaign runs <id>` dumps the bare stored record(s) (not
|
realizations; `aura campaign runs <id>` dumps the bare stored record(s) (not
|
||||||
the `{"campaign_run": …}` emit wrapper above). If `presentation.persist_taps`
|
the `{"campaign_run": …}` emit wrapper above). If `presentation.persist_taps`
|
||||||
is non-empty, the run also persists the named taps under
|
is non-empty, the run also persists every member's taps under
|
||||||
`runs/traces/<trace_name>/…`, chartable with `aura chart`. The
|
`runs/traces/<trace_name>/…` — the deterministic `{campaign8}-{run}` handle
|
||||||
`sweep`/`walkforward --trace` analog persists every member's taps as a
|
the `campaign_run` record's `trace_name` field prints, never a user-chosen
|
||||||
family charted the same way, by the handle the run prints (members keyed
|
name — chartable with `aura chart <trace_name>` (members keyed
|
||||||
`<cell>/<member>`) — also by the `--trace <NAME>` you chose, when that name
|
`<cell>/<member>`); `aura chart` also resolves a bare campaign id/name
|
||||||
uniquely names one recorded run (`aura chart <NAME>` resolves it against the
|
against the stored campaign documents when no literal trace directory
|
||||||
stored campaign documents; a name reused across runs refuses rather than
|
matches, refusing rather than guessing when the name is ambiguous across
|
||||||
guessing which one you mean).
|
recorded runs. A single run reports its handle the same way: the `trace_name` value on
|
||||||
|
its own stdout line, absent when the run recorded nothing. That handle is
|
||||||
|
`<render-name>-<id8>` (#311) — the render name plus an 8-hex digest over the
|
||||||
|
run's manifest, with the aura binary's build sha and the project repo's HEAD
|
||||||
|
excluded — so two runs of one blueprint that differ in an identity-bearing
|
||||||
|
input keep both traces, and `aura chart <render-name>` lists the handles
|
||||||
|
recorded under that name rather than charting one of them.
|
||||||
|
|
||||||
Exit codes: a clean run exits 0; a usage error exits 2; a refusal before any
|
Exit codes: a clean run exits 0; a usage error exits 2; a refusal before any
|
||||||
cell runs (invalid document, missing project, unresolvable strategy) exits 1;
|
cell runs (invalid document, missing project, unresolvable strategy) exits 1;
|
||||||
a run that **completes with one or more failed cells** exits 3 — the run record
|
a run that **completes with one or more failed cells** exits 3 — the run record
|
||||||
and every healthy cell persist, and the failed cells are named on stderr
|
and every healthy cell persist, and the failed cells are named on stderr
|
||||||
(#272). A **gate-emptied cell** — a `std::gate` stage that filters out every
|
(#272). A directly-run blueprint (`aura exec <blueprint>.json`, no campaign)
|
||||||
member — is a *successful* cell, not a failed one: the gate legitimately
|
has no cells at all: its own refusals — a binding mismatch, a synthetic-data
|
||||||
answered "no survivors", so the run still exits 0, the cell's realization is
|
mismatch, a compile error, a tap-bind content fault (unknown fold/tap,
|
||||||
recorded as truncated at that stage, and an informational `aura:`-prefixed
|
duplicate) — are the content of the argv-named file and exit 2 like a usage
|
||||||
note names the cell on stderr. Exit 3 is reserved for cells that could not be
|
error; only its environment refusals (a tap-trace store write failure)
|
||||||
evaluated (faults), never for an empty-but-valid result.
|
exit 1. The no-data family (no local data, no data in the requested window,
|
||||||
|
no recorded geometry) belongs to the real-data paths: on `reproduce` it
|
||||||
|
exits 1, while inside a campaign the same fault is contained per cell
|
||||||
|
(#272) — the run completes and exits 3, naming the failed cells (#297;
|
||||||
|
the full partition lives in
|
||||||
|
[C14](design/contracts/c14-headless-two-faces.md)). `aura
|
||||||
|
reproduce`'s identity/pip guard (a stored member's recorded instrument or
|
||||||
|
broker/pip label contradicting the source it re-derives against) is
|
||||||
|
likewise class 1 on the plain verb — its source is derived from the
|
||||||
|
family's own manifest, so a mismatch there is data drift, not a caller
|
||||||
|
error (Fork 6, #299).
|
||||||
|
|
||||||
|
The class boundary is FORM vs VALUE, not "environment/data/IO vs everything
|
||||||
|
else": an `--override` value that is well-formed but out of the node's own
|
||||||
|
domain (e.g. a negative length) is not caught at the compile/bind seam above
|
||||||
|
— it panics inside the node's own constructor at bootstrap. `aura exec`
|
||||||
|
catches that panic at the dispatch boundary (`catch_member_panic`) and
|
||||||
|
renders it as a runtime-class refusal, exit 1 — the same class an
|
||||||
|
environment/data fault gets, and the same class the campaign leg gives the
|
||||||
|
identical value as a per-cell fault (#272). A form fault the surface detects
|
||||||
|
before the node ever runs (kind mismatch, unknown param, compile error)
|
||||||
|
stays class 2 argv-content; a well-formed value the node itself refuses in
|
||||||
|
its own domain is class 1, regardless of which leg hit it.
|
||||||
|
|
||||||
|
A **gate-emptied cell** — a `std::gate` stage that filters out every member —
|
||||||
|
is a *successful* cell, not a failed one: the gate legitimately answered "no
|
||||||
|
survivors", so the run still exits 0, the cell's realization is recorded as
|
||||||
|
truncated at that stage, and an informational `aura:`-prefixed note names the
|
||||||
|
cell on stderr. Exit 3 is reserved for cells that could not be evaluated
|
||||||
|
(faults), never for an empty-but-valid result.
|
||||||
|
|||||||
@@ -201,6 +201,13 @@ the repo, not session memory, is the source of truth.
|
|||||||
optional gated document `description`); descriptions never influence
|
optional gated document `description`); descriptions never influence
|
||||||
execution or identity ids, and registered artifacts are never
|
execution or identity ids, and registered artifacts are never
|
||||||
retroactively invalidated.
|
retroactively invalidated.
|
||||||
|
- **[C30 — Stability discipline: artifacts are stable, code is movable](contracts/c30-stability-discipline.md)** —
|
||||||
|
No Rust-API stability before 1.0 (no semver, no shims): node crates ride
|
||||||
|
the engine checkout as path deps, external embeddings pin a rev, and the
|
||||||
|
cdylib handshake refuses non-identical aura-core builds (build-identity
|
||||||
|
stamp, #348). The compatibility promise attaches to the artifact plane
|
||||||
|
(C18/C29/invariant 8); no surface may claim "stable API" while this
|
||||||
|
contract stands.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -23,3 +23,7 @@ never line-ordered. Duplicate campaign instruments are refused at both the
|
|||||||
validate tier and the executor's preflight: the per-cell family name embeds
|
validate tier and the executor's preflight: the per-cell family name embeds
|
||||||
the raw instrument string, so uniqueness is what keeps concurrent appends from
|
the raw instrument string, so uniqueness is what keeps concurrent appends from
|
||||||
racing one name's run-index assignment.
|
racing one name's run-index assignment.
|
||||||
|
|
||||||
|
**Current-state cross-command-path example (superseded by the #319 sugar
|
||||||
|
retirement, 2026-07-25):** "e.g. a swept member's `sqn` vs the same cell
|
||||||
|
re-run under `aura generalize`".
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ loop that reaches a unique state after each input tick. Same input (incl. seed)
|
|||||||
→ bit-identical run. Two backtests are fully disjoint and run concurrently
|
→ bit-identical run. Two backtests are fully disjoint and run concurrently
|
||||||
without locking. The bit-identity is *per run*: one backtest of given (inputs,
|
without locking. The bit-identity is *per run*: one backtest of given (inputs,
|
||||||
seed) reproduces byte-for-byte. It does **not** extend to a *derived metric*
|
seed) reproduces byte-for-byte. It does **not** extend to a *derived metric*
|
||||||
recomputed for the same params by two *different command paths* (e.g. a swept
|
recomputed for the same params by two *different command paths* (e.g. a
|
||||||
member's `sqn` vs the same cell re-run under `aura generalize`), which may differ
|
campaign cell's `sqn` within its sweep stage vs the same params re-run
|
||||||
|
through `exec`'s blueprint leg with `--override`, #319), which may differ
|
||||||
by floating-point reassociation (≤1 ULP) because the two paths accumulate the
|
by floating-point reassociation (≤1 ULP) because the two paths accumulate the
|
||||||
same logical reduction in a different operation order (IEEE-754 non-associativity).
|
same logical reduction in a different operation order (IEEE-754 non-associativity).
|
||||||
C1 governs the determinism of a single run, not the cross-command bit-identity of
|
C1 governs the determinism of a single run, not the cross-command bit-identity of
|
||||||
|
|||||||
@@ -171,3 +171,11 @@ entry left open: declared-tap consumers now move `(Timestamp, Cell)` (zero
|
|||||||
per-cycle heap), and the `Recorder`→`Probe` rename is retired — `Recorder` keeps
|
per-cycle heap), and the `Recorder`→`Probe` rename is retired — `Recorder` keeps
|
||||||
its name on the legacy live/`--trace`/test-tap surface, whose migration onto the
|
its name on the legacy live/`--trace`/test-tap surface, whose migration onto the
|
||||||
subscriber shape is tracked as #308.
|
subscriber shape is tracked as #308.
|
||||||
|
|
||||||
|
**Refinement (2026-07-27 — the single-run path folds too, #308).** `run_signal_r`
|
||||||
|
moved onto the folded delivery shape `run_blueprint_member` already used, which
|
||||||
|
narrowed `Recorder`'s surviving callers to the campaign trace writer and the
|
||||||
|
test-tap sites. The superseded clause read: "`GatedRecorder` and `SeriesReducer`
|
||||||
|
(`aura-std`) are the folding siblings of the per-cycle `Recorder`
|
||||||
|
(`aura-std/src/recorder.rs`), which survives for the live / `--trace` / test-tap
|
||||||
|
path." The `--trace` flag it named retired with the `run` verb (#319).
|
||||||
|
|||||||
@@ -96,8 +96,10 @@ of a zero-output node. The run loop (`aura-engine/src/harness.rs`) debug-asserts
|
|||||||
topological order before the source loop, and the `finalize()` epilogue after it
|
topological order before the source loop, and the `finalize()` epilogue after it
|
||||||
drains — each pinned by its own once-per-run mirror test; `GatedRecorder` and
|
drains — each pinned by its own once-per-run mirror test; `GatedRecorder` and
|
||||||
`SeriesReducer` (`aura-std`) are the folding siblings of the per-cycle `Recorder`
|
`SeriesReducer` (`aura-std`) are the folding siblings of the per-cycle `Recorder`
|
||||||
(`aura-std/src/recorder.rs`), which survives for the live / `--trace` / test-tap
|
(`aura-std/src/recorder.rs`), which survives for the callers that read a
|
||||||
path.
|
per-cycle series back: the campaign trace writer
|
||||||
|
(`aura-runner::runner::persist_campaign_traces`, whose retained-delivery re-run
|
||||||
|
builds the trace columns) and the test-tap sites.
|
||||||
|
|
||||||
`BlueprintNode::signature()` (`aura-engine/src/blueprint.rs`) answers uniformly for
|
`BlueprintNode::signature()` (`aura-engine/src/blueprint.rs`) answers uniformly for
|
||||||
both arms: a primitive returns its recipe's schema, a composite *derives* it via
|
both arms: a primitive returns its recipe's schema, a composite *derives* it via
|
||||||
|
|||||||
@@ -276,18 +276,20 @@ under it — every result is net, no second gross-labelled result kind.
|
|||||||
`cost_nodes_for` (beside `stop_rule_for_regime`, `aura-runner/src/translate.rs`) is
|
`cost_nodes_for` (beside `stop_rule_for_regime`, `aura-runner/src/translate.rs`) is
|
||||||
the one doc→builder binding; `wrap_r` (`aura-runner/src/member.rs`) carries the
|
the one doc→builder binding; `wrap_r` (`aura-runner/src/member.rs`) carries the
|
||||||
optional cost leg (cost_graph off the executor's four geometry outputs, the vol
|
optional cost leg (cost_graph off the executor's four geometry outputs, the vol
|
||||||
proxy in production, a gated cost recorder in reduce mode as the `summarize_r` join
|
proxy in production, a gated cost recorder under folded delivery as the
|
||||||
input, the `LinComb(4)` `net_r_equity` curve in trace mode). Both re-run sides
|
`summarize_r` join input, the `LinComb(4)` `net_r_equity` curve under
|
||||||
re-derive the model (`cost_specs_from_params`), so the C1 drift alarm compares like
|
retained delivery). Both re-run sides re-derive the model
|
||||||
with like; costed families reproduce bit-identically (incl. `Carry`).
|
(`cost_specs_from_params`), so the C1 drift alarm compares like with like;
|
||||||
|
costed families reproduce bit-identically (incl. `Carry`).
|
||||||
`TapChannel::Net` (`aura-runner/src/runner.rs`) routes `net_r_equity` to persisted
|
`TapChannel::Net` (`aura-runner/src/runner.rs`) routes `net_r_equity` to persisted
|
||||||
curves; a cost-less doc requesting it keeps a remedy-naming skip notice. There are
|
curves; a cost-less doc requesting it keeps a remedy-naming skip notice. There are
|
||||||
no `--cost-*` run-path flags (removed #221/#234); cost travels in the document.
|
no `--cost-*` run-path flags (removed #221/#234); cost travels in the document.
|
||||||
|
|
||||||
**Risk regime as a structural campaign axis.** The `StopRule{Fixed, Vol}` axis is
|
**Risk regime as a structural campaign axis.** The `StopRule{Fixed, Vol, VolTf}`
|
||||||
realized at the campaign-document level as `CampaignDoc.risk: [RiskRegime]`
|
axis is realized at the campaign-document level as `CampaignDoc.risk:
|
||||||
(`aura-research`, variants `Vol{length, k}` and `VolTf{period_minutes, length, k}`
|
[RiskRegime]` (`aura-research`, variants `Vol{length, k}`,
|
||||||
(#262), the fixed-stop rule additive when needed) — a kept-separate matrix axis,
|
`VolTf{period_minutes, length, k}` (#262), and `Fixed{distance}` (#338, binding
|
||||||
|
the shipped `FixedStop` composite)) — a kept-separate matrix axis,
|
||||||
peer of instruments and windows. The executor keys the nominee map by `(strategy,
|
peer of instruments and windows. The executor keys the nominee map by `(strategy,
|
||||||
window, regime)`, so `generalize` aggregates *within* a regime, never across.
|
window, regime)`, so `generalize` aggregates *within* a regime, never across.
|
||||||
Regimes are **compared** at presentation, never argmax-**selected** across (a
|
Regimes are **compared** at presentation, never argmax-**selected** across (a
|
||||||
|
|||||||
@@ -34,3 +34,15 @@ walk-forward families (axes 2–4 above) build their members over **one** shared
|
|||||||
`Arc<DataServer>` (one `FileCache`), so a window is parsed once and every member's
|
`Arc<DataServer>` (one `FileCache`), so a window is parsed once and every member's
|
||||||
`M1FieldSource` borrows the same cached `Arc<[M1Parsed]>` chunks
|
`M1FieldSource` borrows the same cached `Arc<[M1Parsed]>` chunks
|
||||||
(`crates/aura-ingest/src/lib.rs:316`). The single-pass *parse* cost stays tracked as #95.
|
(`crates/aura-ingest/src/lib.rs:316`). The single-pass *parse* cost stays tracked as #95.
|
||||||
|
|
||||||
|
**Current-state bound-override + identity-anchor clause (superseded by the
|
||||||
|
#319 sugar retirement, 2026-07-25):** "so axis 1 (param-sweep) may name a
|
||||||
|
bound param — the family boundary re-opens it on the probe and on every
|
||||||
|
member reload, and the axis binds it per cell; `run`/`mc` still require
|
||||||
|
every param resolved (a truly open param refuses). Identity is untouched:
|
||||||
|
`content_id_of` (`crates/aura-research/src/lib.rs`) and `topology_hash`
|
||||||
|
(`crates/aura-cli/src/main.rs`) read the authored document, never a
|
||||||
|
re-opened probe, and each member manifest records its per-cell bindings
|
||||||
|
(#246, ratified 2026-07-12; the restriction this amends — axes bind only
|
||||||
|
open knobs — was an implementation consequence of `bind()` shrinking the
|
||||||
|
param surface, not a recorded decision)."
|
||||||
|
|||||||
@@ -5,13 +5,25 @@ RNG-seed) → deterministic run → metrics`. Parameters are typed, ranged, runt
|
|||||||
values injected at graph build — no recompile per param-set; the optimizer sees a
|
values injected at graph build — no recompile per param-set; the optimizer sees a
|
||||||
generic vector of typed ranges. A **bound blueprint param is that param's
|
generic vector of typed ranges. A **bound blueprint param is that param's
|
||||||
default**: "open" means *must be bound by an axis*, "bound" means *default,
|
default**: "open" means *must be bound by an axis*, "bound" means *default,
|
||||||
overridable by an axis*, so axis 1 (param-sweep) may name a bound param — the
|
overridable by an axis*, so a campaign axis (param-sweep) may name a bound
|
||||||
family boundary re-opens it on the probe and on every member reload, and the axis
|
param — the family boundary re-opens it on the probe and on every member
|
||||||
binds it per cell; `run`/`mc` still require every param resolved (a truly open
|
reload, and the axis binds it per cell; `exec`'s blueprint leg still requires
|
||||||
param refuses). Identity is untouched: `content_id_of`
|
every param resolved (a truly open param refuses), and its own `--override
|
||||||
(`crates/aura-research/src/lib.rs`) and `topology_hash`
|
NODE.PARAM=VALUE` (#246 residue, #319) reopens exactly the named bound param
|
||||||
(`crates/aura-cli/src/main.rs`) read the authored document, never a re-opened
|
for that one execution — the single-member analogue of a campaign axis. The
|
||||||
probe, and each member manifest records its per-cell bindings (#246, ratified
|
reopened execution's `topology_hash` stays the base document's own — reference
|
||||||
|
semantics, not the reopened topology's (#343, revised; full clause in
|
||||||
|
[C24](c24-blueprint-data.md)'s "Reproduction identity"). Identity is untouched: `content_id_of` (`crates/aura-research/src/lib.rs`) is
|
||||||
|
the one hashing primitive, read directly off the authored document by
|
||||||
|
`aura_runner::member`'s `run_signal_r`/`run_blueprint_member` — where the
|
||||||
|
manifest's `topology_hash` is computed inline by default (`run_signal_r`'s
|
||||||
|
`topo: Option<&str>` parameter's `None` arm, every caller but one; #319 Task 9
|
||||||
|
retired the CLI-side wrapper that used to duplicate this one call), or
|
||||||
|
supplied by the caller as a ready reference-semantics hash (`Some`, #343
|
||||||
|
revised — `exec`'s override branch passes the loaded base document's own id,
|
||||||
|
computed before `reopen_all`, so the SAME value reaches both the record line
|
||||||
|
and the trace-store persistence) — never a re-opened probe either way, and
|
||||||
|
each member manifest records its per-cell bindings (#246, ratified
|
||||||
2026-07-12; the restriction this amends — axes bind only open knobs — was an
|
2026-07-12; the restriction this amends — axes bind only open knobs — was an
|
||||||
implementation consequence of `bind()` shrinking the param surface, not a recorded
|
implementation consequence of `bind()` shrinking the param surface, not a recorded
|
||||||
decision). Raw data is shared read-only across sims via `Arc<[T]>` (the
|
decision). Raw data is shared read-only across sims via `Arc<[T]>` (the
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ A research project compiles to an external cdylib exporting one symbol,
|
|||||||
`AURA_PROJECT`, a two-tier `#[repr(C)]` `ProjectDescriptor`
|
`AURA_PROJECT`, a two-tier `#[repr(C)]` `ProjectDescriptor`
|
||||||
(`aura-core::project`, `crates/aura-core/src/project.rs`, emitted by the
|
(`aura-core::project`, `crates/aura-core/src/project.rs`, emitted by the
|
||||||
`aura_project!` macro). A **C tier** — `magic` (`AURAPROJ`),
|
`aura_project!` macro). A **C tier** — `magic` (`AURAPROJ`),
|
||||||
`descriptor_version`, the rustc-version and aura-core-version stamps, the
|
`descriptor_version`, the rustc-version and aura-core build-fingerprint
|
||||||
|
stamps ([C30](c30-stability-discipline.md)), the
|
||||||
namespace, all C-compatible field types — is validated **before** any Rust-ABI
|
namespace, all C-compatible field types — is validated **before** any Rust-ABI
|
||||||
field is touched; a **Rust tier** — the vocabulary resolver
|
field is touched; a **Rust tier** — the vocabulary resolver
|
||||||
`fn(&str) -> Option<PrimitiveBuilder>` and the enumerable type-id list — is read
|
`fn(&str) -> Option<PrimitiveBuilder>` and the enumerable type-id list — is read
|
||||||
@@ -37,7 +38,7 @@ via `cargo metadata` (debug default, `--release` opt-in) and loads it
|
|||||||
**load-and-hold** — the `Library` is leaked, never unloaded, so its `'static`
|
**load-and-hold** — the `Library` is leaked, never unloaded, so its `'static`
|
||||||
strings and fn-pointers stay valid (`aura-runner::project::load`,
|
strings and fn-pointers stay valid (`aura-runner::project::load`,
|
||||||
`crates/aura-runner/src/project.rs`). `validate_c_tier` checks the four stamps
|
`crates/aura-runner/src/project.rs`). `validate_c_tier` checks the four stamps
|
||||||
front-to-back; a mismatch of either version stamp refuses with exit 1 naming
|
front-to-back; a mismatch of either stamp refuses with exit 1 naming
|
||||||
both sides (`ProjectError::Incompatible`). The project vocabulary is
|
both sides (`ProjectError::Incompatible`). The project vocabulary is
|
||||||
charter-checked at load (`check_charter`): non-empty namespace, every id
|
charter-checked at load (`check_charter`): non-empty namespace, every id
|
||||||
`<ns>::`-prefixed, no duplicate, no collision against the std vocabulary,
|
`<ns>::`-prefixed, no duplicate, no collision against the std vocabulary,
|
||||||
|
|||||||
@@ -27,3 +27,27 @@ every hand-rolled usage line reads `Usage: aura <verb> …` (#179, cycle 0101);
|
|||||||
refusal diagnostics stay unprefixed (diagnostics are not usage lines). The
|
refusal diagnostics stay unprefixed (diagnostics are not usage lines). The
|
||||||
machine-first help surface (JSON/manifest help, stdin op-scripts) stays on the
|
machine-first help surface (JSON/manifest help, stdin op-scripts) stays on the
|
||||||
#157/C21 track, not this cycle (settled as human/GNU convention compliance).
|
#157/C21 track, not this cycle (settled as human/GNU convention compliance).
|
||||||
|
|
||||||
|
**Current-state "Dual grammar" section (superseded by the #319 sugar
|
||||||
|
retirement, 2026-07-25):** "The four dual-grammar subcommands
|
||||||
|
(run/sweep/walkforward/mc) keep both grammars under one token via an
|
||||||
|
optional `[blueprint]` positional plus a post-parse `is_file()` dispatch; the
|
||||||
|
execution layer is unchanged (arg-plumbing via thin `*_from` adapters). The
|
||||||
|
machine-first help surface (JSON/manifest help, stdin op-scripts) is
|
||||||
|
deferred to the #157 / C21 track, distinct from this human/GNU-convention
|
||||||
|
compliance."
|
||||||
|
|
||||||
|
**Current-state "Single document grammar" section's first-draft routing
|
||||||
|
sentence (superseded by the #319 sugar retirement, 2026-07-25):** "one verb,
|
||||||
|
`exec <target>`, dispatches on `is_blueprint_file` (a first-positional
|
||||||
|
naming an existing `.json` file selects the loaded-blueprint leg; anything
|
||||||
|
else — a campaign file or a registered content id — the campaign leg)." This
|
||||||
|
undersold the routing by one layer: the code always also peeked the file's
|
||||||
|
top-level `"kind"` field (`is_campaign_document_file`) to tell a campaign
|
||||||
|
document ending in `.json` from a blueprint file — both being ordinary
|
||||||
|
readable `.json` files `is_blueprint_file` alone cannot distinguish — and,
|
||||||
|
after an independent review found a malformed campaign file was silently
|
||||||
|
misrouted to the blueprint leg's own JSON-parse error prose, gained a
|
||||||
|
neutral not-valid-JSON refusal ahead of the leg choice (review Minor-3,
|
||||||
|
2026-07-25's fix cycle). The current contract's "Single document grammar"
|
||||||
|
section names this real routing.
|
||||||
|
|||||||
@@ -59,12 +59,107 @@ caller branches on the failure class without parsing stderr.
|
|||||||
in `crates/aura-cli/src/main.rs`, threaded from the run registry —
|
in `crates/aura-cli/src/main.rs`, threaded from the run registry —
|
||||||
[C18](c18-registry.md)).
|
[C18](c18-registry.md)).
|
||||||
|
|
||||||
**Dual grammar.** The four dual-grammar subcommands (run/sweep/walkforward/mc)
|
**`aura-runner`'s single-run refusals now honor this same partition (#297,
|
||||||
keep both grammars under one token via an optional `[blueprint]` positional plus
|
2026-07-26).** Before this cycle, every refusal inside `aura-runner`'s
|
||||||
a post-parse `is_file()` dispatch; the execution layer is unchanged (arg-plumbing
|
single-run verb paths (`aura exec <blueprint.json>`, both the bias-output and
|
||||||
via thin `*_from` adapters). The machine-first help surface (JSON/manifest help,
|
bare-measurement legs) exited 1 uniformly — a `std::process::exit(1)` baked
|
||||||
stdin op-scripts) is deferred to the #157 / [C21](c21-world.md) track, distinct
|
into the library itself, bypassing the partition above rather than
|
||||||
from this human/GNU-convention compliance.
|
instantiating it. The #297 conversion turns every such site into a returned
|
||||||
|
`RunnerError { exit_code, message }` the CLI prints and exits on; the class
|
||||||
|
is now assigned by the same criterion as the rest of this partition — is the
|
||||||
|
fault in the content of what argv named, or in the environment the command
|
||||||
|
needs? The binding refusal, the synthetic/binding mismatch, the blueprint
|
||||||
|
compile error, and tap-bind content faults (unknown fold, unknown tap,
|
||||||
|
duplicate bind) are argv-named-content faults and exit 2; the no-local-data /
|
||||||
|
no-data-in-window / no-recorded-geometry refusals and a tap-trace store I/O
|
||||||
|
failure (`bound.finish`) are environment faults and stay at 1. `cost_knob`
|
||||||
|
(via `aura-runner::translate::cost_nodes_for`) has no single-run production
|
||||||
|
caller at all — its two production callers are both worker-side: reached
|
||||||
|
from inside a sweep worker (`run_blueprint_member`) it surfaces as a per-cell
|
||||||
|
`MemberFault::Bind` — never a process exit (the campaign contract
|
||||||
|
[C28](c28-stratification.md) already guaranteed); reached from
|
||||||
|
`persist_campaign_traces`'s C1 drift-alarm re-run it propagates as a `String`
|
||||||
|
through that function's existing channel, exiting 1 via the campaign run
|
||||||
|
summary (`campaign_run.rs`'s `?` after the run record is already persisted).
|
||||||
|
|
||||||
|
**The class-1 set is not only environment/data/IO.** It also covers the exec
|
||||||
|
blueprint leg's bootstrap domain-refusal path: an `--override` value that is
|
||||||
|
well-formed but out of a node's own domain (e.g. a negative length) is not
|
||||||
|
caught at the compile/bind seam above — it panics inside the node's own
|
||||||
|
constructor at bootstrap. `aura exec` catches that panic at the dispatch
|
||||||
|
boundary (`catch_member_panic`, `crates/aura-cli/src/main.rs`) and renders it
|
||||||
|
as a runtime-class refusal, exit 1 — the same class the campaign leg gives
|
||||||
|
the identical value as a per-cell fault (#272). The boundary is FORM vs
|
||||||
|
VALUE: a form fault the surface detects before the node ever runs (kind
|
||||||
|
mismatch, unknown param, compile error) is class 2 argv-content; a
|
||||||
|
well-formed value the node itself refuses in its own domain is class 1,
|
||||||
|
regardless of which leg hit it.
|
||||||
|
|
||||||
|
**The reproduce guard's classes are context-borne (#299, 2026-07-26).**
|
||||||
|
Reproducing a stored family refuses up front when the effective data
|
||||||
|
source's identity or pip/broker geometry contradicts the member manifests
|
||||||
|
(divergence would be mechanical — refusing beats reporting a false
|
||||||
|
`DIVERGED`). The refusal prose is one; the class is carried by who named
|
||||||
|
the source: the explicit-source library seam (`reproduce_family_in`)
|
||||||
|
refuses class 2 (the caller named it), while the plain verb's derived
|
||||||
|
path — the source is reconstructed from the manifest — refuses the same
|
||||||
|
drift as class 1 (environment/data: a re-ingested sidecar, a stored label
|
||||||
|
outside the known shapes; nobody named the faulting input).
|
||||||
|
|
||||||
|
**Single document grammar (#319, 2026-07-25).** The five dual-grammar
|
||||||
|
subcommands (run/sweep/walkforward/mc/generalize) that used to keep two
|
||||||
|
grammars under one token are retired; one verb, `exec <target>`, dispatches
|
||||||
|
on `is_blueprint_file` layered with a "kind"-field peek
|
||||||
|
(`classify_exec_document_file`): a first-positional naming an existing
|
||||||
|
`.json` file that parses as JSON and carries no top-level `"kind"` selects
|
||||||
|
the loaded-blueprint leg; a file carrying `"kind"`, or a target that is not a
|
||||||
|
readable `.json` file at all (a registered content id), selects the campaign
|
||||||
|
leg. A file that does not parse as JSON at all refuses neutrally (exit 2)
|
||||||
|
before either leg's own document-shape validation runs, rather than
|
||||||
|
silently choosing a leg (review Minor-3, 2026-07-25). The execution layer
|
||||||
|
this collapses onto is unchanged in kind (arg-plumbing via thin adapters).
|
||||||
|
The machine-first help surface (JSON/manifest help, stdin op-scripts) is
|
||||||
|
deferred to the #157 / [C21](c21-world.md) track, distinct from this
|
||||||
|
human/GNU-convention compliance.
|
||||||
|
|
||||||
|
**`exec`'s target-classification exit partition is pinned, precisely scoped
|
||||||
|
(#342 item 5).** The partition below governs exec's TARGET-CLASSIFICATION
|
||||||
|
refusals at the routing seam — the shape-peek (`classify_exec_document_file`)
|
||||||
|
that decides which leg, if any, a target reaches, strictly before either
|
||||||
|
leg's own document-shape validation ever runs. A target fault at this seam
|
||||||
|
splits along the same usage/runtime line the exit-code partition above
|
||||||
|
already draws, keyed on whether a *real, existing document's content* is
|
||||||
|
being judged (usage, exit 2 — the content of an argv-named file is itself
|
||||||
|
part of the argument) or the target simply fails to resolve to any recorded
|
||||||
|
state at all (runtime, exit 1 — the needed environment/recorded state is
|
||||||
|
missing). Missing-state faults (exit 1): a target naming no readable file and
|
||||||
|
no plausible content id (`'{target}' is neither a readable .json file nor a
|
||||||
|
64-hex content id`), and a well-formed-but-unregistered 64-hex content id
|
||||||
|
(`no campaign {id} in the project store`) — no file or store entry exists to
|
||||||
|
have content in either case. File-content faults at the routing seam (exit
|
||||||
|
2): a file that does not parse as JSON at all, a valid-JSON op-script array
|
||||||
|
(not a document exec can execute at all — it names the `graph build`
|
||||||
|
escalation instead), and a kind-bearing document whose `"kind"` is not
|
||||||
|
`"campaign"` (e.g. a process document handed to `exec`) — all three are a
|
||||||
|
real, readable file whose content the ROUTING seam itself cannot classify
|
||||||
|
into a leg. The wrong-kind case is the one realignment this pin made (#342
|
||||||
|
item 5): it shipped at exit 1 (mirroring the campaign leg's own missing-state
|
||||||
|
refusals) until this pass, despite being a routing-seam content fault like
|
||||||
|
its op-script and not-valid-JSON siblings, not a missing-state fault like the
|
||||||
|
file-vs-id cases — realigned to exit 2 to match its true class, the one
|
||||||
|
sanctioned behaviour change of this pin.
|
||||||
|
|
||||||
|
Once a target classifies as campaign or blueprint, it falls under that leg's
|
||||||
|
**own** established validation partition, not this one — and the two legs'
|
||||||
|
content-validation exits differ today, named honestly rather than harmonized:
|
||||||
|
a `kind:"campaign"` document whose *content* then fails the campaign
|
||||||
|
doc-tier gate (e.g. an empty axis) exits 1, runtime-class, per the
|
||||||
|
established campaign contract (pinned by
|
||||||
|
`campaign_run_invalid_file_refuses_before_touching_store`), while the
|
||||||
|
blueprint leg's own content faults (a blueprint envelope that fails to parse
|
||||||
|
or build) exit 2, usage-class. No behaviour changes here — only this pin's
|
||||||
|
scope is named precisely: it is the routing seam's partition, not a
|
||||||
|
uniform content-validation rule across both legs.
|
||||||
|
|
||||||
**Two artifact classes, two redundancy budgets** (#249, ratified 2026-07-13).
|
**Two artifact classes, two redundancy budgets** (#249, ratified 2026-07-13).
|
||||||
The data layer the programmatic face emits is a public interface read raw (by
|
The data layer the programmatic face emits is a public interface read raw (by
|
||||||
|
|||||||
@@ -17,10 +17,14 @@ stream; reference data feeds source/session nodes from beside the hot path.
|
|||||||
|
|
||||||
**Session context — `bars_since_open` is the shipped contract (#154).** The
|
**Session context — `bars_since_open` is the shipped contract (#154).** The
|
||||||
session-context node `Session` (`crates/aura-market/src/session.rs`) emits
|
session-context node `Session` (`crates/aura-market/src/session.rs`) emits
|
||||||
**one** scalar stream, `bars_since_open: i64` (tz-aware, DST-correct, over a
|
**one** scalar stream, `bars_since_open: i64` (tz-aware, DST-correct), over a
|
||||||
baked Frankfurt open), and takes no scalar params; the zero-arg
|
timezone/open time that is now data-authorable via the typed construction-args
|
||||||
`SessionFrankfurt` roster preset (#261) exposes it with a single
|
channel (`Session` roster-reaches `std_vocabulary` as an arg-bearing type,
|
||||||
`period_minutes` knob. This is a deliberate narrowing pinned in the node's own
|
#271: `args: {"tz": <IANA name>, "open": "HH:MM"}`, applied before its one
|
||||||
|
scalar param, `period_minutes`). The zero-arg `SessionFrankfurt` roster preset
|
||||||
|
(#261) stays as baked sugar over the 09:00 `Europe/Berlin` open — the
|
||||||
|
canonical path for any OTHER open/timezone is `Session` + `args`, not a new
|
||||||
|
preset per market. This is a deliberate narrowing pinned in the node's own
|
||||||
contract: `bars_since_open` alone is the gate — a downstream `EqConst(== N)`
|
contract: `bars_since_open` alone is the gate — a downstream `EqConst(== N)`
|
||||||
subsumes an `in_session: bool` stream (pre-open instants read `<= 0`, which
|
subsumes an `in_session: bool` stream (pre-open instants read `<= 0`, which
|
||||||
never match), and nothing consumes a `session_open_ts: timestamp`. The two
|
never match), and nothing consumes a `session_open_ts: timestamp`. The two
|
||||||
|
|||||||
@@ -271,3 +271,34 @@ and both callers are untouched; maintenance is lazy-only — put-time indexing
|
|||||||
was rejected because it would need a roster-free doc-level identity function
|
was rejected because it would need a roster-free doc-level identity function
|
||||||
whose equivalence to the loaded-composite path no green test ratifies (decision
|
whose equivalence to the loaded-composite path no green test ratifies (decision
|
||||||
log: #191 comments).
|
log: #191 comments).
|
||||||
|
|
||||||
|
**Current-state `runs.jsonl`/`families.jsonl` CLI clauses (superseded by the
|
||||||
|
#319 sugar retirement, 2026-07-25):** "No live producer writes it today —
|
||||||
|
sweep / walk-forward / mc persist to the family store and `aura run` does not
|
||||||
|
persist — so the flat lib API is retained but selectively live: `rank_by`
|
||||||
|
backs `aura runs family … rank`, `optimize` backs walk-forward's in-sample
|
||||||
|
step (`aura-campaign` and the registry's own selection helper both call it),
|
||||||
|
while `append`/`load` remain public API with no in-tree caller (a latent
|
||||||
|
surface for external consumers)." … "CLI: `aura runs families`, `aura runs
|
||||||
|
family <id> [rank <metric>]`; `aura sweep`/`walkforward`/`mc` persist via
|
||||||
|
`append_family` with an optional `--name`."
|
||||||
|
|
||||||
|
**Current-state "Cross-instrument generalization" paragraph (superseded by
|
||||||
|
the #319 sugar retirement, 2026-07-25):** "`FamilyKind::CrossInstrument`:
|
||||||
|
`aura generalize` runs one candidate across an instrument list and persists
|
||||||
|
the M per-instrument runs via `append_family`, each member self-identifying
|
||||||
|
through `RunManifest.instrument`. The generalization score (worst-case R
|
||||||
|
floor + sign-agreement + per-instrument breakdown) is a **recomputable
|
||||||
|
aggregate** over those members, not a persisted family-level record."
|
||||||
|
|
||||||
|
**Current-state "The campaign executor" opening sentence (superseded by the
|
||||||
|
#319 sugar retirement, 2026-07-25):** "`aura campaign run <file|content-id>`
|
||||||
|
executes a campaign (a file is register-then-run sugar; the content id is
|
||||||
|
canonical): a zero-fault referential gate, then the process pipeline."
|
||||||
|
|
||||||
|
**Current-state "Single-run trace identity (#311)" clause (superseded
|
||||||
|
2026-07-27 — the audit that closed the #311 cycle found `topology_hash`
|
||||||
|
itself, a C24 content id, feeding the digest, in violation of C29's Id
|
||||||
|
treatment):** "What remains — the merged params, window, seed, broker,
|
||||||
|
instrument, `topology_hash`, and the project's `namespace`/`dylib_sha256` (the
|
||||||
|
C13 hot-reload discriminator) — is identity-bearing."
|
||||||
|
|||||||
@@ -39,9 +39,10 @@ by aura. The experiments-&-results plane is the run registry in `aura-registry`
|
|||||||
|
|
||||||
- `runs.jsonl` — the append-only flat store, one `RunReport` per line
|
- `runs.jsonl` — the append-only flat store, one `RunReport` per line
|
||||||
(`RunManifest` + `RunMetrics`), with a typed read-path (`load`) and best-first
|
(`RunManifest` + `RunMetrics`), with a typed read-path (`load`) and best-first
|
||||||
ranking (`rank_by`/`optimize`). No live producer writes it today — sweep /
|
ranking (`rank_by`/`optimize`). No live producer writes it today — the campaign
|
||||||
walk-forward / mc persist to the family store and `aura run` does not persist —
|
executor (`exec`'s campaign leg, `aura-campaign`, #319) persists to the family
|
||||||
so the flat lib API is retained but selectively live: `rank_by` backs
|
store instead, and `exec`'s blueprint leg (single run) does not persist — so
|
||||||
|
the flat lib API is retained but selectively live: `rank_by` backs
|
||||||
`aura runs family … rank`, `optimize` backs walk-forward's in-sample step
|
`aura runs family … rank`, `optimize` backs walk-forward's in-sample step
|
||||||
(`aura-campaign` and the registry's own selection helper both call it), while
|
(`aura-campaign` and the registry's own selection helper both call it), while
|
||||||
`append`/`load` remain public API with no in-tree caller (a latent surface for
|
`append`/`load` remain public API with no in-tree caller (a latent surface for
|
||||||
@@ -49,14 +50,16 @@ by aura. The experiments-&-results plane is the run registry in `aura-registry`
|
|||||||
- `families.jsonl` — the family store. A sweep / Monte-Carlo / walk-forward /
|
- `families.jsonl` — the family store. A sweep / Monte-Carlo / walk-forward /
|
||||||
cross-instrument run persists as a *set of related records*, each a
|
cross-instrument run persists as a *set of related records*, each a
|
||||||
`FamilyRunRecord` (a `RunReport` stamped with `family` + `run` + `kind` +
|
`FamilyRunRecord` (a `RunReport` stamped with `family` + `run` + `kind` +
|
||||||
`ordinal`; `FamilyKind ∈ {Sweep, MonteCarlo, WalkForward, CrossInstrument}`).
|
`ordinal`; `FamilyKind ∈ {Sweep, MonteCarlo, WalkForward, CrossInstrument}`,
|
||||||
`group_families` re-derives a family from the stored links (re-listable /
|
the last now dead — below). `group_families` re-derives a family from the
|
||||||
rankable as a unit — C21). The user-facing `family_id = "{family}-{run}"` handle
|
stored links (re-listable / rankable as a unit — C21). The user-facing
|
||||||
is **derived** from the stored `family` name plus a per-name `run` index
|
`family_id = "{family}-{run}"` handle is **derived** from the stored `family`
|
||||||
(numeric max+1 — not a content hash, so re-running the same family mints a fresh
|
name plus a per-name `run` index (numeric max+1 — not a content hash, so
|
||||||
id). CLI: `aura runs families`, `aura runs family <id> [rank <metric>]`;
|
re-running the same family mints a fresh id). CLI: `aura runs families`,
|
||||||
`aura sweep`/`walkforward`/`mc` persist via `append_family` with an optional
|
`aura runs family <id> [rank <metric>]`; the campaign executor persists per-cell
|
||||||
`--name`.
|
sweep/walk-forward families via `append_family` under a deterministic,
|
||||||
|
campaign-derived name — no user-supplied `--name` (the retired quintet's
|
||||||
|
naming flag, #319).
|
||||||
- `campaign_runs.jsonl` — one thin `CampaignRunRecord` per campaign run (below),
|
- `campaign_runs.jsonl` — one thin `CampaignRunRecord` per campaign run (below),
|
||||||
over untouched family records.
|
over untouched family records.
|
||||||
- `blueprints/<hash>.json`, `processes/`, `campaigns/` — the content-addressed
|
- `blueprints/<hash>.json`, `processes/`, `campaigns/` — the content-addressed
|
||||||
@@ -109,12 +112,13 @@ the byte-exact `topology_hash` keeps every debug role untouched (introspection-o
|
|||||||
the blueprint does not use leaves the id byte-stable. `--content-id` and
|
the blueprint does not use leaves the id byte-stable. `--content-id` and
|
||||||
`--identity-id` are combinable.
|
`--identity-id` are combinable.
|
||||||
|
|
||||||
**Cross-instrument generalization.** `FamilyKind::CrossInstrument`: `aura generalize`
|
**Cross-instrument generalization (retired standalone family, #319).** The
|
||||||
runs one candidate across an instrument list and persists the M per-instrument runs
|
`FamilyKind::CrossInstrument` variant is now dead: the standalone `aura
|
||||||
via `append_family`, each member self-identifying through `RunManifest.instrument`.
|
generalize` verb that used to run one candidate across an instrument list and
|
||||||
The generalization score (worst-case R floor + sign-agreement + per-instrument
|
persist the M per-instrument runs via `append_family` is retired. The
|
||||||
breakdown) is a **recomputable aggregate** over those members, not a persisted
|
surviving cross-instrument grading is `std::generalize`'s campaign-scope
|
||||||
family-level record.
|
computation (below, "Annotators are terminal") — a **recomputable aggregate**
|
||||||
|
over the cells' nominees, not a persisted family-level record.
|
||||||
|
|
||||||
**Research-artifact document stores.** `processes/` and `campaigns/` hold two
|
**Research-artifact document stores.** `processes/` and `campaigns/` hold two
|
||||||
document types (C25 roles 5 / 6b): the **process document** (a named
|
document types (C25 roles 5 / 6b): the **process document** (a named
|
||||||
@@ -133,7 +137,24 @@ show <content-id>` prints a registered document's canonical bytes (#300), so the
|
|||||||
generate → retrieve → hand-extend → re-register loop needs no direct store
|
generate → retrieve → hand-extend → re-register loop needs no direct store
|
||||||
filesystem access.
|
filesystem access.
|
||||||
|
|
||||||
**The campaign executor.** `aura campaign run <file|content-id>` executes a
|
**The bound-override coincidence (#246, ratified #328).** A campaign axis naming a
|
||||||
|
**bound** param of the referenced blueprint is deliberately accepted alongside one
|
||||||
|
naming an open param: `validate_campaign_refs` checks each axis's name against
|
||||||
|
`param_space()` **OR** `bound_param_space()` — the bound value stands as the
|
||||||
|
axis's default, and the axis's own values re-open it for that campaign. There is
|
||||||
|
no schema flag distinguishing the two cases (no `open`/`bound` marker on the axis
|
||||||
|
entry); name coincidence against one of the two namespaces IS the mechanism, and
|
||||||
|
the referenced blueprint is the single source of truth for which namespace a name
|
||||||
|
falls in. Pinned by
|
||||||
|
`referential_tier_accepts_a_kind_correct_axis_over_a_bound_param`
|
||||||
|
(`aura-registry/src/lib.rs`). The single-run analogue — `exec`'s own
|
||||||
|
`--override NODE.PARAM=VALUE` reopening a bound param for one execution — now
|
||||||
|
truthfully has the identical hash consequence (#343, revised): both legs
|
||||||
|
stamp the referenced/loaded document's own id, never the reopened topology's,
|
||||||
|
exactly as a reopened campaign member's has always been ([C24](c24-blueprint-data.md)'s
|
||||||
|
"Reproduction identity" carries the full clause).
|
||||||
|
|
||||||
|
**The campaign executor.** `aura exec <file|content-id>` (#319) executes a
|
||||||
campaign (a file is register-then-run sugar; the content id is canonical): a
|
campaign (a file is register-then-run sugar; the content id is canonical): a
|
||||||
zero-fault referential gate, then the process pipeline. The executable shape is
|
zero-fault referential gate, then the process pipeline. The executable shape is
|
||||||
`std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]? [std::generalize]?`
|
`std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]? [std::generalize]?`
|
||||||
@@ -154,8 +175,8 @@ carry `manifest.selection`), deflation nulls seeded from the doc's `seed` — th
|
|||||||
realization is a pure function of doc + stores + data (C1). Harness/data binding
|
realization is a pure function of doc + stores + data (C1). Harness/data binding
|
||||||
stays consumer-side behind the one-method **`MemberRunner`** seam (the shipped
|
stays consumer-side behind the one-method **`MemberRunner`** seam (the shipped
|
||||||
implementation is `aura_runner::DefaultMemberRunner`; the CLI binds the loaded-
|
implementation is `aura_runner::DefaultMemberRunner`; the CLI binds the loaded-
|
||||||
blueprint reduce convention with a unique suffix-join of raw axis names onto the
|
blueprint folded-delivery convention with a unique suffix-join of raw axis names
|
||||||
wrapped `param_space`). The `campaign_runs.jsonl` sibling records one thin
|
onto the wrapped `param_space`). The `campaign_runs.jsonl` sibling records one thin
|
||||||
`CampaignRunRecord` per run — campaign/process ids, seed, and per-cell realized
|
`CampaignRunRecord` per run — campaign/process ids, seed, and per-cell realized
|
||||||
stage prefixes linking family ids, gate survivor ordinals, and sweep selections —
|
stage prefixes linking family ids, gate survivor ordinals, and sweep selections —
|
||||||
run-counted per campaign id. Zero survivors truncate a cell's realized prefix and
|
run-counted per campaign id. Zero survivors truncate a cell's realized prefix and
|
||||||
@@ -215,7 +236,7 @@ is a **closed vocabulary** of the wrap convention's four sink names
|
|||||||
intrinsic `DocFault::UnknownTap` — the escalation for a new observable is a new
|
intrinsic `DocFault::UnknownTap` — the escalation for a new observable is a new
|
||||||
vocabulary entry or an authored blueprint sink, never an open node-path namespace).
|
vocabulary entry or an authored blueprint sink, never an open node-path namespace).
|
||||||
Scope is the per-cell **nominee only**: after the pipeline settles the CLI re-runs
|
Scope is the per-cell **nominee only**: after the pipeline settles the CLI re-runs
|
||||||
each nominee once in non-reduce mode (all four channels drained, windowed to the
|
each nominee once under retained delivery (all four channels drained, windowed to the
|
||||||
nominee manifest's own ns bounds) and **asserts metrics equality** against the
|
nominee manifest's own ns bounds) and **asserts metrics equality** against the
|
||||||
recorded nominee — the C1 drift alarm, a hard refusal on divergence (the reproduce
|
recorded nominee — the C1 drift alarm, a hard refusal on divergence (the reproduce
|
||||||
precedent, enforced). Traces land in the existing `TraceStore` as
|
precedent, enforced). Traces land in the existing `TraceStore` as
|
||||||
@@ -230,6 +251,41 @@ unproducible-tap skip (`net_r_equity` needs a cost leg the campaign runner wires
|
|||||||
of). Known debt: `aura chart` over the campaign family ROOT (cells spanning
|
of). Known debt: `aura chart` over the campaign family ROOT (cells spanning
|
||||||
instruments) is untested / semantically undefined — only per-cell read-back is pinned.
|
instruments) is untested / semantically undefined — only per-cell read-back is pinned.
|
||||||
|
|
||||||
|
**Single-run trace identity (#311).** A single `aura exec` run's taps land under
|
||||||
|
`traces/<render-name>-<id8>/`. `id8` is the 8-hex head of a SHA-256 over the run's
|
||||||
|
own `RunManifest` with two keys *removed* — `commit`, the aura binary's build sha,
|
||||||
|
and `project.commit`, the project repo's HEAD plus a `-dirty` marker — and
|
||||||
|
`params`/`defaults` *merged* into one name-sorted sequence. The two removed keys
|
||||||
|
answer *who built or checked out the code*, not *what the run was*, and the second
|
||||||
|
is re-derived on every invocation, so including either would make the store an
|
||||||
|
unbounded directory generator. `params` and `defaults` are merged because the
|
||||||
|
partition between them records *how* a bound value was supplied (varied vs. held
|
||||||
|
at its default, #246) rather than *what* the run's effective parameterisation is —
|
||||||
|
the same provenance-not-identity distinction, applied to a different pair of keys;
|
||||||
|
the merge is lossless since the two vectors are disjoint by construction. What
|
||||||
|
remains — the merged params, window, seed, broker, instrument, and the
|
||||||
|
project's `namespace`/`dylib_sha256` (the C13 hot-reload discriminator) — is
|
||||||
|
identity-bearing, with one substitution (2026-07-27, C29): `topology_hash` is
|
||||||
|
a **content** id (`content_id_of(blueprint_to_json(..))`, #158/C24) that also
|
||||||
|
hashes a blueprint's `doc` field and every other C23 debug symbol, so a
|
||||||
|
description-only edit would otherwise mint a fresh directory for a
|
||||||
|
bit-identical run — forbidden by C29's own Id treatment ("description fields
|
||||||
|
… are blanked for the identity id"). The digest hashes
|
||||||
|
`blueprint_identity_json`'s debug-symbol-blind projection (#171) in
|
||||||
|
`topology_hash`'s place instead; `manifest.topology_hash` itself is
|
||||||
|
untouched, keeping its own #343 reference-semantics content id. The two
|
||||||
|
provenance keys are removed rather than blanked
|
||||||
|
because `ProjectProvenance.commit` skips serializing when it is `None`: blanking
|
||||||
|
would canonicalise an absent key to `null` and split one identity in two.
|
||||||
|
`aura-runner::run_identity_digest` is the single implementation; both single-run
|
||||||
|
entry points assemble the manifest *before* the tap bind and reuse that one value
|
||||||
|
for the record, so a handle can never name a directory whose identity the stored
|
||||||
|
record does not describe. This is consistent with C18's plane split above:
|
||||||
|
`commit = identity` names plane (1), the code identity; a *run's* identity (plane
|
||||||
|
2) is its manifest minus provenance, with params/defaults collapsed to their
|
||||||
|
effective union. The campaign leg is unaffected — its handle is
|
||||||
|
`derive_trace_name`'s `{campaign8}-{run}`, already collision-free by construction.
|
||||||
|
|
||||||
**Identity-ref resolution is index-first (#191).** `find_blueprint_by_identity`
|
**Identity-ref resolution is index-first (#191).** `find_blueprint_by_identity`
|
||||||
consults the persistent `blueprint_identity_index.jsonl` sidecar (identity id →
|
consults the persistent `blueprint_identity_index.jsonl` sidecar (identity id →
|
||||||
content id; a fixed-name sibling of the runs store, appended under the #276 lock)
|
content id; a fixed-name sibling of the runs store, appended under the #276 lock)
|
||||||
|
|||||||
@@ -34,3 +34,16 @@ scaffolding's last data weld — `wrap_r`'s hard-wired `price`←close role and
|
|||||||
`M1Field::Close`-only open sites — is retired; a strategy's input roles now bind
|
`M1Field::Close`-only open sites — is retired; a strategy's input roles now bind
|
||||||
archive columns by name (C26). `wrap_r`'s remaining R-scaffolding retirement
|
archive columns by name (C26). `wrap_r`'s remaining R-scaffolding retirement
|
||||||
stays #159.]
|
stays #159.]
|
||||||
|
|
||||||
|
**Current-state "Pre-C24 scaffolding retired" paragraph's family-builder
|
||||||
|
naming (superseded by the #319 sugar retirement, 2026-07-25):** "The family
|
||||||
|
builders are now generic and blueprint-driven: `blueprint_sweep_family`,
|
||||||
|
`blueprint_walkforward_family`, `blueprint_mc_family` (and
|
||||||
|
`blueprint_sweep_over`) in `crates/aura-runner/src/family.rs`." Those four
|
||||||
|
functions are themselves retired by #319 (2026-07-25): the campaign executor
|
||||||
|
(`aura_campaign::exec::execute`) builds every family now, driven by the
|
||||||
|
process document's stage pipeline; `family.rs` keeps only the synthetic-
|
||||||
|
stream helpers `aura_runner::reproduce` calls to re-derive a recorded family
|
||||||
|
(`showcase_prices`, `walkforward_prices`, `walkforward_window_source`,
|
||||||
|
`synthetic_walk_sources`, `DataSource`/`DataChoice`). The current contract's
|
||||||
|
"Pre-C24 scaffolding retired" paragraph names this real state.
|
||||||
|
|||||||
@@ -71,13 +71,20 @@ rather than a hand-enumerated menu. Persisted experiment *intent* is the campaig
|
|||||||
document (C25/C18).
|
document (C25/C18).
|
||||||
|
|
||||||
**Pre-C24 scaffolding retired.** The pre-C24 scaffolding — `HarnessKind` and the
|
**Pre-C24 scaffolding retired.** The pre-C24 scaffolding — `HarnessKind` and the
|
||||||
per-strategy `*_sweep_family` functions — is **gone**. The family builders are now
|
per-strategy `*_sweep_family` functions — is **gone**. The generic blueprint-driven
|
||||||
generic and blueprint-driven: `blueprint_sweep_family`, `blueprint_walkforward_family`,
|
family builders that first replaced it (`blueprint_sweep_family`,
|
||||||
`blueprint_mc_family` (and `blueprint_sweep_over`) in
|
`blueprint_walkforward_family`, `blueprint_mc_family`, `blueprint_sweep_over`) are,
|
||||||
`crates/aura-runner/src/family.rs`. The R-evaluator scaffold `wrap_r`
|
in turn, retired by the #319 sugar retirement (2026-07-25): the **campaign
|
||||||
(defined in `crates/aura-runner/src/member.rs`; imported and called from
|
executor** (`aura_campaign::exec::execute`) now builds every family, driven by
|
||||||
`runner.rs`) survives; its last hard data weld — the
|
the process document's stage pipeline (`std::sweep`/`std::grid`,
|
||||||
hard-wired `price`←close role and the `M1Field::Close`-only open sites — is retired
|
`std::walk_forward`, `std::monte_carlo`). `crates/aura-runner/src/family.rs`
|
||||||
|
retains only the synthetic-stream helpers serving `aura_runner::reproduce`
|
||||||
|
(`showcase_prices`, `walkforward_prices`, `walkforward_window_source`,
|
||||||
|
`synthetic_walk_sources`, `DataSource`/`DataChoice`) — reproduction's own
|
||||||
|
re-derivation path, not a family-minting path. The R-evaluator scaffold
|
||||||
|
`wrap_r` (defined in `crates/aura-runner/src/member.rs`; imported and called
|
||||||
|
from `runner.rs`) survives; its last hard data weld — the hard-wired
|
||||||
|
`price`←close role and the `M1Field::Close`-only open sites — is retired
|
||||||
(a strategy's input roles now bind archive columns by name, C26), and `wrap_r`'s
|
(a strategy's input roles now bind archive columns by name, C26), and `wrap_r`'s
|
||||||
remaining R-scaffolding retirement is tracked at #159.
|
remaining R-scaffolding retirement is tracked at #159.
|
||||||
|
|
||||||
|
|||||||
@@ -135,3 +135,52 @@ the mean shows the net/duty-cycle level. Deferred refinements: rendering the min
|
|||||||
envelope honestly as range bars / OHLC rather than a polyline (#112); a `--width`
|
envelope honestly as range bars / OHLC rather than a polyline (#112); a `--width`
|
||||||
budget flag and true intra-bucket min/max ordering for the non-default continuous
|
budget flag and true intra-bucket min/max ordering for the non-default continuous
|
||||||
x-mode (#110).
|
x-mode (#110).
|
||||||
|
|
||||||
|
**Current-state "Single run" clause (superseded 2026-07-27 by #311's
|
||||||
|
identity-keyed single-run trace directory):** "`aura exec <blueprint.json>`
|
||||||
|
(#319) persists every tap the blueprint declares to the trace store under the
|
||||||
|
run's own name, on both shapes: …"
|
||||||
|
|
||||||
|
**Current-state "Encoding / storage / rendering split (C14)" clause
|
||||||
|
(superseded 2026-07-27 by #311's identity-keyed single-run trace directory):**
|
||||||
|
"file I/O is `aura-registry::TraceStore`, persisting each run under
|
||||||
|
`runs/traces/<name>/` beside the run registry's `runs.jsonl`"
|
||||||
|
|
||||||
|
**Current-state "Single-run trace identity (#311)" clause (superseded
|
||||||
|
2026-07-27 — the audit that closed the #311 cycle found `topology_hash`
|
||||||
|
itself, a C24 content id, feeding the digest, in violation of C29's Id
|
||||||
|
treatment):** "Everything else the manifest carries is identity-bearing,
|
||||||
|
`project.dylib_sha256` included (it is what C13 hot-reload varies)."
|
||||||
|
|
||||||
|
**Current-state "Single run" + "Families" + "Newcomer" clauses (superseded by
|
||||||
|
the #319 sugar retirement, 2026-07-25):** "**Single run.** `aura run
|
||||||
|
<blueprint.json>` persists every tap the blueprint declares to the trace
|
||||||
|
store under the run's own name, on both shapes: a `bias`-output strategy
|
||||||
|
(`aura-runner::member::run_signal_r`, R-wrapped) and a bare measurement
|
||||||
|
blueprint with ≥1 declared tap but no `bias`
|
||||||
|
(`aura-runner::measure::run_measurement`). A tap-free run writes nothing to
|
||||||
|
`runs/` and its stdout stays byte-identical. The CLI `--trace <NAME>` flag is
|
||||||
|
**retired** on `run` (and on `mc`): it parses but is refused at dispatch
|
||||||
|
(exit 2) — naming a trace is the family / campaign path's job, not a per-run
|
||||||
|
flag.
|
||||||
|
|
||||||
|
**Families.** `aura sweep|walkforward --real <SYM> … --trace <NAME>`
|
||||||
|
persists *each member* via the campaign path: the dissolved verb is
|
||||||
|
translated into a content-addressed process + campaign document whose
|
||||||
|
`presentation.persist_taps` requests the tap vocabulary, run through the one
|
||||||
|
campaign executor; `aura-runner::runner::persist_campaign_traces` writes
|
||||||
|
each member under a depth-2 fan-out `runs/traces/<name>/<cell>/<member>/`.
|
||||||
|
Every written member is independently re-run once in non-reduce trace mode
|
||||||
|
over its own recorded window, and its re-derived metrics are asserted equal
|
||||||
|
to the recorded member's — a **C1 drift alarm** that refuses (exit 1) rather
|
||||||
|
than persist a silently-wrong trace. The **synthetic** sweep / walk-forward
|
||||||
|
path refuses `--trace` (exit 2); **Monte-Carlo is excluded from trace
|
||||||
|
persistence** (`mc --real` refuses `--name`/`--trace`, exit 2 — the
|
||||||
|
real-data R-bootstrap campaign itself runs, but records no per-member
|
||||||
|
family traces; the synthetic `--seeds` family's realization argument, C12,
|
||||||
|
does not carry over to one real series). `TraceStore::ensure_name_free`
|
||||||
|
makes name resolution a total function, refusing cross-kind reuse of one
|
||||||
|
name by both a run and a family." … (Newcomer, tail sentence) "so `aura run`
|
||||||
|
prints summary R-metrics to stdout but writes no on-disk trace — a
|
||||||
|
**chartable** trace today comes from a blueprint that declares taps (single
|
||||||
|
run) or from a `--real … --trace` family campaign."
|
||||||
|
|||||||
@@ -56,35 +56,97 @@ per fired cycle, tagged `ctx.now()`), matching a trace of timestamped events (C1
|
|||||||
**Encoding / storage / rendering split (C14).** A drained tap is encoded as a
|
**Encoding / storage / rendering split (C14).** A drained tap is encoded as a
|
||||||
columnar (SoA, C7) `ColumnarTrace` — struct→JSON only — in `aura-engine`
|
columnar (SoA, C7) `ColumnarTrace` — struct→JSON only — in `aura-engine`
|
||||||
(`report.rs`); file I/O is `aura-registry::TraceStore`, persisting each run under
|
(`report.rs`); file I/O is `aura-registry::TraceStore`, persisting each run under
|
||||||
`runs/traces/<name>/` beside the run registry's `runs.jsonl`; rendering is
|
`runs/traces/<handle>/` beside the run registry's `runs.jsonl` — `<handle>` is a
|
||||||
|
single run's own identity-keyed name (`<render-name>-<id8>`, #311) or a
|
||||||
|
campaign family's derived name, unchanged; rendering is
|
||||||
`aura-cli` (`render_chart_html` + the vendored `chart-viewer.js`). The engine stays
|
`aura-cli` (`render_chart_html` + the vendored `chart-viewer.js`). The engine stays
|
||||||
headless.
|
headless.
|
||||||
|
|
||||||
**Single run.** `aura run <blueprint.json>` persists every tap the blueprint
|
**Single run.** `aura exec <blueprint.json>` (#319) persists every tap the
|
||||||
declares to the trace store under the run's own name, on both shapes: a
|
blueprint declares to the trace store under the run's own identity-keyed
|
||||||
`bias`-output strategy (`aura-runner::member::run_signal_r`, R-wrapped) and a bare
|
handle — `<render-name>-<id8>`, #311 — on both shapes: a `bias`-output strategy (`aura-runner::member::run_signal_r`,
|
||||||
measurement blueprint with ≥1 declared tap but no `bias`
|
R-wrapped) and a bare measurement blueprint with ≥1 declared tap but no
|
||||||
(`aura-runner::measure::run_measurement`). A tap-free run writes nothing to `runs/`
|
`bias` (`aura-runner::measure::run_measurement`). A tap-free run writes
|
||||||
and its stdout stays byte-identical. The CLI `--trace <NAME>` flag is **retired** on
|
nothing to `runs/` and its stdout stays byte-identical. `exec` carries no
|
||||||
`run` (and on `mc`): it parses but is refused at dispatch (exit 2) — naming a trace
|
free-standing `--trace <NAME>` flag — naming a trace is the family / campaign
|
||||||
is the family / campaign path's job, not a per-run flag.
|
path's job, not a per-run flag (the repeatable `--tap TAP=FOLD` selector,
|
||||||
|
#310, only chooses which of the blueprint's own declared taps to subscribe,
|
||||||
|
never names the trace).
|
||||||
|
|
||||||
**Families.** `aura sweep|walkforward --real <SYM> … --trace <NAME>` persists *each
|
A run that persists anything reports the handle it wrote under as
|
||||||
member* via the campaign path: the dissolved verb is translated into a
|
`trace_name` on its stdout line (#309) — appended to the record's own bytes,
|
||||||
content-addressed process + campaign document whose `presentation.persist_taps`
|
absent entirely when the run recorded nothing — so the handle is read, never
|
||||||
requests the tap vocabulary, run through the one campaign executor;
|
guessed. The library hands it to an embedding caller as a value beside the
|
||||||
`aura-runner::runner::persist_campaign_traces` writes each member under a depth-2
|
report, like the unbound-tap names (C27/#297); it is not part of the stored
|
||||||
fan-out `runs/traces/<name>/<cell>/<member>/`. Every written member is
|
record.
|
||||||
independently re-run once in non-reduce trace mode over its own recorded window,
|
|
||||||
and its re-derived metrics are asserted equal to the recorded member's — a **C1
|
**Single-run trace identity (#311).** The handle is `<render-name>-<id8>`,
|
||||||
drift alarm** that refuses (exit 1) rather than persist a silently-wrong trace. The
|
where `id8` is the 8-hex head of a SHA-256 over the run's own `RunManifest`
|
||||||
**synthetic** sweep / walk-forward path refuses `--trace` (exit 2);
|
with two keys *removed* — `commit` (the aura binary's build sha) and
|
||||||
**Monte-Carlo is excluded from trace persistence** (`mc --real` refuses
|
`project.commit` (the project repo's HEAD plus a `-dirty` marker, re-derived
|
||||||
`--name`/`--trace`, exit 2 — the real-data R-bootstrap campaign itself runs, but
|
on every invocation) — and `params`/`defaults` *merged* into one name-sorted
|
||||||
records no per-member family traces; the synthetic `--seeds` family's realization
|
sequence. The two removed keys are provenance — who built or checked out the
|
||||||
argument, C12, does not carry over to one real series).
|
code — not what the run was; including either would make the store an
|
||||||
`TraceStore::ensure_name_free` makes name resolution a total function, refusing
|
unbounded directory generator. The merge is the same distinction applied to a
|
||||||
cross-kind reuse of one name by both a run and a family.
|
different pair: `params`/`defaults` record *how* a bound value was supplied
|
||||||
|
(varied vs. held at its default, #246), not *what* the run's effective
|
||||||
|
parameterisation is, and the two vectors are disjoint by construction, so the
|
||||||
|
merge is lossless. Everything else the manifest carries is identity-bearing,
|
||||||
|
`project.dylib_sha256` included (it is what C13 hot-reload varies) —
|
||||||
|
**except** `topology_hash` (2026-07-27, C29): it is a **content** id
|
||||||
|
(`content_id_of(blueprint_to_json(..))`, #158/C24) that also hashes a
|
||||||
|
blueprint's `doc` field and every other C23 debug symbol, so a
|
||||||
|
description-only edit would otherwise mint a fresh directory for a
|
||||||
|
bit-identical run, which C29's own Id treatment forbids. The digest hashes
|
||||||
|
`blueprint_identity_json`'s debug-symbol-blind projection (#171) in
|
||||||
|
`topology_hash`'s place instead; `manifest.topology_hash` itself is
|
||||||
|
untouched. That projection blanks every C23 debug symbol, not descriptions
|
||||||
|
alone — the render name, and node, role, output, tap and gang names — while
|
||||||
|
param openness stays identity-bearing.
|
||||||
|
|
||||||
|
**What that does and does not reach.** The blanking applies to the
|
||||||
|
substituted hash only, not to the whole digest, so a debug symbol that also
|
||||||
|
appears in another manifest field still reaches the handle through that
|
||||||
|
field. Two consequences hold today, both observed:
|
||||||
|
|
||||||
|
- A *description-only* edit converges: same handle, one directory.
|
||||||
|
- A *node rename* does **not** converge, because `manifest.params` records
|
||||||
|
node-qualified names (`fast.length` vs `quick.length`) and the merged
|
||||||
|
parameter vector is hashed. Two blueprints differing only in node names
|
||||||
|
therefore compute bit-identically but are addressed separately. Whether
|
||||||
|
the handle should follow the record here (param names are part of what
|
||||||
|
the record describes) or follow #171's projection (param names are not
|
||||||
|
identity-bearing) is an open fork — #354; the render name likewise
|
||||||
|
survives, deliberately, as the handle's readable prefix.
|
||||||
|
|
||||||
|
One further observable consequence: two structurally identical blueprints
|
||||||
|
whose declared *tap names* differ share a directory while writing
|
||||||
|
differently named tap files, and the later run's `index.json` lists only its
|
||||||
|
own, since the write path never prunes (tracked as #352, unchanged here). The
|
||||||
|
manifest is assembled *before* the tap bind and the same value is reused for
|
||||||
|
the record, so the handle can never name a directory whose identity the
|
||||||
|
stored record does not describe. Consequences: two runs of one blueprint
|
||||||
|
differing in any identity-bearing input write two directories and neither
|
||||||
|
overwrites the other; two runs with the same effective parameterisation —
|
||||||
|
including a no-op `--override` that merely moves a value between `params` and
|
||||||
|
`defaults` — write one; and the bare render name is no longer a directory, so
|
||||||
|
`aura chart <render-name>` refuses with the handles recorded under that name
|
||||||
|
(`recorded_handles_with_prefix`, the same helper the family-id branch uses).
|
||||||
|
|
||||||
|
**Families.** A campaign document's `presentation.persist_taps` (the closed
|
||||||
|
tap vocabulary, C18/C27) requests traces for each cell's nominee, run through
|
||||||
|
`aura exec <campaign.json|id>` (#319 — a document's `axes` replace the
|
||||||
|
retired `sweep|walkforward --real <SYM> --trace <NAME>` grammar entirely;
|
||||||
|
there is no per-invocation `--trace`/`--name` flag left on any surface):
|
||||||
|
`aura-runner::runner::persist_campaign_traces` writes each member under a
|
||||||
|
depth-2 fan-out `runs/traces/{campaign8}-{run}/<cell>/<member>/`, the name
|
||||||
|
derived deterministically (`derive_trace_name`), never user-chosen. Every
|
||||||
|
written member is independently re-run once under retained delivery over
|
||||||
|
its own recorded window, and its re-derived metrics are asserted equal to
|
||||||
|
the recorded member's — a **C1 drift alarm** that refuses (exit 1) rather
|
||||||
|
than persist a silently-wrong trace. `TraceStore::ensure_name_free` makes
|
||||||
|
name resolution a total function, refusing cross-kind reuse of one name by
|
||||||
|
both a run and a family.
|
||||||
|
|
||||||
**Viewer.** `aura chart <name> [--tap <t>] [--panels]` classifies the name on disk
|
**Viewer.** `aura chart <name> [--tap <t>] [--panels]` classifies the name on disk
|
||||||
(`TraceStore::name_kind`: top-level `index.json` → a single Run; member subdirs → a
|
(`TraceStore::name_kind`: top-level `index.json` → a single Run; member subdirs → a
|
||||||
@@ -117,10 +179,10 @@ vocabulary (an SMA-cross → `Bias` strategy), `.gitignore`, and a project `CLAU
|
|||||||
The engine also ships example blueprints under
|
The engine also ships example blueprints under
|
||||||
`crates/aura-cli/examples/r_{sma,breakout,channel,meanrev}.json`. Honest gap against
|
`crates/aura-cli/examples/r_{sma,breakout,channel,meanrev}.json`. Honest gap against
|
||||||
the Guarantee's "populated trace immediately": these shipped blueprints are
|
the Guarantee's "populated trace immediately": these shipped blueprints are
|
||||||
*strategies* (a `bias` output, no declared taps), so `aura run` prints summary
|
*strategies* (a `bias` output, no declared taps), so `aura exec` prints summary
|
||||||
R-metrics to stdout but writes no on-disk trace — a **chartable** trace today comes
|
R-metrics to stdout but writes no on-disk trace — a **chartable** trace today comes
|
||||||
from a blueprint that declares taps (single run) or from a `--real … --trace` family
|
from a blueprint that declares taps (single run) or from a campaign document
|
||||||
campaign.
|
requesting `presentation.persist_taps`.
|
||||||
|
|
||||||
**Deferred.** Live sink streams *during* a run are not built — taps are
|
**Deferred.** Live sink streams *during* a run are not built — taps are
|
||||||
buffer-then-drain (collected, then written after the run), and there is no local
|
buffer-then-drain (collected, then written after the run), and there is no local
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# C23 — Graph compilation and behaviour-preserving optimisation: history
|
||||||
|
|
||||||
|
> FROZEN HISTORICAL RECORD. Each block below was true as of its cycle/date stamp
|
||||||
|
> and may be superseded; this file is NOT current truth and NOT a grounding
|
||||||
|
> surface. Current contract: [c23-graph-compilation.md](c23-graph-compilation.md).
|
||||||
|
|
||||||
|
**Render-name run-time-role clause (#331; superseded 2026-07-27 by #311's
|
||||||
|
identity-keyed single-run trace directory):** "…but at *run* time, outside
|
||||||
|
compilation, it keys the trace directory (`traces/<name>/`); that operational
|
||||||
|
role, not any load-bearing weight inside the flat graph, is exactly why the
|
||||||
|
authored-blueprint intakes shape-gate it."
|
||||||
@@ -9,7 +9,13 @@ entirely (there is no composite in the flat graph). The blueprint's field /
|
|||||||
input-role *names* are **non-load-bearing** — the wiring resolves by index, and names
|
input-role *names* are **non-load-bearing** — the wiring resolves by index, and names
|
||||||
survive at most as **informative debug symbols** (exactly as `FieldSpec.name` already
|
survive at most as **informative debug symbols** (exactly as `FieldSpec.name` already
|
||||||
is, C8), kept for tracing / rendering (C9 graph-as-data) but carrying no run
|
is, C8), kept for tracing / rendering (C9 graph-as-data) but carrying no run
|
||||||
semantics. The flat graph is then the target of **behaviour-preserving optimisation**
|
semantics. (Reconciling with the root composite's own render name, #331: that
|
||||||
|
claim stands unweakened — the name never influences compilation, identity, or
|
||||||
|
execution semantics — but at *run* time, outside compilation, it prefixes the
|
||||||
|
trace directory (`traces/<name>-<id8>/`, #311 — the run's own identity digest
|
||||||
|
completes it); that operational role, not any load-bearing
|
||||||
|
weight inside the flat graph, is exactly why the authored-blueprint intakes
|
||||||
|
shape-gate it.) The flat graph is then the target of **behaviour-preserving optimisation**
|
||||||
— any transform that leaves every observable sink trace bit-identical (C1 is the
|
— any transform that leaves every observable sink trace bit-identical (C1 is the
|
||||||
correctness invariant) — on two levels:
|
correctness invariant) — on two levels:
|
||||||
|
|
||||||
@@ -75,3 +81,5 @@ points (`compile`, `compile_with_params`, and the `bind` paths) as well as from
|
|||||||
- [C9](c09-fractal-composition.md) — inlining vs runtime sub-engine.
|
- [C9](c09-fractal-composition.md) — inlining vs runtime sub-engine.
|
||||||
- [C11](c11-sources-record-replay.md), [C12](c12-atomic-sim-unit.md) — recorded-stream sharing and the sweep family.
|
- [C11](c11-sources-record-replay.md), [C12](c12-atomic-sim-unit.md) — recorded-stream sharing and the sweep family.
|
||||||
- [C19](c19-bootstrap.md) — the flat-graph representation this optimises.
|
- [C19](c19-bootstrap.md) — the flat-graph representation this optimises.
|
||||||
|
|
||||||
|
> History: [c23-graph-compilation.history.md](c23-graph-compilation.history.md)
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ so the Tier-2 bump discipline is dormant and structurally-semantic additions
|
|||||||
(the `gangs` section) land as additive-optional fields of v1; the first ship
|
(the `gangs` section) land as additive-optional fields of v1; the first ship
|
||||||
consciously freezes v1, gangs included, and activates the bump discipline.
|
consciously freezes v1, gangs included, and activates the bump discipline.
|
||||||
|
|
||||||
**Milestone delivered — 2026-06-30, cycle 0090 — the serializable format + loader + construction service.** A green end-to-end milestone fieldtest (`fieldtests/milestone-topology-as-data/`) proves the full author → serialize → load → construct → introspect → reproduce story from the public surface alone, 0 behavioural bugs; the round-trippable format (#155), the construction service (#157), and the forward-compat two-tier discipline (#156) all shipped. Polish filed forward: op-script grammar docs + a stale example (#163), the canonical trailing-newline / `Composite` value ergonomics (#164), CLI discoverability of `build`/`introspect` (#159).
|
**Milestone delivered — 2026-06-30, cycle 0090 — the serializable format + loader + construction service.** A green end-to-end milestone fieldtest proved the full author → serialize → load → construct → introspect → reproduce story from the public surface alone, 0 behavioural bugs; the round-trippable format (#155), the construction service (#157), and the forward-compat two-tier discipline (#156) all shipped. Polish filed forward: op-script grammar docs + a stale example (#163), the canonical trailing-newline / `Composite` value ergonomics (#164), CLI discoverability of `build`/`introspect` (#159).
|
||||||
|
|
||||||
**Realization (2026-06-30, cycle 0092 — runs are built FROM blueprint-data, #165).**
|
**Realization (2026-06-30, cycle 0092 — runs are built FROM blueprint-data, #165).**
|
||||||
`aura run <blueprint.json>` now loads a serialized **signal** blueprint
|
`aura run <blueprint.json>` now loads a serialized **signal** blueprint
|
||||||
@@ -157,4 +157,100 @@ convert every deliberate template improvement into forced churn of the frozen
|
|||||||
fixture — the same cross-purpose coupling that rules out regenerating the
|
fixture — the same cross-purpose coupling that rules out regenerating the
|
||||||
fixture from the scaffolder.
|
fixture from the scaffolder.
|
||||||
|
|
||||||
|
**Current-state "Runs and families are built FROM blueprint-data" section
|
||||||
|
(superseded by the #319 sugar retirement, 2026-07-25):** "`aura run
|
||||||
|
<blueprint.json>` loads a serialized **signal** blueprint and emits a
|
||||||
|
`RunReport` **bit-identical** (C1) to its Rust-built twin; the run scaffolding
|
||||||
|
(sinks / broker / data) is supplied **at run**, not serialized. Beyond a
|
||||||
|
single run, the World constructs and orchestrates **families** of harnesses
|
||||||
|
from topology-data:
|
||||||
|
|
||||||
|
- `aura sweep <blueprint.json> --axis <name>=<csv>` → `FamilyKind::Sweep`. A
|
||||||
|
sweep needs an **open** blueprint (a fully-bound one has an empty
|
||||||
|
`param_space`).
|
||||||
|
- `aura mc <blueprint.json> --seeds N` → `FamilyKind::MonteCarlo`, each seed a
|
||||||
|
distinct synthetic price walk drawn disjoint-parallel through the engine
|
||||||
|
`monte_carlo` seam (invariant 1). MC binds no axis, so it needs a **closed**
|
||||||
|
blueprint (the sweep's distinction inverted); an open one returns a named
|
||||||
|
`Err`, rendered exit-2 at the builder boundary — no hidden exit in the pure
|
||||||
|
builder.
|
||||||
|
- `aura walkforward <blueprint.json> --axis <name>=<csv> [--select
|
||||||
|
argmax|plateau:mean|plateau:worst]` → `FamilyKind::WalkForward`:
|
||||||
|
re-optimizes the loaded blueprint's params over the `--axis` grid on each
|
||||||
|
24/12/12 IS window, selects the winner by `sqn_normalized`, runs it
|
||||||
|
out-of-sample. Reduce-mode members are R-measured (`oos_r` the meaningful
|
||||||
|
summary; stitched pip-equity empty, C10).
|
||||||
|
|
||||||
|
The family builders live in `aura-runner::family` (`blueprint_sweep_family` /
|
||||||
|
`blueprint_mc_family` / `blueprint_walkforward_family`). Every member manifest
|
||||||
|
carries the **shared** `topology_hash` (one signal topology, only params
|
||||||
|
vary; `member_key` distinguishes members), and each family stores its
|
||||||
|
blueprint(s) content-addressed so `aura reproduce` re-derives every member
|
||||||
|
bit-identically (`aura-runner::reproduce`, C18). The synthetic-walk DGP is
|
||||||
|
the MC machinery, not trader-grade statistics; a real-data block-bootstrap —
|
||||||
|
and retiring the `synthetic_walk_sources` `len:60`↔warm-up coupling — rides
|
||||||
|
#172."
|
||||||
|
|
||||||
|
**Current-state "Axis discovery" section (superseded by the #319 sugar
|
||||||
|
retirement, 2026-07-25):** "`aura sweep <blueprint.json> --list-axes` lists
|
||||||
|
a loaded blueprint's open sweepable knobs (one `<name>:<kind>` per line,
|
||||||
|
`param_space()` order) and exits; the printed names are exactly what
|
||||||
|
`--axis` binds. Every listed name is **mandatory** on `sweep` /
|
||||||
|
`walkforward` — the blueprint must be fully bound before it runs — so a
|
||||||
|
subset grid is refused with the missing knob named (`BindError::MissingKnob`,
|
||||||
|
`aura-engine`) and there is no default; pin a knob you do not want to vary
|
||||||
|
with a single-value axis. A single `blueprint_axis_probe` (`aura-runner`)
|
||||||
|
single-sources the wrapped probe for the sweep terminal, the MC closed-check,
|
||||||
|
and the listing, so **listed == swept by construction** (and stays so across
|
||||||
|
the harness retirement — the listing tracks whatever the sweep actually
|
||||||
|
resolves). One raw namespace, `<node>.<param>` (a splice path keeps its
|
||||||
|
interior path, e.g. `anchor.sess.period_minutes`), is the only user-facing
|
||||||
|
axis name (#328): `graph introspect --params` and `--list-axes` are
|
||||||
|
line-identical (open params bare, bound params with `default=`) and both
|
||||||
|
print exactly what `--axis` binds, on both sweep routes. The wrapped
|
||||||
|
`<blueprint>.<node>.<param>` form the probe still resolves against
|
||||||
|
internally is retired from the surface, with a translation refusal naming
|
||||||
|
the raw candidate on both intake seams (`--axis` and `campaign validate`).
|
||||||
|
`--trace` on `sweep` / `walkforward` writes per-member traces on the
|
||||||
|
real-data campaign path (depth-2 fan-out, chartable by the printed family
|
||||||
|
handle, #224); the synthetic path still refuses (`run` / `mc` refuse
|
||||||
|
`--trace` outright). The live trace-writer is the campaign
|
||||||
|
`presentation.persist_taps` (`persist_campaign_traces`,
|
||||||
|
`aura-runner::runner`)."
|
||||||
|
|
||||||
|
**Current-state `name`-op gated-intake clause (superseded by the #319 sugar
|
||||||
|
retirement, 2026-07-25):** "Gated by a shared shape check (non-empty, single
|
||||||
|
path segment, no `/`, `\`, `.` or `..`) applied at this op intake **plus
|
||||||
|
every CLI intake that reads an authored blueprint envelope from a file** —
|
||||||
|
`register`, `introspect --content-id <FILE>`, the bare graph-file viewer,
|
||||||
|
`run`, `introspect --params <FILE>`, `sweep --list-axes`, and each of
|
||||||
|
`sweep`/`walkforward`/`mc`/`generalize`'s file-reading entry points
|
||||||
|
(synthetic and `--real` alike) — one class of intake, one gate. The
|
||||||
|
refusal's core sentence (`name_gate_fault_prose`) is byte-uniform across
|
||||||
|
every one of these sites; only the leading context prefix varies — `run`
|
||||||
|
and the bare graph-file viewer prepend `aura: <path>:`, while `register`,
|
||||||
|
the sweep-axis family (`validate_and_register_axes`), and `introspect`
|
||||||
|
prepend bare `aura: `. Store read-back (`reproduce`, `use` resolution,
|
||||||
|
`introspect`/`--params` by content id) stays deliberately ungated — C29: a
|
||||||
|
registered artifact is never retroactively invalidated."
|
||||||
|
|
||||||
**[C26, 2026-07-10 (#231): the single-price data weld inside the surviving `wrap_r` scaffolding is retired — input roles bind archive columns by name; the wrapper's remaining R-scaffolding retirement stays #159.]** (From the C24 Forbids clause as of that date; the single-price weld was retired at #231/C26, but `wrap_r` itself survives — its full R-scaffolding retirement stays deferred, #159.)
|
**[C26, 2026-07-10 (#231): the single-price data weld inside the surviving `wrap_r` scaffolding is retired — input roles bind archive columns by name; the wrapper's remaining R-scaffolding retirement stays #159.]** (From the C24 Forbids clause as of that date; the single-price weld was retired at #231/C26, but `wrap_r` itself survives — its full R-scaffolding retirement stays deferred, #159.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> The disjoint-parallel synthetic
|
||||||
|
> price walks a sweep/MC family draws (`aura_runner::family`) are the family
|
||||||
|
> machinery, not trader-grade statistics; a real-data block-bootstrap — and
|
||||||
|
> retiring the `synthetic_walk_sources` `len:60`↔warm-up coupling — rides #172.
|
||||||
|
|
||||||
|
(superseded by the #319 sugar retirement, 2026-07-25 — audit-close correction:
|
||||||
|
no family-minting path draws synthetic walks after the family-builder
|
||||||
|
retirement; only `reproduce` consumes them)
|
||||||
|
|
||||||
|
**"Registered-blueprint splice" clause, build-free-introspection sentence
|
||||||
|
(amended by the 2026-07-26 harvest, #339 item 4):** "build-free introspection
|
||||||
|
paths pass `&|_| None`." (The CLI's introspection paths now resolve `use`
|
||||||
|
refs through the store exactly like `graph build` does, via the shared
|
||||||
|
`parse_and_resolve_ops` phase — retiring the earlier build-free resolver on
|
||||||
|
the introspection side; the engine's `subgraph` closure contract itself is
|
||||||
|
unchanged.)
|
||||||
|
|||||||
@@ -70,37 +70,41 @@ loop for the round-trippable vocabulary.
|
|||||||
|
|
||||||
**Out of the round-trippable set** (deliberate; fails clean as `UnknownNodeType`):
|
**Out of the round-trippable set** (deliberate; fails clean as `UnknownNodeType`):
|
||||||
recording sinks (they capture an `mpsc::Sender` — runtime identity, not param-generic
|
recording sinks (they capture an `mpsc::Sender` — runtime identity, not param-generic
|
||||||
data, C19) and construction-arg builders (`LinComb` / `CostSum` / `SimBroker` /
|
data, C19) and `SimBroker` (a scalar-typed baked value, a narrower gap than the args
|
||||||
`Session` — structural-axis args, a C20 concern), additively addable later (#156).
|
channel). `LinComb` / `CostSum` / `Session` — formerly listed here — entered the
|
||||||
|
round-trippable set with #271's typed construction args (see the add-op `args`
|
||||||
|
clause and the data-driven `format_version` below).
|
||||||
|
|
||||||
### Runs and families are built FROM blueprint-data
|
### Runs and families are built FROM documents, via `exec` (#319, 2026-07-25)
|
||||||
|
|
||||||
`aura run <blueprint.json>` loads a serialized **signal** blueprint and emits a
|
`aura exec <blueprint.json>` loads a serialized **signal** blueprint and runs it
|
||||||
`RunReport` **bit-identical** (C1) to its Rust-built twin; the run scaffolding (sinks
|
once, emitting a `RunReport` **bit-identical** (C1) to its Rust-built twin (a
|
||||||
/ broker / data) is supplied **at run**, not serialized. Beyond a single run, the
|
no-bias blueprint with ≥1 declared tap runs the measurement leg instead); the
|
||||||
World constructs and orchestrates **families** of harnesses from topology-data:
|
run scaffolding (sinks / broker / data) is supplied **at run**, not serialized.
|
||||||
|
Beyond a single run, the World constructs and orchestrates **families** from a
|
||||||
|
**campaign document** (role 6b, [C18](c18-registry.md)/[C25](c25-role-model.md)):
|
||||||
|
`aura exec <campaign.json|id>` drives the document's `strategies[].axes` through
|
||||||
|
the `aura-campaign` executor (`aura_campaign::exec::execute`) — one member per
|
||||||
|
(strategy, instrument, window) cell, the process document's stage pipeline
|
||||||
|
(`std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]?
|
||||||
|
[std::generalize]?`) deciding what each cell realizes (grid family, gated
|
||||||
|
survivors, walk-forward roll, MC bootstrap, cross-instrument generalization —
|
||||||
|
C18's cycle-0107/0108 realizations). The one deliberate argv residue,
|
||||||
|
`--override NODE.PARAM=VALUE`, reopens a bound param for the execution without
|
||||||
|
touching the document (blueprint leg: `override_paths` + `reopen_all`; campaign
|
||||||
|
leg: injected as a single-value axis ahead of `validate_campaign_refs`, refusing
|
||||||
|
a collision with a document-declared axis).
|
||||||
|
|
||||||
- `aura sweep <blueprint.json> --axis <name>=<csv>` → `FamilyKind::Sweep`. A sweep
|
Every member manifest carries the **shared** `topology_hash` (one signal
|
||||||
needs an **open** blueprint (a fully-bound one has an empty `param_space`).
|
topology, only params vary; `member_key` distinguishes members), and the
|
||||||
- `aura mc <blueprint.json> --seeds N` → `FamilyKind::MonteCarlo`, each seed a
|
executor stores its blueprint(s) content-addressed so `aura reproduce`
|
||||||
distinct synthetic price walk drawn disjoint-parallel through the engine
|
re-derives every member bit-identically (C18). The synthetic price walks
|
||||||
`monte_carlo` seam (invariant 1). MC binds no axis, so it needs a **closed**
|
(`aura_runner::family::synthetic_walk_sources`) survive the #319 retirement
|
||||||
blueprint (the sweep's distinction inverted); an open one returns a named `Err`,
|
only as `reproduce`'s re-derivation inputs for historically minted synthetic
|
||||||
rendered exit-2 at the builder boundary — no hidden exit in the pure builder.
|
families — no family-minting path draws them anymore (the campaign executor
|
||||||
- `aura walkforward <blueprint.json> --axis <name>=<csv> [--select
|
is the one family builder, C20) — and they are not trader-grade statistics; a
|
||||||
argmax|plateau:mean|plateau:worst]` → `FamilyKind::WalkForward`: re-optimizes the
|
real-data block-bootstrap — and retiring the `len:60`↔warm-up coupling —
|
||||||
loaded blueprint's params over the `--axis` grid on each 24/12/12 IS window,
|
rides #172.
|
||||||
selects the winner by `sqn_normalized`, runs it out-of-sample. Reduce-mode members
|
|
||||||
are R-measured (`oos_r` the meaningful summary; stitched pip-equity empty, C10).
|
|
||||||
|
|
||||||
The family builders live in `aura-runner::family` (`blueprint_sweep_family` /
|
|
||||||
`blueprint_mc_family` / `blueprint_walkforward_family`). Every member manifest carries
|
|
||||||
the **shared** `topology_hash` (one signal topology, only params vary; `member_key`
|
|
||||||
distinguishes members), and each family stores its blueprint(s) content-addressed so
|
|
||||||
`aura reproduce` re-derives every member bit-identically (`aura-runner::reproduce`,
|
|
||||||
C18). The synthetic-walk DGP is the MC machinery, not trader-grade statistics; a
|
|
||||||
real-data block-bootstrap — and retiring the `synthetic_walk_sources` `len:60`↔warm-up
|
|
||||||
coupling — rides #172.
|
|
||||||
|
|
||||||
### Reproduction identity
|
### Reproduction identity
|
||||||
|
|
||||||
@@ -115,24 +119,44 @@ itself blueprint-data. The **identity id** (#171) is an additive sibling:
|
|||||||
--identity-id`. The byte-exact content id keeps the store/reproduce roles; the
|
--identity-id`. The byte-exact content id keeps the store/reproduce roles; the
|
||||||
identity id is introspection-only until a dedup consumer exists.
|
identity id is introspection-only until a dedup consumer exists.
|
||||||
|
|
||||||
|
**`topology_hash` carries reference semantics on every leg (#343, revised).**
|
||||||
|
`exec`'s `--override NODE.PARAM=VALUE` (above) reopens a bound param before
|
||||||
|
bootstrap, but the executed run's `topology_hash` stamps the **loaded (or
|
||||||
|
stored) base document's own content id** — computed before the reopen —
|
||||||
|
never a transient reopened variant, exactly as the campaign leg's #246
|
||||||
|
axis-over-bound-param path has always stamped the referenced strategy's id
|
||||||
|
for a reopened member ([C18](c18-registry.md)'s bound-override coincidence).
|
||||||
|
The manifest's `params` map carries the variation (the bound value still
|
||||||
|
moves from `defaults` to `params`, #249); `params` plus the base document
|
||||||
|
deterministically reconstruct the executed run, byte-identically —
|
||||||
|
reproduction identity, not the hash, is where the variation lives. A no-op
|
||||||
|
override (re-binding a param to its existing bound value) therefore changes
|
||||||
|
nothing about identity: the bare run's and the no-op-override run's
|
||||||
|
`topology_hash` are equal. An unresolved `topology_hash` (a file-target exec
|
||||||
|
never registered via `graph register`) is still the honest name of a real,
|
||||||
|
reconstructible topology, not a dangling reference — store-resolution
|
||||||
|
(`get_blueprint`) is a lookup convenience, never an identity guarantee.
|
||||||
|
|
||||||
### Axis discovery
|
### Axis discovery
|
||||||
|
|
||||||
`aura sweep <blueprint.json> --list-axes` lists a loaded blueprint's open sweepable
|
`aura graph introspect --params <blueprint.json|id>` (#328) is the one
|
||||||
knobs (one `<name>:<kind>` per line, `param_space()` order) and exits; the printed
|
axis-discovery surface: it lists a loaded blueprint's open knobs (one
|
||||||
names are exactly what `--axis` binds. Every listed name is **mandatory** on `sweep` /
|
`<name>:<kind>` per line, `param_space()` order) followed by its bound knobs
|
||||||
`walkforward` — the blueprint must be fully bound before it runs — so a subset grid is
|
(`<name>:<kind> default=<value>`) — the campaign-axis namespace
|
||||||
refused with the missing knob named (`BindError::MissingKnob`, `aura-engine`) and
|
`validate_campaign_refs` checks a campaign document's `strategies[].axes`
|
||||||
there is no default; pin a knob you do not want to vary with a single-value axis. A
|
against. Every open name a campaign document's `axes` varies is **mandatory**:
|
||||||
single `blueprint_axis_probe` (`aura-runner`) single-sources the wrapped probe for the
|
a subset is refused with the missing knob named (`BindError::MissingKnob`,
|
||||||
sweep terminal, the MC closed-check, and the listing, so **listed == swept by
|
`aura-engine`) and there is no default; pin a knob you do not want to vary with
|
||||||
construction** (and stays so across the harness retirement — the listing tracks
|
a single-value axis. One raw namespace, `<node>.<param>` (a splice path keeps
|
||||||
whatever the sweep actually resolves). The names are prefixed by the current wrapping
|
its interior path, e.g. `anchor.sess.period_minutes`), is the only user-facing
|
||||||
(`sma_signal.fast.length`, the nested-composite prefix), which is why discovery lives
|
axis name (#328) — the wrapped `<blueprint>.<node>.<param>` form is retired
|
||||||
on the sweep verb (it owns the wrapping), not `graph introspect`. `--trace` on
|
from the surface, with a did-you-mean refusal naming the raw candidate at the
|
||||||
`sweep` / `walkforward` writes per-member traces on the real-data campaign path
|
one remaining intake seam, `campaign validate` (#319 retired the standalone
|
||||||
(depth-2 fan-out, chartable by the printed family handle, #224); the synthetic path
|
`--axis`/`--list-axes` CLI flags this used to also gate). Per-member traces
|
||||||
still refuses (`run` / `mc` refuse `--trace` outright). The live trace-writer is the
|
are the campaign document's `presentation.persist_taps` (closed vocabulary,
|
||||||
campaign `presentation.persist_taps` (`persist_campaign_traces`, `aura-runner::runner`).
|
C18 cycle-0109) or, on `exec`'s blueprint leg, the repeatable `--tap
|
||||||
|
TAP=FOLD` selector (#310) over a blueprint's own declared taps (C27); the
|
||||||
|
live trace-writer is `persist_campaign_traces` (`aura-runner::runner`).
|
||||||
|
|
||||||
### The construction service (op-script)
|
### The construction service (op-script)
|
||||||
|
|
||||||
@@ -144,17 +168,45 @@ per-op checks — name resolution, edge kind-match (`edge_kind_check`), the doub
|
|||||||
arm, param bind, and acyclicity (a `connect` that would close a cycle is rejected
|
arm, param bind, and acyclicity (a `connect` that would close a cycle is rejected
|
||||||
eagerly at the closing op, `GraphSession::closes_cycle`, #161) — and **holistic**
|
eagerly at the closing op, `GraphSession::closes_cycle`, #161) — and **holistic**
|
||||||
finalize checks — wiring totality (`validate_wiring`), param-namespace injectivity
|
finalize checks — wiring totality (`validate_wiring`), param-namespace injectivity
|
||||||
(`check_param_namespace_injective`), root-role boundness (`check_root_roles_bound`).
|
(`check_param_namespace_injective`). Both cadences call the **same extracted
|
||||||
Both cadences call the **same extracted predicates** — no second validator. The
|
predicates** — no second validator. The holistic faults read **by-identifier**
|
||||||
holistic faults read **by-identifier** (#162): `finish()` translates the
|
(#162): `finish()` translates the index-carrying `CompileError`s (`UnconnectedPort` /
|
||||||
index-carrying `CompileError`s (`UnconnectedPort` / `RoleKindMismatch` /
|
`RoleKindMismatch`) into by-identifier `OpError` variants while still *calling* the
|
||||||
`UnboundRootRole`) into by-identifier `OpError` variants while still *calling* the
|
unchanged holistic gates. **Root-role boundness moved off this list (#317, open
|
||||||
unchanged holistic gates. Build-free introspection answers over the closed vocabulary:
|
patterns):** `check_root_roles_bound` no longer runs in `finish()` — a `finish()`ed
|
||||||
`graph introspect --vocabulary | --node <T> | --unwired`. The engine `Op` stays
|
op-script may still carry an open (unbound) root role, declaring a reusable
|
||||||
serde-free; the wire DTO (`OpDoc`) is CLI-side (`aura-cli::graph_construct`).
|
pattern's formal parameter; only `compile`/bootstrap (the runnability gate, not a
|
||||||
Acceptance: a graph built purely through the ops compiles identical (C1) to its
|
construction gate) still call it, unchanged. Build-free introspection answers over
|
||||||
Rust-built twin, an invalid op is rejected at the op naming the cause, introspection
|
the closed vocabulary: `graph introspect --vocabulary | --node <T> | --unwired`. The
|
||||||
answers without a build.
|
engine `Op` stays serde-free; the wire DTO (`OpDoc`) is CLI-side
|
||||||
|
(`aura-cli::graph_construct`). Acceptance: a graph built purely through the ops
|
||||||
|
compiles identical (C1) to its Rust-built twin, an invalid op is rejected at the op
|
||||||
|
naming the cause, introspection answers without a build.
|
||||||
|
|
||||||
|
**Registered-blueprint splice (#317).** The `use` op resolves a reference — a store
|
||||||
|
content id, a unique content-id prefix (#302 semantics), or a registry **name
|
||||||
|
label** (`graph register --name`, latest-wins, `aura-registry`'s
|
||||||
|
`blueprint_names.jsonl` sidecar) — to a previously registered blueprint and
|
||||||
|
splices it into the building graph as a nested `BlueprintNode::Composite` under an
|
||||||
|
instance identifier. The **engine never resolves**: `GraphSession`/`replay` take a
|
||||||
|
second injected closure, `subgraph: &dyn Fn(&str) -> Option<Composite>`, mirroring
|
||||||
|
`vocab`'s closed-lookup posture exactly — this is the same enforcement-shift
|
||||||
|
permission §"Enforcement shift" above already grants the node vocabulary, extended
|
||||||
|
to registered-blueprint references. All store I/O — label/prefix resolution, the
|
||||||
|
`get_blueprint` fetch, the C29 doc-gate re-walk over the fetched composite (before
|
||||||
|
it ever reaches the session, so a doc-less fetched entry cannot enter a NEW
|
||||||
|
composition — a **backstop**: the register verb already gates both input forms, so
|
||||||
|
this fires only for store content written before C29 or through the raw in-crate
|
||||||
|
path), and the resolution echo — happens CLI-side, at DTO conversion, before
|
||||||
|
replay; the CLI's introspection paths resolve `use` refs through the store
|
||||||
|
exactly like `graph build` does (`parse_and_resolve_ops`, the shared first
|
||||||
|
phase both callers run — #339 item 4 harvest retired the earlier build-free
|
||||||
|
`&|_| None` resolver on the introspection side). The engine's closure
|
||||||
|
contract above is unchanged: only the CLI-side caller supplied for
|
||||||
|
introspection now always threads a real store-backed resolver. The echo
|
||||||
|
(`aura: note: use "<instance>": <label-or-prefix> -> <full id>`) is the existing
|
||||||
|
`aura: note:` benign-diagnostic marker (C14) — a new instance of the existing
|
||||||
|
class, not a new one, so the exit-code/marker taxonomy is unchanged.
|
||||||
|
|
||||||
**Invariant-5 lockstep.** The eager acyclicity gate is a *second* home of invariant 5
|
**Invariant-5 lockstep.** The eager acyclicity gate is a *second* home of invariant 5
|
||||||
alongside the bootstrap Kahn sort (`harness.rs`, `BootstrapError::Cycle`); the two
|
alongside the bootstrap Kahn sort (`harness.rs`, `BootstrapError::Cycle`); the two
|
||||||
@@ -166,16 +218,44 @@ rejected at construction.
|
|||||||
|
|
||||||
The op-script is a JSON **array of ops**, each object internally tagged by `"op"`,
|
The op-script is a JSON **array of ops**, each object internally tagged by `"op"`,
|
||||||
replayed in order; nodes are referenced **by identifier**, ports as dotted
|
replayed in order; nodes are referenced **by identifier**, ports as dotted
|
||||||
`<identifier>.<port>`. The eight verbs:
|
`<identifier>.<port>`. The eleven verbs:
|
||||||
|
|
||||||
|
- `name` — `{"op":"name","name":<str>}` — set the composite's render name
|
||||||
|
(#331), script-level and at-most-once (a second refuses); omitted, the
|
||||||
|
built composite keeps the CLI's own seed default (`"graph"`). Gated by a
|
||||||
|
shared shape check (non-empty, single path segment, no `/`, `\`, `.` or
|
||||||
|
`..`) applied at this op intake **plus every CLI intake that reads an
|
||||||
|
authored blueprint envelope from a file** — `graph register`, `introspect
|
||||||
|
--content-id <FILE>`, the bare graph-file viewer, `introspect --params
|
||||||
|
<FILE>`'s file branch, `introspect --taps <FILE>`'s file branch (#337,
|
||||||
|
same `composite_from_authored_text` route), and `exec`'s blueprint leg
|
||||||
|
(#319; its narrower, envelope-only grammar shares the same
|
||||||
|
`gate_authored_root_name` call directly rather than through the
|
||||||
|
shape-discriminating wrapper) — one
|
||||||
|
class of intake, one gate. The refusal's core sentence
|
||||||
|
(`name_gate_fault_prose`) is byte-uniform across every one of these
|
||||||
|
sites; only the leading context prefix varies — `exec` and the bare
|
||||||
|
graph-file viewer prepend `aura: <path>:`, while `register` and
|
||||||
|
`introspect` prepend bare `aura: `. Store read-back (`reproduce`, `use`
|
||||||
|
resolution, `introspect`/`--params` by content id) stays deliberately
|
||||||
|
ungated — C29: a registered artifact is never retroactively invalidated.
|
||||||
- `source` — `{"op":"source","role":<str>,"kind":<ScalarKind>}` — declare a root
|
- `source` — `{"op":"source","role":<str>,"kind":<ScalarKind>}` — declare a root
|
||||||
source role producing a base column of `kind`.
|
source role producing a base column of `kind`.
|
||||||
- `input` — `{"op":"input","role":<str>}` — declare a root input role (kind inferred
|
- `input` — `{"op":"input","role":<str>}` — declare a root input role (kind inferred
|
||||||
from the slots it feeds).
|
from the slots it feeds) — a reusable **pattern**'s formal parameter, left open
|
||||||
- `add` — `{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}` —
|
until an enclosing graph wires it (#317's open patterns): `finish()` accepts an
|
||||||
|
op-script that never binds it, only `compile`/bootstrap require it bound.
|
||||||
|
- `add` — `{"op":"add","type":<TypeId>,"name":<str>?,"args":{<arg>:<str>}?,"bind":{<param>:<Scalar>}?}` —
|
||||||
add a node of compiled-in type identity `type`; **`name` is its identifier**
|
add a node of compiled-in type identity `type`; **`name` is its identifier**
|
||||||
(mirrors the builder's `.named(...)`; defaults to the lowercased type label, so
|
(mirrors the builder's `.named(...)`; defaults to the lowercased type label, so
|
||||||
two unnamed nodes of one type collide); `bind` sets params.
|
two unnamed nodes of one type collide); `args` (#271) configures an **arg-bearing**
|
||||||
|
type's structural, non-scalar construction (`Session`'s IANA timezone/open,
|
||||||
|
`LinComb`/`CostSum`'s arity) through the closed, load-time-validated `ArgKind`
|
||||||
|
table (`Tz`/`TimeOfDay`/`Count`) — applied BEFORE `bind`, so a bound param
|
||||||
|
`args` unlocks (e.g. `LinComb`'s `weights[i]`) is only bindable once `arity` is
|
||||||
|
consumed; an arg-bearing type given no `args` refuses (`BadArg(MissingArg)`), a
|
||||||
|
`Plain` type given `args` it does not declare also refuses
|
||||||
|
(`BadArg(NotArgBearing)`); `bind` sets the (now real) params.
|
||||||
- `feed` — `{"op":"feed","role":<str>,"into":[<port>,…]}` — fan a root role into
|
- `feed` — `{"op":"feed","role":<str>,"into":[<port>,…]}` — fan a root role into
|
||||||
interior input slots.
|
interior input slots.
|
||||||
- `connect` — `{"op":"connect","from":<port>,"to":<port>}` — wire an interior output
|
- `connect` — `{"op":"connect","from":<port>,"to":<port>}` — wire an interior output
|
||||||
@@ -189,19 +269,32 @@ replayed in order; nodes are referenced **by identifier**, ports as dotted
|
|||||||
recorded observation point (a `Composite.taps` entry, C27), not a boundary output.
|
recorded observation point (a `Composite.taps` entry, C27), not a boundary output.
|
||||||
Name-addressed like every other op (no raw index); tap names are their own
|
Name-addressed like every other op (no raw index); tap names are their own
|
||||||
namespace (a duplicate refuses). The `finish` gate threads op-declared taps into
|
namespace (a duplicate refuses). The `finish` gate threads op-declared taps into
|
||||||
the built `Composite` (`.with_taps`); a single `aura run` records each, a sweep
|
the built `Composite` (`.with_taps`); a single `aura exec` (#319) records
|
||||||
leaves them inert.
|
each, a campaign member run leaves them inert.
|
||||||
- `gang` — `{"op":"gang","as":"channel_length","into":["channel_hi.length","channel_lo.length"]}`
|
- `gang` — `{"op":"gang","as":"channel_length","into":["channel_hi.length","channel_lo.length"]}`
|
||||||
— fuse two or more sibling params into ONE public knob: the member addresses
|
— fuse two or more sibling params into ONE public knob: the member addresses
|
||||||
leave the sweepable param space and `as` replaces them; the bound or swept
|
leave the sweepable param space and `as` replaces them; the bound or swept
|
||||||
value fans out to every member at bootstrap. Members must share one scalar
|
value fans out to every member at bootstrap. Members must share one scalar
|
||||||
kind and stay open (un-bound).
|
kind and stay open (un-bound).
|
||||||
|
- `doc` — `{"op":"doc","text":<str>}` — declare the composite's one-line meaning
|
||||||
|
(C29, #316) — the op-script twin of the builder's `.doc(...)`; at most one per
|
||||||
|
script, a second is a fault (a declarative script carries no last-wins
|
||||||
|
ambiguity).
|
||||||
|
- `use` — `{"op":"use","ref":{"content_id":<str>}|{"name":<str>},"name":<str>?,"bind":{<path>:<Scalar>}?}`
|
||||||
|
— splice a **registered** blueprint into the building graph as a nested
|
||||||
|
composite under an instance identifier (`name`, defaulting to the fetched
|
||||||
|
composite's own name); `bind` path-qualifies the spliced instance's params
|
||||||
|
(e.g. `"sma.length"`), applied after the splice (#317). An instance's open
|
||||||
|
input roles become its `<instance>.<role>` in-ports, its output boundary
|
||||||
|
aliases its `<instance>.<field>` out-fields — the same `Composite` arm every
|
||||||
|
other add/wiring path walks, not a new mechanism. See §"The construction
|
||||||
|
service" below for the resolution split.
|
||||||
|
|
||||||
Value forms are the serialized representations: a `Scalar` bind value is the typed tag
|
Value forms are the serialized representations: a `Scalar` bind value is the typed tag
|
||||||
form `{"I64":2}` / `{"F64":0.5}` / `{"Bool":true}` / `{"Timestamp":<i64>}`, and `kind`
|
form `{"I64":2}` / `{"F64":0.5}` / `{"Bool":true}` / `{"Timestamp":<i64>}`, and `kind`
|
||||||
is the capitalized `ScalarKind` (`"F64"`). Worked example:
|
is the capitalized `ScalarKind` (`"F64"`). Worked example:
|
||||||
`fieldtests/cycle-0088-construction-op-script/c0088_1_sma_crossover.json` (an
|
`crates/aura-cli/examples/r_sma.json` — an SMA-crossover bias carrying both an
|
||||||
SMA-crossover bias).
|
`{"I64":…}` and an `{"F64":…}` bind value.
|
||||||
|
|
||||||
### Canonical form (#164)
|
### Canonical form (#164)
|
||||||
|
|
||||||
@@ -224,12 +317,21 @@ Tier-1 (additive-optional) is serde-default silent-ignore with **no** `format_ve
|
|||||||
bump — a new optional field defaults to prior behaviour (C1). Tier-2 (must-understand:
|
bump — a new optional field defaults to prior behaviour (C1). Tier-2 (must-understand:
|
||||||
a new node type, edge semantics, or structural-axis kind) **bumps** `format_version` so
|
a new node type, edge semantics, or structural-axis kind) **bumps** `format_version` so
|
||||||
an old reader refuses cleanly (`LoadError::UnsupportedVersion` / `UnknownNodeType`).
|
an old reader refuses cleanly (`LoadError::UnsupportedVersion` / `UnknownNodeType`).
|
||||||
**Pre-ship dormancy (#61):** until the first external ship there are no out-of-repo
|
**Pre-ship dormancy (#61, ended by #271):** until the first external ship there were
|
||||||
readers — reader and writer change atomically in one commit — so the Tier-2 bump
|
no out-of-repo readers — reader and writer changed atomically in one commit — so the
|
||||||
discipline is dormant and structurally-semantic additions (the `gangs` section) land as
|
Tier-2 bump discipline lay dormant and structurally-semantic additions (the `gangs`
|
||||||
additive-optional fields of v1; the first ship consciously freezes v1, gangs included,
|
section) landed as additive-optional fields of v1. #271's construction args are the
|
||||||
and activates the bump discipline. The per-section required-flag scheme is deferred (no
|
first exercised Tier-2 bump (data-driven, next paragraph); v1 remains the version of
|
||||||
current Tier-2 section to validate it; recorded on #156).
|
every args-free document, gangs included. The per-section required-flag scheme stays
|
||||||
|
deferred (no per-section case yet; recorded on #156).
|
||||||
|
|
||||||
|
**First data-driven bump (#271):** construction args are the first Tier-2 case whose
|
||||||
|
version is decided PER DOCUMENT, not a single global bump. The writer emits
|
||||||
|
`format_version: 1` for an args-free document (byte-identical to every pre-#271
|
||||||
|
document — content ids stable) and `2` the moment any primitive, at any composite
|
||||||
|
nesting depth, carries `args` — the must-understand signal an old (pre-#271) reader
|
||||||
|
needs, since it cannot construct a pending arg-bearing type at all. The loader accepts
|
||||||
|
the closed range `1..=2`.
|
||||||
|
|
||||||
### Enforcement shift — invariant 9 on the data plane
|
### Enforcement shift — invariant 9 on the data plane
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# C25 — The role model: nine authoring roles, cut by artifact + surface + iteration cost: history
|
||||||
|
|
||||||
|
> FROZEN HISTORICAL RECORD. Each block below was true as of its cycle/date stamp and may be superseded; this file is NOT current truth and NOT a grounding surface. Current contract: [c25-role-model.md](c25-role-model.md).
|
||||||
|
|
||||||
|
**Current-state executor-verb-set sentence (owner-minuted 2026-07-21 on #295,
|
||||||
|
delivered by #300; superseded by the #319 sugar retirement, 2026-07-25):**
|
||||||
|
"The executor verb set is settled: `run`, the four thin per-verb generators,
|
||||||
|
and the document verbs validate/introspect/register/show/run/runs — `show`
|
||||||
|
being #300's read-back addition. The verbs' per-verb identity is re-ratified
|
||||||
|
(#300 F8 — reduction, not grammar collapse)."
|
||||||
@@ -74,12 +74,14 @@ present but faceless: no addressed verb families yet. Role homes in the project
|
|||||||
layout and docs-by-role are open (#192).
|
layout and docs-by-role are open (#192).
|
||||||
|
|
||||||
**Document-first completion** is the resolved direction on the control-surface
|
**Document-first completion** is the resolved direction on the control-surface
|
||||||
amendment (owner-minuted 2026-07-21 on #295), delivered by #300. The executor verb
|
amendment (owner-minuted 2026-07-21 on #295), delivered by #300. The executor
|
||||||
set is settled: `run`, the four thin per-verb generators, and the document verbs
|
surface is settled again, one reduction further (#319, 2026-07-25): a single
|
||||||
validate/introspect/register/show/run/runs — `show` being #300's read-back
|
`exec <target>` verb runs both document classes — a campaign (file or content
|
||||||
addition. The verbs' per-verb identity is re-ratified (#300 F8 — reduction, not
|
id) and a signal blueprint (single run) — over the document verbs
|
||||||
grammar collapse). A typed-protocol host or MCP face remains demand-driven and
|
validate/introspect/register/show/runs (`show` #300's read-back addition); the
|
||||||
unbuilt.
|
one argv residue is `--override NODE.PARAM=VALUE`, a per-execution bound-param
|
||||||
|
reopen threading through both `exec` legs. A typed-protocol host or MCP face
|
||||||
|
remains demand-driven and unbuilt.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
- [C16](c16-engine-project-split.md) — the game-engine analogy this extends to people
|
- [C16](c16-engine-project-split.md) — the game-engine analogy this extends to people
|
||||||
@@ -88,3 +90,5 @@ unbuilt.
|
|||||||
- [C20](c20-strategy-harness.md), [C21](c21-world.md) — the node/harness/World tiers behind the 6a/6b split
|
- [C20](c20-strategy-harness.md), [C21](c21-world.md) — the node/harness/World tiers behind the 6a/6b split
|
||||||
- [C24](c24-blueprint-data.md) — topology-as-data, the strategy designer's artifact
|
- [C24](c24-blueprint-data.md) — topology-as-data, the strategy designer's artifact
|
||||||
- [C26](c26-input-binding.md) — a role's input contract carried in blueprint data
|
- [C26](c26-input-binding.md) — a role's input contract carried in blueprint data
|
||||||
|
|
||||||
|
> History: [c25-role-model.history.md](c25-role-model.history.md)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# C27 — Declared taps: named measurement points bind sinks run-mode-aware: history
|
||||||
|
|
||||||
|
> FROZEN HISTORICAL RECORD. Each block below was true as of its cycle/date stamp
|
||||||
|
> and may be superseded; this file is NOT current truth and NOT a grounding
|
||||||
|
> surface. Current contract: [c27-declared-taps.md](c27-declared-taps.md).
|
||||||
|
|
||||||
|
**Current-state tap-plan sentence (2026-07-21, #283; superseded 2026-07-24 by
|
||||||
|
the #310 `--tap` selector):** "…and the shared `bind_tap_plan`/`BoundTaps`
|
||||||
|
pair called by both declared-tap entry points, `run_signal_r`
|
||||||
|
(`aura-runner::member`) and `run_measurement` (`aura-runner::measure`); both
|
||||||
|
CLI verbs pass a record-all plan."
|
||||||
|
|
||||||
|
**Current-state "single CLI verb" clause (superseded by the #319 sugar
|
||||||
|
retirement, 2026-07-25):** "…both arms of the single CLI verb `aura run`,
|
||||||
|
whose repeatable `--tap TAP=FOLD` selector (#310) makes the `Named`
|
||||||
|
selection data-reachable…"
|
||||||
|
|
||||||
|
**Current-state `RunOutcome` sentence (superseded 2026-07-27 by #311's
|
||||||
|
identity-keyed single-run trace directory):** "Since #309 both entry points
|
||||||
|
return that pair as a named `RunOutcome { report, skipped, trace_name }`, the
|
||||||
|
third field being the trace-store handle the run recorded under (`Some` under
|
||||||
|
exactly the condition that opens the write path, `None` otherwise) — the same
|
||||||
|
beside-the-report discipline, widened rather than re-cut: the record keeps its
|
||||||
|
shape and the shell renders the handle."
|
||||||
@@ -42,7 +42,10 @@ input role, which `check_root_roles_bound` rejects ([C26](c26-input-binding.md))
|
|||||||
observation is optional, a fed input is mandatory. A declared-but-unbound tap
|
observation is optional, a fed input is mandatory. A declared-but-unbound tap
|
||||||
compiles and runs, its producer evaluating and its output discarded (a no-out-edge
|
compiles and runs, its producer evaluating and its output discarded (a no-out-edge
|
||||||
producer is a valid runnable sink — the Kahn sort emits it,
|
producer is a valid runnable sink — the Kahn sort emits it,
|
||||||
`check_ports_connected` gates only inputs).
|
`check_ports_connected` gates only inputs). The CLI surfaces this to the human
|
||||||
|
as a note (`aura: note: declared tap "…" unbound this run`) printed from the
|
||||||
|
unbound names the entry points return beside their report — never emitted by
|
||||||
|
the library itself (#297).
|
||||||
|
|
||||||
**Why.** Observability must be expressible in a hand-authored blueprint — the
|
**Why.** Observability must be expressible in a hand-authored blueprint — the
|
||||||
measurement-shaped study computes in the graph and surfaces via taps, no throwaway
|
measurement-shaped study computes in the graph and surfaces via taps, no throwaway
|
||||||
@@ -68,18 +71,62 @@ and the roster-enumerating refusal — plus a scalar-typed param schema; all cor
|
|||||||
entries are param-less today, the seam ships in every entry's build signature),
|
entries are param-less today, the seam ships in every entry's build signature),
|
||||||
and the shared `bind_tap_plan`/`BoundTaps` pair called by both declared-tap entry
|
and the shared `bind_tap_plan`/`BoundTaps` pair called by both declared-tap entry
|
||||||
points, `run_signal_r` (`aura-runner::member`) and `run_measurement`
|
points, `run_signal_r` (`aura-runner::member`) and `run_measurement`
|
||||||
(`aura-runner::measure`); both CLI verbs pass a record-all plan. The `record`
|
(`aura-runner::measure`) — both arms of the single CLI verb `aura exec` (#319),
|
||||||
|
whose repeatable `--tap TAP=FOLD` selector (#310) makes the `Named` selection
|
||||||
|
data-reachable: no flag keeps the record-all default, any flag replaces the
|
||||||
|
plan entirely (unlisted taps stay unbound/inert). `BoundTaps` carries the
|
||||||
|
unbound tap names out (`skipped: Vec<String>`), riding beside the returned
|
||||||
|
report rather than inside it, so the CLI — not the library — prints the
|
||||||
|
unbound-tap note (#297). Since #309 both entry points return that pair as a
|
||||||
|
named `RunOutcome { report, skipped, trace_name }`, the third field being the
|
||||||
|
trace-store handle the run recorded under — since #311 the run's own identity,
|
||||||
|
`<render-name>-<id8>`, minted from the manifest the entry point now assembles
|
||||||
|
*before* the bind and reuses for the record (`Some` under exactly the
|
||||||
|
condition that opens the write path, `None` otherwise) — the same beside-the-report
|
||||||
|
discipline, widened rather than re-cut: the record keeps its shape and the
|
||||||
|
shell renders the handle. The boundary is thereby
|
||||||
|
fixed in place: *selecting* a subscription is run-mode authority, exercised
|
||||||
|
by the run-mode owner — on the one-shot path the CLI invocation itself, a
|
||||||
|
projection exercising this contract's authority, not a second home for
|
||||||
|
intent under [C25](c25-role-model.md) — while *adding* a fold stays a Rust
|
||||||
|
entry (role 2). The campaign/document carrier, and the reconciliation of the
|
||||||
|
presentation-tap namespace (`persist_taps`) with declared taps it requires,
|
||||||
|
is deferred to the Measurement-reachable milestone (#312/#327, minuted on
|
||||||
|
#312). The `record`
|
||||||
consumer (`aura-runner::tap_recorder::TapRecorder`) holds the trace store's
|
consumer (`aura-runner::tap_recorder::TapRecorder`) holds the trace store's
|
||||||
streaming writer in-graph — `initialize` opens (deferred acquisition), `eval`
|
streaming writer in-graph — `initialize` opens (deferred acquisition), `eval`
|
||||||
appends `(Timestamp, Cell)` (zero per-cycle heap, #77), `finalize` reports
|
appends `(Timestamp, Cell)` (zero per-cycle heap, #77), `finalize` reports
|
||||||
exactly one terminal outcome; fold consumers (`aura-std::TapFold`) land one
|
exactly one terminal outcome; fold consumers (`aura-std::TapFold`) land one
|
||||||
summary row; live closures run inline (`aura-std::TapLive`). The sweep/reduce
|
summary row, emitted at finalize and stamped with the instant of the last
|
||||||
path never calls `bind_tap`.
|
contributing (warm) value — `first` alone pins the first contributing
|
||||||
|
instant; `min`/`max` deliberately do not carry the extremum's timestamp (a
|
||||||
|
whole-window row privileges no interior instant) — ratified as-is, #335;
|
||||||
|
live closures run inline
|
||||||
|
(`aura-std::TapLive`). The sweep/reduce
|
||||||
|
path never calls `bind_tap`. On the single-run path the wrap's own recording
|
||||||
|
sinks now use that same folded delivery as the declared-tap folds (#308,
|
||||||
|
`wrap_r`'s `fold_series`) — one summary row per series at finalize, the dense
|
||||||
|
record gated to closed rows plus the final row — so both entry points that
|
||||||
|
consume only aggregates, `run_signal_r` and `run_blueprint_member`, share one
|
||||||
|
delivery shape.
|
||||||
|
|
||||||
The chain-pruning benefit — a sweep paying zero for the study wires behind an
|
The chain-pruning benefit — a sweep paying zero for the study wires behind an
|
||||||
unbound tap — is **deferred to the future DCE cycle** ([C23](c23-graph-compilation.md));
|
unbound tap — is **deferred to the future DCE cycle** ([C23](c23-graph-compilation.md));
|
||||||
the mechanism ships now, verified sound.
|
the mechanism ships now, verified sound.
|
||||||
|
|
||||||
|
**#337 (2026-07-26 harvest) ships the positive discovery view**:
|
||||||
|
`Composite::declared_taps()` (`crates/aura-engine/src/blueprint.rs`) walks the
|
||||||
|
blueprint depth-first collecting `(tap name, source wire, column kind)`, bare
|
||||||
|
at every depth, bounds-total over an invalid wire; `graph introspect --taps
|
||||||
|
<FILE|ID>` renders one row per declared tap (a tap-less blueprint is a
|
||||||
|
stderr-noted listing, exit 0). This closes the **recovery-only** discovery
|
||||||
|
gap: before #337, the only way to learn a blueprint's declared tap names was
|
||||||
|
provoking `bind_tap_plan`'s `UnknownTap` refusal roster (#333) — a real name
|
||||||
|
surfaced only as the side effect of naming a wrong one first. The refusal
|
||||||
|
roster itself is unchanged and remains the **recovery** half (what to do once
|
||||||
|
a tap name is already wrong); `--taps` is the **discovery** half (learning the
|
||||||
|
right names up front).
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
- [C26](c26-input-binding.md) — the input-side twin (`input_roles`); `check_root_roles_bound`, the mandatory-input counterpart
|
- [C26](c26-input-binding.md) — the input-side twin (`input_roles`); `check_root_roles_bound`, the mandatory-input counterpart
|
||||||
- [C23](c23-graph-compilation.md) — compilation/lowering and the deferred DCE cycle the tap design anticipates
|
- [C23](c23-graph-compilation.md) — compilation/lowering and the deferred DCE cycle the tap design anticipates
|
||||||
|
|||||||
@@ -167,12 +167,31 @@ demand-gated, no tracking issue): measurement runs as sweep-family citizens
|
|||||||
(report unification, campaign engine generic-over-`M`), until a concrete
|
(report unification, campaign engine generic-over-`M`), until a concrete
|
||||||
family/campaign demand exists.
|
family/campaign demand exists.
|
||||||
|
|
||||||
**Deferred.** ~24 refusal sites inside `aura-runner`'s single-run verb paths still
|
**#297 (closed, 2026-07-26).** `aura-runner` no longer terminates the host
|
||||||
terminate the process (`std::process::exit`; the #283 tap-plan refusals added four
|
process anywhere: the 14 recon-enumerated `std::process::exit` sites inside
|
||||||
— typed as `TapPlanError` before the exit, so the eventual conversion is a
|
the single-run verb paths (`member.rs` ×8, `measure.rs` ×5, `translate.rs`
|
||||||
mechanical rewrap); their conversion to `RunnerError` propagation is tracked as
|
×1 — the "~24" this paragraph used to cite predates the #319 dual-grammar
|
||||||
**#297** (the campaign path already refuses via `MemberFault`, never a process
|
retirement and never updated in lockstep) are now `RunnerError { exit_code,
|
||||||
exit).
|
message }` constructors threaded through a fallible chain; the two entry
|
||||||
|
points, `run_signal_r` and `run_measurement`, return a fallible outcome
|
||||||
|
carrying the report beside the per-invocation values the shell renders
|
||||||
|
(`Result<RunOutcome<_>, RunnerError>` since #309 widened the original
|
||||||
|
`(_, Vec<String>)` pair), and the CLI's `exec_blueprint_leg`
|
||||||
|
remaps via one shared `exit_on_runner_error` helper (the `dispatch_reproduce`
|
||||||
|
pattern). The one hole in "a sweep worker never process-exits" — `cost_knob`,
|
||||||
|
reachable from `run_blueprint_member` — is closed the same cycle: the
|
||||||
|
campaign worker maps its fault into the cell's own `MemberFault` channel
|
||||||
|
(per-cell isolation, this contract's stated campaign guarantee unchanged),
|
||||||
|
while the campaign trace-persist path — the only other production caller —
|
||||||
|
reports it through its existing string-error channel into the campaign
|
||||||
|
summary's exit. Refusal prose is byte-identical everywhere;
|
||||||
|
only who prints it changed, plus — per the adjudicated C14 partition — the
|
||||||
|
exit class of refusals whose fault is in the content of what argv named.
|
||||||
|
What remains printing in the assembly crate is the deliberate note/info
|
||||||
|
residuum — the fold/trace notes and the traces-persisted line
|
||||||
|
(`runner.rs`) and the stale-dylib warning (`project.rs`) — benign
|
||||||
|
diagnostics that print and never exit; migrating them needs a note-channel
|
||||||
|
design of its own.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
- [C1](c01-determinism.md) — determinism / bit-identity, the correctness invariant the layer cuts preserve
|
- [C1](c01-determinism.md) — determinism / bit-identity, the correctness invariant the layer cuts preserve
|
||||||
|
|||||||
@@ -27,13 +27,23 @@ cannot pass its short-name alibi
|
|||||||
restatement.
|
restatement.
|
||||||
3. **Register seam (data plane).** The registry's public `put_blueprint`
|
3. **Register seam (data plane).** The registry's public `put_blueprint`
|
||||||
parses the canonical bytes and requires a gate-passing doc on the root
|
parses the canonical bytes and requires a gate-passing doc on the root
|
||||||
composite and on every named nested composite — the gate guards the
|
composite and on **every** nested composite (the walk recurses
|
||||||
store boundary itself, so the register verb, every register-then-run
|
unconditionally — it does not stop at an unnamed one) — the gate guards
|
||||||
|
the store boundary itself, so the register verb, every register-then-run
|
||||||
sugar path, and any future caller are gated by construction. The
|
sugar path, and any future caller are gated by construction. The
|
||||||
op-script vocabulary carries the `doc` op as the builder `.doc(...)`'s
|
op-script vocabulary carries the `doc` op as the builder `.doc(...)`'s
|
||||||
twin ([C25](c25-role-model.md): a closed, typed metadata construct).
|
twin ([C25](c25-role-model.md): a closed, typed metadata construct).
|
||||||
Process/campaign documents take an additive-optional top-level
|
Process/campaign documents take an additive-optional top-level
|
||||||
`description`, gated only when present (`DocFault::BadDescription`).
|
`description`, gated only when present (`DocFault::BadDescription`).
|
||||||
|
4. **Use seam (data plane, construction-time, #317).** The `use`
|
||||||
|
construction op re-runs the SAME `gate_composite_docs` walk over a
|
||||||
|
blueprint FETCHED from the store, before it ever splices into a new
|
||||||
|
composition — a build-time gate on already-registered content, not a
|
||||||
|
second write-path check. A doc-less entry (reachable only through the
|
||||||
|
raw in-crate write survivor class, since `put_blueprint`'s own gate keeps
|
||||||
|
the store clean going forward) cannot enter a NEW composition this way,
|
||||||
|
but stays readable and runnable on its own — no retroactive invalidation,
|
||||||
|
the same discipline the Forbids clause states for the register seam.
|
||||||
|
|
||||||
**Scope at this record's writing (#321).** The gate walks five
|
**Scope at this record's writing (#321).** The gate walks five
|
||||||
vocabularies — process/campaign blocks, metrics, tap slots, tap folds, and
|
vocabularies — process/campaign blocks, metrics, tap slots, tap folds, and
|
||||||
@@ -50,6 +60,11 @@ content id and are blanked for the identity id, exactly as `Composite.doc`
|
|||||||
([C24](c24-blueprint-data.md)), so node docs move no id. Documents have no
|
([C24](c24-blueprint-data.md)), so node docs move no id. Documents have no
|
||||||
identity projection — their pair is: absent `description` is byte-identical
|
identity projection — their pair is: absent `description` is byte-identical
|
||||||
to the field-less form (existing content ids stable), present participates.
|
to the field-less form (existing content ids stable), present participates.
|
||||||
|
A single `aura exec` run's #311 trace-directory handle honours this same
|
||||||
|
treatment (2026-07-27 audit fix): its digest hashes the blueprint's identity
|
||||||
|
projection, not `manifest.topology_hash` (a content id), so a
|
||||||
|
description-only blueprint edit mints no fresh directory for a bit-identical
|
||||||
|
run (`aura-runner::run_identity_digest`, [C18](c18-registry.md)).
|
||||||
|
|
||||||
**Forbids.** The engine evaluating description text — the gate is
|
**Forbids.** The engine evaluating description text — the gate is
|
||||||
deterministic string shape, never content judgement (no freetext logic
|
deterministic string shape, never content judgement (no freetext logic
|
||||||
@@ -67,6 +82,8 @@ for whom the binary is the only always-present teacher. Field evidence
|
|||||||
execution semantics and document schemas by CAS forensics — the removed
|
execution semantics and document schemas by CAS forensics — the removed
|
||||||
failure class is exactly that forensic recovery.
|
failure class is exactly that forensic recovery.
|
||||||
|
|
||||||
Rendering of the carried texts on the help/introspection surfaces is #315;
|
Rendering of the carried texts on the help/introspection surfaces shipped
|
||||||
the generated agent bootstrap card is #267; a fold introspection surface
|
with #315; the agent bootstrap card was retired as superseded (#267 — the
|
||||||
is blocked on #310.
|
C29 surfaces themselves carry it); the fold introspection surface shipped
|
||||||
|
with #310 and renders the fold-registry roster — labels exactly as the
|
||||||
|
`--tap` selector accepts them, doc lines from the registry entries (#332).
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# C30 — Stability discipline: artifacts are stable, code is movable
|
||||||
|
|
||||||
|
**Guarantee.** The project's compatibility promise attaches to recorded
|
||||||
|
data, never to Rust API. Before 1.0 the workspace's library surface
|
||||||
|
promises nothing between commits; every cargo consumer either moves with
|
||||||
|
the engine checkout (path-dep node crates, staleness refused at the load
|
||||||
|
handshake) or pins a rev deliberately (external embeddings). The one
|
||||||
|
handshake guarding the ABI seam compares build identity, never a
|
||||||
|
hand-maintained version.
|
||||||
|
|
||||||
|
1. **No API stability before 1.0.** Signatures, module homes, crate
|
||||||
|
rosters, and trait shapes move freely, commit over commit — no
|
||||||
|
deprecation cycles, no compat shims, no semver bumps. The workspace
|
||||||
|
version (0.1.0) is not a communication channel; nothing downstream
|
||||||
|
reads it. What a change may *not* silently break is behaviour pinned
|
||||||
|
by the artifact plane (point 4).
|
||||||
|
|
||||||
|
2. **Two consumer classes, two mechanisms.**
|
||||||
|
- A **native node crate** (scaffolded by `aura nodes new`) consumes
|
||||||
|
the engine as a cargo *path* dependency into the local engine
|
||||||
|
checkout — the scaffolder emits exactly this
|
||||||
|
(`crates/aura-cli/src/scaffold.rs`, template `CARGO_TOML`). Node
|
||||||
|
crate and engine move together: cargo rebuilds the crate when
|
||||||
|
aura-core changes, and a dylib from any other engine state is
|
||||||
|
refused at load (point 3), never trusted.
|
||||||
|
- An **external embedding** (a World program in its own repo)
|
||||||
|
consumes the workspace crates as a cargo git dependency pinned to a
|
||||||
|
rev. The pin names the engine; the embedding's own committed
|
||||||
|
`Cargo.lock` freezes the rest of the graph — dependencies the
|
||||||
|
engine itself takes as git-branch references (e.g. the data-server)
|
||||||
|
resolve at lock time, so rev + lockfile together are the
|
||||||
|
reproducible-build contract (fieldtest finding, 2026-07-26).
|
||||||
|
Updating either is a deliberate act that accepts whatever moved,
|
||||||
|
with the design ledger and commit bodies as the changelog.
|
||||||
|
|
||||||
|
3. **The load handshake refuses non-identical builds.** The cdylib
|
||||||
|
handshake already refuses a rustc mismatch (`RUSTC_VERSION`, emitted
|
||||||
|
by build.rs). The aura-core stamp must be equally honest: a
|
||||||
|
build-identity fingerprint derived mechanically from aura-core's
|
||||||
|
sources, so a node dylib loads only against the aura-core it was
|
||||||
|
built from — *source-level* identity, an accepted limit: the
|
||||||
|
consuming build's lockfile and feature selection stay outside the
|
||||||
|
stamp, so the seam refuses staleness of aura-core itself, never the
|
||||||
|
full link graph. A hand-maintained version that never moves disarms the
|
||||||
|
refusal (the pre-C30 state: `CORE_VERSION` frozen at 0.1.0 matched
|
||||||
|
every stale dylib); a hand-bumped one re-arms it only between bumps —
|
||||||
|
an implicit ABI promise this contract refuses to make. *(Re-armed by
|
||||||
|
#348: a source-derived fingerprint replaced the crate-version stamp.)*
|
||||||
|
|
||||||
|
4. **The artifact plane is the stable tier.** What was already law,
|
||||||
|
stated as the positive promise: registered artifacts are never
|
||||||
|
retroactively invalidated ([C29](c29-self-description.md)); document
|
||||||
|
format changes carry `format_version` with readers keeping old forms
|
||||||
|
readable ([C18](c18-registry.md)); deploy artifacts are frozen
|
||||||
|
([C13](c13-hot-reload-frozen-deploy.md), invariant 8). Compatibility
|
||||||
|
work happens here: a change that would orphan recorded artifacts is a
|
||||||
|
design decision for the ledger, never a refactor.
|
||||||
|
|
||||||
|
5. **The erosion guard.** No doc, README, or scaffold may describe any
|
||||||
|
Rust surface as "stable API" while this contract stands. Inviting a
|
||||||
|
consumer means naming the point-2 mechanism (path or pin), never
|
||||||
|
promising stillness. Amending this contract is the only way to
|
||||||
|
promise more.
|
||||||
|
|
||||||
|
**Rationale.** #296 recorded the tension: an invited external consumer
|
||||||
|
(#295 deliberately enlarges the public library surface) versus a working
|
||||||
|
discipline of behaviour-preserving reshaping ([C28](c28-stratification.md)
|
||||||
|
relocated whole rosters twice in one pass). The resolution promises where
|
||||||
|
the project can keep promises — the recorded-data plane, whose guarantees
|
||||||
|
already exist — and refuses to promise where it cannot. The empirical
|
||||||
|
ground at decision time: the one real downstream project is data-only (an
|
||||||
|
`Aura.toml` directory with no `Cargo.toml`, consuming the released binary
|
||||||
|
and documents), so the only cargo consumers are engine-scaffolded node
|
||||||
|
crates (path-dep by construction) and the engine's own fieldtest
|
||||||
|
fixtures. A stability promise to nobody would cost every cycle and inform
|
||||||
|
no one; hardening toward 1.0 stays available at any time, while stability
|
||||||
|
promised early is practically irrevocable.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
- [C13](c13-hot-reload-frozen-deploy.md) — frozen deploy artifacts (the promise at the deploy edge)
|
||||||
|
- [C16](c16-engine-project-split.md) — engine/project split; reuse is cargo-native (invariant 9)
|
||||||
|
- [C18](c18-registry.md) — the run registry and `format_version` (the artifact plane's mechanics)
|
||||||
|
- [C28](c28-stratification.md) — the movable crate ladder this contract licenses
|
||||||
|
- [C29](c29-self-description.md) — registered artifacts never retroactively invalidated
|
||||||
|
- Decision record: #296 (options weighed, skeptic findings); re-arming the handshake: #348
|
||||||
+37
-19
@@ -11,6 +11,10 @@ Entries are alphabetical.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### arg kind
|
||||||
|
**Avoid:** ArgKind, construction-arg type
|
||||||
|
The closed vocabulary of construction-arg value shapes (`Tz`/`TimeOfDay`/`Count`, #271) — deliberately NOT a `scalar base type`: args are bootstrap metadata, never streamed, so the two closedness axes stay separate. Each kind's accepted strict form IS its canonical form (an exact IANA name; zero-padded `HH:MM`; a plain positive decimal count, no sign or leading zeros) — there is no normalization layer, so a near-miss string refuses rather than being rewritten. The per-kind forms are deliberately asymmetric (`"03"` refuses as a count while `"09:30"` requires the pad): canonical is each domain's conventional notation — fixed-width for wall-clock times, minimal for numbers.
|
||||||
|
|
||||||
### atomic sim unit
|
### atomic sim unit
|
||||||
**Avoid:** atomic unit, sim unit
|
**Avoid:** atomic unit, sim unit
|
||||||
The primitive `(frozen topology + param-set + data-window + RNG-seed) → deterministic run → metrics` over which the four orchestration axes operate. One frozen-topology unit equals one harness instance.
|
The primitive `(frozen topology + param-set + data-window + RNG-seed) → deterministic run → metrics` over which the four orchestration axes operate. One frozen-topology unit equals one harness instance.
|
||||||
@@ -29,11 +33,15 @@ A strategy's primary, backtestable DAG output: one signed, bounded `f64 ∈ [-1,
|
|||||||
|
|
||||||
### blueprint
|
### blueprint
|
||||||
**Avoid:** —
|
**Avoid:** —
|
||||||
The param-generic, input-role-generic graph-as-data produced by running a Rust builder; it carries free numeric params and free input roles before bootstrap. Bootstrapped into a frozen instance by binding params + data + seed. Registered and inspected headless (`aura graph register`, `aura graph introspect --params` — the raw `param_space` namespace campaign axes bind against; cycle 0107/#196); a *bound* param is an overridable **default** — a sweep axis naming it re-opens it per family (#246), while `run` uses it as-is.
|
The param-generic, input-role-generic graph-as-data produced by running a Rust builder; it carries free numeric params and free input roles before bootstrap. Bootstrapped into a frozen instance by binding params + data + seed. Registered and inspected headless (`aura graph register`, `aura graph introspect --params` — the raw `param_space` namespace campaign axes bind against; cycle 0107/#196); a *bound* param is an overridable **default** — a sweep axis naming it re-opens it per family (#246), while a plain `exec` uses it as-is (#319).
|
||||||
|
|
||||||
|
### blueprint label
|
||||||
|
**Avoid:** —
|
||||||
|
A registry-level name pointing at a registered blueprint's content id (`graph register --name <label>`, #317) — a mutable, latest-wins pointer over the immutable content-addressed store, not a second identity: re-registering under an existing label *repoints* it (the earlier content id stays reachable by its own id, never invalidated). `graph introspect --registered` lists every label with its content-id prefix and root `doc` line — the discovery surface a `use (op)` reference by name resolves against. Orthogonal to the composite's own **render name** (the `name` field carried in the blueprint bytes, op-settable via `{"op":"name",...}`, #331; defaults to `"graph"` if omitted): the render name *prefixes* the run's trace directory (`traces/<name>-<id8>/`, #311 — the run's own identity digest completes it) and is a `use`-splice's default instance identifier, while the label is an explicit, separately-set store pointer.
|
||||||
|
|
||||||
### bootstrap
|
### bootstrap
|
||||||
**Avoid:** —
|
**Avoid:** —
|
||||||
The distinct, recursive construction phase that binds `(blueprint + param-set + data bindings + seed)` into a frozen instance — buffers sized, topology fixed. The explicit name for the "wiring / graph build" that C7/C12 reference — the construction/compilation sense. Disambiguation: the *statistical* moving-block bootstrap of a trade-R series (`r_bootstrap`, `RBootstrap`) is a different thing that keeps its statistics name — it appears as the deflation null, the `aura mc` R path, and since 0108 the `std::monte_carlo` stage's `stage bootstrap` annotation; context (construction vs annotation) disambiguates.
|
The distinct, recursive construction phase that binds `(blueprint + param-set + data bindings + seed)` into a frozen instance — buffers sized, topology fixed. The explicit name for the "wiring / graph build" that C7/C12 reference — the construction/compilation sense. Disambiguation: the *statistical* moving-block bootstrap of a trade-R series (`r_bootstrap`, `RBootstrap`) is a different thing that keeps its statistics name — it appears as the deflation null and, since 0108, the `std::monte_carlo` stage's `stage bootstrap` annotation (the campaign executor's one R-bootstrap path, #319); context (construction vs annotation) disambiguates.
|
||||||
|
|
||||||
### bot
|
### bot
|
||||||
**Avoid:** —
|
**Avoid:** —
|
||||||
@@ -45,11 +53,11 @@ A downstream consumer node, never part of the strategy: the signal-quality side
|
|||||||
|
|
||||||
### campaign document
|
### campaign document
|
||||||
**Avoid:** experiment doc, campaign file
|
**Avoid:** experiment doc, campaign file
|
||||||
The role-6b research artifact (#188/#189): persisted experiment intent as closed-vocabulary canonical JSON — instruments × windows × strategy refs (by `content id` or `identity id`) × per-strategy param axes (each axis declares its `ScalarKind` once over bare values) × a process reference (content-id-only) × data-level presentation (taps to persist — the closed `tap` vocabulary — and tables to emit). Authored, validated, and executed headless (`aura campaign validate|introspect|register|show|run|runs` — `show` prints a registered document's canonical bytes back, #300), content-addressed beside blueprints in the registry store; carries P1 control constructs (bounded axes, gates, ladders) as intent, executed by `aura campaign run` (v2 pipeline shape `std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]? [std::generalize]?` — the two annotators terminal, cycles 0107/0108, #198/#200) into a `campaign run` realization. `std::sweep`'s own selection group (`metric`+`select`) is optional, all-or-nothing, and permitted only as the pipeline's terminal stage when omitted (a selection-free sweep, #210).
|
The role-6b research artifact (#188/#189): persisted experiment intent as closed-vocabulary canonical JSON — instruments × windows × strategy refs (by `content id` or `identity id`) × per-strategy param axes (each axis declares its `ScalarKind` once over bare values) × a process reference (content-id-only) × data-level presentation (taps to persist — the closed `tap` vocabulary — and tables to emit). Authored headless (`aura campaign validate|introspect|register|runs|show` — `show` prints a registered document's canonical bytes back, #300), content-addressed beside blueprints in the registry store; carries P1 control constructs (bounded axes, gates, ladders) as intent, executed by `aura exec` (#319; v2 pipeline shape `std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]? [std::generalize]?` — the two annotators terminal, cycles 0107/0108, #198/#200) into a `campaign run` realization. `std::sweep`'s own selection group (`metric`+`select`) is optional, all-or-nothing, and permitted only as the pipeline's terminal stage when omitted (a selection-free sweep, #210).
|
||||||
|
|
||||||
### campaign run
|
### campaign run
|
||||||
**Avoid:** campaign execution record, realized campaign
|
**Avoid:** campaign execution record, realized campaign
|
||||||
One execution of a `campaign document` (`aura campaign run <file|content id>`; a file is register-then-run sugar — the content id is the canonical address). Realized once per (strategy, instrument, window) cell in doc order and recorded as a thin `CampaignRunRecord` line in the registry's `campaign_runs.jsonl` — linking the per-stage family ids, gate survivor ordinals, `stage bootstrap` annotations, and (campaign-scope, per strategy × window) `generalizations` entries (`generalization` with `worst_case`/`sign_agreement`/`per_instrument`, plus `winners` params and `missing` instruments on shortfall) over untouched family records, run-counted per campaign id. A cell whose gate leaves no survivors records its realized prefix and the run exits 0: a null result is a valid research result. Deterministic from doc + stores + data (C1): deflation and bootstrap nulls seed from the doc's `seed`. When the document requests `persist_taps`, the record's sparse `trace_name` (`"{campaign8}-{run}"`) points at the TraceStore family holding each nominee cell's persisted taps (0109/#201). NB the stdout emit wraps each record as `{"campaign_run": …}`; the stored `campaign_runs.jsonl` line is the bare record.
|
One execution of a `campaign document` (`aura exec <file|content id>`, #319; a file is register-then-run sugar — the content id is the canonical address). Realized once per (strategy, instrument, window) cell in doc order and recorded as a thin `CampaignRunRecord` line in the registry's `campaign_runs.jsonl` — linking the per-stage family ids, gate survivor ordinals, `stage bootstrap` annotations, and (campaign-scope, per strategy × window) `generalizations` entries (`generalization` with `worst_case`/`sign_agreement`/`per_instrument`, plus `winners` params and `missing` instruments on shortfall) over untouched family records, run-counted per campaign id. A cell whose gate leaves no survivors records its realized prefix and the run exits 0: a null result is a valid research result. Deterministic from doc + stores + data (C1): deflation and bootstrap nulls seed from the doc's `seed`. When the document requests `persist_taps`, the record's sparse `trace_name` (`"{campaign8}-{run}"`) points at the TraceStore family holding each nominee cell's persisted taps (0109/#201). NB the stdout emit wraps each record as `{"campaign_run": …}`; the stored `campaign_runs.jsonl` line is the bare record.
|
||||||
|
|
||||||
### cdylib
|
### cdylib
|
||||||
**Avoid:** —
|
**Avoid:** —
|
||||||
@@ -63,6 +71,10 @@ The type-erased 64-bit word holding one scalar-base-type value with its kind str
|
|||||||
**Avoid:** —
|
**Avoid:** —
|
||||||
A node that wires a sub-graph and exposes one output (a combined signal, or a strategy) — composition is fractal and acyclic. May carry an optional authored rationale (`doc`, #125) — a non-load-bearing debug symbol shown in the graph tooltip, identity-blind like the name. Also names the multi-column stream a node emits: the **record** a producer's `eval` returns, bundling 1..K base scalar columns (e.g. OHLCV), each bound field-wise by a consumer (C7/C8).
|
A node that wires a sub-graph and exposes one output (a combined signal, or a strategy) — composition is fractal and acyclic. May carry an optional authored rationale (`doc`, #125) — a non-load-bearing debug symbol shown in the graph tooltip, identity-blind like the name. Also names the multi-column stream a node emits: the **record** a producer's `eval` returns, bundling 1..K base scalar columns (e.g. OHLCV), each bound field-wise by a consumer (C7/C8).
|
||||||
|
|
||||||
|
### construction arg
|
||||||
|
**Avoid:** construction-time param, arg
|
||||||
|
A typed, closed, non-scalar construction input (`args`, #271) that configures an **arg-bearing** vocabulary type's structural shape — `Session`'s IANA timezone/open, `LinComb`/`CostSum`'s arity — through a second channel beside `bind`, consumed once at construction (`PrimitiveBuilder::try_args`) before any param binds, never streamed, never swept, never bound. Values are closed, load-time-validated strings (see `arg kind`); the accepted pairs are id-bearing (they enter the blueprint's content id, like `bind` values) and survive the identity projection.
|
||||||
|
|
||||||
### content id
|
### content id
|
||||||
**Avoid:** —
|
**Avoid:** —
|
||||||
The SHA-256 (hex) of an artifact's byte-canonical form — for a blueprint, `blueprint_to_json` (the byte-exact identity that keys the reproduction store and anchors `aura reproduce`, stamped into a run manifest as the `topology hash`); for a `process document` / `campaign document`, its canonical JSON (keying the sibling registry stores). One primitive, library-hosted in `aura-research` (`content_id_of`; the CLI delegates). Surfaced as `aura graph|process|campaign introspect --content-id`; two artifacts share a content id only when their canonical bytes agree, debug names included — contrast `identity id`.
|
The SHA-256 (hex) of an artifact's byte-canonical form — for a blueprint, `blueprint_to_json` (the byte-exact identity that keys the reproduction store and anchors `aura reproduce`, stamped into a run manifest as the `topology hash`); for a `process document` / `campaign document`, its canonical JSON (keying the sibling registry stores). One primitive, library-hosted in `aura-research` (`content_id_of`; the CLI delegates). Surfaced as `aura graph|process|campaign introspect --content-id`; two artifacts share a content id only when their canonical bytes agree, debug names included — contrast `identity id`.
|
||||||
@@ -77,7 +89,7 @@ A composable downstream **C9 graph of cost nodes**, in **R**, that **approximate
|
|||||||
|
|
||||||
### cross-instrument generalization
|
### cross-instrument generalization
|
||||||
**Avoid:** cross-symbol pooling, pooled generalization
|
**Avoid:** cross-symbol pooling, pooled generalization
|
||||||
The validation read that grades how consistently one *brought* candidate holds across a set of instruments, scored on its weakest one — the across-instrument axis of the anti-false-discovery discipline. Realised by `aura generalize` and, since 0108 (#200), by the `std::generalize` process stage at campaign scope (per (strategy, window) over the cells' nominees across instruments, recorded in the `campaign run`'s `generalizations`); an aggregator (a recomputable family score), never a selector that picks a winner.
|
The validation read that grades how consistently one *brought* candidate holds across a set of instruments, scored on its weakest one — the across-instrument axis of the anti-false-discovery discipline. Realised by the `std::generalize` process stage at campaign scope (since 0108, #200; per (strategy, window) over the cells' nominees across instruments, recorded in `CampaignRunRecord.generalizations`, executed via `aura exec`, #319) — the standalone `aura generalize` verb that used to persist its own per-instrument family is retired (#319: `FamilyKind::CrossInstrument` is now dead); an aggregator (a recomputable family score), never a selector that picks a winner.
|
||||||
|
|
||||||
### cycle
|
### cycle
|
||||||
**Avoid:** —
|
**Avoid:** —
|
||||||
@@ -191,7 +203,7 @@ The recorded chance that a deflated sweep winner is noise rather than edge — a
|
|||||||
|
|
||||||
### plateau selection
|
### plateau selection
|
||||||
**Avoid:** plateau-over-peak (as a noun)
|
**Avoid:** plateau-over-peak (as a noun)
|
||||||
A selection objective that argmaxes the neighbourhood-smoothed metric surface (mean or worst-case) rather than the bare in-sample peak, preferring a robust parameter plateau to a lucky spike. Opt-in via `--select plateau:mean|plateau:worst`; the default selection stays a bare argmax.
|
A selection objective that argmaxes the neighbourhood-smoothed metric surface (mean or worst-case) rather than the bare in-sample peak, preferring a robust parameter plateau to a lucky spike. Opt-in via a process document's `std::sweep`/`std::walk_forward` stage `"select": "plateau:mean"|"plateau:worst"` field; the default selection stays a bare argmax.
|
||||||
|
|
||||||
### playground
|
### playground
|
||||||
**Avoid:** —
|
**Avoid:** —
|
||||||
@@ -233,13 +245,15 @@ A node that converts a finer stream to a coarser bar stream, emitting a complete
|
|||||||
**Avoid:** risk section
|
**Avoid:** risk section
|
||||||
One entry of a campaign document's structural risk axis (`risk`): a
|
One entry of a campaign document's structural risk axis (`risk`): a
|
||||||
serializable protective-stop regime (variants `vol{length,k}` — per-cycle —
|
serializable protective-stop regime (variants `vol{length,k}` — per-cycle —
|
||||||
and `vol_tf{period_minutes,length,k}` — per completed time bucket) the matrix
|
`vol_tf{period_minutes,length,k}` — per completed time bucket — and
|
||||||
runs every cell under, so cells differ by execution discipline, never by
|
`fixed{distance}` — a constant price-unit distance, binding the shipped
|
||||||
signal. Absent or empty = one implicit default regime; the regime's stop
|
`FixedStop` composite) the matrix runs every cell under, so cells differ by
|
||||||
defines the risk unit R — in `vol{length,k}` (stop = k·√EMA(Δ², length) over
|
execution discipline, never by signal. Absent or empty = one implicit default
|
||||||
m1 cycles) `length` only smooths the vol estimator while `k` scales the stop
|
regime; the regime's stop defines the risk unit R — in `vol{length,k}` (stop =
|
||||||
distance, so the stop's timescale stays one cycle (`vol_tf` sets the stop's
|
k·√EMA(Δ², length) over m1 cycles) `length` only smooths the vol estimator
|
||||||
timescale via `period_minutes`).
|
while `k` scales the stop distance, so the stop's timescale stays one cycle
|
||||||
|
(`vol_tf` sets the stop's timescale via `period_minutes`; `fixed` has no
|
||||||
|
timescale — the distance never adapts).
|
||||||
|
|
||||||
### run
|
### run
|
||||||
**Avoid:** —
|
**Avoid:** —
|
||||||
@@ -259,7 +273,7 @@ The four streamed scalar kinds — `i64`, `f64`, `bool`, `timestamp` (a newtype
|
|||||||
|
|
||||||
### session node
|
### session node
|
||||||
**Avoid:** session window
|
**Avoid:** session window
|
||||||
A node that exposes session context as a scalar stream so session logic stays inside the stream model. The shipped `Session` / `SessionFrankfurt` node (`aura-std`) emits ONE `i64` field, `bars_since_open` — the count of completed bar-periods since the local (tz-aware, DST-correct) session open, indexed off the just-closed bar's close instant (in-session closes read exact positive multiples: 09:15→1, 09:45→3; pre-open ≤0). `SessionFrankfurt` bakes the Frankfurt 09:00 `Europe/Berlin` open with a single `period_minutes` knob; its `trigger` input only clocks it once per completed bar (the value is ignored). Calendars and instrument specs remain metadata beside the hot path.
|
A node that exposes session context as a scalar stream so session logic stays inside the stream model. The shipped `Session` / `SessionFrankfurt` node (`aura-market`) emits ONE `i64` field, `bars_since_open` — the count of completed bar-periods since the local (tz-aware, DST-correct) session open, indexed off the just-closed bar's close instant (in-session closes read exact positive multiples: 09:15→1, 09:45→3; pre-open ≤0). `Session` is roster-reachable for ANY IANA timezone/open through the typed **construction arg** channel (#271: `args: {"tz": <IANA name>, "open": "HH:MM"}`, applied before its one scalar param, `period_minutes`); `SessionFrankfurt` stays as baked sugar over the Frankfurt 09:00 `Europe/Berlin` open. Its `trigger` input only clocks it once per completed bar (the value is ignored). Calendars and instrument specs remain metadata beside the hot path.
|
||||||
|
|
||||||
### sign-agreement
|
### sign-agreement
|
||||||
**Avoid:** sign consistency, market breadth
|
**Avoid:** sign consistency, market breadth
|
||||||
@@ -319,27 +333,31 @@ The harness's structural parameterization — which strategy, instrument(s), bro
|
|||||||
|
|
||||||
### sweep
|
### sweep
|
||||||
**Avoid:** param-sweep, parameter sweep
|
**Avoid:** param-sweep, parameter sweep
|
||||||
An orchestration axis varying tuning params (grid or random) within a fixed structure. The inner, param-tuning loop, distinct from the structural experiment matrix. On a loaded blueprint every open knob (`--list-axes`) is **required** — a subset is refused with the missing knob named; pin an unwanted knob with a single-value axis (`--axis name=<one-value>`), there is no default. `aura sweep --axis` takes the `--list-axes`-printed, root-composite-wrapped name (e.g. `graph.fast.length`) — not the raw `param_space` name (`fast.length`) that `graph introspect --params` and a campaign document's axes use; the CLI strips exactly one leading wrapper segment (#210). The `aura sweep` CLI verb is now thin sugar over the `campaign document` path — its blueprint form (`<bp.json> --real`) translates to a generated, content-addressed campaign run through the one executor (#210); the built-in `--strategy` sweep surface was retired by #159 (its hard-wired harnesses removed) — sweep now runs from a blueprint + `--axis`, and a retired `--strategy` token falls to the generic usage error. A ganged pair contributes ONE axis.
|
An orchestration axis varying tuning params (grid or random) within a fixed structure. The inner, param-tuning loop, distinct from the structural experiment matrix. On a loaded blueprint every open knob (`graph introspect --params`) is **required** on a campaign document's `strategies[].axes` — a subset is refused with the missing knob named; pin an unwanted knob with a single-value axis (one value in its `values` list), there is no default. One raw namespace, `<node>.<param>` (a splice path keeps its interior path), is the only axis name (#328): `graph introspect --params` and a campaign axis key are line-identical (open params bare, bound params with `default=`) — the older `<blueprint>.<node>.<param>` wrapped form (e.g. `graph.fast.length`) is retired from the surface; naming it in a campaign document gets a did-you-mean toward the raw candidate, the one remaining intake seam (#319 retired the standalone `--axis`/`--list-axes` CLI flags along with the blueprint-form `aura sweep` verb, whose own `--strategy` built-in surface #159 had already retired). Realised as a campaign document's axes (#210), executed through the one `aura exec` executor (#319). A ganged pair contributes ONE axis.
|
||||||
|
|
||||||
### tap
|
### tap
|
||||||
**Avoid:** probe, monitor, scope (for the observation slot — "probe" is taken by the sweep-terminal `blueprint_axis_probe` sense; the #77 `Recorder`→`Probe` rename was retired, 2026-07-21)
|
**Avoid:** probe, monitor, scope (for the observation slot — "probe" is taken by the sweep-terminal `blueprint_axis_probe` sense; the #77 `Recorder`→`Probe` rename was retired, 2026-07-21)
|
||||||
A named recorded stream produced by a recording `sink` — the addressable label (e.g. `equity`, `net_r_equity`) under which one sink's per-cycle output is persisted as a columnar (SoA) `ColumnarTrace` and selected for charting via `--tap`. Distinct from the `sink` node that emits it (a tap is the stream, the sink is the role) and from a whole recorded run (a bundle of taps); taps fire at their own cadences and are fused only by joining on the recorded timestamp, never by positional index. In a `campaign document`, `persist_taps` names taps from the CLOSED vocabulary `equity | exposure | r_equity | net_r_equity` (`tap_vocabulary`, 0109/#201) — a new observable is a new vocabulary entry or an authored blueprint sink, never an open node-path namespace. Persisted taps are charted by the printed handle: a campaign run's via the record's `trace_name`, a `sweep`/`walkforward --trace` family's via the family handle the run prints (`aura chart <handle>`; its members are keyed `<cell>/<member>` in the chart) — or, equivalently, by the `--trace <NAME>` the user chose, when `NAME` uniquely resolves against the recorded campaign documents (#238; a name reused across runs refuses rather than guessing).
|
A named recorded stream produced by a recording `sink` — the addressable label (e.g. `equity`, `net_r_equity`) under which one sink's per-cycle output is persisted as a columnar (SoA) `ColumnarTrace` and selected for charting via `--tap`. Distinct from the `sink` node that emits it (a tap is the stream, the sink is the role) and from a whole recorded run (a bundle of taps); taps fire at their own cadences and are fused only by joining on the recorded timestamp, never by positional index. In a `campaign document`, `persist_taps` names taps from the CLOSED vocabulary `equity | exposure | r_equity | net_r_equity` (`tap_vocabulary`, 0109/#201) — a new observable is a new vocabulary entry or an authored blueprint sink, never an open node-path namespace. Persisted taps are charted by the printed handle: a campaign run's deterministic `trace_name` (`"{campaign8}-{run}"`, never a user-chosen name since #319) charts its whole family (`aura chart <handle>`; members keyed `<cell>/<member>` in the chart) — or, equivalently, a bare campaign id/name that uniquely resolves against the recorded campaign documents (#238; a name reused across runs refuses rather than guessing). A single run reports its own handle the same way, as `trace_name` on its stdout line (#309) — the run's render name plus its 8-hex **run identity** (`<render-name>-<id8>`, #311: a SHA-256 over the run's manifest with the two provenance keys removed — the aura binary's build sha and the project repo's HEAD/dirty marker — and `params`/`defaults` merged into one name-sorted sequence, since that partition records how a value was supplied, not what the run's effective parameterisation is — so two runs differing in any identity-bearing input land in two directories, and two runs with the same effective parameterisation, including one reached via a no-op `--override`, land in one), absent when the run recorded nothing. NB a `family id` is not a handle: it names one cell/stage record within a run, and cutting it down does not yield the run's handle.
|
||||||
|
|
||||||
Since C27 (#282) the word also names a second, author-facing sense: a **declared tap** — a `Composite.taps` entry `Tap { name, from: {node, field} }` a hand-authored blueprint declares on an interior output wire, the output-side twin of an `input_role`. It is a pure declaration (no channel endpoint); the harness binds it run-mode-aware (a single `aura run` constructs a recorder at each and persists the series through the trace store; a sweep leaves it unbound and inert). This is an OPEN, per-blueprint author-declared name — distinct from the CLOSED campaign `persist_taps` vocabulary above, which selects among fixed observables of the standard R-harness. Both senses land as `ColumnarTrace`s in the same trace store; the closed vocabulary is what a `campaign document` selects, the declared tap is what a blueprint author names.
|
Since C27 (#282) the word also names a second, author-facing sense: a **declared tap** — a `Composite.taps` entry `Tap { name, from: {node, field} }` a hand-authored blueprint declares on an interior output wire, the output-side twin of an `input_role`. It is a pure declaration (no channel endpoint); the harness binds it run-mode-aware (a single `aura exec` constructs a recorder at each and persists the series through the trace store; a campaign member run leaves it unbound and inert). This is an OPEN, per-blueprint author-declared name — distinct from the CLOSED campaign `persist_taps` vocabulary above, which selects among fixed observables of the standard R-harness. Both senses land as `ColumnarTrace`s in the same trace store; the closed vocabulary is what a `campaign document` selects, the declared tap is what a blueprint author names.
|
||||||
|
|
||||||
Since #283 what CONSUMES a declared tap is itself declared per run by a **tap plan**: a subscription is either `Named { label, params }` — resolved against a layered **fold registry** whose core vocabulary is `record | count | sum | mean | min | max | first | last`, each entry carrying a doc line (the help surface and the roster-enumerating refusal) and a scalar-typed param schema (all core entries param-less; growth is a new Rust entry per C25, and higher layers register entries without touching the core) — or `Live(closure)`, the single non-data variant (an in-process consumer with consumer-owned loss policy). `record` streams the full series to the trace store at constant memory (no buffer-then-drain); folds keep an O(1) accumulator and land one summary row at finalize. Both CLI verbs (`aura run`, `aura measure`) pass a record-all plan, so their semantics are unchanged.
|
Since #283 what CONSUMES a declared tap is itself declared per run by a **tap plan**: a subscription is either `Named { label, params }` — resolved against a layered **fold registry** whose core vocabulary is `record | count | sum | mean | min | max | first | last`, each entry carrying a doc line (the help surface and the roster-enumerating refusal) and a scalar-typed param schema (all core entries param-less; growth is a new Rust entry per C25, and higher layers register entries without touching the core) — or `Live(closure)`, the single non-data variant (an in-process consumer with consumer-owned loss policy). `record` streams the full series to the trace store at constant memory (no buffer-then-drain); folds keep an O(1) accumulator and land one summary row at finalize. Both declared-tap entry points are arms of the single verb `aura exec` (#319; `aura measure` is the post-hoc IC analysis over already-persisted traces and constructs no tap plan); `aura exec` subscribes every declared tap to `record` by default, and its repeatable `--tap TAP=FOLD` selector (#310) replaces that default with an explicit plan — only listed taps are bound, unlisted taps stay unbound/inert. A fold's one summary row is emitted at finalize and stamped with the instant of the last contributing (warm) value — `first` alone pins the first contributing instant, and `min`/`max` deliberately do not carry the extremum's instant (ratified #335).
|
||||||
|
|
||||||
### topology hash
|
### topology hash
|
||||||
**Avoid:** —
|
**Avoid:** —
|
||||||
The `content id` of a run's signal blueprint in its run-record role: stamped into the manifest as `topology_hash`, keying the reproduction store and anchoring `aura reproduce` — the same SHA-256 over the same canonical bytes. Distinct from the debug-name-blind `identity id`.
|
The `content id` of a run's signal blueprint in its run-record role: stamped into the manifest as `topology_hash`, keying the reproduction store and anchoring `aura reproduce` — the same SHA-256 over the same canonical bytes. Distinct from the debug-name-blind `identity id`.
|
||||||
|
|
||||||
|
### use (op)
|
||||||
|
**Avoid:** —
|
||||||
|
The construction op that splices a **registered** blueprint into the building graph as a nested `composite` under a chosen instance name (`{"op":"use","ref":{"content_id"|"name":…},"name":…,"bind":{…}}`, #317): the reference — a content id, a unique content-id prefix, or a `blueprint label` — resolves CLI-side, before the engine ever sees it; the fetched composite is C29-gated, spliced, and its resolution echoed on stderr (`aura: note:`, the existing benign marker). The emitted blueprint carries the splice inline as an ordinary nested composite — no reference or registry dependency survives into the artifact. An instance's open input roles become its `<instance>.<role>` in-ports, its output boundary fields its `<instance>.<field>` out-fields — the existing nested-composite param-prefix discipline extends unchanged (`<instance>.<node>.<param>`, the raw axis form since #328). Pairs with the `input` op: a pattern meant to be `use`d declares its formal parameters as open `input` roles, left unbound at `finish()` (#317's open-pattern amendment) — only running it standalone requires them bound.
|
||||||
|
|
||||||
### veto
|
### veto
|
||||||
**Avoid:** risk-manager
|
**Avoid:** risk-manager
|
||||||
The **optional** documented pre-trade-gate seam in the execution chain (`stop-rule → [Veto] → position-management`): position / exposure / notional caps that may reject or scale a trade. A pass-through identity DCE'd away when absent (C19/C23); kept as a named seam — it is what LEAN calls "Risk Management" and nautilus the RiskEngine.
|
The **optional** documented pre-trade-gate seam in the execution chain (`stop-rule → [Veto] → position-management`): position / exposure / notional caps that may reject or scale a trade. A pass-through identity DCE'd away when absent (C19/C23); kept as a named seam — it is what LEAN calls "Risk Management" and nautilus the RiskEngine.
|
||||||
|
|
||||||
### walk-forward
|
### walk-forward
|
||||||
**Avoid:** —
|
**Avoid:** —
|
||||||
An orchestration axis: rolling in-sample optimize + out-of-sample test across moving windows, stitched into one out-of-sample verdict plus parameter stability. As with `sweep`, every open knob named by `--list-axes` is **required** on a loaded blueprint — a subset is refused with the missing knob named; pin one with a single-value axis, there is no default. The `aura walkforward <blueprint.json> --real <sym> --axis <name>=<csv> …` CLI verb is thin sugar over the `campaign document` path — translated to a generated campaign (`std::sweep → std::walk_forward`) run through the one executor (#210; blueprint-generic over arbitrary blueprints and axes since #220).
|
An orchestration axis: rolling in-sample optimize + out-of-sample test across moving windows, stitched into one out-of-sample verdict plus parameter stability. As with `sweep`, every open knob a campaign document's axes name is **required** — a subset is refused with the missing knob named; pin one with a single-value axis, there is no default. Realised as a campaign document's `std::sweep → std::walk_forward` process pipeline (#210; blueprint-generic over arbitrary blueprints and axes since #220), executed through the one `aura exec` executor (#319) — the standalone `aura walkforward` CLI verb is retired.
|
||||||
|
|
||||||
### World
|
### World
|
||||||
**Avoid:** —
|
**Avoid:** —
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user