diff --git a/Cargo.lock b/Cargo.lock index 00dcd21..5d0867e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,6 +295,7 @@ dependencies = [ "serde_json", "sha2", "toml", + "zip", ] [[package]] diff --git a/crates/aura-campaign/src/lib.rs b/crates/aura-campaign/src/lib.rs index dda1b4c..3fbb3cf 100644 --- a/crates/aura-campaign/src/lib.rs +++ b/crates/aura-campaign/src/lib.rs @@ -61,6 +61,10 @@ pub struct CellSpec { /// them to its harness convention and runs the member over `cell.instrument` /// restricted to `window_ms` (inclusive epoch-ms, a sub-range of /// `cell.window_ms` — a walk-forward stage passes sub-windows). +/// +/// The shipped default implementation is `aura_runner::DefaultMemberRunner` +/// (the C28 assembly crate); this crate deliberately does not depend on it — +/// consumers pass the runner in (#295). pub trait MemberRunner: Sync { fn run_member( &self, diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index 34c3d1f..eb4040b 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -1,7 +1,7 @@ //! `aura campaign run` — the driver that turns a stored campaign document into //! a realized run-set (#198). The execution *semantics* (preflight, cell loop, //! stage sequencing, selection, registry writes) live in `aura-campaign`; the -//! [`MemberRunner`] implementation over the shipped loaded-blueprint machinery +//! [`MemberRunner`](aura_campaign::MemberRunner) implementation over the shipped loaded-blueprint machinery //! and the trace-persistence disk-layout writers live in `aura-runner`'s //! `runner` module (#295 Task 7 — `DefaultMemberRunner` + //! `persist_campaign_traces`). This module owns what is CLI-specific: target diff --git a/crates/aura-runner/Cargo.toml b/crates/aura-runner/Cargo.toml index 1302449..ae149a5 100644 --- a/crates/aura-runner/Cargo.toml +++ b/crates/aura-runner/Cargo.toml @@ -30,3 +30,12 @@ serde_json = { workspace = true } sha2 = "0.10" libloading = "0.8" toml = "0.8" + +[dev-dependencies] +# zip: hand-packs a tiny synthetic M1 archive for the library-reachability E2E +# (tests/world_member_run_e2e.rs) — already transitive via the data-server +# dependency above (a normal, non-dev dependency there); declared directly +# here so test code may `use zip::...` (mirrors aura-ingest's Cargo.toml and +# aura-cli's tests/common/mod.rs, no new dependency actually enters the build +# graph). +zip = "2" diff --git a/crates/aura-runner/tests/world_member_run_e2e.rs b/crates/aura-runner/tests/world_member_run_e2e.rs new file mode 100644 index 0000000..ff82dfb --- /dev/null +++ b/crates/aura-runner/tests/world_member_run_e2e.rs @@ -0,0 +1,152 @@ +//! Property (#295): the canonical member-run recipe — [`DefaultMemberRunner`] +//! driven through [`aura_campaign::CellSpec`] — is reachable as a **pure +//! library call**: no `aura` binary, no `aura-cli` crate in the dependency +//! graph (this test binary links only `aura-runner` + its own deps). This is +//! the acceptance evidence `examples/world_member_run.rs` demonstrates by +//! printing; here it is pinned by assertion instead of eyeballed output, and +//! runs over a tiny synthetic archive rather than skipping when no local host +//! archive is mounted. +//! +//! A second property rides along for free at no extra fixture cost (C1, +//! determinism): two independently constructed runners (fresh `DataServer`, +//! fresh `DefaultMemberRunner`) driven over byte-identical input produce a +//! byte-identical `RunReport` — same input, same run, reproducibly. + +use std::collections::BTreeMap; +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use aura_campaign::{CellSpec, MemberRunner}; +use aura_ingest::DataServer; +use aura_runner::{DefaultMemberRunner, Env}; + +const SYMBOL: &str = "TESTSYM"; +// 2024-06-03T08:00:00Z (a real Monday) — arbitrary, fixed, deterministic. +const BASE_MS: i64 = 1_717_401_600_000; +const N_BARS: i64 = 60; + +/// A unique scratch directory under the OS temp root, removed on drop — +/// mirrors `aura-ingest`'s own `tests::ScratchDir` (pid + atomic counter, no +/// `tempfile` dev-dependency needed for one throwaway fixture dir). +struct ScratchDir(PathBuf); + +impl ScratchDir { + fn new() -> Self { + static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir() + .join(format!("aura-runner-e2e-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create scratch dir"); + Self(dir) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for ScratchDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Packs one `RawM1Record`-shaped 48-byte little-endian record — the exact +/// on-disk format `data-server` reads (mirrors `aura-ingest`'s and +/// `aura-cli`'s own synthetic-archive test helpers). +fn pack_record(unix_ms_time: i64, open: f64, high: f64, low: f64, close: f64) -> [u8; 48] { + const DELPHI_EPOCH_OFFSET_DAYS: f64 = 25569.0; + const MS_PER_DAY: f64 = 86_400_000.0; + let dt = unix_ms_time as f64 / MS_PER_DAY + DELPHI_EPOCH_OFFSET_DAYS; + let mut rec = [0u8; 48]; + rec[0..8].copy_from_slice(&dt.to_le_bytes()); + rec[8..16].copy_from_slice(&open.to_le_bytes()); + rec[16..24].copy_from_slice(&high.to_le_bytes()); + rec[24..32].copy_from_slice(&low.to_le_bytes()); + rec[32..40].copy_from_slice(&close.to_le_bytes()); + rec[40..44].copy_from_slice(&0.5_f32.to_le_bytes()); + rec[44..48].copy_from_slice(&100_i32.to_le_bytes()); + rec +} + +/// Writes a tiny, deterministic one-month M1 archive (`N_BARS` consecutive +/// 1-minute bars, strictly ascending close so the shipped Donchian breakout +/// blueprint actually latches a non-flat bias) plus its geometry sidecar, +/// under `dir`. Returns the inclusive `(first_ms, last_ms)` bar span. +fn write_tiny_archive(dir: &Path) -> (i64, i64) { + let path = dir.join(format!("{SYMBOL}_2024_06.m1")); + let file = std::fs::File::create(&path).expect("create synthetic m1 zip"); + let mut zip = zip::ZipWriter::new(file); + zip.start_file::(format!("{SYMBOL}.bin"), Default::default()) + .expect("start synthetic m1 zip entry"); + for i in 0..N_BARS { + let ms = BASE_MS + i * 60_000; + let close = 100.0 + i as f64 * 2.0; // strictly ascending -> a real breakout + let open = close - 1.0; + let high = close + 0.5; + let low = open - 0.5; + zip.write_all(&pack_record(ms, open, high, low, close)).expect("write record"); + } + zip.finish().expect("finish synthetic m1 zip"); + std::fs::write( + dir.join(format!("{SYMBOL}.meta.json")), + "{\"schemaVersion\":2,\"symbol\":\"TESTSYM\",\"digits\":1,\"pipSize\":1,\ + \"tickSize\":0.1,\"lotSize\":1,\"baseAsset\":\"TESTSYM\",\"quoteAsset\":\"EUR\"}", + ) + .expect("write synthetic geometry sidecar"); + (BASE_MS, BASE_MS + (N_BARS - 1) * 60_000) +} + +/// One fresh `DefaultMemberRunner` over its own `DataServer` instance pointed +/// at `data_dir`, running `cell` end to end. +fn run_once( + data_dir: &Path, + cell: &CellSpec, + window_ms: (i64, i64), +) -> Result { + let env = Env::std(); + let server = std::sync::Arc::new(DataServer::new(data_dir)); + let runner = DefaultMemberRunner::new(&env, server, BTreeMap::new(), Vec::new()); + runner.run_member(cell, &[], window_ms) +} + +#[test] +/// Property: `DefaultMemberRunner::run_member` — the shipped C28 assembly +/// recipe — runs a real member backtest to completion through nothing but +/// `aura-runner` + `aura-campaign` library calls, and does so deterministically: +/// two independently constructed runners over byte-identical archive input +/// produce a byte-identical `RunReport` (C1). +fn default_member_runner_is_reachable_as_a_pure_library_call_and_is_deterministic() { + let scratch = ScratchDir::new(); + let (first_ms, last_ms) = write_tiny_archive(scratch.path()); + + let blueprint_json = + include_str!("../../aura-cli/examples/r_breakout.json").to_string(); + let cell = CellSpec { + strategy_ordinal: 0, + strategy_id: aura_research::content_id_of(&blueprint_json), + blueprint_json, + axes: BTreeMap::new(), + instrument: SYMBOL.to_string(), + window_ms: (first_ms, last_ms), + regime: None, + regime_ordinal: 0, + }; + + let report_a = run_once(scratch.path(), &cell, (first_ms, last_ms)) + .expect("the member run must succeed over its own tiny archive"); + let report_b = run_once(scratch.path(), &cell, (first_ms, last_ms)) + .expect("a second, independently constructed runner must succeed identically"); + + assert_eq!( + report_a, report_b, + "same cell, same archive, two independent DefaultMemberRunner instances: \ + the RunReport must be byte-identical (C1 determinism)" + ); + assert!( + report_a.metrics.total_pips.abs() > 0.0, + "the fixture's strictly-ascending closes must drive a non-flat run, else \ + the determinism check above is vacuous (both sides equally empty): {:?}", + report_a.metrics + ); +} diff --git a/crates/aura-vocabulary/tests/c28_layering.rs b/crates/aura-vocabulary/tests/c28_layering.rs index eef77aa..95667ca 100644 --- a/crates/aura-vocabulary/tests/c28_layering.rs +++ b/crates/aura-vocabulary/tests/c28_layering.rs @@ -12,6 +12,27 @@ fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..") } +/// Every `crates/*` directory that has its own `Cargo.toml` — the actual workspace +/// membership, read from disk rather than duplicated by hand, so the `allowed` table +/// below can be checked for completeness instead of silently omitting a crate. +fn workspace_crate_names() -> Vec { + let crates_dir = workspace_root().join("crates"); + let mut names: Vec = std::fs::read_dir(&crates_dir) + .unwrap_or_else(|e| panic!("read {}: {e}", crates_dir.display())) + .filter_map(|entry| { + let entry = entry.expect("dir entry"); + let path = entry.path(); + if path.join("Cargo.toml").is_file() { + Some(entry.file_name().into_string().expect("utf8 dir name")) + } else { + None + } + }) + .collect(); + names.sort(); + names +} + /// The `aura-*` keys under `[dependencies]` (NOT `[dev-dependencies]`) of a crate. fn prod_intra_workspace_deps(crate_name: &str) -> Vec { let manifest = workspace_root() @@ -35,6 +56,7 @@ fn prod_intra_workspace_deps(crate_name: &str) -> Vec { fn node_crates_obey_the_c28_import_direction() { // (crate, the intra-workspace [dependencies] C28 permits for its layer) let allowed: &[(&str, &[&str])] = &[ + ("aura-core", &[]), // foundation: no aura-* deps ("aura-analysis", &[]), // foundation: no aura-* deps ("aura-measurement", &["aura-core", "aura-analysis"]), ("aura-std", &["aura-core"]), @@ -46,7 +68,74 @@ fn node_crates_obey_the_c28_import_direction() { "aura-vocabulary", &["aura-core", "aura-std", "aura-market", "aura-strategy", "aura-backtest"], ), + ( + "aura-composites", + &["aura-core", "aura-engine", "aura-std", "aura-strategy", "aura-backtest"], + ), + ("aura-ingest", &["aura-core", "aura-engine"]), + ("aura-research", &["aura-core"]), + ( + "aura-registry", + &["aura-core", "aura-engine", "aura-backtest", "aura-analysis", "aura-research"], + ), + ( + "aura-campaign", + &[ + "aura-core", + "aura-engine", + "aura-backtest", + "aura-analysis", + "aura-registry", + "aura-research", + ], + ), + ( + "aura-runner", + &[ + "aura-core", + "aura-engine", + "aura-std", + "aura-strategy", + "aura-backtest", + "aura-composites", + "aura-ingest", + "aura-registry", + "aura-research", + "aura-campaign", + "aura-vocabulary", + ], + ), + ("aura-bench", &["aura-core", "aura-engine", "aura-std", "aura-ingest"]), + ( + "aura-cli", + &[ + "aura-core", + "aura-engine", + "aura-std", + "aura-strategy", + "aura-backtest", + "aura-registry", + "aura-research", + "aura-campaign", + "aura-vocabulary", + "aura-ingest", + "aura-measurement", + "aura-runner", + ], + ), ]; + + let mut table_names: Vec<&str> = allowed.iter().map(|(name, _)| *name).collect(); + table_names.sort(); + let disk_names = workspace_crate_names(); + assert_eq!( + table_names, + disk_names.iter().map(String::as_str).collect::>(), + "C28 full-workspace: `allowed` must enumerate exactly the crates under `crates/` \ + (add the new crate's permitted set here, even a foundation crate with no aura-* \ + deps) — otherwise it escapes the C28 guard silently" + ); + for (crate_name, permitted) in allowed { for dep in prod_intra_workspace_deps(crate_name) { assert!( @@ -56,4 +145,48 @@ fn node_crates_obey_the_c28_import_direction() { ); } } + + for (crate_name, permitted) in allowed { + for shell_or_assembly in ["aura-cli", "aura-runner"] { + let exempt = *crate_name == "aura-cli" && shell_or_assembly == "aura-runner"; + assert!( + exempt || !permitted.contains(&shell_or_assembly), + "C28 assembly violation: `{crate_name}`'s permitted set names \ + `{shell_or_assembly}`, but the shell/assembly crates are imported \ + by nothing except the shell itself" + ); + } + } +} + +#[test] +fn the_shell_defines_no_domain_modules_and_no_lib_target() { + let root = workspace_root(); + let manifest_text = + std::fs::read_to_string(root.join("crates/aura-cli/Cargo.toml")).unwrap(); + let manifest: toml::Value = + toml::from_str(&manifest_text).expect("Cargo.toml parses as TOML"); + assert!( + manifest.get("lib").is_none(), + "C28: the shell exports nothing — aura-cli must stay a pure binary crate" + ); + let mut modules: Vec = std::fs::read_dir(root.join("crates/aura-cli/src")) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .collect(); + modules.sort(); + assert_eq!( + modules, + [ + "campaign_run.rs", + "graph_construct.rs", + "main.rs", + "render.rs", + "research_docs.rs", + "scaffold.rs", + "verb_sugar.rs", + ], + "C28 (#295): the shell holds argv/dispatch, argv->document translation, \ + and presentation only — a new module needs a library home" + ); } diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 69dddb3..4a298ce 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -1253,6 +1253,13 @@ manifest stamps the untouched bound defaults (`defaults`, one-directionally widened beside `params`, which keeps its "what varied" reproduce semantics) so a raw reader of a fully-bound run no longer sees a misleading `"params": []`; the blueprint itself carries no such duplication. +**Amendment (2026-07-20, #295 — the programmatic face is an executor).** The +member-run recipe — the definition of what a standard aura backtest is — is +library content (`aura-runner`/`aura-backtest`/`aura-measurement`, C28 +assembly position), not CLI content: the binary is argv → document → +executor → presentation, and a downstream World program reaches the same +recipe with no binary involved. See C25's control-surface amendment for the +projection rule. ### C15 — Resampling-as-node; sessions/calendars **Guarantee.** A resampler is a node (finer stream → coarser bar stream), @@ -2508,6 +2515,15 @@ throughput lives. Realization: cycle 0106 (#189) shipped roles 5/6b their artifacts (process/campaign documents, C18 realization note); roles 4/7/8 remain technically present but faceless (no addressed verb families yet); role homes in the project layout and docs-by-role are open (#192 context). +**Amendment (2026-07-20, #295 — control surfaces are projections).** The +text-first invariant fixes the control-surface layering: the text artifact +vocabulary (blueprints, process/campaign documents, registry records) is the +canonical layer, and every control surface — the one-shot CLI's executor +verbs, any future long-running host, any MCP face, a World program — is a +projection/executor over those artifacts, never a second home for intent. +Which projection is built next (document-first flag-vocabulary completion, a +typed-protocol host, an MCP face, or none until demand) is deliberately open +— recorded as an open direction fork on #295. ### C26 — Harness input binding: role names bind archive columns **Guarantee.** A strategy blueprint declares WHICH data it consumes as the NAMES @@ -2518,10 +2534,11 @@ the campaign cell's (or run invocation's) instrument. A campaign document may **override** the name default per role via the additive `data.bindings` block (`role name → column name`; serde `default` + skip-if-empty, so binding-less documents keep their content ids) — the 6b rebind seam: the blueprint's content -identity never changes. Resolution (`aura-cli`'s `binding` module, -`resolve_binding`) produces ONE ordered plan consumed by BOTH halves of the old -weld: the columns are opened (`aura_ingest::open_columns`, the generalization of -`open_ohlc`/#92) and the wrapped root roles declared (`wrap_r`) in the **same +identity never changes. Resolution (`aura-runner`'s `binding` module, +`resolve_binding` — moved out of the shell with #295) produces ONE ordered +plan consumed by BOTH halves of the old weld: the columns are opened +(`aura_ingest::open_columns`, the generalization of `open_ohlc`/#92) and the +wrapped root roles declared (`aura-runner::member`'s `wrap_r`) in the **same canonical order** — `M1Field` declaration order (open, high, low, close, spread, volume), filtered to the consumed set — which is the C4 merge tie-break order, so role i receives source i by construction. A close column is always part of @@ -2544,7 +2561,7 @@ the binding VALUE-space grows additively from archive columns to recorded-stream references — a root role like `sentiment` becomes bindable to a recorded stream then, and until then refuses with the vocabulary-naming message. The role-name key-space and the campaign `bindings` carrier are unchanged by that growth. -**Why.** The single-price weld (`wrap_r`'s hard-wired `price`←close and the six +**Why.** The single-price weld (`aura-runner::member`'s `wrap_r`'s hard-wired `price`←close and the six `M1Field::Close`-only open sites) made every strategy monocular regardless of what its blueprint declared; binding by role name keeps the blueprint the single source of its own data needs (C24: topology-as-data carries its input contract), @@ -2625,19 +2642,32 @@ outer: Beside the ladder stands the **process column** — the research-process machinery (the run registry C18, the process/campaign document types, campaign execution). It consumes run *artifacts*, not market streams, and orchestrates runs of any -ladder layer; it stands beside the ladder, not on a rung. The **shell** (the -`aura` CLI) imports everything and is imported by nothing. +ladder layer; it stands beside the ladder, not on a rung. Between ladder/column +and the shell stands the **assembly** position (`aura-runner`, #295): it +composes the rungs into the canonical member-run recipe — harness assembly, +input binding (C26), the C1-load-bearing param↔config translators, and the +shipped default `MemberRunner` — importing the ladder rungs, `aura-composites`, +`aura-ingest`, and the process column, and imported only by the shell and by +downstream World programs. The **shell** (the `aura` CLI) imports everything, +is imported by nothing, exports nothing, and holds no domain logic: +argv/dispatch, argv→document translation, and presentation only (rendering +stays in the shell until the C22 web face provides its second consumer — #295 +deferral). **Import rule.** An outer ladder layer may import inner layers, never the reverse; *measurement* and *strategy* are siblings (no import either way); *backtest* may import *strategy*. The process column imports the engine layer -plus the run-artifact and metric interfaces, and is imported by no ladder crate; -column-internal edges are free. The shell imports all; nothing imports the -shell. **`dev-dependencies` are exempt** — tests may cross layers (the existing -`engine`/`ingest`/`registry` → `std` edges are dev-only and stay). The rule -binds **crate structure, not graph composition**: a blueprint may feed a -measurement statistic into a bias input — data flow inside a graph is free (C24); -layers govern imports, graphs compose freely. +plus the run-artifact and metric interfaces — realized since #291/#292 as a +production dependency on `aura-backtest` (the trading instantiation +`M = RunMetrics`) — and is imported by no ladder crate; column-internal edges +are free. The assembly position imports ladder and column alike and is +imported by neither; the shell imports all; nothing imports the shell — both +now enforced by the full-workspace `c28_layering` table plus its shell-content +check (#295). **`dev-dependencies` are exempt** — tests may cross layers (the +existing `engine`/`ingest`/`registry` → `std` edges are dev-only and stay). +The rule binds **crate structure, not graph composition**: a blueprint may +feed a measurement statistic into a bias input — data flow inside a graph is +free (C24); layers govern imports, graphs compose freely. **Forbids.** An inner layer importing an outer one — most sharply the engine layer importing backtest reductions, or market importing strategy. A ladder crate @@ -2719,9 +2749,16 @@ cleanly by layer, so the layering is realized only partially: `aura-backtest`, the statistics kernel to `aura-analysis`, dependency inversion, ladder enforced by the structural test; realizes item 1 of #147); (3) the #286 - shape dispatch plus the per-run scaffold as a library (seeds an `aura-backtest` - crate, gives *measurement* its run verb, removes the measured O(cycles) run-time - retention); (4) split the `aura-std` roster along engine / market / strategy / + shape dispatch plus the per-run scaffold as a library — **done** (#295: the + pure per-run scaffold pieces live in `aura-backtest::scaffold`; the composed + recipe — harness assembly, binding, translators, the default `MemberRunner` + — is the assembly crate `aura-runner`; the measurement rung is seeded as + `aura-measurement` with the IC; the shell is reduced to + argv/translation/presentation, enforced structurally. Residual: ~20 refusal + sites inside `aura-runner`'s single-run verb paths still terminate the + process; their conversion to `RunnerError` propagation is tracked as #297 — + the campaign path already refuses via `MemberFault`, never a process exit); + (4) split the `aura-std` roster along engine / market / strategy / backtest — **done** (#288: four `aura-core`-only node crates, the closed roster moved to `aura-vocabulary`, the ladder direction enforced by a structural test); (5) split `aura-analysis` into generic statistics and backtest metrics — @@ -2731,6 +2768,12 @@ cleanly by layer, so the layering is realized only partially: names now realized (`aura-market`/`aura-strategy`/`aura-backtest`/ `aura-vocabulary`); full evidence on #288, #286 and the milestone. +Provenance note (#295): the shell-boundary cut closes a ratified structural +debt (this contract's own phase plan) and un-blocks the future World program; +it was not a demonstrated downstream blocker — the first downstream project's +observed friction was document-vocabulary gaps, never Rust-level recipe +reachability. + --- ## Open architectural threads not yet resolved