diff --git a/Cargo.lock b/Cargo.lock index 5ce6004..99746f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,9 +118,11 @@ dependencies = [ "clap", "data-server", "libc", + "libloading", "serde", "serde_json", "sha2", + "toml", ] [[package]] @@ -622,6 +624,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "log" version = "0.4.32" @@ -840,6 +852,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -948,6 +969,47 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "typenum" version = "1.20.1" @@ -1094,6 +1156,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/crates/aura-cli/Cargo.toml b/crates/aura-cli/Cargo.toml index 932eefe..cc09026 100644 --- a/crates/aura-cli/Cargo.toml +++ b/crates/aura-cli/Cargo.toml @@ -40,6 +40,8 @@ libc = "0.2" # SHA256 user-settled); lives in the research-side CLI, off the frozen engine # (invariant 8). sha2 = "0.10" +libloading = "0.8" +toml = "0.8" # clap: the vetted standard argument parser. Adopted (C16 per-case review) to # replace the hand-rolled argv parser + ten duplicated help surfaces with one # declarative source, giving scoped --help, --version, --flag=value, and diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index d9f91e4..2360382 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -7,9 +7,13 @@ use std::collections::BTreeMap; use aura_engine::{blueprint_to_json, replay, BindOpError, GraphSession, Op, OpError, Scalar, ScalarKind}; -use aura_std::{std_vocabulary, std_vocabulary_types}; use serde::Deserialize; +// `std_vocabulary` is now only reached through `crate::project::Env::resolve` in +// production code; tests still exercise it directly. +#[cfg(test)] +use aura_std::std_vocabulary; + /// The wire DTO for one construction op — the document's by-identifier shape, /// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind /// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized @@ -106,11 +110,11 @@ fn format_op_error(e: &OpError) -> String { /// the emitted #155 blueprint JSON — or a `op N (kind): cause` message (a per-op /// fault, attributed by the retained op-kind list) / `finalize: cause` (a /// holistic fault past the last op). -pub fn build_from_str(doc: &str) -> Result { +pub fn build_from_str(doc: &str, env: &crate::project::Env) -> Result { let docs: Vec = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?; let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect(); let ops: Vec = docs.into_iter().map(Op::from).collect(); - match replay("graph", ops, &std_vocabulary) { + match replay("graph", ops, &|t| env.resolve(t)) { Ok(composite) => blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}")), Err((idx, err)) => { let cause = format_op_error(&err); @@ -124,14 +128,14 @@ pub fn build_from_str(doc: &str) -> Result { /// `aura graph build`: read the op-list document from stdin, build, and print the /// blueprint to stdout — or the cause to stderr and exit non-zero. -pub fn build_cmd() { +pub fn build_cmd(env: &crate::project::Env) { use std::io::Read; let mut doc = String::new(); if let Err(e) = std::io::stdin().read_to_string(&mut doc) { eprintln!("aura: reading stdin: {e}"); std::process::exit(1); } - match build_from_str(&doc) { + match build_from_str(&doc, env) { // `print!`, not `println!`: the canonical artifact is exactly the library // `blueprint_to_json` bytes (the form #158 content-addresses) — a JSON value // with no trailing newline. The CLI is a transport; it must not frame the @@ -147,8 +151,8 @@ pub fn build_cmd() { /// `aura graph introspect --node `: a type's ports + kinds + param paths, /// read off the pre-build schema (no graph built). `Err` if `T` is not in the /// closed vocabulary. -pub fn introspect_node(type_id: &str) -> Result { - let builder = std_vocabulary(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?; +pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result { + let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?; let schema = builder.schema(); let mut out = format!("{}\n", builder.label()); for port in &schema.inputs { @@ -165,9 +169,10 @@ pub fn introspect_node(type_id: &str) -> Result { /// `aura graph introspect --unwired`: the still-open interior slots of a partial /// op-list document, by-identifier (applies the ops, does NOT finalize). -pub fn introspect_unwired(doc: &str) -> Result { +pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result { let docs: Vec = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?; - let mut session = GraphSession::new("introspect", &std_vocabulary); + let resolver = |t: &str| env.resolve(t); + let mut session = GraphSession::new("introspect", &resolver); for (i, d) in docs.into_iter().enumerate() { session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?; } @@ -181,7 +186,7 @@ pub fn introspect_unwired(doc: &str) -> Result { /// `aura graph introspect`: dispatch the read-only queries. Exactly one of /// `--vocabulary` / `--node ` / `--unwired` / `--content-id` must be set; /// zero or more than one is the usage error (exit 2). -pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) { +pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) { let count = cmd.vocabulary as usize + cmd.node.is_some() as usize + cmd.unwired as usize @@ -193,11 +198,11 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) { std::process::exit(2); } if cmd.vocabulary { - for t in std_vocabulary_types() { + for t in env.type_ids() { println!("{t}"); } } else if let Some(type_id) = cmd.node.as_deref() { - match introspect_node(type_id) { + match introspect_node(type_id, env) { Ok(s) => print!("{s}"), Err(m) => { eprintln!("aura: {m}"); @@ -211,7 +216,7 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) { eprintln!("aura: reading stdin: {e}"); std::process::exit(1); } - match introspect_unwired(&doc) { + match introspect_unwired(&doc, env) { Ok(s) => print!("{s}"), Err(m) => { eprintln!("aura: {m}"); @@ -229,7 +234,7 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) { eprintln!("aura: reading stdin: {e}"); std::process::exit(1); } - match build_from_str(&doc) { + match build_from_str(&doc, env) { Ok(json) => println!("{}", crate::content_id(&json)), Err(m) => { eprintln!("aura: {m}"); @@ -283,7 +288,7 @@ mod tests { {"op":"connect","from":"sub.value","to":"bias.signal"}, {"op":"expose","from":"bias.bias","as":"bias"} ]"#; - let json = super::build_from_str(doc).expect("valid document builds"); + let json = super::build_from_str(doc, &crate::project::Env::std()).expect("valid document builds"); assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}"); assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}"); } @@ -299,14 +304,14 @@ mod tests { {"op":"feed","role":"price","into":["fast.series"]}, {"op":"expose","from":"fast.value","as":"out"} ]"#; - let json = super::build_from_str(doc).expect("name-keyed add builds"); + let json = super::build_from_str(doc, &crate::project::Env::std()).expect("name-keyed add builds"); assert!(json.contains("\"name\":\"fast\""), "the blueprint carries the node name: {json}"); } #[test] fn build_from_str_reports_unknown_node_at_its_op_index() { let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#; - let err = super::build_from_str(doc).unwrap_err(); + let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err(); assert_eq!(err, "op 1 (add): unknown node type \"Nope\""); } @@ -321,7 +326,7 @@ mod tests { {"op":"feed","role":"price","into":["fast.series","slow.series"]}, {"op":"connect","from":"fast.value","to":"sub.lhs"} ]"#; - let err = super::build_from_str(doc).unwrap_err(); + let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err(); assert!(err.starts_with("finalize: "), "finalize fault, got: {err}"); assert!(err.contains("sub.rhs") && err.contains("is unconnected"), "names the unconnected slot by-identifier, got: {err}"); @@ -330,11 +335,11 @@ mod tests { #[test] fn introspect_node_lists_ports_kinds_and_params() { - let out = super::introspect_node("SMA").expect("SMA is in the vocabulary"); + let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary"); assert!(out.contains("series"), "lists the input port: {out}"); assert!(out.contains("value"), "lists the output field: {out}"); assert!(out.contains("length"), "lists the param path: {out}"); - assert!(super::introspect_node("Nope").is_err(), "rejects an unknown type"); + assert!(super::introspect_node("Nope", &crate::project::Env::std()).is_err(), "rejects an unknown type"); } /// A bind whose value-kind mismatches the param reads as prose, like the connect @@ -342,7 +347,7 @@ mod tests { #[test] fn build_from_str_bad_bind_kind_reads_as_prose() { let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#; - let err = super::build_from_str(doc).unwrap_err(); + let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err(); assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64"); } @@ -351,7 +356,7 @@ mod tests { #[test] fn build_from_str_unknown_bind_param_reads_as_prose() { let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"window":{"I64":2}}}]"#; - let err = super::build_from_str(doc).unwrap_err(); + let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err(); assert_eq!(err, "op 0 (add): node fast has no param \"window\""); } @@ -372,7 +377,7 @@ mod tests { /// does not have to read source to learn the `{"I64": }` wrapping. #[test] fn introspect_node_shows_the_bind_form() { - let out = super::introspect_node("SMA").expect("SMA is in the vocabulary"); + let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary"); assert!(out.contains("(bind {\"I64\": })"), "shows the bind-value form: {out}"); } @@ -383,7 +388,7 @@ mod tests { {"op":"add","type":"Sub"}, {"op":"connect","from":"fast.value","to":"sub.lhs"} ]"#; - let out = super::introspect_unwired(doc).expect("partial document introspects"); + let out = super::introspect_unwired(doc, &crate::project::Env::std()).expect("partial document introspects"); assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}"); assert!(out.contains("fast.series"), "fast.series is still open: {out}"); assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}"); diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 0df2ae9..fda72be 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -13,6 +13,7 @@ mod render; mod graph_construct; +mod project; use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series}; use aura_core::{zip_params, Cell, Firing, ParamSpec, Scalar, ScalarKind, Timestamp}; @@ -28,13 +29,18 @@ use aura_engine::{ use aura_registry::{ check_r_metric, generalization, group_families, mc_member_reports, optimize_deflated, optimize_plateau, rank_by, sweep_member_reports, walkforward_member_reports, FamilyKind, - FamilyMember, Generalization, NameKind, PlateauMode, Registry, RunTraces, TraceStore, WriteKind, + FamilyMember, Generalization, NameKind, PlateauMode, Registry, RunTraces, WriteKind, }; use aura_std::{ Add, Bias, CarryCost, ConstantCost, Delay, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Mul, Recorder, RollingMax, RollingMin, SeriesReducer, SimBroker, Sma, Sqrt, Sub, VolSlippageCost, - std_vocabulary, PM_FIELD_NAMES, PM_RECORD_KINDS, + PM_FIELD_NAMES, PM_RECORD_KINDS, }; +// `std_vocabulary` is now only reached through `project::Env::resolve` in production +// code; the test module still builds reference blueprints against it directly, so +// the import is test-only. +#[cfg(test)] +use aura_std::std_vocabulary; use std::sync::mpsc::{self, Receiver}; use std::sync::LazyLock; use std::collections::HashSet; @@ -180,6 +186,7 @@ fn sim_optimal_manifest( selection: None, instrument: None, topology_hash: None, + project: None, } } @@ -193,12 +200,13 @@ fn persist_traces( manifest: &RunManifest, eq_rows: &[(Timestamp, Vec)], ex_rows: &[(Timestamp, Vec)], + env: &project::Env, ) { let taps = vec![ ColumnarTrace::from_rows("equity", &[ScalarKind::F64], eq_rows), ColumnarTrace::from_rows("exposure", &[ScalarKind::F64], ex_rows), ]; - if let Err(e) = TraceStore::open("runs").write(name, manifest, &taps) { + if let Err(e) = env.trace_store().write(name, manifest, &taps) { eprintln!("aura: trace persist failed: {e}"); std::process::exit(1); } @@ -499,8 +507,8 @@ fn filter_to_tap(data: ChartData, tap: &str) -> Result { /// single run charts all its taps (or the one `--tap` selects); a family overlays /// one tap (default `equity`) across its members; an unknown name is a runtime error /// (stderr + exit 1), never a panic. -fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode) { - let store = TraceStore::open("runs"); +fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode, env: &project::Env) { + let store = env.trace_store(); match store.name_kind(name) { NameKind::Run => { let traces = match store.read(name) { @@ -554,9 +562,9 @@ fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode) { /// Run the sample harness and fold it into a `RunReport` (drain both sinks → /// `f64_field` → `summarize` → pair with a `RunManifest`). Pure and deterministic /// (C1): the same build yields the same report. -fn run_sample(trace: Option<&str>) -> RunReport { +fn run_sample(trace: Option<&str>, env: &project::Env) -> RunReport { if let Some(n) = trace - && let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run) + && let Err(e) = env.trace_store().ensure_name_free(n, WriteKind::Run) { eprintln!("aura: {e}"); std::process::exit(1); @@ -580,7 +588,7 @@ fn run_sample(trace: Option<&str>) -> RunReport { SYNTHETIC_PIP_SIZE, ); if let Some(name) = trace { - persist_traces(name, &manifest, &eq_rows, &ex_rows); + persist_traces(name, &manifest, &eq_rows, &ex_rows, env); } let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); RunReport { manifest, metrics } @@ -593,14 +601,16 @@ fn run_sample(trace: Option<&str>) -> RunReport { /// (a Source is single-pass), so the run source streams the window untouched. /// A no-local-data condition (unknown symbol, or a window overlapping no file / no /// bars) is a runtime error: stderr + exit(1), not a panic. -fn run_sample_real(symbol: &str, from_ms: Option, to_ms: Option, trace: Option<&str>) -> RunReport { +fn run_sample_real( + symbol: &str, from_ms: Option, to_ms: Option, trace: Option<&str>, env: &project::Env, +) -> RunReport { if let Some(n) = trace - && let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run) + && let Err(e) = env.trace_store().ensure_name_free(n, WriteKind::Run) { eprintln!("aura: {e}"); std::process::exit(1); } - let (source, window, pip_size) = open_real_source(symbol, from_ms, to_ms); + let (source, window, pip_size) = open_real_source(symbol, from_ms, to_ms, env); let (mut h, rx_eq, rx_ex) = sample_harness(pip_size); h.run(vec![source]); @@ -617,7 +627,7 @@ fn run_sample_real(symbol: &str, from_ms: Option, to_ms: Option, trace pip_size, ); if let Some(name) = trace { - persist_traces(name, &manifest, &eq_rows, &ex_rows); + persist_traces(name, &manifest, &eq_rows, &ex_rows, env); } let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); RunReport { manifest, metrics } @@ -656,8 +666,8 @@ enum DataSource { } /// No-local-data refusal — stderr + exit(1), mirroring `run_sample_real`. -fn no_real_data(symbol: &str) -> ! { - eprintln!("aura: no local data for symbol '{symbol}' at {}", data_server::DEFAULT_DATA_PATH); +fn no_real_data(symbol: &str, env: &project::Env) -> ! { + eprintln!("aura: no local data for symbol '{symbol}' at {}", env.data_path()); std::process::exit(1) } @@ -667,14 +677,16 @@ fn no_real_data(symbol: &str) -> ! { /// the symbol's geometry metadata (not bar data) before the run source opens, so an /// instrument with no recorded geometry refuses without a guessed pip; the pip is /// the provider's recorded value, honest by construction. -fn pip_or_refuse(server: &std::sync::Arc, symbol: &str) -> f64 { +fn pip_or_refuse( + server: &std::sync::Arc, symbol: &str, env: &project::Env, +) -> f64 { match aura_ingest::instrument_geometry(server, symbol) { Some(geo) => geo.pip_size, None => { eprintln!( "aura: no recorded geometry for symbol '{symbol}' at {} — \ refusing to run a real instrument with a guessed pip", - data_server::DEFAULT_DATA_PATH + env.data_path() ); std::process::exit(1); } @@ -691,10 +703,11 @@ fn probe_window( symbol: &str, from_ms: Option, to_ms: Option, + env: &project::Env, ) -> (Timestamp, Timestamp) { let mut probe = aura_ingest::M1FieldSource::open(server, symbol, from_ms, to_ms, aura_ingest::M1Field::Close) - .unwrap_or_else(|| no_real_data(symbol)); - let first = aura_engine::Source::peek(&probe).unwrap_or_else(|| no_real_data(symbol)); + .unwrap_or_else(|| no_real_data(symbol, env)); + let first = aura_engine::Source::peek(&probe).unwrap_or_else(|| no_real_data(symbol, env)); let mut last = first; while let Some((t, _)) = aura_engine::Source::next(&mut probe) { last = t; @@ -712,20 +725,21 @@ fn open_real_source( symbol: &str, from_ms: Option, to_ms: Option, + env: &project::Env, ) -> (Box, (Timestamp, Timestamp), f64) { // Per-instrument pip from the recorded sidecar; resolved BEFORE bar-data access // so an instrument with no geometry refuses without touching the archive. - let server = std::sync::Arc::new(data_server::DataServer::new(data_server::DEFAULT_DATA_PATH)); - let pip = pip_or_refuse(&server, symbol); + let server = std::sync::Arc::new(data_server::DataServer::new(env.data_path())); + let pip = pip_or_refuse(&server, symbol, env); if !server.has_symbol(symbol) { - no_real_data(symbol); + no_real_data(symbol, env); } // Manifest window: drain a separate probe (single-pass Source) for first/last ts. - let window = probe_window(&server, symbol, from_ms, to_ms); + let window = probe_window(&server, symbol, from_ms, to_ms, env); let source: Box = match aura_ingest::M1FieldSource::open(&server, symbol, from_ms, to_ms, aura_ingest::M1Field::Close) { Some(s) => Box::new(s), - None => no_real_data(symbol), + None => no_real_data(symbol, env), }; (source, window, pip) } @@ -735,14 +749,14 @@ impl DataSource { /// with no recorded geometry / absent data — both BEFORE any member runs (Fork C/G), /// via the same `pip_or_refuse` / `no_real_data` helpers as /// `run_sample_real`. - fn from_choice(choice: DataChoice) -> DataSource { + fn from_choice(choice: DataChoice, env: &project::Env) -> DataSource { match choice { DataChoice::Synthetic => DataSource::Synthetic, DataChoice::Real { symbol, from_ms, to_ms } => { - let server = std::sync::Arc::new(data_server::DataServer::new(data_server::DEFAULT_DATA_PATH)); - let pip = pip_or_refuse(&server, &symbol); + let server = std::sync::Arc::new(data_server::DataServer::new(env.data_path())); + let pip = pip_or_refuse(&server, &symbol, env); if !server.has_symbol(&symbol) { - no_real_data(&symbol); + no_real_data(&symbol, env); } DataSource::Real { server, symbol, from_ms, to_ms, pip } } @@ -759,14 +773,14 @@ impl DataSource { /// The full run window, probed once. Synthetic: the showcase span. Real: /// `probe_window` drains a separate single-pass probe source for first/last ts /// (the same helper `run_sample_real` uses for its manifest window). - fn full_window(&self) -> (Timestamp, Timestamp) { + fn full_window(&self, env: &project::Env) -> (Timestamp, Timestamp) { match self { DataSource::Synthetic => { let s: Vec> = vec![Box::new(VecSource::new(showcase_prices()))]; window_of(&s).expect("non-empty showcase stream") } DataSource::Real { server, symbol, from_ms, to_ms, .. } => { - probe_window(server, symbol, *from_ms, *to_ms) + probe_window(server, symbol, *from_ms, *to_ms, env) } } } @@ -777,37 +791,39 @@ impl DataSource { /// longer built-in stream (byte-unchanged from the pre-`DataSource` /// `walkforward_family`, which derived its span the same way). Real: the same /// probed `--from..--to` window as `full_window`. - fn wf_full_span(&self) -> (Timestamp, Timestamp) { + fn wf_full_span(&self, env: &project::Env) -> (Timestamp, Timestamp) { match self { DataSource::Synthetic => { let s: Vec> = vec![Box::new(VecSource::new(walkforward_prices()))]; window_of(&s).expect("non-empty walkforward stream") } DataSource::Real { server, symbol, from_ms, to_ms, .. } => { - probe_window(server, symbol, *from_ms, *to_ms) + probe_window(server, symbol, *from_ms, *to_ms, env) } } } /// A fresh full-window source per member (single-pass). Synthetic: showcase. - fn run_sources(&self) -> Vec> { + fn run_sources(&self, env: &project::Env) -> Vec> { match self { DataSource::Synthetic => vec![Box::new(VecSource::new(showcase_prices()))], DataSource::Real { server, symbol, from_ms, to_ms, .. } => vec![Box::new( aura_ingest::M1FieldSource::open(server, symbol, *from_ms, *to_ms, aura_ingest::M1Field::Close) - .unwrap_or_else(|| no_real_data(symbol)), + .unwrap_or_else(|| no_real_data(symbol, env)), )], } } /// A fresh windowed source for an IS/OOS sub-window (walk-forward). Synthetic: /// `walkforward_window_source`. Real: `open_window` (ns-native `Timestamp`). - fn windowed_sources(&self, from: Timestamp, to: Timestamp) -> Vec> { + fn windowed_sources( + &self, from: Timestamp, to: Timestamp, env: &project::Env, + ) -> Vec> { match self { DataSource::Synthetic => vec![Box::new(walkforward_window_source(from, to))], DataSource::Real { server, symbol, .. } => vec![Box::new( aura_ingest::M1FieldSource::open_window(server, symbol, Some(from), Some(to), aura_ingest::M1Field::Close) - .unwrap_or_else(|| no_real_data(symbol)), + .unwrap_or_else(|| no_real_data(symbol, env)), )], } } @@ -927,9 +943,9 @@ fn sample_blueprint() -> Composite { /// the same report. Each point builds a fresh blueprint (fresh sink channels), /// bootstraps it under the point vector, runs it, and folds the drained sinks to /// metrics — the per-point closure the engine `sweep` drives disjointly. -fn sweep_family(trace: Option<&str>, data: &DataSource) -> SweepFamily { +fn sweep_family(trace: Option<&str>, data: &DataSource, env: &project::Env) -> SweepFamily { let pip = data.pip_size(); - let window = data.full_window(); + let window = data.full_window(env); let bp = sample_blueprint_with_sinks(pip).0; let space = bp.param_space(); let (tf, ts, (mf, ms, msig)) = data.strategy_lengths(); @@ -949,7 +965,7 @@ fn sweep_family(trace: Option<&str>, data: &DataSource) -> SweepFamily { let mut h = bp .bootstrap_with_cells(point) .expect("grid points are kind-checked against param_space"); - let sources = data.run_sources(); + let sources = data.run_sources(env); h.run(sources); let eq_rows = rx_eq.try_iter().collect::>(); let ex_rows = rx_ex.try_iter().collect::>(); @@ -957,7 +973,7 @@ fn sweep_family(trace: Option<&str>, data: &DataSource) -> SweepFamily { let key = member_key(&named, &varying); let manifest = sim_optimal_manifest(named, window, 0, pip); if let Some(name) = trace { - persist_traces(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows); + persist_traces(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, env); } let equity = f64_field(&eq_rows, 0); let exposure = f64_field(&ex_rows, 0); @@ -1010,9 +1026,9 @@ fn momentum_blueprint_with_sinks(pip_size: f64) -> ( /// all three axes varying. Mirrors `sweep_family`: capture the binder's varying /// axes, key each member via the generic portable `member_key`. With `--trace`, /// persist each member under `runs/traces///`. -fn momentum_sweep_family(trace: Option<&str>, data: &DataSource) -> SweepFamily { +fn momentum_sweep_family(trace: Option<&str>, data: &DataSource, env: &project::Env) -> SweepFamily { let pip = data.pip_size(); - let window = data.full_window(); + let window = data.full_window(env); let bp = momentum_blueprint_with_sinks(pip).0; let space = bp.param_space(); let binder = bp @@ -1026,7 +1042,7 @@ fn momentum_sweep_family(trace: Option<&str>, data: &DataSource) -> SweepFamily let mut h = bp .bootstrap_with_cells(point) .expect("grid points are kind-checked against param_space"); - let sources = data.run_sources(); + let sources = data.run_sources(env); h.run(sources); let eq_rows = rx_eq.try_iter().collect::>(); let ex_rows = rx_ex.try_iter().collect::>(); @@ -1034,7 +1050,7 @@ fn momentum_sweep_family(trace: Option<&str>, data: &DataSource) -> SweepFamily let key = member_key(&named, &varying); let manifest = sim_optimal_manifest(named, window, 0, pip); if let Some(name) = trace { - persist_traces(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows); + persist_traces(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, env); } let equity = f64_field(&eq_rows, 0); let exposure = f64_field(&ex_rows, 0); @@ -1128,9 +1144,9 @@ fn r_sma_friendly_name(space_name: &str) -> String { } } -fn r_sma_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid) -> SweepFamily { +fn r_sma_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid, env: &project::Env) -> SweepFamily { let pip = data.pip_size(); - let window = data.full_window(); + let window = data.full_window(env); // a single throwaway floated build, only to resolve param_space (borrow) then // seed the named axes (move) — its taps are never drained, the per-point run_one // rebuilds with live ones. Mirrors momentum_sweep_family: param_space takes &self, @@ -1188,7 +1204,7 @@ fn r_sma_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid) -> S let mut h = r_sma_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, reduce, None) .bootstrap_with_cells(point) .expect("r-sma grid points are kind-checked against param_space"); - h.run(data.run_sources()); + h.run(data.run_sources(env)); // record the swept knobs PLUS the fixed R-defining bias scale, matching the // single run: a family member must be reproducible from its own manifest // (C18). The stop knobs are now real swept axes (they appear in `point`), so @@ -1224,7 +1240,7 @@ fn r_sma_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid) -> S let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); let req_rows: Vec<(Timestamp, Vec)> = rx_req.try_iter().collect(); if let Some(name) = trace { - persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows, &[]); + persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows, &[], env); } let mut m = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); m.r = Some(summarize_r(&r_rows, &[])); @@ -1251,7 +1267,9 @@ fn r_sma_space() -> Vec { /// but windowed (`windowed_sources`) and always folded (O(trades)/member): each /// member's RunReport carries `metrics.r = Some(summarize_r(..))`, so the family is /// rankable by an R metric. -fn r_sma_sweep_over(from: Timestamp, to: Timestamp, data: &DataSource, grid: &RGrid) -> (SweepFamily, Option>) { +fn r_sma_sweep_over( + from: Timestamp, to: Timestamp, data: &DataSource, grid: &RGrid, env: &project::Env, +) -> (SweepFamily, Option>) { let pip = data.pip_size(); let (tx_eq, _) = mpsc::channel(); let (tx_ex, _) = mpsc::channel(); @@ -1291,7 +1309,7 @@ fn r_sma_sweep_over(from: Timestamp, to: Timestamp, data: &DataSource, grid: &RG let mut h = r_sma_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, true, None) .bootstrap_with_cells(point) .expect("r-sma grid points are kind-checked against param_space"); - let sources = data.windowed_sources(from, to); + let sources = data.windowed_sources(from, to, env); let window = window_of(&sources).expect("non-empty in-sample window"); h.run(sources); let mut named: Vec<(String, Scalar)> = zip_params(&space, point).into_iter() @@ -1317,6 +1335,7 @@ fn r_sma_sweep_over(from: Timestamp, to: Timestamp, data: &DataSource, grid: &RG /// curve is bounded, and `stitch` needs the full pip-equity series. fn run_oos_r( params: &[Cell], from: Timestamp, to: Timestamp, trace: Option<&str>, data: &DataSource, + env: &project::Env, ) -> (Vec<(Timestamp, f64)>, RunReport) { let pip = data.pip_size(); let (tx_eq, rx_eq) = mpsc::channel(); @@ -1327,7 +1346,7 @@ fn run_oos_r( let space = bp.param_space(); let mut h = bp.bootstrap_with_cells(params) .expect("chosen params pre-validated by the in-sample GridSpace::new"); - let sources = data.windowed_sources(from, to); + let sources = data.windowed_sources(from, to, env); let window = window_of(&sources).expect("non-empty out-of-sample window"); h.run(sources); let eq_rows = rx_eq.try_iter().collect::>(); @@ -1340,7 +1359,7 @@ fn run_oos_r( let mut manifest = sim_optimal_manifest(named, window, 0, pip); manifest.broker = r_sma_broker_label(pip); if let Some(name) = trace { - persist_traces_r(&format!("{name}/oos{}", from.0), &manifest, &eq_rows, &ex_rows, &req_rows, &[]); + persist_traces_r(&format!("{name}/oos{}", from.0), &manifest, &eq_rows, &ex_rows, &req_rows, &[], env); } let equity = f64_field(&eq_rows, 0); let exposure = f64_field(&ex_rows, 0); @@ -1356,9 +1375,9 @@ fn run_oos_r( /// the open .axis/bootstrap_with_cells path. Each member folds the dense R-record via /// summarize_r, so the family is rankable by sqn / expectancy_r / ... (parity with /// r-sma). With --trace, raw recorders persist the per-cycle streams. -fn r_breakout_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid) -> SweepFamily { +fn r_breakout_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid, env: &project::Env) -> SweepFamily { let pip = data.pip_size(); - let window = data.full_window(); + let window = data.full_window(env); let mut varying: HashSet = HashSet::new(); if grid.channel.len() > 1 { varying.insert("channel".to_string()); @@ -1382,7 +1401,7 @@ fn r_breakout_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid) .compile_with_params(&[]) .expect("valid r-breakout blueprint"); let mut h = Harness::bootstrap(flat).expect("valid r-breakout harness"); - h.run(data.run_sources()); + h.run(data.run_sources(env)); let named: Vec<(String, Scalar)> = vec![ ("channel".to_string(), Scalar::i64(c)), ("stop_length".to_string(), Scalar::i64(sl)), @@ -1409,7 +1428,7 @@ fn r_breakout_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid) let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); let req_rows: Vec<(Timestamp, Vec)> = rx_req.try_iter().collect(); if let Some(name) = trace { - persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows, &[]); + persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows, &[], env); } let mut m = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); m.r = Some(summarize_r(&r_rows, &[])); @@ -1422,9 +1441,9 @@ fn r_breakout_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid) SweepFamily { space: vec![], points } } -fn r_meanrev_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid) -> SweepFamily { +fn r_meanrev_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid, env: &project::Env) -> SweepFamily { let pip = data.pip_size(); - let window = data.full_window(); + let window = data.full_window(env); let mut varying: HashSet = HashSet::new(); if grid.window.len() > 1 { varying.insert("window".to_string()); @@ -1453,7 +1472,7 @@ fn r_meanrev_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid) .compile_with_params(&[]) .expect("valid r-meanrev blueprint"); let mut h = Harness::bootstrap(flat).expect("valid r-meanrev harness"); - h.run(data.run_sources()); + h.run(data.run_sources(env)); let named: Vec<(String, Scalar)> = vec![ ("window".to_string(), Scalar::i64(n)), ("band_k".to_string(), Scalar::f64(bk)), @@ -1481,7 +1500,7 @@ fn r_meanrev_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid) let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); let req_rows: Vec<(Timestamp, Vec)> = rx_req.try_iter().collect(); if let Some(name) = trace { - persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows, &[]); + persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows, &[], env); } let mut m = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); m.r = Some(summarize_r(&r_rows, &[])); @@ -1500,20 +1519,13 @@ fn r_meanrev_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid) #[cfg(test)] fn sweep_report() -> String { let mut out = String::new(); - for pt in &sweep_family(None, &DataSource::Synthetic).points { + for pt in &sweep_family(None, &DataSource::Synthetic, &project::Env::std()).points { out.push_str(&pt.report.to_json()); out.push('\n'); } out } -/// The default run registry: an append-only JSONL store under the current -/// working directory. (A project-configured runs-dir via `Aura.toml` is a later -/// refinement.) -fn default_registry() -> Registry { - Registry::open("runs/runs.jsonl") -} - /// Which built-in strategy `aura sweep` runs. Default (today's behaviour) is the /// SMA-cross sample; `momentum` is the bool-param demo. #[derive(Clone, Copy, PartialEq, Debug)] @@ -1608,20 +1620,23 @@ fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String { /// (`sma`/`momentum`/`r-sma`) persists each member's streams under /// `runs/traces///` (opt-in); the `r-sma` member also carries /// the `r_equity` tap (via `persist_traces_r`). -fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource, grid: &RGrid) { +fn run_sweep( + strategy: Strategy, name: &str, persist: bool, data: DataSource, grid: &RGrid, + env: &project::Env, +) { if persist - && let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family) + && let Err(e) = env.trace_store().ensure_name_free(name, WriteKind::Family) { eprintln!("aura: {e}"); std::process::exit(1); } - let reg = default_registry(); + let reg = env.registry(); let family = match strategy { - Strategy::SmaCross => sweep_family(persist.then_some(name), &data), - Strategy::Momentum => momentum_sweep_family(persist.then_some(name), &data), - Strategy::RSma => r_sma_sweep_family(persist.then_some(name), &data, grid), - Strategy::RBreakout => r_breakout_sweep_family(persist.then_some(name), &data, grid), - Strategy::RMeanRev => r_meanrev_sweep_family(persist.then_some(name), &data, grid), + Strategy::SmaCross => sweep_family(persist.then_some(name), &data, env), + Strategy::Momentum => momentum_sweep_family(persist.then_some(name), &data, env), + Strategy::RSma => r_sma_sweep_family(persist.then_some(name), &data, grid, env), + Strategy::RBreakout => r_breakout_sweep_family(persist.then_some(name), &data, grid, env), + Strategy::RMeanRev => r_meanrev_sweep_family(persist.then_some(name), &data, grid, env), }; let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) { Ok(id) => id, @@ -1649,6 +1664,7 @@ fn run_generalize( metric: &str, from_ms: Option, to_ms: Option, + env: &project::Env, ) { // data-free metric pre-check: refuse a non-R / unknown metric before any run. if let Err(e) = check_r_metric(metric) { @@ -1658,8 +1674,8 @@ fn run_generalize( let mut members: Vec = Vec::new(); for symbol in symbols { let choice = DataChoice::Real { symbol: symbol.clone(), from_ms, to_ms }; - let data = DataSource::from_choice(choice); // per-instrument pip_or_refuse + has_symbol - let family = r_sma_sweep_family(None, &data, grid); // single-cell grid -> 1 member + let data = DataSource::from_choice(choice, env); // per-instrument pip_or_refuse + has_symbol + let family = r_sma_sweep_family(None, &data, grid, env); // single-cell grid -> 1 member let mut report = family.points[0].report.clone(); report.manifest.instrument = Some(symbol.clone()); members.push(report); @@ -1670,7 +1686,7 @@ fn run_generalize( Err(e) => { eprintln!("aura: {e}"); std::process::exit(2); } }; println!("{}", generalize_json(&agg)); - let reg = default_registry(); + let reg = env.registry(); match reg.append_family(name, FamilyKind::CrossInstrument, &members) { Ok(id) => println!("{{\"family_id\":\"{id}\"}}"), Err(e) => { eprintln!("aura: failed to persist family: {e}"); std::process::exit(1); } @@ -1685,15 +1701,18 @@ fn run_generalize( /// print each carrying the assigned id, then the stitched summary line. With /// `--trace`, also persist each OOS member's streams under /// `runs/traces//oos/` (opt-in). Deterministic (C1). -fn run_walkforward(strategy: Strategy, name: &str, persist: bool, data: DataSource, grid: &RGrid, select: Selection) { +fn run_walkforward( + strategy: Strategy, name: &str, persist: bool, data: DataSource, grid: &RGrid, select: Selection, + env: &project::Env, +) { if persist - && let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family) + && let Err(e) = env.trace_store().ensure_name_free(name, WriteKind::Family) { eprintln!("aura: {e}"); std::process::exit(1); } - let reg = default_registry(); - let result = walkforward_family(strategy, persist.then_some(name), &data, grid, select); + let reg = env.registry(); + let result = walkforward_family(strategy, persist.then_some(name), &data, grid, select, env); let id = match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result)) { @@ -1743,9 +1762,9 @@ fn select_winner( /// Other strategies have no walk-forward form yet (exit 2). fn walkforward_family( strategy: Strategy, trace: Option<&str>, data: &DataSource, grid: &RGrid, - select: Selection, + select: Selection, env: &project::Env, ) -> WalkForwardResult { - let span = data.wf_full_span(); + 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, @@ -1758,12 +1777,12 @@ fn walkforward_family( Strategy::SmaCross => { let space = sample_blueprint_with_sinks(data.pip_size()).0.param_space(); walk_forward(roller, space, |w: WindowBounds| { - let (is_family, lattice) = sweep_over(w.is.0, w.is.1, data); + let (is_family, lattice) = sweep_over(w.is.0, w.is.1, data, env); let (best, selection) = match select_winner(&is_family, "total_pips", select, lattice.as_deref()) { Ok(v) => v, Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); } }; - let (oos_equity, mut oos_report) = run_oos(&best.params, w.oos.0, w.oos.1, trace, data); + let (oos_equity, mut oos_report) = run_oos(&best.params, w.oos.0, w.oos.1, trace, data, env); oos_report.manifest.selection = Some(selection); WindowRun { // The tag-free sweep winner is the chosen point; its kinds live on @@ -1777,12 +1796,12 @@ fn walkforward_family( Strategy::RSma => { let space = r_sma_space(); walk_forward(roller, space, |w: WindowBounds| { - let (is_family, lattice) = r_sma_sweep_over(w.is.0, w.is.1, data, grid); + let (is_family, lattice) = r_sma_sweep_over(w.is.0, w.is.1, data, grid, env); let (best, selection) = match select_winner(&is_family, "sqn_normalized", select, lattice.as_deref()) { Ok(v) => v, Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); } }; - let (oos_equity, mut oos_report) = run_oos_r(&best.params, w.oos.0, w.oos.1, trace, data); + let (oos_equity, mut oos_report) = run_oos_r(&best.params, w.oos.0, w.oos.1, trace, data, env); oos_report.manifest.selection = Some(selection); WindowRun { chosen_params: best.params, oos_equity, oos_report } }) @@ -1799,7 +1818,9 @@ fn walkforward_family( /// Sweep the built-in named grid over an in-sample window, sourcing the in-memory /// windowed stream. Mirrors `sweep_family`, but windowed by `[from, to]`. -fn sweep_over(from: Timestamp, to: Timestamp, data: &DataSource) -> (SweepFamily, Option>) { +fn sweep_over( + from: Timestamp, to: Timestamp, data: &DataSource, env: &project::Env, +) -> (SweepFamily, Option>) { let pip = data.pip_size(); let bp = sample_blueprint_with_sinks(pip).0; let space = bp.param_space(); @@ -1817,7 +1838,7 @@ fn sweep_over(from: Timestamp, to: Timestamp, data: &DataSource) -> (SweepFamily let mut h = bp .bootstrap_with_cells(point) .expect("grid points are kind-checked against param_space"); - let sources = data.windowed_sources(from, to); + let sources = data.windowed_sources(from, to, env); let window = window_of(&sources).expect("non-empty in-sample window"); h.run(sources); let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); @@ -1839,6 +1860,7 @@ fn run_oos( to: Timestamp, trace: Option<&str>, data: &DataSource, + env: &project::Env, ) -> (Vec<(Timestamp, f64)>, RunReport) { let pip = data.pip_size(); let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks(pip); @@ -1846,14 +1868,14 @@ fn run_oos( let mut h = bp .bootstrap_with_cells(params) .expect("chosen params pre-validated by the in-sample GridSpace::new"); - let sources = data.windowed_sources(from, to); + let sources = data.windowed_sources(from, to, env); let window = window_of(&sources).expect("non-empty out-of-sample window"); h.run(sources); let eq_rows = rx_eq.try_iter().collect::>(); let ex_rows = rx_ex.try_iter().collect::>(); let manifest = sim_optimal_manifest(zip_params(&space, params), window, 0, pip); if let Some(name) = trace { - persist_traces(&format!("{name}/oos{}", from.0), &manifest, &eq_rows, &ex_rows); + persist_traces(&format!("{name}/oos{}", from.0), &manifest, &eq_rows, &ex_rows, env); } let equity = f64_field(&eq_rows, 0); let exposure = f64_field(&ex_rows, 0); @@ -1941,7 +1963,10 @@ fn walkforward_window_source(from: Timestamp, to: Timestamp) -> VecSource { /// helper (mirrors `sweep_report`). #[cfg(test)] fn walkforward_report() -> String { - let result = walkforward_family(Strategy::SmaCross, None, &DataSource::Synthetic, &RGrid::default(), Selection::Argmax); + let result = walkforward_family( + Strategy::SmaCross, None, &DataSource::Synthetic, &RGrid::default(), Selection::Argmax, + &project::Env::std(), + ); let mut out = String::new(); for w in &result.windows { out.push_str(&w.run.oos_report.to_json()); @@ -1957,7 +1982,7 @@ fn walkforward_report() -> String { /// realization of a synthetic price walk (C12 axis 4). Mirrors `sweep_family`, /// varying the *seed* rather than a tuning param. The seed -> `Source` /// construction lives inside the per-draw closure (eager-agnostic, #71). -fn mc_family(trace: Option<&str>) -> McFamily { +fn mc_family(trace: Option<&str>, env: &project::Env) -> McFamily { let base_point: Vec = Vec::new(); monte_carlo(&base_point, &[1, 2, 3], |seed, _base| { let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE); @@ -1978,7 +2003,7 @@ fn mc_family(trace: Option<&str>) -> McFamily { SYNTHETIC_PIP_SIZE, ); if let Some(name) = trace { - persist_traces(&format!("{name}/seed{seed}"), &manifest, &eq_rows, &ex_rows); + persist_traces(&format!("{name}/seed{seed}"), &manifest, &eq_rows, &ex_rows, env); } let equity = f64_field(&eq_rows, 0); let exposure = f64_field(&ex_rows, 0); @@ -2004,15 +2029,15 @@ fn mc_aggregate_json(agg: &McAggregate) -> String { /// it to the family store via `append_family` (C18/C21), print each draw's record /// line (carrying the assigned `family_id`) plus the aggregate line. With `--trace`, /// also persist each draw's streams under `runs/traces//seed/` (opt-in). -fn run_mc(name: &str, persist: bool) { +fn run_mc(name: &str, persist: bool, env: &project::Env) { if persist - && let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family) + && let Err(e) = env.trace_store().ensure_name_free(name, WriteKind::Family) { eprintln!("aura: {e}"); std::process::exit(1); } - let reg = default_registry(); - let family = mc_family(persist.then_some(name)); + let reg = env.registry(); + let family = mc_family(persist.then_some(name), env); let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) { Ok(id) => id, Err(e) => { @@ -2044,8 +2069,11 @@ enum McArgs { /// every OOS window's per-trade R series in roll order, and print one moving-block /// bootstrap `mc_r_bootstrap` line (`E[R]` distribution + P(`E[R]` <= 0)). Frictionless /// gross R (no costs); deterministic given `seed` (C1). -fn run_mc_r_bootstrap(data: DataSource, grid: &RGrid, block_len: usize, n_resamples: usize, seed: u64) { - println!("{}", mc_r_bootstrap_report(&data, grid, block_len, n_resamples, seed)); +fn run_mc_r_bootstrap( + data: DataSource, grid: &RGrid, block_len: usize, n_resamples: usize, seed: u64, + env: &project::Env, +) { + println!("{}", mc_r_bootstrap_report(&data, grid, block_len, n_resamples, seed, env)); } /// Assemble the `mc` R-bootstrap line: run the r-sma walk-forward, pool the OOS @@ -2054,8 +2082,11 @@ fn run_mc_r_bootstrap(data: DataSource, grid: &RGrid, block_len: usize, n_resamp /// wiring (walk-forward -> non-empty pooling -> bootstrap -> render) is reachable /// over synthetic data in a `#[cfg(test)]` unit, mirroring `mc_report` / /// `walkforward_report` / `sweep_report`. -fn mc_r_bootstrap_report(data: &DataSource, grid: &RGrid, block_len: usize, n_resamples: usize, seed: u64) -> String { - let result = walkforward_family(Strategy::RSma, None, data, grid, Selection::Argmax); +fn mc_r_bootstrap_report( + data: &DataSource, grid: &RGrid, block_len: usize, n_resamples: usize, seed: u64, + env: &project::Env, +) -> String { + let result = walkforward_family(Strategy::RSma, None, data, grid, Selection::Argmax, env); let pooled = pooled_oos_trade_rs(&result); let boot = r_bootstrap(&pooled, n_resamples, block_len, seed); mc_r_bootstrap_json(&boot) @@ -2083,7 +2114,7 @@ fn mc_r_bootstrap_json(b: &RBootstrap) -> String { /// computation, separate from the store-dependent id. #[cfg(test)] fn mc_report() -> String { - let family = mc_family(None); + let family = mc_family(None, &project::Env::std()); let mut out = String::new(); for draw in &family.draws { out.push_str(&draw.report.to_json()); @@ -2096,8 +2127,8 @@ fn mc_report() -> String { /// `aura runs families`: one header line per stored family (id, kind, member /// count), in first-seen store order. -fn runs_families() { - let reg = default_registry(); +fn runs_families(env: &project::Env) { + let reg = env.registry(); let members = match reg.load_family_members() { Ok(m) => m, Err(e) => { @@ -2116,8 +2147,8 @@ fn runs_families() { /// `aura runs family [rank ]`: list one family's member reports in /// ordinal order, or best-first by `metric`. An unknown id is an empty family /// (prints nothing, exit 0); an unknown metric is a usage error (stderr + exit 2). -fn runs_family(id: &str, rank: Option<&str>) { - let reg = default_registry(); +fn runs_family(id: &str, rank: Option<&str>, env: &project::Env) { + let reg = env.registry(); let members = match reg.load_family_members() { Ok(m) => m, Err(e) => { @@ -2196,7 +2227,12 @@ fn point_from_params(space: &[ParamSpec], params: &[(String, Scalar)]) -> Vec ReproduceReport { +fn reproduce_family_in( + reg: &Registry, + id: &str, + data: &DataSource, + env: &project::Env, +) -> ReproduceReport { let members = reg.load_family_members().unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); @@ -2208,7 +2244,7 @@ fn reproduce_family_in(reg: &Registry, id: &str, data: &DataSource) -> Reproduce std::process::exit(1); }; let pip = data.pip_size(); - let window = data.full_window(); + let window = data.full_window(env); let mut outcomes = Vec::new(); for member in &family.members { let stored = &member.report; @@ -2230,7 +2266,7 @@ fn reproduce_family_in(reg: &Registry, id: &str, data: &DataSource) -> Reproduce // param-space probe (below) and the re-run each consume one. The doc was // canonical-serialized at store time, so every reload is infallible. let reload = || { - blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap_or_else(|e| { + blueprint_from_json(&doc, &|t| env.resolve(t)).unwrap_or_else(|e| { eprintln!("aura: stored blueprint {hash} does not parse: {e:?}"); std::process::exit(1); }) @@ -2277,11 +2313,11 @@ fn reproduce_family_in(reg: &Registry, id: &str, data: &DataSource) -> Reproduce // stored window bounds; the winner params come from the shared // manifest->cells recovery below (as Sweep members do). let (from, to) = stored.manifest.window; - let s = data.windowed_sources(from, to); + let s = data.windowed_sources(from, to, env); let w = window_of(&s).expect("non-empty OOS window"); (s, w) } - _ => (data.run_sources(), window), + _ => (data.run_sources(env), window), }; let rerun = run_blueprint_member( reload(), @@ -2292,6 +2328,7 @@ fn reproduce_family_in(reg: &Registry, id: &str, data: &DataSource) -> Reproduce seed, pip, &hash, + env, ); outcomes.push((label, rerun.metrics == stored.metrics)); } @@ -2302,8 +2339,8 @@ fn reproduce_family_in(reg: &Registry, id: &str, data: &DataSource) -> Reproduce /// content-addressed blueprint store and verify each member reproduces bit-identically /// (C18 "re-derives full results on demand"). Synthetic data this cycle (deterministic); /// recorded-dataset reproduction rides the DataServer seam (#124). -fn reproduce_family(id: &str) { - let rep = reproduce_family_in(&default_registry(), id, &DataSource::Synthetic); +fn reproduce_family(id: &str, env: &project::Env) { + let rep = reproduce_family_in(&env.registry(), id, &DataSource::Synthetic, env); let total = rep.outcomes.len(); let ok = rep.outcomes.iter().filter(|(_, b)| *b).count(); for (label, identical) in &rep.outcomes { @@ -2403,9 +2440,9 @@ fn macd_prices() -> Vec<(Timestamp, Scalar)> { /// (the same bootstrap path the SMA sample's compiled view uses), drive it on the /// synthetic stream, and fold both sinks into a `RunReport`. Pure and /// deterministic (C1). -fn run_macd(trace: Option<&str>) -> RunReport { +fn run_macd(trace: Option<&str>, env: &project::Env) -> RunReport { if let Some(n) = trace - && let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run) + && let Err(e) = env.trace_store().ensure_name_free(n, WriteKind::Run) { eprintln!("aura: {e}"); std::process::exit(1); @@ -2436,7 +2473,7 @@ fn run_macd(trace: Option<&str>) -> RunReport { SYNTHETIC_PIP_SIZE, ); if let Some(name) = trace { - persist_traces(name, &manifest, &eq_rows, &ex_rows); + persist_traces(name, &manifest, &eq_rows, &ex_rows, env); } let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); RunReport { manifest, metrics } @@ -2767,6 +2804,7 @@ fn wrap_r( #[allow(clippy::type_complexity)] fn resolve_run_data( data: &RunData, + env: &project::Env, ) -> ( Vec>, (Timestamp, Timestamp), @@ -2780,7 +2818,7 @@ fn resolve_run_data( (sources, window, SYNTHETIC_PIP_SIZE) } RunData::Real { symbol, from, to } => { - let (source, window, pip_size) = open_real_source(symbol, *from, *to); + let (source, window, pip_size) = open_real_source(symbol, *from, *to, env); (vec![source], window, pip_size) } } @@ -2791,7 +2829,9 @@ fn resolve_run_data( /// run over `data`, and build the RunReport (manifest carries topology_hash). /// The single construction+run path shared by the `aura run ` CLI /// arm and its bit-identical test. -fn run_signal_r(signal: Composite, params: &[Scalar], data: RunData, seed: u64) -> RunReport { +fn run_signal_r( + signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &project::Env, +) -> RunReport { let topo = topology_hash(&signal); // before signal is consumed let names: Vec = signal .param_space() @@ -2809,7 +2849,7 @@ fn run_signal_r(signal: Composite, params: &[Scalar], data: RunData, seed: u64) .compile_with_params(params) .expect("signal binds + wraps to a valid harness"); let mut h = Harness::bootstrap(flat).expect("valid r-sma harness"); - let (sources, window, pip_size) = resolve_run_data(&data); + let (sources, window, pip_size) = resolve_run_data(&data, env); h.run(sources); let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); @@ -2819,6 +2859,7 @@ fn run_signal_r(signal: Composite, params: &[Scalar], data: RunData, seed: u64) let mut manifest = sim_optimal_manifest(named_params, window, seed, pip_size); manifest.broker = r_sma_broker_label(pip_size); manifest.topology_hash = Some(topo); + manifest.project = env.provenance(); let mut metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); metrics.r = Some(summarize_r(&r_rows, &[])); RunReport { manifest, metrics } @@ -2840,6 +2881,7 @@ fn run_blueprint_member( seed: u64, pip: f64, topo: &str, + env: &project::Env, ) -> RunReport { let (tx_eq, rx_eq) = mpsc::channel(); let (tx_ex, rx_ex) = mpsc::channel(); @@ -2853,6 +2895,7 @@ fn run_blueprint_member( let mut manifest = sim_optimal_manifest(named, window, seed, pip); manifest.broker = r_sma_broker_label(pip); manifest.topology_hash = Some(topo.to_string()); + manifest.project = env.provenance(); let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); let (total_pips, max_drawdown) = rx_eq .try_iter() @@ -2877,8 +2920,8 @@ fn run_blueprint_member( /// `blueprint_from_json`-validated at the `["sweep", ..]` / `["mc", ..]` /// boundary before this runs, so a malformed doc has already exited 2 and /// never reaches the `.expect`. -fn blueprint_axis_probe(doc: &str) -> Composite { - let signal = blueprint_from_json(doc, &|t| std_vocabulary(t)) +fn blueprint_axis_probe(doc: &str, env: &project::Env) -> Composite { + let signal = blueprint_from_json(doc, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary; reload is infallible"); let (tx_eq, _) = mpsc::channel(); let (tx_ex, _) = mpsc::channel(); @@ -2890,8 +2933,8 @@ fn blueprint_axis_probe(doc: &str) -> Composite { /// `aura sweep --list-axes`: one `:` line per open /// sweepable knob, in `param_space()` order; a closed blueprint prints nothing. /// Names are exactly what `--axis` binds (same probe as the sweep terminal). -fn list_blueprint_axes(doc: &str) { - for p in blueprint_axis_probe(doc).param_space() { +fn list_blueprint_axes(doc: &str, env: &project::Env) { + for p in blueprint_axis_probe(doc, env).param_space() { println!("{}:{:?}", p.name, p.kind); // ScalarKind Debug -> I64/F64/Bool/Timestamp } } @@ -2915,21 +2958,22 @@ fn blueprint_sweep_family( doc: &str, axes: &[(String, Vec)], data: &DataSource, + env: &project::Env, ) -> Result { // 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. let reload = |d: &str| { - blueprint_from_json(d, &|t| std_vocabulary(t)) + blueprint_from_json(d, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary; reload is infallible") }; let topo = topology_hash(&reload(doc)); let pip = data.pip_size(); - let window = data.full_window(); + let window = data.full_window(env); // a single throwaway floated build, only to resolve param_space (borrow) then seed // the named axes (move) — its taps are never drained. The wrapped probe is the one // `blueprint_axis_probe` single-sources (identical `false, true, None` wrap). - let probe = blueprint_axis_probe(doc); + let probe = blueprint_axis_probe(doc, env); let space = probe.param_space(); // A fully bound blueprint has no open knob — there is nothing to sweep. Refuse it up // front (mirroring blueprint_mc_family's closed-blueprint guard, inverted) BEFORE the @@ -2956,7 +3000,7 @@ fn blueprint_sweep_family( .sweep(|point| { // fresh per-member graph (Composite is !Clone, reload per member) run through // the shared reduce-mode member path — the same fn reproduction re-runs. - run_blueprint_member(reload(doc), point, &space, data.run_sources(), window, 0, pip, &topo) + run_blueprint_member(reload(doc), point, &space, data.run_sources(env), window, 0, pip, &topo, env) }) // render the sweep terminal's BindError to a message (the fn's String error contract), // so `UnknownKnob("nope")` still surfaces verbatim at the IO wrapper. @@ -2970,14 +3014,15 @@ fn blueprint_sweep_family( /// sweep terminal (no panic, no hidden exit) for the caller to render. fn blueprint_sweep_over( doc: &str, axes: &[(String, Vec)], from: Timestamp, to: Timestamp, data: &DataSource, + env: &project::Env, ) -> Result<(SweepFamily, Vec), BindError> { let reload = |d: &str| { - blueprint_from_json(d, &|t| std_vocabulary(t)) + 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 topo = topology_hash(&reload(doc)); - let probe = blueprint_axis_probe(doc); + let probe = blueprint_axis_probe(doc, env); 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"); @@ -2986,9 +3031,9 @@ fn blueprint_sweep_over( binder = binder.axis(n, vals.clone()); } binder.sweep_with_lattice(|point| { - let sources = data.windowed_sources(from, to); + let sources = data.windowed_sources(from, to, env); let window = window_of(&sources).expect("non-empty in-sample window"); - run_blueprint_member(reload(doc), point, &space, sources, window, 0, pip, &topo) + run_blueprint_member(reload(doc), point, &space, sources, window, 0, pip, &topo, env) }) } @@ -2996,16 +3041,17 @@ fn blueprint_sweep_over( /// blueprint — the loaded-member analog of `run_oos_r`. 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). +#[allow(clippy::too_many_arguments)] fn run_oos_blueprint( doc: &str, params: &[Cell], space: &[ParamSpec], from: Timestamp, to: Timestamp, - topo: &str, data: &DataSource, + topo: &str, data: &DataSource, env: &project::Env, ) -> (Vec<(Timestamp, f64)>, RunReport) { - let reload = blueprint_from_json(doc, &|t| std_vocabulary(t)) + let reload = blueprint_from_json(doc, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary; reload is infallible"); let pip = data.pip_size(); - let sources = data.windowed_sources(from, to); + let sources = data.windowed_sources(from, to, env); 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); + let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env); (Vec::new(), report) } @@ -3017,8 +3063,9 @@ fn run_oos_blueprint( /// `exit(2)` with the sweep terminal's message, as the hard-wired arm does. fn blueprint_walkforward_family( doc: &str, axes: &[(String, Vec)], data: &DataSource, select: Selection, + env: &project::Env, ) -> WalkForwardResult { - let span = data.wf_full_span(); + 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, @@ -3027,8 +3074,8 @@ fn blueprint_walkforward_family( std::process::exit(2); } }; - let space = blueprint_axis_probe(doc).param_space(); - let topo = topology_hash(&blueprint_from_json(doc, &|t| std_vocabulary(t)) + let space = blueprint_axis_probe(doc, env).param_space(); + let topo = topology_hash(&blueprint_from_json(doc, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary; reload is infallible")); // 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 @@ -3042,19 +3089,19 @@ fn blueprint_walkforward_family( .next() .expect("roller yields >= 1 window (validated above)") .is; - if let Err(e) = blueprint_sweep_over(doc, axes, first_is.0, first_is.1, data) { + if let Err(e) = blueprint_sweep_over(doc, axes, first_is.0, first_is.1, data, env) { eprintln!("aura: {e:?}"); std::process::exit(2); } walk_forward(roller, space.clone(), |w: WindowBounds| { - let (is_family, lattice) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data) + let (is_family, lattice) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env) .expect("axes validated in the dispatch-boundary pre-flight"); let (best, selection) = match select_winner(&is_family, "sqn_normalized", select, Some(&lattice)) { Ok(v) => v, Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); } }; let (oos_equity, mut oos_report) = - run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data); + run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env); oos_report.manifest.selection = Some(selection); WindowRun { chosen_params: best.params, oos_equity, oos_report } }) @@ -3079,16 +3126,18 @@ fn synthetic_walk_sources(seed: u64) -> Vec> { /// 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`. -fn blueprint_mc_family(doc: &str, n_seeds: u64, data: &DataSource) -> Result { +fn blueprint_mc_family( + doc: &str, n_seeds: u64, data: &DataSource, env: &project::Env, +) -> Result { let reload = |d: &str| { - blueprint_from_json(d, &|t| std_vocabulary(t)) + blueprint_from_json(d, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary; reload is infallible") }; let topo = topology_hash(&reload(doc)); 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).param_space(); + 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 @@ -3109,7 +3158,7 @@ fn blueprint_mc_family(doc: &str, n_seeds: u64, data: &DataSource) -> Result= 2 seeds, if every draw's // metrics are bit-identical to the first, no seed reached a distinguishable realization — @@ -3150,13 +3199,16 @@ fn blueprint_mc_family(doc: &str, n_seeds: u64, data: &DataSource) -> Result)], name: &str, persist: bool, data: DataSource) { +fn run_blueprint_sweep( + doc: &str, axes: &[(String, Vec)], name: &str, persist: bool, data: DataSource, + env: &project::Env, +) { let _ = persist; // reserved for the deferred per-member trace path; the family record below is unconditional - let family = blueprint_sweep_family(doc, axes, &data).unwrap_or_else(|e| { + let family = blueprint_sweep_family(doc, axes, &data, env).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(2); }); - let reg = default_registry(); + let reg = env.registry(); // Store the canonical blueprint ONCE, keyed by the family's shared topology_hash — // exactly the bytes whose SHA256 the members carry (#164 byte-canonical, round-trip // idempotent). One stored topology per family (C18/C11/C12). @@ -3167,7 +3219,7 @@ fn run_blueprint_sweep(doc: &str, axes: &[(String, Vec)], name: &str, pe .clone() .expect("a blueprint sweep stamps every member's topology_hash"); let canonical = blueprint_to_json( - &blueprint_from_json(doc, &|t| std_vocabulary(t)) + &blueprint_from_json(doc, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary"), ) .expect("a loaded blueprint re-serializes"); @@ -3193,9 +3245,12 @@ fn run_blueprint_sweep(doc: &str, axes: &[(String, Vec)], name: &str, pe /// `topology_hash` (the C18 hook, so `aura reproduce` re-derives it), record it as /// a `FamilyKind::WalkForward` family, and print each OOS member line + the summary. /// Mirrors `run_blueprint_sweep` (content-addressed family verb; no ensure_name_free). -fn run_blueprint_walkforward(doc: &str, axes: &[(String, Vec)], name: &str, data: DataSource, select: Selection) { - let result = blueprint_walkforward_family(doc, axes, &data, select); - let reg = default_registry(); +fn run_blueprint_walkforward( + doc: &str, axes: &[(String, Vec)], name: &str, data: DataSource, select: Selection, + env: &project::Env, +) { + let result = blueprint_walkforward_family(doc, axes, &data, select, env); + let reg = env.registry(); let topo = result.windows[0] .run .oos_report @@ -3204,7 +3259,7 @@ fn run_blueprint_walkforward(doc: &str, axes: &[(String, Vec)], name: &s .clone() .expect("a blueprint walk-forward stamps every member's topology_hash"); let canonical = blueprint_to_json( - &blueprint_from_json(doc, &|t| std_vocabulary(t)).expect("doc parse-validated at the dispatch boundary"), + &blueprint_from_json(doc, &|t| env.resolve(t)).expect("doc parse-validated at the dispatch boundary"), ) .expect("a loaded blueprint re-serializes"); reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); }); @@ -3223,12 +3278,12 @@ fn run_blueprint_walkforward(doc: &str, axes: &[(String, Vec)], name: &s /// `topology_hash` (the 0094 hook, so `aura reproduce` re-derives it), record it as a /// `FamilyKind::MonteCarlo` family (C18/C21 lineage), and print each draw's member line /// (carrying the seed) plus the aggregate — mirroring `run_mc` / `run_blueprint_sweep`. -fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource) { - let family = blueprint_mc_family(doc, n_seeds, &data).unwrap_or_else(|e| { +fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource, env: &project::Env) { + let family = blueprint_mc_family(doc, n_seeds, &data, env).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(2); }); - let reg = default_registry(); + let reg = env.registry(); // Store the canonical blueprint ONCE, keyed by the family's shared topology_hash. let topo = family.draws[0] .report @@ -3237,7 +3292,7 @@ fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource) { .clone() .expect("a blueprint mc stamps every member's topology_hash"); let canonical = blueprint_to_json( - &blueprint_from_json(doc, &|t| std_vocabulary(t)) + &blueprint_from_json(doc, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary"), ) .expect("a loaded blueprint re-serializes"); @@ -3483,9 +3538,10 @@ fn run_r_sma( const_cost: Option, slip_vol_mult: Option, carry_per_cycle: Option, + env: &project::Env, ) -> RunReport { if let Some(n) = trace - && let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run) + && let Err(e) = env.trace_store().ensure_name_free(n, WriteKind::Run) { eprintln!("aura: {e}"); std::process::exit(1); @@ -3525,7 +3581,7 @@ fn run_r_sma( .expect("valid r-sma blueprint"); let mut h = Harness::bootstrap(flat).expect("valid r-sma harness"); - let (sources, window, pip_size) = resolve_run_data(&data); + let (sources, window, pip_size) = resolve_run_data(&data, env); h.run(sources); let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); @@ -3555,8 +3611,12 @@ fn run_r_sma( Some(R_SMA_FAST), Some(R_SMA_SLOW), ))); + // Project provenance is paired with `topology_hash` — the same #158 + // reproducibility-anchor group, exactly the three spots that already stamp + // `topology_hash` today (this fn, `run_signal_r`, `run_blueprint_member`). + manifest.project = env.provenance(); if let Some(name) = trace { - persist_traces_r(name, &manifest, &eq_rows, &ex_rows, &req_rows, &net_rows); + persist_traces_r(name, &manifest, &eq_rows, &ex_rows, &req_rows, &net_rows, env); } let mut metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); metrics.r = Some(summarize_r(&r_rows, &cost_rows)); @@ -3576,6 +3636,7 @@ fn persist_traces_r( ex_rows: &[(Timestamp, Vec)], req_rows: &[(Timestamp, Vec)], net_rows: &[(Timestamp, Vec)], + env: &project::Env, ) { let mut taps = vec![ ColumnarTrace::from_rows("equity", &[ScalarKind::F64], eq_rows), @@ -3585,7 +3646,7 @@ fn persist_traces_r( if !net_rows.is_empty() { taps.push(ColumnarTrace::from_rows("net_r_equity", &[ScalarKind::F64], net_rows)); } - if let Err(e) = TraceStore::open("runs").write(name, manifest, &taps) { + if let Err(e) = env.trace_store().write(name, manifest, &taps) { eprintln!("aura: trace persist failed: {e}"); std::process::exit(1); } @@ -3638,14 +3699,16 @@ fn parse_scalar_csv(csv: &str) -> Option> { /// Route a parsed `run` invocation to its harness handler. The compile-time `match` IS /// the selector — the harnesses are Rust-authored built-ins, picked by name (C9/C17). -fn run_dispatch(args: RunArgs) -> Result { +fn run_dispatch(args: RunArgs, env: &project::Env) -> Result { let trace = args.trace.as_deref(); Ok(match (args.harness, args.data) { - (HarnessKind::Sma, RunData::Synthetic) => run_sample(trace), - (HarnessKind::Macd, RunData::Synthetic) => run_macd(trace), - (HarnessKind::RSma, data) => run_r_sma(data, trace, args.cost, args.slip_vol_mult, args.carry_per_cycle), + (HarnessKind::Sma, RunData::Synthetic) => run_sample(trace, env), + (HarnessKind::Macd, RunData::Synthetic) => run_macd(trace, env), + (HarnessKind::RSma, data) => { + run_r_sma(data, trace, args.cost, args.slip_vol_mult, args.carry_per_cycle, env) + } (HarnessKind::Sma, RunData::Real { symbol, from, to }) => { - run_sample_real(&symbol, from, to, trace) + run_sample_real(&symbol, from, to, trace, env) } (HarnessKind::Macd, RunData::Real { .. }) => { return Err("the macd harness has no --real form".to_string()) @@ -3671,6 +3734,9 @@ fn run_dispatch(args: RunArgs) -> Result { struct Cli { #[command(subcommand)] command: Command, + /// Load the project dylib from target/release instead of target/debug. + #[arg(long, global = true)] + release: bool, } #[derive(Subcommand)] @@ -4167,7 +4233,7 @@ fn parse_axes( /// `aura run`: the loaded-blueprint branch (an existing `.json` first-positional) or /// the built-in harness-kind dispatch. -fn dispatch_run(a: RunCmd) { +fn dispatch_run(a: RunCmd, env: &project::Env) { match is_blueprint_file(&a.blueprint) { Some(path) => { if a.harness.is_some() { @@ -4191,14 +4257,14 @@ fn dispatch_run(a: RunCmd) { eprintln!("aura: {path}: {e}"); std::process::exit(2); }); - let signal = blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap_or_else(|e| { + let signal = blueprint_from_json(&doc, &|t| env.resolve(t)).unwrap_or_else(|e| { eprintln!("aura: {path}: {e:?}"); std::process::exit(2); }); // Refuse an open (free-knob) blueprint at the dispatch boundary, mirroring // `blueprint_mc_family`'s closed-guard: `run` bootstraps over the EMPTY point, so a // free knob would panic in `compile_with_params` — reject it clean instead (#176). - let free = blueprint_axis_probe(&doc).param_space(); + let free = blueprint_axis_probe(&doc, env).param_space(); if !free.is_empty() { eprintln!( "aura: run requires a closed blueprint (no free parameters); {} free knob(s) — \ @@ -4215,7 +4281,7 @@ fn dispatch_run(a: RunCmd) { None => Vec::new(), }; let data = run_data_from(a.real.as_deref(), a.from, a.to); - let report = run_signal_r(signal, ¶ms, data, a.seed.unwrap_or(0)); + let report = run_signal_r(signal, ¶ms, data, a.seed.unwrap_or(0), env); println!("{}", report.to_json()); } None => { @@ -4227,7 +4293,7 @@ fn dispatch_run(a: RunCmd) { eprintln!("aura: {m}"); std::process::exit(2); }); - match run_dispatch(run_args) { + match run_dispatch(run_args, env) { Ok(report) => println!("{}", report.to_json()), Err(msg) => { eprintln!("aura: {msg}"); @@ -4238,36 +4304,37 @@ fn dispatch_run(a: RunCmd) { } } -fn dispatch_chart(a: ChartCmd) { +fn dispatch_chart(a: ChartCmd, env: &project::Env) { emit_chart( &a.name, a.tap.as_deref(), if a.panels { ChartMode::Panels } else { ChartMode::Overlay }, + env, ); } -fn dispatch_graph(a: GraphCmd) { +fn dispatch_graph(a: GraphCmd, env: &project::Env) { match a.sub { None => print!("{}", render::render_html(&sample_blueprint())), - Some(GraphSub::Build) => graph_construct::build_cmd(), - Some(GraphSub::Introspect(i)) => graph_construct::introspect_cmd(i), + Some(GraphSub::Build) => graph_construct::build_cmd(env), + Some(GraphSub::Introspect(i)) => graph_construct::introspect_cmd(i, env), } } -fn dispatch_generalize(a: GeneralizeCmd) { +fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) { let (name, symbols, grid, metric, from_ms, to_ms) = generalize_args_from(&a).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); }); - run_generalize(&name, &symbols, &grid, &metric, from_ms, to_ms); + run_generalize(&name, &symbols, &grid, &metric, from_ms, to_ms, env); } -fn dispatch_runs(a: RunsCmd) { +fn dispatch_runs(a: RunsCmd, env: &project::Env) { match a.sub { - RunsSub::Families => runs_families(), + RunsSub::Families => runs_families(env), RunsSub::Family { id, rank_kw, metric } => match (rank_kw.as_deref(), metric) { - (None, None) => runs_family(&id, None), - (Some("rank"), Some(m)) => runs_family(&id, Some(&m)), + (None, None) => runs_family(&id, None, env), + (Some("rank"), Some(m)) => runs_family(&id, Some(&m), env), _ => { eprintln!("aura: Usage: aura runs family [rank ]"); std::process::exit(2); @@ -4276,13 +4343,13 @@ fn dispatch_runs(a: RunsCmd) { } } -fn dispatch_reproduce(a: ReproduceCmd) { - reproduce_family(&a.id); +fn dispatch_reproduce(a: ReproduceCmd, env: &project::Env) { + reproduce_family(&a.id, env); } /// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the /// first-positional is an existing `.json`, else the built-in `--strategy` grid sweep. -fn dispatch_sweep(a: SweepCmd) { +fn dispatch_sweep(a: SweepCmd, env: &project::Env) { match is_blueprint_file(&a.blueprint) { Some(path) => { let usage = || "Usage: aura sweep --axis = [--axis …] [--name | --trace ] [--real [--from ] [--to ]]".to_string(); @@ -4291,7 +4358,7 @@ fn dispatch_sweep(a: SweepCmd) { std::process::exit(2); }); // Parse-validate the blueprint once at the boundary (with file-path context). - if let Err(e) = blueprint_from_json(&doc, &|t| std_vocabulary(t)) { + if let Err(e) = blueprint_from_json(&doc, &|t| env.resolve(t)) { eprintln!("aura: {path}: {e:?}"); std::process::exit(2); } @@ -4320,7 +4387,7 @@ fn dispatch_sweep(a: SweepCmd) { eprintln!("aura: --list-axes lists axes and takes no other flags"); std::process::exit(2); } - list_blueprint_axes(&doc); + list_blueprint_axes(&doc, env); return; } let axes = parse_axes(&a.axis, &usage).unwrap_or_else(|m| { @@ -4340,7 +4407,7 @@ fn dispatch_sweep(a: SweepCmd) { eprintln!("aura: {m}"); std::process::exit(2); }); - run_blueprint_sweep(&doc, &axes, &name, persist, DataSource::from_choice(data)); + run_blueprint_sweep(&doc, &axes, &name, persist, DataSource::from_choice(data, env), env); } None => { let usage = || "Usage: aura sweep [--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--channel ] [--window ] [--band-k ]".to_string(); @@ -4373,14 +4440,14 @@ fn dispatch_sweep(a: SweepCmd) { eprintln!("aura: {m}"); std::process::exit(2); }); - run_sweep(strategy, &name, persist, DataSource::from_choice(data), &grid); + run_sweep(strategy, &name, persist, DataSource::from_choice(data, env), &grid, env); } } } /// `aura walkforward`: IS-refit walk-forward over a loaded blueprint (an existing /// `.json` first-positional) or the built-in `--strategy` walk-forward. -fn dispatch_walkforward(a: WalkforwardCmd) { +fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { match is_blueprint_file(&a.blueprint) { Some(path) => { let usage = || "Usage: aura walkforward --axis = [--axis …] [--select ] [--name ]".to_string(); @@ -4388,7 +4455,7 @@ fn dispatch_walkforward(a: WalkforwardCmd) { eprintln!("aura: {path}: {e}"); std::process::exit(2); }); - if let Err(e) = blueprint_from_json(&doc, &|t| std_vocabulary(t)) { + if let Err(e) = blueprint_from_json(&doc, &|t| env.resolve(t)) { eprintln!("aura: {path}: {e:?}"); std::process::exit(2); } @@ -4418,7 +4485,7 @@ fn dispatch_walkforward(a: WalkforwardCmd) { None => Selection::Argmax, }; let name = a.name.clone().unwrap_or_else(|| "walkforward".to_string()); - run_blueprint_walkforward(&doc, &axes, &name, DataSource::Synthetic, select); + run_blueprint_walkforward(&doc, &axes, &name, DataSource::Synthetic, select, env); } None => { let usage = || "Usage: aura walkforward [--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--select ]".to_string(); @@ -4452,14 +4519,14 @@ fn dispatch_walkforward(a: WalkforwardCmd) { eprintln!("aura: {m}"); std::process::exit(2); }); - run_walkforward(strategy, &name, persist, DataSource::from_choice(data), &grid, select); + run_walkforward(strategy, &name, persist, DataSource::from_choice(data, env), &grid, select, env); } } } /// `aura mc`: loaded-blueprint Monte-Carlo (an existing `.json` first-positional), or /// the built-in synthetic seed-resweep / r-sma R-bootstrap split. -fn dispatch_mc(a: McCmd) { +fn dispatch_mc(a: McCmd, env: &project::Env) { match is_blueprint_file(&a.blueprint) { Some(path) => { let usage = "Usage: aura mc --seeds [--name ]"; @@ -4467,7 +4534,7 @@ fn dispatch_mc(a: McCmd) { eprintln!("aura: {path}: {e}"); std::process::exit(2); }); - if let Err(e) = blueprint_from_json(&doc, &|t| std_vocabulary(t)) { + if let Err(e) = blueprint_from_json(&doc, &|t| env.resolve(t)) { eprintln!("aura: {path}: {e:?}"); std::process::exit(2); } @@ -4497,7 +4564,7 @@ fn dispatch_mc(a: McCmd) { } }; let name = a.name.clone().unwrap_or_else(|| "mc".to_string()); - run_blueprint_mc(&doc, n_seeds, &name, DataSource::Synthetic); + run_blueprint_mc(&doc, n_seeds, &name, DataSource::Synthetic, env); } None => { let usage = || "Usage: aura mc [--name |--trace ] | aura mc --strategy r-sma [--real [--from ] [--to ]] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--block-len ] [--resamples ] [--seed ]".to_string(); @@ -4549,10 +4616,10 @@ fn dispatch_mc(a: McCmd) { McArgs::Synthetic { name, persist } }; match mc_args { - McArgs::Synthetic { name, persist } => run_mc(&name, persist), - McArgs::RealR { choice, grid, block_len, n_resamples, seed } => { - run_mc_r_bootstrap(DataSource::from_choice(choice), &grid, block_len, n_resamples, seed) - } + McArgs::Synthetic { name, persist } => run_mc(&name, persist, env), + McArgs::RealR { choice, grid, block_len, n_resamples, seed } => run_mc_r_bootstrap( + DataSource::from_choice(choice, env), &grid, block_len, n_resamples, seed, env, + ), } } } @@ -4568,16 +4635,30 @@ fn main() { unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } - match Cli::parse().command { - Command::Run(a) => dispatch_run(a), - Command::Chart(a) => dispatch_chart(a), - Command::Graph(a) => dispatch_graph(a), - Command::Sweep(a) => dispatch_sweep(a), - Command::Walkforward(a) => dispatch_walkforward(a), - Command::Generalize(a) => dispatch_generalize(a), - Command::Mc(a) => dispatch_mc(a), - Command::Runs(a) => dispatch_runs(a), - Command::Reproduce(a) => dispatch_reproduce(a), + let cli = Cli::parse(); + let env = match std::env::current_dir() + .ok() + .and_then(|d| project::discover_from(&d)) + { + Some(root) => match project::load(&root, cli.release) { + Ok(p) => project::Env::with_project(p), + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(1); + } + }, + None => project::Env::std(), + }; + match cli.command { + Command::Run(a) => dispatch_run(a, &env), + Command::Chart(a) => dispatch_chart(a, &env), + Command::Graph(a) => dispatch_graph(a, &env), + Command::Sweep(a) => dispatch_sweep(a, &env), + Command::Walkforward(a) => dispatch_walkforward(a, &env), + Command::Generalize(a) => dispatch_generalize(a, &env), + Command::Mc(a) => dispatch_mc(a, &env), + Command::Runs(a) => dispatch_runs(a, &env), + Command::Reproduce(a) => dispatch_reproduce(a, &env), } } @@ -4877,13 +4958,14 @@ mod tests { #[test] fn data_source_synthetic_pip_and_window_match_the_built_ins() { + let env = project::Env::std(); let d = DataSource::Synthetic; assert_eq!(d.pip_size(), SYNTHETIC_PIP_SIZE); - assert!(!d.run_sources().is_empty()); + assert!(!d.run_sources(&env).is_empty()); assert_eq!(d.wf_window_sizes(), (24, 12, 12)); // full_window equals window_of over the showcase stream (byte-unchanged source) let s: Vec> = vec![Box::new(VecSource::new(showcase_prices()))]; - assert_eq!(d.full_window(), window_of(&s).unwrap()); + assert_eq!(d.full_window(&env), window_of(&s).unwrap()); } #[test] @@ -5073,29 +5155,33 @@ mod tests { group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports, FamilyKind, Registry, }; + let env = project::Env::std(); let dir = std::env::temp_dir().join(format!("aura-cli-fam-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); // the exact persist chain `run_sweep`/`run_mc`/`run_walkforward` use, against - // a fresh temp store (the run_* fns themselves bind `default_registry()`): + // a fresh temp store (the run_* fns themselves bind `env.registry()`): // engine family -> per-kind extractor -> append_family. let sid = reg .append_family( "sweep", FamilyKind::Sweep, - &sweep_member_reports(&sweep_family(None, &DataSource::Synthetic)), + &sweep_member_reports(&sweep_family(None, &DataSource::Synthetic, &env)), ) .expect("sweep family"); let mid = reg - .append_family("mc", FamilyKind::MonteCarlo, &mc_member_reports(&mc_family(None))) + .append_family("mc", FamilyKind::MonteCarlo, &mc_member_reports(&mc_family(None, &env))) .expect("mc family"); let wid = reg .append_family( "walkforward", FamilyKind::WalkForward, - &walkforward_member_reports(&walkforward_family(Strategy::SmaCross, None, &DataSource::Synthetic, &RGrid::default(), Selection::Argmax)), + &walkforward_member_reports(&walkforward_family( + Strategy::SmaCross, None, &DataSource::Synthetic, &RGrid::default(), Selection::Argmax, + &env, + )), ) .expect("walkforward family"); assert_eq!((sid.as_str(), mid.as_str(), wid.as_str()), ("sweep-0", "mc-0", "walkforward-0")); @@ -5116,8 +5202,9 @@ mod tests { // the MACD strategy authors a nested EMA-of-EMA composite, compiles it to a // flat runnable harness (the call not panicking proves the compile+bootstrap // path), and runs it. C1 determinism: two runs are bit-identical. - let r1 = run_macd(None); - let r2 = run_macd(None); + let env = project::Env::std(); + let r1 = run_macd(None, &env); + let r2 = run_macd(None, &env); assert_eq!(r1.metrics, r2.metrics); assert_eq!(r1.to_json(), r2.to_json()); @@ -5197,8 +5284,9 @@ mod tests { // The headline: a real-data run over the bounded Sept-2024 window yields // a finite, C1-deterministic RunReport. - let r1 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None); - let r2 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None); + let env = project::Env::std(); + let r1 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None, &env); + let r2 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None, &env); assert!( r1.metrics.total_pips.is_finite(), @@ -5231,21 +5319,23 @@ mod tests { } // Bounded to the vetted Sept-2024 window so the pip-label + determinism // checks run fast (not the full unbounded archive). + let env = project::Env::std(); let report = - run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None); + run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None, &env); // The looked-up index pip (1.0) reaches the manifest — not the FX 0.0001. assert_eq!(report.manifest.broker, "sim-optimal(pip_size=1)"); // Deterministic (C1): a second run yields the same report. let again = - run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None); + run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None, &env); assert_eq!(report.manifest.broker, again.manifest.broker); assert_eq!(report.metrics.total_pips, again.metrics.total_pips); } #[test] fn run_sample_is_deterministic_and_non_trivial() { - let r1 = run_sample(None); - let r2 = run_sample(None); + let env = project::Env::std(); + let r1 = run_sample(None, &env); + let r2 = run_sample(None, &env); // C1 determinism: two runs are bit-identical (metrics + rendered JSON). assert_eq!(r1.metrics, r2.metrics); assert_eq!(r1.to_json(), r2.to_json()); @@ -5384,8 +5474,9 @@ mod tests { #[test] fn momentum_sweep_is_deterministic_and_has_eight_points() { - let a = momentum_sweep_family(None, &DataSource::Synthetic); - let b = momentum_sweep_family(None, &DataSource::Synthetic); + let env = project::Env::std(); + let a = momentum_sweep_family(None, &DataSource::Synthetic, &env); + let b = momentum_sweep_family(None, &DataSource::Synthetic, &env); assert_eq!(a.points.len(), 8, "2x2x2 grid = 8 points"); assert_eq!(a, b, "C1: the momentum family is a pure function of the build"); } @@ -5440,6 +5531,7 @@ mod tests { fn blueprint_sweep_member_equals_single_run_and_shares_topology_hash() { // An OPEN signal (both SMA knobs free) so the sweep can bind them by name; the // serialized doc round-trips to the topology the single run hashes. + let env = project::Env::std(); let open = sma_signal(None, None); let doc = blueprint_to_json(&open).expect("serializes"); let data = DataSource::Synthetic; @@ -5448,7 +5540,7 @@ mod tests { ("sma_signal.fast.length".to_string(), vec![Scalar::i64(2)]), ("sma_signal.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(6)]), ]; - let family = blueprint_sweep_family(&doc, &axes, &data).expect("named axes resolve"); + let family = blueprint_sweep_family(&doc, &axes, &data, &env).expect("named axes resolve"); assert_eq!(family.points.len(), 2, "2x1 grid -> 2 members"); // (b) every member carries the shared topology_hash of the loaded signal. @@ -5466,6 +5558,7 @@ mod tests { &[Scalar::i64(2), Scalar::i64(4)], RunData::Synthetic, 0, + &env, ); let member4 = &family.points[0].report; // slow=4 is the first odometer point assert_eq!(member4.metrics, single.metrics, "loaded sweep member == single run"); @@ -5484,26 +5577,28 @@ mod tests { // The open fixture's two SMA lengths are the sweepable knobs; the probe // wraps the signal (name "sma_signal") so the names are prefixed — // exactly what `--axis` binds. + let env = project::Env::std(); let open = include_str!("../tests/fixtures/sma_signal_open.json"); - let space = blueprint_axis_probe(open).param_space(); + let space = blueprint_axis_probe(open, &env).param_space(); let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect(); assert_eq!(names, ["sma_signal.fast.length", "sma_signal.slow.length"]); assert!(space.iter().all(|p| matches!(p.kind, ScalarKind::I64))); // A closed blueprint (both lengths bound) has no open axes. let closed = include_str!("../tests/fixtures/sma_signal.json"); - assert!(blueprint_axis_probe(closed).param_space().is_empty()); + assert!(blueprint_axis_probe(closed, &env).param_space().is_empty()); } #[test] fn blueprint_walkforward_family_refits_each_window() { // The open fixture's two SMA lengths are re-fit per IS window over a 2x2 grid. + let env = project::Env::std(); let doc = include_str!("../tests/fixtures/sma_signal_open.json"); let axes = vec![ ("sma_signal.fast.length".to_string(), vec![Scalar::i64(2), Scalar::i64(3)]), ("sma_signal.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(6)]), ]; - let result = blueprint_walkforward_family(doc, &axes, &DataSource::Synthetic, Selection::Argmax); + let result = blueprint_walkforward_family(doc, &axes, &DataSource::Synthetic, Selection::Argmax, &env); // 24/12/12 over the 60-bar synthetic span -> 3 rolling windows. assert_eq!(result.windows.len(), 3, "three rolling IS/OOS windows"); for w in &result.windows { @@ -5520,9 +5615,10 @@ mod tests { // topology_hash, and DIFFERING metrics — the seed reaches the DATA (a distinct // synthetic walk per draw), not just the manifest label. The anti-degenerate guard: // a regression to seed-as-label-only would make the three draws identical. + let env = project::Env::std(); let closed = sma_signal(Some(2), Some(4)); let doc = blueprint_to_json(&closed).expect("serializes"); - let family = blueprint_mc_family(&doc, 3, &DataSource::Synthetic).expect("closed blueprint"); + let family = blueprint_mc_family(&doc, 3, &DataSource::Synthetic, &env).expect("closed blueprint"); assert_eq!(family.draws.len(), 3, "one draw per seed"); assert_eq!(family.draws.iter().map(|d| d.seed).collect::>(), vec![1, 2, 3]); @@ -5544,9 +5640,10 @@ mod tests { // distribution. A CLOSED deep-lookback signal whose slow SMA length (60) equals the // fixed 60-bar synthetic walk never warms, so every seed yields zero trades and thus // identical metrics; that is a wrong result with no error (C10 refuse-don't-guess). + let env = project::Env::std(); let deep = sma_signal(Some(2), Some(60)); // slow len == walk len -> never warms let doc = blueprint_to_json(&deep).expect("serializes"); - let err = blueprint_mc_family(&doc, 3, &DataSource::Synthetic) + let err = blueprint_mc_family(&doc, 3, &DataSource::Synthetic, &env) .expect_err("a vacuous (all-identical) Monte-Carlo is rejected, not returned"); assert!( err.contains("vacuous") || err.contains("identical"), @@ -5561,9 +5658,10 @@ mod tests { // is unit-testable (the IO wrapper renders it to stderr + exit 2, mirroring the sibling // blueprint_sweep_family). MC binds no axis, so a free knob would have no binder; the // rejection pre-empts the downstream compile_with_params arity panic. + let env = project::Env::std(); let open = sma_signal(None, None); // both SMA knobs free -> non-empty param_space let doc = blueprint_to_json(&open).expect("serializes"); - let err = blueprint_mc_family(&doc, 4, &DataSource::Synthetic) + let err = blueprint_mc_family(&doc, 4, &DataSource::Synthetic, &env) .expect_err("an open blueprint is rejected, not run"); assert!(err.contains("closed blueprint"), "names the closed-blueprint requirement: {err}"); } @@ -5576,6 +5674,7 @@ mod tests { std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); + let env = project::Env::std(); let open = sma_signal(None, None); let doc = blueprint_to_json(&open).expect("serializes"); let data = DataSource::Synthetic; @@ -5584,7 +5683,7 @@ mod tests { ("sma_signal.fast.length".to_string(), vec![Scalar::i64(2)]), ("sma_signal.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(6)]), ]; - let family = blueprint_sweep_family(&doc, &axes, &data).expect("axes resolve"); + let family = blueprint_sweep_family(&doc, &axes, &data, &env).expect("axes resolve"); // persist exactly as run_blueprint_sweep does: store the blueprint, append the family. let topo = family.points[0].report.manifest.topology_hash.clone().expect("topo"); @@ -5596,7 +5695,7 @@ mod tests { .expect("append"); // reproduce: every member re-derives bit-identically (incl the open-at-end member). - let rep = reproduce_family_in(®, &id, &data); + let rep = reproduce_family_in(®, &id, &data, &env); assert_eq!(rep.outcomes.len(), 2, "two members reproduced"); assert!( rep.outcomes.iter().all(|(_, ok)| *ok), @@ -5615,10 +5714,11 @@ mod tests { let reg = Registry::open(dir.join("runs.jsonl")); // a CLOSED signal (both SMA knobs bound) — MC binds no axis. + let env = project::Env::std(); let closed = sma_signal(Some(2), Some(4)); let doc = blueprint_to_json(&closed).expect("serializes"); let data = DataSource::Synthetic; - let family = blueprint_mc_family(&doc, 3, &data).expect("closed blueprint"); + let family = blueprint_mc_family(&doc, 3, &data, &env).expect("closed blueprint"); // persist exactly as run_blueprint_mc does: store the blueprint, append the MC family. let topo = family.draws[0].report.manifest.topology_hash.clone().expect("topo"); @@ -5631,7 +5731,7 @@ mod tests { // reproduce: every MC member re-derives bit-identically (its seed-driven walk is // reconstructed from manifest.seed — the realization branch). - let rep = reproduce_family_in(®, &id, &data); + let rep = reproduce_family_in(®, &id, &data, &env); assert_eq!(rep.outcomes.len(), 3, "three MC members reproduced"); assert!( rep.outcomes.iter().all(|(_, ok)| *ok), @@ -5650,6 +5750,7 @@ mod tests { /// the RED phase compiles whether the builder returns a `BindError` or a `String`. #[test] fn blueprint_sweep_family_rejects_a_fully_bound_blueprint() { + let env = project::Env::std(); let closed = sma_signal(Some(2), Some(4)); // both SMA knobs bound -> empty param_space let doc = blueprint_to_json(&closed).expect("serializes"); // an axis naming a bound-out knob: the pre-fix path returns UnknownKnob for it. @@ -5657,7 +5758,7 @@ mod tests { "sma_signal.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(6)], )]; - let err = match blueprint_sweep_family(&doc, &axes, &DataSource::Synthetic) { + let err = match blueprint_sweep_family(&doc, &axes, &DataSource::Synthetic, &env) { Ok(_) => panic!("a fully-bound blueprint has nothing to sweep"), Err(e) => format!("{e:?}"), }; @@ -5682,10 +5783,11 @@ mod tests { std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); + let env = project::Env::std(); let closed = sma_signal(Some(2), Some(4)); // MC binds no axis -> closed blueprint let doc = blueprint_to_json(&closed).expect("serializes"); let data = DataSource::Synthetic; - let family = blueprint_mc_family(&doc, 3, &data).expect("closed blueprint"); + let family = blueprint_mc_family(&doc, 3, &data, &env).expect("closed blueprint"); let topo = family.draws[0].report.manifest.topology_hash.clone().expect("topo"); let canonical = @@ -5695,7 +5797,7 @@ mod tests { .append_family("mcseed", FamilyKind::MonteCarlo, &mc_member_reports(&family)) .expect("append"); - let rep = reproduce_family_in(®, &id, &data); + let rep = reproduce_family_in(®, &id, &data, &env); assert_eq!(rep.outcomes.len(), 3, "three MC members"); for (label, _) in &rep.outcomes { assert!( @@ -5750,7 +5852,7 @@ mod tests { fn run_r_sma_synthetic_folds_an_r_block() { // the r-sma harness scores the SMA-cross signal in R: one shell-callable run // yields a RunReport whose metrics.r is Some with a finite SQN and >= 1 trade. - let report = run_r_sma(RunData::Synthetic, None, None, None, None); + let report = run_r_sma(RunData::Synthetic, None, None, None, None, &project::Env::std()); let r = report.metrics.r.as_ref().expect("r-sma run must populate metrics.r"); assert!(r.n_trades >= 1, "expected >= 1 trade, got {}", r.n_trades); assert!(r.sqn.is_finite(), "SQN must be finite, got {}", r.sqn); @@ -5800,10 +5902,11 @@ mod tests { /// twin — same metrics, same traces, same topology_hash. #[test] fn loaded_signal_runs_bit_identical_to_rust_built() { + let env = project::Env::std(); let json = blueprint_to_json(&sma_signal(Some(2), Some(4))).expect("serializes"); let loaded = blueprint_from_json(&json, &|t| std_vocabulary(t)).expect("loads"); - let a = run_signal_r(sma_signal(Some(2), Some(4)), &[], RunData::Synthetic, 0); - let b = run_signal_r(loaded, &[], RunData::Synthetic, 0); + let a = run_signal_r(sma_signal(Some(2), Some(4)), &[], RunData::Synthetic, 0, &env); + let b = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env); assert_eq!(a.to_json(), b.to_json(), "loaded run is bit-identical incl. topology_hash"); assert_eq!(a.manifest.topology_hash.as_deref().map(str::len), Some(64), "64-hex sha256 present"); } @@ -5892,11 +5995,14 @@ mod tests { // closes >= 1 trade across its windows). Guards the wiring + the non-empty // pooling branch the parser/primitive unit tests cannot reach; mirrors // `mc_report` / `walkforward_report`. Deterministic (C1). - let result = walkforward_family(Strategy::RSma, None, &DataSource::Synthetic, &RGrid::default(), Selection::Argmax); + let env = project::Env::std(); + let result = walkforward_family( + Strategy::RSma, None, &DataSource::Synthetic, &RGrid::default(), Selection::Argmax, &env, + ); let pooled = pooled_oos_trade_rs(&result); assert!(!pooled.is_empty(), "synthetic r-sma walk-forward must pool >= 1 OOS trade R"); - let line = mc_r_bootstrap_report(&DataSource::Synthetic, &RGrid::default(), 1, 256, 1); + let line = mc_r_bootstrap_report(&DataSource::Synthetic, &RGrid::default(), 1, 256, 1, &env); let v: serde_json::Value = serde_json::from_str(&line).expect("canonical json line"); let obj = &v["mc_r_bootstrap"]; // n_trades is the pooled-series length the bootstrap actually saw — non-zero @@ -5909,7 +6015,7 @@ mod tests { // C1 at the CLI edge: the assembled real-R line is byte-identical on re-run. assert_eq!( line, - mc_r_bootstrap_report(&DataSource::Synthetic, &RGrid::default(), 1, 256, 1), + mc_r_bootstrap_report(&DataSource::Synthetic, &RGrid::default(), 1, 256, 1, &env), ); } } diff --git a/crates/aura-cli/src/project.rs b/crates/aura-cli/src/project.rs new file mode 100644 index 0000000..4e8530c --- /dev/null +++ b/crates/aura-cli/src/project.rs @@ -0,0 +1,589 @@ +//! Project discovery + the cdylib load boundary (cycle 0102, C13/C16). +//! +//! `discover_from` walks up to the nearest `Aura.toml` (the way cargo finds +//! `Cargo.toml`); `load` locates the project's cdylib via `cargo metadata`, +//! loads it **load-and-hold** (leaked, never unloaded), validates the C tier +//! of the descriptor BEFORE touching any Rust-ABI field, then applies the +//! vocabulary charter. `Env` is the per-invocation context every verb reads: +//! merged resolver, runs root, data path, provenance. + +use aura_core::PrimitiveBuilder; +use aura_core::project::{ + AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_VERSION, AURA_PROJECT_SYMBOL, + CORE_VERSION, ProjectDescriptor, RUSTC_VERSION, StrSlice, +}; +use aura_engine::ProjectProvenance; +use aura_registry::{Registry, TraceStore}; +use aura_std::{std_vocabulary, std_vocabulary_types}; +use sha2::{Digest, Sha256}; +use std::fmt; +use std::path::{Path, PathBuf}; + +/// Parsed `Aura.toml` — static project context only (C17): paths, nothing else. +#[derive(Debug, Default, PartialEq, serde::Deserialize)] +pub struct AuraToml { + #[serde(default)] + pub paths: AuraPaths, +} + +#[derive(Debug, Default, PartialEq, serde::Deserialize)] +pub struct AuraPaths { + /// Data archive root; default: the data-server default. + #[serde(default)] + pub data: Option, + /// Registry root, relative to the project root; default `"runs"`. + #[serde(default)] + pub runs: Option, +} + +#[derive(Debug)] +pub enum ProjectError { + Toml(PathBuf, String), + CargoMetadata(String), + ArtifactMissing(PathBuf), + Load(PathBuf, String), + NotAProjectDylib(PathBuf), + Incompatible { what: &'static str, dylib: String, host: String }, + Charter(String), +} + +impl fmt::Display for ProjectError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Toml(p, e) => write!(f, "failed to parse {}: {e}", p.display()), + Self::CargoMetadata(e) => write!(f, "cargo metadata failed: {e}"), + Self::ArtifactMissing(p) => write!( + f, + "project dylib not found at {} — run `cargo build` in the \ + project first (or pass --release to load the release build)", + p.display() + ), + Self::Load(p, e) => write!(f, "failed to load {}: {e}", p.display()), + Self::NotAProjectDylib(p) => write!( + f, + "{} exports no valid AURA_PROJECT descriptor (not an aura \ + project dylib, or an incompatible descriptor layout)", + p.display() + ), + Self::Incompatible { what, dylib, host } => write!( + f, + "project dylib is ABI-incompatible: {what} mismatch \ + (dylib: {dylib}, host: {host}) — rebuild the project with \ + the host's toolchain/aura-core" + ), + Self::Charter(e) => write!(f, "project vocabulary rejected: {e}"), + } + } +} + +/// A successfully loaded project. The `Library` behind `resolver`/`type_ids` +/// is leaked (load-and-hold): the fn pointers are valid for 'static. +pub struct ProjectEnv { + pub root: PathBuf, + pub toml: AuraToml, + pub namespace: String, + pub dylib_sha256: String, + pub commit: Option, + resolver: fn(&str) -> Option, + type_id_list: &'static [&'static str], +} + +impl ProjectEnv { + fn resolve(&self, type_id: &str) -> Option { + (self.resolver)(type_id) + } +} + +/// The per-invocation context every verb reads. Outside a project every +/// accessor collapses to today's literal defaults (byte-identical behaviour). +pub struct Env { + project: Option, +} + +impl Env { + pub fn std() -> Self { + Self { project: None } + } + pub fn with_project(p: ProjectEnv) -> Self { + Self { project: Some(p) } + } + + /// The one resolver every verb consumes: project first, then std. The + /// charter makes overlap impossible, so order is not load-bearing. + pub fn resolve(&self, type_id: &str) -> Option { + match &self.project { + Some(p) => p.resolve(type_id).or_else(|| std_vocabulary(type_id)), + None => std_vocabulary(type_id), + } + } + + /// project ∪ std type ids, project first (for `introspect --vocabulary`). + pub fn type_ids(&self) -> Vec<&'static str> { + let mut out: Vec<&'static str> = Vec::new(); + if let Some(p) = &self.project { + out.extend_from_slice(p.type_id_list); + } + out.extend_from_slice(std_vocabulary_types()); + out + } + + /// The registry root: `/` inside a project, + /// the literal `runs` outside (today's behaviour). + pub fn runs_root(&self) -> PathBuf { + match &self.project { + Some(p) => { + let runs = p.toml.paths.runs.clone().unwrap_or_else(|| "runs".into()); + p.root.join(runs) + } + None => PathBuf::from("runs"), + } + } + + pub fn registry(&self) -> Registry { + Registry::open(self.runs_root().join("runs.jsonl")) + } + + pub fn trace_store(&self) -> TraceStore { + TraceStore::open(self.runs_root()) + } + + /// The data archive root: `paths.data` inside a project when set, + /// the data-server default otherwise. + pub fn data_path(&self) -> String { + self.project + .as_ref() + .and_then(|p| p.toml.paths.data.clone()) + .unwrap_or_else(|| data_server::DEFAULT_DATA_PATH.to_string()) + } + + pub fn provenance(&self) -> Option { + self.project.as_ref().map(|p| ProjectProvenance { + namespace: p.namespace.clone(), + dylib_sha256: p.dylib_sha256.clone(), + commit: p.commit.clone(), + }) + } +} + +/// Walk up from `start` to the nearest directory containing `Aura.toml`. +pub fn discover_from(start: &Path) -> Option { + let mut dir = Some(start); + while let Some(d) = dir { + if d.join("Aura.toml").is_file() { + return Some(d.to_path_buf()); + } + dir = d.parent(); + } + None +} + +fn parse_aura_toml(root: &Path) -> Result { + let path = root.join("Aura.toml"); + let text = std::fs::read_to_string(&path) + .map_err(|e| ProjectError::Toml(path.clone(), e.to_string()))?; + toml::from_str(&text).map_err(|e| ProjectError::Toml(path, e.to_string())) +} + +/// Derive the cdylib artifact path from `cargo metadata` output (pure — +/// unit-testable on a canned JSON document). +fn artifact_from_metadata( + metadata_json: &str, + release: bool, +) -> Result { + let v: serde_json::Value = serde_json::from_str(metadata_json) + .map_err(|e| ProjectError::CargoMetadata(e.to_string()))?; + let target_dir = v["target_directory"] + .as_str() + .ok_or_else(|| ProjectError::CargoMetadata("no target_directory".into()))?; + let packages = v["packages"] + .as_array() + .ok_or_else(|| ProjectError::CargoMetadata("no packages".into()))?; + let lib_name = packages + .iter() + .flat_map(|p| p["targets"].as_array().into_iter().flatten()) + .find(|t| { + t["kind"] + .as_array() + .is_some_and(|k| k.iter().any(|s| s.as_str() == Some("cdylib"))) + }) + .and_then(|t| t["name"].as_str()) + .ok_or_else(|| { + ProjectError::CargoMetadata( + "no cdylib target in the project crate (is `crate-type = \ + [\"cdylib\"]` set?)" + .into(), + ) + })?; + let profile = if release { "release" } else { "debug" }; + let file = format!( + "{}{}{}", + std::env::consts::DLL_PREFIX, + lib_name.replace('-', "_"), + std::env::consts::DLL_SUFFIX + ); + Ok(Path::new(target_dir).join(profile).join(file)) +} + +fn artifact_path(root: &Path, release: bool) -> Result { + let out = std::process::Command::new("cargo") + .args(["metadata", "--format-version", "1", "--no-deps"]) + .current_dir(root) + .output() + .map_err(|e| ProjectError::CargoMetadata(e.to_string()))?; + if !out.status.success() { + return Err(ProjectError::CargoMetadata( + String::from_utf8_lossy(&out.stderr).trim().to_string(), + )); + } + artifact_from_metadata(&String::from_utf8_lossy(&out.stdout), release) +} + +/// The vocabulary charter (decision log on the milestone reference issue): +/// non-empty namespace; every listed id `\::`-prefixed; no duplicate in +/// the merged project ∪ std set; every listed id resolves (list↔resolver +/// cross-check). Pure — unit-testable without a dylib. +fn check_charter( + namespace: &str, + ids: &[&str], + resolve: &dyn Fn(&str) -> Option, +) -> Result<(), ProjectError> { + if namespace.is_empty() { + return Err(ProjectError::Charter("empty namespace".into())); + } + let prefix = format!("{namespace}::"); + let mut seen = std::collections::BTreeSet::new(); + for id in ids { + if !id.starts_with(&prefix) { + return Err(ProjectError::Charter(format!( + "type id `{id}` lacks the project prefix `{prefix}`" + ))); + } + if !seen.insert(*id) { + return Err(ProjectError::Charter(format!("duplicate type id `{id}`"))); + } + if std_vocabulary(id).is_some() || std_vocabulary_types().contains(id) { + return Err(ProjectError::Charter(format!( + "type id `{id}` collides with the std vocabulary" + ))); + } + if resolve(id).is_none() { + return Err(ProjectError::Charter(format!( + "listed type id `{id}` does not resolve through the project \ + resolver (list/resolver drift)" + ))); + } + } + Ok(()) +} + +fn project_commit(root: &Path) -> Option { + let head = std::process::Command::new("git") + .args(["-C"]) + .arg(root) + .args(["rev-parse", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success())?; + let mut sha = String::from_utf8_lossy(&head.stdout).trim().to_string(); + let dirty = std::process::Command::new("git") + .args(["-C"]) + .arg(root) + .args(["status", "--porcelain"]) + .output() + .ok() + .filter(|o| o.status.success())?; + if !dirty.stdout.is_empty() { + sha.push_str("-dirty"); + } + Some(sha) +} + +/// Validate the C tier of a descriptor's already-extracted fields — pure +/// value checks, no dylib/pointer work (the raw reads happen once, in +/// `load`, before this runs). Front-to-back: magic, descriptor version, +/// rustc stamp, aura-core stamp, then the namespace. Returns the namespace +/// on success. Unit-testable without a dylib by constructing the fields +/// directly, the same way `check_charter` is testable without a resolver. +fn validate_c_tier( + magic: u64, + descriptor_version: u32, + rustc_version: StrSlice, + aura_core_version: StrSlice, + namespace: StrSlice, + dylib_path: &Path, +) -> Result { + if magic != AURA_DESCRIPTOR_MAGIC { + return Err(ProjectError::NotAProjectDylib(dylib_path.to_path_buf())); + } + if descriptor_version != AURA_DESCRIPTOR_VERSION { + return Err(ProjectError::Incompatible { + what: "descriptor version", + dylib: descriptor_version.to_string(), + host: AURA_DESCRIPTOR_VERSION.to_string(), + }); + } + let dylib_rustc = unsafe { rustc_version.as_str() } + .ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))?; + if dylib_rustc != RUSTC_VERSION { + return Err(ProjectError::Incompatible { + what: "rustc version", + dylib: dylib_rustc.to_string(), + host: RUSTC_VERSION.to_string(), + }); + } + let dylib_core = unsafe { aura_core_version.as_str() } + .ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))?; + if dylib_core != CORE_VERSION { + return Err(ProjectError::Incompatible { + what: "aura-core version", + dylib: dylib_core.to_string(), + host: CORE_VERSION.to_string(), + }); + } + unsafe { namespace.as_str() } + .ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf())) + .map(str::to_string) +} + +/// Locate, load (load-and-hold), verify, and charter-check the project dylib. +pub fn load(root: &Path, release: bool) -> Result { + let toml = parse_aura_toml(root)?; + let dylib_path = artifact_path(root, release)?; + if !dylib_path.is_file() { + return Err(ProjectError::ArtifactMissing(dylib_path)); + } + let bytes = std::fs::read(&dylib_path) + .map_err(|e| ProjectError::Load(dylib_path.clone(), e.to_string()))?; + let dylib_sha256 = format!("{:x}", Sha256::digest(&bytes)); + + // Load-and-hold: leak the Library so every pointer read from the + // descriptor is valid for 'static. There is deliberately no unload path + // (one-shot process; ledger C13 note). + let lib = unsafe { libloading::Library::new(&dylib_path) } + .map_err(|e| ProjectError::Load(dylib_path.clone(), e.to_string()))?; + let lib: &'static libloading::Library = Box::leak(Box::new(lib)); + + let sym = unsafe { lib.get::<*const ProjectDescriptor>(AURA_PROJECT_SYMBOL) } + .map_err(|_| ProjectError::NotAProjectDylib(dylib_path.clone()))?; + let desc_ptr: *const ProjectDescriptor = *sym; + if desc_ptr.is_null() { + return Err(ProjectError::NotAProjectDylib(dylib_path)); + } + + // ---- C tier, validated front-to-back; no Rust-ABI field touched yet ---- + let magic = unsafe { (*desc_ptr).magic }; + let descriptor_version = unsafe { (*desc_ptr).descriptor_version }; + let rustc_version = unsafe { (*desc_ptr).rustc_version }; + let aura_core_version = unsafe { (*desc_ptr).aura_core_version }; + let namespace_stamp = unsafe { (*desc_ptr).namespace }; + let namespace = validate_c_tier( + magic, + descriptor_version, + rustc_version, + aura_core_version, + namespace_stamp, + &dylib_path, + )?; + + // ---- Rust tier: stamps match, the ABI is trusted from here on ---- + let desc: &'static ProjectDescriptor = unsafe { &*desc_ptr }; + let resolver = desc.vocabulary; + let type_id_list = (desc.type_ids)(); + check_charter(&namespace, type_id_list, &|t| resolver(t))?; + + Ok(ProjectEnv { + root: root.to_path_buf(), + toml, + namespace, + dylib_sha256, + commit: project_commit(root), + resolver, + type_id_list, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn discover_walks_up_to_aura_toml() { + let tmp = std::env::temp_dir().join(format!("aura-disc-{}", std::process::id())); + let nested = tmp.join("a/b/c"); + std::fs::create_dir_all(&nested).unwrap(); + assert_eq!(discover_from(&nested), None); + std::fs::write(tmp.join("Aura.toml"), "").unwrap(); + assert_eq!(discover_from(&nested), Some(tmp.clone())); + assert_eq!(discover_from(&tmp), Some(tmp.clone())); + std::fs::remove_dir_all(&tmp).unwrap(); + } + + #[test] + fn aura_toml_parses_empty_partial_and_unknown_keys() { + let t: AuraToml = toml::from_str("").unwrap(); + assert_eq!(t, AuraToml::default()); + let t: AuraToml = toml::from_str("[paths]\nruns = \"r\"").unwrap(); + assert_eq!(t.paths.runs, Some(PathBuf::from("r"))); + assert_eq!(t.paths.data, None); + // unknown keys tolerated (forward-compat; serde default is lenient) + let t: AuraToml = toml::from_str("[future]\nx = 1").unwrap(); + assert_eq!(t, AuraToml::default()); + } + + #[test] + fn artifact_path_derives_from_metadata_json() { + let json = r#"{ + "target_directory": "/tmp/proj/target", + "packages": [{"targets": [ + {"kind": ["cdylib"], "name": "demo-project"} + ]}] + }"#; + let p = artifact_from_metadata(json, false).unwrap(); + let expect = format!( + "/tmp/proj/target/debug/{}demo_project{}", + std::env::consts::DLL_PREFIX, + std::env::consts::DLL_SUFFIX + ); + assert_eq!(p, PathBuf::from(expect)); + let p = artifact_from_metadata(json, true).unwrap(); + assert!(p.to_string_lossy().contains("/release/")); + // no cdylib target -> named error + let bad = r#"{"target_directory":"/t","packages":[{"targets":[{"kind":["lib"],"name":"x"}]}]}"#; + assert!(matches!( + artifact_from_metadata(bad, false), + Err(ProjectError::CargoMetadata(_)) + )); + } + + fn none_resolver(_: &str) -> Option { + None + } + + #[test] + fn charter_rejects_each_violation_and_accepts_valid() { + let ok_resolver = |t: &str| { + if t == "p::A" || t == "p::B" { std_vocabulary("SMA") } else { None } + }; + // valid + assert!(check_charter("p", &["p::A", "p::B"], &ok_resolver).is_ok()); + // empty namespace + assert!(matches!( + check_charter("", &[], &none_resolver), + Err(ProjectError::Charter(_)) + )); + // unprefixed id + let e = check_charter("p", &["A"], &ok_resolver).unwrap_err(); + assert!(e.to_string().contains("lacks the project prefix")); + // duplicate id + let e = check_charter("p", &["p::A", "p::A"], &ok_resolver).unwrap_err(); + assert!(e.to_string().contains("duplicate")); + // list/resolver drift + let e = check_charter("p", &["p::C"], &ok_resolver).unwrap_err(); + assert!(e.to_string().contains("does not resolve")); + } + + fn matching_stamps() -> (u64, u32, StrSlice, StrSlice) { + ( + AURA_DESCRIPTOR_MAGIC, + AURA_DESCRIPTOR_VERSION, + StrSlice::new(RUSTC_VERSION), + StrSlice::new(CORE_VERSION), + ) + } + + #[test] + fn validate_c_tier_accepts_matching_stamps() { + let (magic, version, rustc, core) = matching_stamps(); + let path = PathBuf::from("/tmp/x.so"); + let ns = validate_c_tier(magic, version, rustc, core, StrSlice::new("demo"), &path) + .unwrap(); + assert_eq!(ns, "demo"); + } + + /// A magic mismatch means the symbol is not an `AURA_PROJECT` descriptor + /// at all (foreign dylib) — refused as `NotAProjectDylib`, not + /// `Incompatible` (there is no known-good stamp to compare against). + #[test] + fn validate_c_tier_rejects_magic_mismatch() { + let (_, version, rustc, core) = matching_stamps(); + let path = PathBuf::from("/tmp/x.so"); + let err = validate_c_tier(0xdead_beef, version, rustc, core, StrSlice::new("demo"), &path) + .unwrap_err(); + assert!(matches!(err, ProjectError::NotAProjectDylib(_))); + } + + #[test] + fn validate_c_tier_rejects_descriptor_version_mismatch() { + let (magic, _, rustc, core) = matching_stamps(); + let path = PathBuf::from("/tmp/x.so"); + let err = validate_c_tier(magic, 99, rustc, core, StrSlice::new("demo"), &path) + .unwrap_err(); + assert!(matches!( + err, + ProjectError::Incompatible { what: "descriptor version", .. } + )); + } + + #[test] + fn validate_c_tier_rejects_rustc_version_mismatch() { + let (magic, version, _, core) = matching_stamps(); + let path = PathBuf::from("/tmp/x.so"); + let bad_rustc = StrSlice::new("rustc 0.0.0-fake"); + let err = validate_c_tier(magic, version, bad_rustc, core, StrSlice::new("demo"), &path) + .unwrap_err(); + assert!(matches!( + err, + ProjectError::Incompatible { what: "rustc version", .. } + )); + } + + #[test] + fn validate_c_tier_rejects_aura_core_version_mismatch() { + let (magic, version, rustc, _) = matching_stamps(); + let path = PathBuf::from("/tmp/x.so"); + let bad_core = StrSlice::new("9.9.9-fake"); + let err = validate_c_tier(magic, version, rustc, bad_core, StrSlice::new("demo"), &path) + .unwrap_err(); + assert!(matches!( + err, + ProjectError::Incompatible { what: "aura-core version", .. } + )); + } + + /// A null stamp pointer (e.g. a zeroed/corrupt descriptor) refuses rather + /// than dereferencing it, same as the standalone `StrSlice::as_str` test + /// in aura-core — here exercised through the loader's own refusal path. + #[test] + fn validate_c_tier_rejects_null_stamp() { + let (magic, version, _, core) = matching_stamps(); + let path = PathBuf::from("/tmp/x.so"); + let null_rustc = StrSlice { ptr: std::ptr::null(), len: 0 }; + let err = validate_c_tier(magic, version, null_rustc, core, StrSlice::new("demo"), &path) + .unwrap_err(); + assert!(matches!(err, ProjectError::NotAProjectDylib(_))); + } + + /// A non-UTF-8 stamp (foreign/corrupt bytes at the pointer) refuses + /// rather than mangling a comparison or panicking. + #[test] + fn validate_c_tier_rejects_non_utf8_stamp() { + let (magic, version, rustc, _) = matching_stamps(); + let path = PathBuf::from("/tmp/x.so"); + static BAD: [u8; 2] = [0xFF, 0xFE]; + let bad_core = StrSlice { ptr: BAD.as_ptr(), len: BAD.len() }; + let err = validate_c_tier(magic, version, rustc, bad_core, StrSlice::new("demo"), &path) + .unwrap_err(); + assert!(matches!(err, ProjectError::NotAProjectDylib(_))); + } + + #[test] + fn env_std_collapses_to_current_defaults() { + let env = Env::std(); + assert_eq!(env.runs_root(), PathBuf::from("runs")); + assert_eq!(env.data_path(), data_server::DEFAULT_DATA_PATH.to_string()); + assert!(env.provenance().is_none()); + assert!(env.resolve("SMA").is_some()); + assert!(env.resolve("demo::Identity").is_none()); + assert!(env.type_ids().contains(&"SMA")); + } +} diff --git a/crates/aura-cli/tests/fixtures/badcharter-project/.gitignore b/crates/aura-cli/tests/fixtures/badcharter-project/.gitignore new file mode 100644 index 0000000..af5cf3d --- /dev/null +++ b/crates/aura-cli/tests/fixtures/badcharter-project/.gitignore @@ -0,0 +1,3 @@ +/target +Cargo.lock +/runs diff --git a/crates/aura-cli/tests/fixtures/badcharter-project/Aura.toml b/crates/aura-cli/tests/fixtures/badcharter-project/Aura.toml new file mode 100644 index 0000000..4a925f5 --- /dev/null +++ b/crates/aura-cli/tests/fixtures/badcharter-project/Aura.toml @@ -0,0 +1 @@ +# badcharter fixture — static project context only (C17); paths only (cycle 0102). diff --git a/crates/aura-cli/tests/fixtures/badcharter-project/Cargo.toml b/crates/aura-cli/tests/fixtures/badcharter-project/Cargo.toml new file mode 100644 index 0000000..f0886de --- /dev/null +++ b/crates/aura-cli/tests/fixtures/badcharter-project/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "badcharter-project" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +aura-core = { path = "../../../../aura-core" } + +# Standalone workspace root: never a member of the engine workspace +# (docs/project-layout.md, the empty-[workspace] note). +[workspace] diff --git a/crates/aura-cli/tests/fixtures/badcharter-project/src/lib.rs b/crates/aura-cli/tests/fixtures/badcharter-project/src/lib.rs new file mode 100644 index 0000000..20617e2 --- /dev/null +++ b/crates/aura-cli/tests/fixtures/badcharter-project/src/lib.rs @@ -0,0 +1,23 @@ +//! Fixture project for the vocabulary-charter e2e (cycle 0102): deliberately +//! lists a type id that is NOT `::`-prefixed, to prove the charter +//! refusal fires through the real `aura-cli::project::load` path (libloading + +//! descriptor read), not merely through the pure `check_charter` unit function. +//! The resolver never needs to resolve anything: the prefix check runs before +//! the list/resolver cross-check, so `vocabulary` can stay a constant `None`. + +use aura_core::PrimitiveBuilder; + +fn vocabulary(_type_id: &str) -> Option { + None +} + +/// Missing the required `badns::` prefix — the charter violation under test. +fn type_ids() -> &'static [&'static str] { + &["Bad"] +} + +aura_core::aura_project! { + namespace: "badns", + vocabulary: vocabulary, + type_ids: type_ids, +} diff --git a/crates/aura-cli/tests/fixtures/demo-project/.gitignore b/crates/aura-cli/tests/fixtures/demo-project/.gitignore new file mode 100644 index 0000000..af5cf3d --- /dev/null +++ b/crates/aura-cli/tests/fixtures/demo-project/.gitignore @@ -0,0 +1,3 @@ +/target +Cargo.lock +/runs diff --git a/crates/aura-cli/tests/fixtures/demo-project/Aura.toml b/crates/aura-cli/tests/fixtures/demo-project/Aura.toml new file mode 100644 index 0000000..9d9ab2d --- /dev/null +++ b/crates/aura-cli/tests/fixtures/demo-project/Aura.toml @@ -0,0 +1,3 @@ +# demo fixture — static project context only (C17); paths only (cycle 0102). +[paths] +runs = "runs" diff --git a/crates/aura-cli/tests/fixtures/demo-project/Cargo.toml b/crates/aura-cli/tests/fixtures/demo-project/Cargo.toml new file mode 100644 index 0000000..71c6905 --- /dev/null +++ b/crates/aura-cli/tests/fixtures/demo-project/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "demo-project" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +aura-core = { path = "../../../../aura-core" } + +# Standalone workspace root: never a member of the engine workspace +# (docs/project-layout.md, the empty-[workspace] note). +[workspace] diff --git a/crates/aura-cli/tests/fixtures/demo-project/demo_signal.json b/crates/aura-cli/tests/fixtures/demo-project/demo_signal.json new file mode 100644 index 0000000..0130a50 --- /dev/null +++ b/crates/aura-cli/tests/fixtures/demo-project/demo_signal.json @@ -0,0 +1 @@ +{"format_version":1,"blueprint":{"name":"demo_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"demo::Identity"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}} diff --git a/crates/aura-cli/tests/fixtures/demo-project/src/lib.rs b/crates/aura-cli/tests/fixtures/demo-project/src/lib.rs new file mode 100644 index 0000000..305aafe --- /dev/null +++ b/crates/aura-cli/tests/fixtures/demo-project/src/lib.rs @@ -0,0 +1,73 @@ +//! Fixture project for the load-boundary e2e (cycle 0102): one pass-through +//! node under the `demo` namespace, exported via the descriptor macro. + +use aura_core::{ + Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, + ScalarKind, +}; + +/// One-input f64 pass-through. Emits `None` until its input has a value. +pub struct Identity { + out: [Cell; 1], +} + +impl Identity { + pub fn new() -> Self { + Self { out: [Cell::from_f64(0.0)] } + } + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "demo::Identity", + NodeSchema { + inputs: vec![PortSpec { + kind: ScalarKind::F64, + firing: Firing::Any, + name: "value".into(), + }], + output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], + params: vec![], + }, + |_| Box::new(Identity::new()), + ) + } +} + +impl Default for Identity { + fn default() -> Self { + Self::new() + } +} + +impl Node for Identity { + fn lookbacks(&self) -> Vec { + vec![1] + } + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + let w = ctx.f64_in(0); + if w.is_empty() { + return None; + } + self.out[0] = Cell::from_f64(w[0]); + Some(&self.out) + } + fn label(&self) -> String { + "demo::Identity".to_string() + } +} + +fn vocabulary(type_id: &str) -> Option { + match type_id { + "demo::Identity" => Some(Identity::builder()), + _ => None, + } +} + +fn type_ids() -> &'static [&'static str] { + &["demo::Identity"] +} + +aura_core::aura_project! { + namespace: "demo", + vocabulary: vocabulary, + type_ids: type_ids, +} diff --git a/crates/aura-cli/tests/project_load.rs b/crates/aura-cli/tests/project_load.rs new file mode 100644 index 0000000..308c95f --- /dev/null +++ b/crates/aura-cli/tests/project_load.rs @@ -0,0 +1,185 @@ +//! E2E: the project-as-crate load boundary (cycle 0102). Builds the fixture +//! project once (cargo, same toolchain, path-dep on this workspace's +//! aura-core), then drives the aura binary from inside the fixture dir. +//! `aura run` persists nothing without --trace, so the tracked fixture stays +//! clean (target/, Cargo.lock, runs/ are fixture-gitignored). + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::OnceLock; + +fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/demo-project") +} + +fn badcharter_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/badcharter-project") +} + +fn built_fixture() -> &'static PathBuf { + static BUILT: OnceLock = OnceLock::new(); + BUILT.get_or_init(|| { + let dir = fixture_dir(); + let out = Command::new("cargo") + .arg("build") + .current_dir(&dir) + .output() + .expect("spawn cargo build for the fixture project"); + assert!( + out.status.success(), + "fixture build failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + dir + }) +} + +fn aura(args: &[&str], cwd: &Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_aura")) + .args(args) + .current_dir(cwd) + .output() + .expect("run aura") +} + +#[test] +fn project_run_resolves_demo_node_and_is_bit_identical() { + let dir = built_fixture(); + let a = aura(&["run", "demo_signal.json"], dir); + assert!( + a.status.success(), + "run failed: {}", + String::from_utf8_lossy(&a.stderr) + ); + let b = aura(&["run", "demo_signal.json"], dir); + assert!(b.status.success()); + assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)"); +} + +#[test] +fn project_run_manifest_carries_provenance() { + let dir = built_fixture(); + let out = aura(&["run", "demo_signal.json"], dir); + assert!(out.status.success()); + let v: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("run report is JSON"); + let p = &v["manifest"]["project"]; + assert_eq!(p["namespace"], "demo"); + let sha = p["dylib_sha256"].as_str().expect("hash present"); + assert_eq!(sha.len(), 64, "sha256 hex"); +} + +#[test] +fn introspect_vocabulary_lists_project_types() { + let dir = built_fixture(); + let out = aura(&["graph", "introspect", "--vocabulary"], dir); + assert!(out.status.success()); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("demo::Identity"), "project id listed"); + assert!(text.contains("SMA"), "std ids still listed"); +} + +#[test] +fn outside_a_project_the_demo_blueprint_is_unknown() { + // cwd = the aura-cli crate dir: no Aura.toml anywhere up the tree. + let cwd = Path::new(env!("CARGO_MANIFEST_DIR")); + let bp = fixture_dir().join("demo_signal.json"); + let out = aura(&["run", bp.to_str().unwrap()], cwd); + assert!(!out.status.success()); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("UnknownNodeType"), "stderr: {err}"); +} + +/// `Env::runs_root()` resolves `Aura.toml`'s `[paths] runs = "runs"` against +/// the DISCOVERED project root (the `Aura.toml` directory found by walking up +/// from cwd), not against the invocation cwd itself: a family recorded while +/// invoking `aura` from a project subdirectory must still land under +/// `/runs/`, and no stray `runs/` must appear under the +/// subdirectory. This is the property that makes `[paths]` project-relative +/// rather than shell-relative (C17). +#[test] +fn project_registry_anchors_at_discovered_root_not_invocation_cwd() { + let dir = built_fixture(); + let sub = dir.join("src"); + let runs_dir = dir.join("runs"); + std::fs::remove_dir_all(&runs_dir).ok(); + let open_bp = + format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let out = Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "sweep", + &open_bp, + "--axis", + "sma_signal.fast.length=2,4", + "--axis", + "sma_signal.slow.length=8,16", + "--name", + "proj-anchor", + ]) + .current_dir(&sub) + .output() + .expect("spawn aura sweep"); + assert!( + out.status.success(), + "sweep failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + runs_dir.join("families.jsonl").is_file(), + "family record must land at the project root's runs/, not the invocation cwd's" + ); + assert!( + !sub.join("runs").exists(), + "no stray runs/ must be created under the invocation subdirectory" + ); + std::fs::remove_dir_all(&runs_dir).ok(); +} + +/// A project whose listed type id is not `::`-prefixed is refused +/// (exit 1, cause named) through the REAL load path — `libloading` + +/// descriptor read + `check_charter` wired together in `project::load` — not +/// merely the pure `check_charter` function (already unit-tested in +/// `aura-cli::project`). Pins the "refuse-don't-guess" vocabulary charter as +/// an end-to-end guarantee: a regression that skipped the charter call in the +/// `load()` glue would pass every existing unit test but still be caught here. +#[test] +fn vocabulary_charter_violation_refuses_end_to_end() { + let dir = badcharter_dir(); + let build = Command::new("cargo") + .arg("build") + .current_dir(&dir) + .output() + .expect("spawn cargo build for the badcharter fixture"); + assert!( + build.status.success(), + "badcharter fixture build failed:\n{}", + String::from_utf8_lossy(&build.stderr) + ); + let run = aura(&["run", "x.json"], &dir); + assert_eq!( + run.status.code(), + Some(1), + "a charter violation is a runtime refusal (exit 1), not a usage error" + ); + let err = String::from_utf8_lossy(&run.stderr); + assert!(err.contains("lacks the project prefix"), "stderr must name the charter cause: {err}"); +} + +#[test] +fn missing_artifact_refuses_with_build_hint() { + // A minimal never-built project: valid cargo metadata, no dylib on disk. + let tmp = std::env::temp_dir().join(format!("aura-nobuild-{}", std::process::id())); + std::fs::create_dir_all(tmp.join("src")).unwrap(); + std::fs::write(tmp.join("Aura.toml"), "").unwrap(); + std::fs::write(tmp.join("src/lib.rs"), "").unwrap(); + std::fs::write( + tmp.join("Cargo.toml"), + "[package]\nname = \"nobuild\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[workspace]\n", + ) + .unwrap(); + let out = aura(&["run", "x.json"], &tmp); + assert_eq!(out.status.code(), Some(1), "runtime refusal"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("cargo build"), "build hint present: {err}"); + std::fs::remove_dir_all(&tmp).ok(); +} diff --git a/crates/aura-core/build.rs b/crates/aura-core/build.rs new file mode 100644 index 0000000..009b180 --- /dev/null +++ b/crates/aura-core/build.rs @@ -0,0 +1,16 @@ +//! Bakes the compiling toolchain's version into `AURA_RUSTC_VERSION` so the +//! project descriptor (src/project.rs) can stamp it. Runs per consuming build: +//! the host binary and a project cdylib each recompile aura-core under their +//! own toolchain, which is exactly what makes the two stamps comparable. + +fn main() { + let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into()); + let out = std::process::Command::new(&rustc) + .arg("--version") + .output() + .expect("rustc --version"); + println!( + "cargo:rustc-env=AURA_RUSTC_VERSION={}", + String::from_utf8_lossy(&out.stdout).trim() + ); +} diff --git a/crates/aura-core/src/lib.rs b/crates/aura-core/src/lib.rs index 8926e44..8d6fdf1 100644 --- a/crates/aura-core/src/lib.rs +++ b/crates/aura-core/src/lib.rs @@ -34,6 +34,7 @@ mod column; mod ctx; mod error; mod node; +pub mod project; mod scalar; mod series_fold; diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs index 4888098..e252905 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -139,11 +139,14 @@ impl PrimitiveBuilder { self } /// The resolved node name: the explicit instance name if set, else the - /// type label ASCII-lowercased verbatim (e.g. "SimBroker" -> "simbroker"). + /// type label with any `::`-namespace prefix stripped, then ASCII- + /// lowercased (e.g. "SimBroker" -> "simbroker", "demo::Identity" -> + /// "identity"). pub fn node_name(&self) -> String { - self.instance_name - .clone() - .unwrap_or_else(|| self.name.to_ascii_lowercase()) + self.instance_name.clone().unwrap_or_else(|| { + let bare = self.name.rsplit("::").next().unwrap_or(self.name); + bare.to_ascii_lowercase() + }) } /// The explicit instance name if one was set via `named()`, else `None`. /// Unlike `node_name()`, this does not resolve the default — callers that must @@ -430,6 +433,16 @@ mod tests { assert_eq!(named.node_name(), "fast"); } + #[test] + fn node_name_strips_namespace_prefix() { + let b = PrimitiveBuilder::new( + "demo::Identity", + NodeSchema { inputs: vec![], output: vec![], params: vec![] }, + |_| unreachable!("never built in this test"), + ); + assert_eq!(b.node_name(), "identity"); + } + #[test] fn instance_name_is_some_only_when_explicitly_named() { // unnamed → None (instance_name() does NOT resolve node_name()'s diff --git a/crates/aura-core/src/project.rs b/crates/aura-core/src/project.rs new file mode 100644 index 0000000..2cc2148 --- /dev/null +++ b/crates/aura-core/src/project.rs @@ -0,0 +1,159 @@ +//! The project-cdylib descriptor contract (C13/C16, cycle 0102). +//! +//! A research project compiles to a cdylib exporting ONE symbol, +//! `AURA_PROJECT`, a [`ProjectDescriptor`]. The descriptor has two ABI tiers: +//! a **C tier** (`magic`, `descriptor_version`, the version stamps, the +//! namespace — all C-compatible field types) the host validates BEFORE +//! trusting anything, and a **Rust tier** (the vocabulary resolver and the +//! enumerable type-id list) the host touches only after both stamps match. +//! The stamp cannot certify the channel it rides on, so it must not itself +//! depend on the Rust ABI it certifies. +//! +//! The stamps are baked at *aura-core compile time* — per consuming build — +//! so host and dylib each carry their own side's truth. + +use crate::node::PrimitiveBuilder; + +/// First 8 bytes of the C tier; ASCII "AURAPROJ". +pub const AURA_DESCRIPTOR_MAGIC: u64 = 0x4155_5241_5052_4F4A; +/// Bumped on any layout change of [`ProjectDescriptor`]. +pub const AURA_DESCRIPTOR_VERSION: u32 = 1; +/// The one exported symbol name (NUL-terminated for the symbol lookup). +pub const AURA_PROJECT_SYMBOL: &[u8] = b"AURA_PROJECT\0"; + +/// The compiling rustc's `--version` line (via build.rs). +pub const RUSTC_VERSION: &str = env!("AURA_RUSTC_VERSION"); +/// This aura-core's crate version. +pub const CORE_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// C-ABI string slice: pointer + length over `'static` UTF-8 bytes. +/// No NUL convention needed; readable without trusting the Rust ABI. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct StrSlice { + pub ptr: *const u8, + pub len: usize, +} + +impl StrSlice { + pub const fn new(s: &'static str) -> Self { + Self { ptr: s.as_ptr(), len: s.len() } + } + + /// Read the slice back as UTF-8. + /// + /// # Safety + /// `ptr`/`len` must describe live UTF-8 bytes — true for any descriptor + /// built by [`aura_project!`](crate::aura_project), whose strings are + /// `'static` and whose library is loaded-and-held (never unloaded). + pub unsafe fn as_str(&self) -> Option<&str> { + if self.ptr.is_null() { + return None; + } + let bytes = unsafe { core::slice::from_raw_parts(self.ptr, self.len) }; + core::str::from_utf8(bytes).ok() + } +} + +/// The exported project descriptor. Field ORDER is ABI: C tier first, +/// Rust tier last; validated front-to-back. +#[repr(C)] +pub struct ProjectDescriptor { + // ---- C tier: validated BEFORE any Rust-ABI field is touched ---- + pub magic: u64, + pub descriptor_version: u32, + pub rustc_version: StrSlice, + pub aura_core_version: StrSlice, + pub namespace: StrSlice, + // ---- Rust tier: only after both stamps match ---- + pub vocabulary: fn(&str) -> Option, + pub type_ids: fn() -> &'static [&'static str], +} + +// The raw pointers are to 'static data; the fn pointers are Sync by nature. +unsafe impl Sync for ProjectDescriptor {} + +/// Declare a project crate's export in one place. Emits the `AURA_PROJECT` +/// static with this build's stamps baked in. +#[macro_export] +macro_rules! aura_project { + (namespace: $ns:literal, vocabulary: $vocab:path, type_ids: $ids:path $(,)?) => { + #[unsafe(no_mangle)] + pub static AURA_PROJECT: $crate::project::ProjectDescriptor = + $crate::project::ProjectDescriptor { + magic: $crate::project::AURA_DESCRIPTOR_MAGIC, + descriptor_version: $crate::project::AURA_DESCRIPTOR_VERSION, + rustc_version: $crate::project::StrSlice::new( + $crate::project::RUSTC_VERSION, + ), + aura_core_version: $crate::project::StrSlice::new( + $crate::project::CORE_VERSION, + ), + namespace: $crate::project::StrSlice::new($ns), + vocabulary: $vocab, + type_ids: $ids, + }; + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fake_vocab(type_id: &str) -> Option { + let _ = type_id; + None + } + fn fake_ids() -> &'static [&'static str] { + &["t::A"] + } + + aura_project! { + namespace: "t", + vocabulary: fake_vocab, + type_ids: fake_ids, + } + + #[test] + fn stamps_are_baked_and_nonempty() { + assert!(RUSTC_VERSION.starts_with("rustc ")); + assert!(!CORE_VERSION.is_empty()); + } + + #[test] + fn str_slice_round_trips() { + let s = StrSlice::new("hello"); + assert_eq!(unsafe { s.as_str() }, Some("hello")); + } + + /// `as_str` refuses a null pointer instead of dereferencing it — the + /// loader's boundary-safety primitive for a descriptor field it hasn't + /// otherwise validated yet. + #[test] + fn str_slice_null_ptr_is_none() { + let s = StrSlice { ptr: std::ptr::null(), len: 0 }; + assert_eq!(unsafe { s.as_str() }, None); + } + + /// `as_str` refuses non-UTF-8 bytes rather than producing a mangled or + /// panicking read, so a corrupt/foreign descriptor field degrades to + /// `None` instead of undefined behaviour downstream. + #[test] + fn str_slice_invalid_utf8_is_none() { + static BAD: [u8; 2] = [0xFF, 0xFE]; + let s = StrSlice { ptr: BAD.as_ptr(), len: BAD.len() }; + assert_eq!(unsafe { s.as_str() }, None); + } + + #[test] + fn macro_emits_a_valid_descriptor() { + let d = &AURA_PROJECT; + assert_eq!(d.magic, AURA_DESCRIPTOR_MAGIC); + assert_eq!(d.descriptor_version, AURA_DESCRIPTOR_VERSION); + assert_eq!(unsafe { d.namespace.as_str() }, Some("t")); + assert_eq!(unsafe { d.rustc_version.as_str() }, Some(RUSTC_VERSION)); + assert_eq!(unsafe { d.aura_core_version.as_str() }, Some(CORE_VERSION)); + assert!((d.vocabulary)("nope").is_none()); + assert_eq!((d.type_ids)(), &["t::A"]); + } +} diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index 184353b..4414767 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -1003,6 +1003,7 @@ mod tests { selection: None, instrument: None, topology_hash: None, + project: None, }, metrics: summarize(&equity, &exposure), } diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index 120e901..6b1fba5 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -71,7 +71,8 @@ pub use harness::{ pub use report::{ derive_position_events, expected_max_of_normals, f64_field, inv_norm_cdf, join_on_ts, r_metrics_from_rs, summarize, summarize_r, ColumnarTrace, FamilySelection, JoinedRow, - PositionAction, PositionEvent, RMetrics, RunManifest, RunMetrics, RunReport, SelectionMode, + PositionAction, PositionEvent, ProjectProvenance, RMetrics, RunManifest, RunMetrics, + RunReport, SelectionMode, }; pub use sweep::{ sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint, diff --git a/crates/aura-engine/src/mc.rs b/crates/aura-engine/src/mc.rs index 9092ee9..0792071 100644 --- a/crates/aura-engine/src/mc.rs +++ b/crates/aura-engine/src/mc.rs @@ -245,6 +245,7 @@ mod tests { selection: None, instrument: None, topology_hash: None, + project: None, }, metrics: summarize(&equity, &exposure), } @@ -271,6 +272,7 @@ mod tests { selection: None, instrument: None, topology_hash: None, + project: None, }, metrics: RunMetrics { total_pips: v, diff --git a/crates/aura-engine/src/report.rs b/crates/aura-engine/src/report.rs index cdd8125..c27b8d6 100644 --- a/crates/aura-engine/src/report.rs +++ b/crates/aura-engine/src/report.rs @@ -57,6 +57,23 @@ pub struct RunManifest { /// serde widening (Tier-1, #156), identical idiom to `selection` / `instrument`. #[serde(default, skip_serializing_if = "Option::is_none")] pub topology_hash: Option, + /// Identity of the loaded project dylib, when the run used one (cycle + /// 0102). Pre-0102 records read as `None`; `None` serializes as an absent + /// key (the same one-directional widening as `instrument`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, +} + +/// Provenance of the project cdylib a run was resolved through (C16/C18). +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ProjectProvenance { + /// The project's vocabulary namespace (from the descriptor). + pub namespace: String, + /// SHA-256 (hex) of the loaded dylib file's bytes. + pub dylib_sha256: String, + /// The project repo's HEAD (+ `-dirty`), when derivable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub commit: Option, } /// A run's full structured result: the descriptor plus the metrics it @@ -280,6 +297,7 @@ mod tests { commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)), seed: 0, broker: "b".into(), selection: None, instrument: None, topology_hash: None, + project: None, }; assert!(!serde_json::to_string(&m).unwrap().contains("selection")); } @@ -298,6 +316,7 @@ mod tests { commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)), seed: 0, broker: "b".into(), selection: None, instrument: Some("GER40".into()), topology_hash: None, + project: None, }; let json = serde_json::to_string(&with).unwrap(); assert!(json.contains("\"instrument\":\"GER40\""), "json: {json}"); @@ -308,11 +327,33 @@ mod tests { commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)), seed: 0, broker: "b".into(), selection: None, instrument: None, topology_hash: None, + project: None, }; // skip_serializing_if omits the key when None -> existing manifest bytes unchanged (C23). assert!(!serde_json::to_string(&without).unwrap().contains("instrument")); } + #[test] + fn runmanifest_project_field_round_trips_and_omits_when_none() { + // A pre-0102 line (no "project" key) must load with project: None. + let legacy = r#"{"commit":"c","params":[],"window":[0,1],"seed":7,"broker":"b"}"#; + let m: RunManifest = serde_json::from_str(legacy).expect("legacy line loads"); + assert_eq!(m.project, None); + // None must serialize with the key absent (byte-stability of old paths). + let out = serde_json::to_string(&m).expect("serializes"); + assert!(!out.contains("\"project\"")); + // Some(..) must round-trip. + let p = ProjectProvenance { + namespace: "demo".into(), + dylib_sha256: "ab".repeat(32), + commit: None, + }; + let m2 = RunManifest { project: Some(p.clone()), ..m }; + let s = serde_json::to_string(&m2).expect("serializes"); + let back: RunManifest = serde_json::from_str(&s).expect("round-trips"); + assert_eq!(back.project, Some(p)); + } + #[test] fn family_selection_round_trips_on_the_manifest() { let sel = FamilySelection { @@ -326,6 +367,7 @@ mod tests { commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)), seed: 0, broker: "b".into(), selection: Some(sel.clone()), instrument: None, topology_hash: None, + project: None, }; let back: RunManifest = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap(); assert_eq!(back.selection, Some(sel)); @@ -466,6 +508,7 @@ mod tests { selection: None, instrument: None, topology_hash: None, + project: None, }, metrics, } @@ -571,6 +614,7 @@ mod tests { selection: None, instrument: None, topology_hash: None, + project: None, }, metrics: RunMetrics { total_pips: 12.0, @@ -602,6 +646,7 @@ mod tests { selection: None, instrument: None, topology_hash: None, + project: None, }, metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, bias_sign_flips: 1, r: None }, }; @@ -625,6 +670,7 @@ mod tests { selection: None, instrument: None, topology_hash: None, + project: None, }, metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, bias_sign_flips: 1, r: None }, }; diff --git a/crates/aura-engine/src/sweep.rs b/crates/aura-engine/src/sweep.rs index d04046d..7e845f7 100644 --- a/crates/aura-engine/src/sweep.rs +++ b/crates/aura-engine/src/sweep.rs @@ -670,6 +670,7 @@ mod tests { selection: None, instrument: None, topology_hash: None, + project: None, }, metrics: summarize(&equity, &exposure), } diff --git a/crates/aura-engine/src/walkforward.rs b/crates/aura-engine/src/walkforward.rs index b2d1d21..7ca992c 100644 --- a/crates/aura-engine/src/walkforward.rs +++ b/crates/aura-engine/src/walkforward.rs @@ -294,6 +294,7 @@ mod tests { selection: None, instrument: None, topology_hash: None, + project: None, }, metrics: summarize(&[], &[]), } diff --git a/crates/aura-engine/tests/random_sweep_e2e.rs b/crates/aura-engine/tests/random_sweep_e2e.rs index c052bfa..190b804 100644 --- a/crates/aura-engine/tests/random_sweep_e2e.rs +++ b/crates/aura-engine/tests/random_sweep_e2e.rs @@ -118,6 +118,7 @@ fn run_point(point: &[Cell]) -> RunReport { selection: None, instrument: None, topology_hash: None, + project: None, }, metrics: summarize(&equity, &exposure), } diff --git a/crates/aura-ingest/examples/ger40_breakout_sweep.rs b/crates/aura-ingest/examples/ger40_breakout_sweep.rs index f5be7e7..43d634d 100644 --- a/crates/aura-ingest/examples/ger40_breakout_sweep.rs +++ b/crates/aura-ingest/examples/ger40_breakout_sweep.rs @@ -69,6 +69,7 @@ fn run_point(server: &Arc, point: &[Cell], from: Timestamp, to: Time selection: None, instrument: None, topology_hash: None, + project: None, }, metrics: summarize(&equity, &exposure), } diff --git a/crates/aura-ingest/examples/ger40_breakout_walkforward.rs b/crates/aura-ingest/examples/ger40_breakout_walkforward.rs index d0329a1..916c66d 100644 --- a/crates/aura-ingest/examples/ger40_breakout_walkforward.rs +++ b/crates/aura-ingest/examples/ger40_breakout_walkforward.rs @@ -79,6 +79,7 @@ fn run_point( selection: None, instrument: None, topology_hash: None, + project: None, }, metrics: summarize(&equity, &exposure), }; diff --git a/crates/aura-ingest/examples/shared/breakout_real.rs b/crates/aura-ingest/examples/shared/breakout_real.rs index 41edfc5..26f167d 100644 --- a/crates/aura-ingest/examples/shared/breakout_real.rs +++ b/crates/aura-ingest/examples/shared/breakout_real.rs @@ -402,6 +402,7 @@ pub fn report_from_trace(trace: &[BarTrace], from: Timestamp, to: Timestamp) -> selection: None, instrument: None, topology_hash: None, + project: None, }, metrics, } diff --git a/crates/aura-ingest/tests/ger40_breakout_world.rs b/crates/aura-ingest/tests/ger40_breakout_world.rs index eea9055..fb6284f 100644 --- a/crates/aura-ingest/tests/ger40_breakout_world.rs +++ b/crates/aura-ingest/tests/ger40_breakout_world.rs @@ -71,6 +71,7 @@ fn run_point(server: &Arc, point: &[Cell], from: Timestamp, to: Time selection: None, instrument: None, topology_hash: None, + project: None, }, metrics: summarize(&equity, &exposure), } diff --git a/crates/aura-ingest/tests/real_bars.rs b/crates/aura-ingest/tests/real_bars.rs index 4acd16c..9b057b7 100644 --- a/crates/aura-ingest/tests/real_bars.rs +++ b/crates/aura-ingest/tests/real_bars.rs @@ -92,6 +92,7 @@ fn run_sample_over(prices: Vec<(Timestamp, Scalar)>) -> RunReport { selection: None, instrument: None, topology_hash: None, + project: None, }, metrics, } diff --git a/crates/aura-ingest/tests/streaming_seam.rs b/crates/aura-ingest/tests/streaming_seam.rs index eaa5aaf..a8b3083 100644 --- a/crates/aura-ingest/tests/streaming_seam.rs +++ b/crates/aura-ingest/tests/streaming_seam.rs @@ -92,6 +92,7 @@ fn run_sample(window: (Timestamp, Timestamp), source: Box) -> RunRep selection: None, instrument: None, topology_hash: None, + project: None, }, metrics: summarize(&equity, &exposure), } diff --git a/crates/aura-registry/src/compat.rs b/crates/aura-registry/src/compat.rs index f94870b..c630609 100644 --- a/crates/aura-registry/src/compat.rs +++ b/crates/aura-registry/src/compat.rs @@ -10,7 +10,9 @@ //! (`Registry::append`, `RunReport::to_json`) is untouched and still emits the //! tagged form, so this is a one-directional widening, not a format change. -use aura_engine::{FamilySelection, RunManifest, RunMetrics, RunReport, Scalar, Timestamp}; +use aura_engine::{ + FamilySelection, ProjectProvenance, RunManifest, RunMetrics, RunReport, Scalar, Timestamp, +}; use serde::de::{self, Deserializer}; use serde::Deserialize; @@ -36,6 +38,8 @@ struct RunManifestRead { instrument: Option, #[serde(default)] topology_hash: Option, + #[serde(default)] + project: Option, } /// A param value that accepts BOTH the current tagged [`Scalar`] form @@ -68,8 +72,9 @@ impl<'de> Deserialize<'de> for ScalarRead { impl From for RunReport { fn from(r: RunReportRead) -> Self { - let RunManifestRead { commit, params, window, seed, broker, selection, instrument, topology_hash } = - r.manifest; + let RunManifestRead { + commit, params, window, seed, broker, selection, instrument, topology_hash, project, + } = r.manifest; RunReport { manifest: RunManifest { commit, @@ -80,6 +85,7 @@ impl From for RunReport { selection, instrument, topology_hash, + project, }, metrics: r.metrics, } diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index 93166ba..d3e0c16 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -589,6 +589,7 @@ mod tests { selection: None, instrument: None, topology_hash: None, + project: None, }, metrics: RunMetrics { total_pips, max_drawdown, bias_sign_flips: flips, r: None }, } @@ -936,6 +937,7 @@ mod tests { commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)), seed: 0, broker: "b".into(), selection: None, instrument: None, topology_hash: None, + project: None, }, metrics: RunMetrics { total_pips, max_drawdown: 0.0, bias_sign_flips: 0, diff --git a/crates/aura-registry/src/trace_store.rs b/crates/aura-registry/src/trace_store.rs index ff59924..37b0fa4 100644 --- a/crates/aura-registry/src/trace_store.rs +++ b/crates/aura-registry/src/trace_store.rs @@ -256,6 +256,7 @@ mod tests { selection: None, instrument: None, topology_hash: None, + project: None, } } diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index c485e06..5531a84 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -1053,6 +1053,24 @@ toolchain plugins. **Why.** Hot-reload makes the research loop fast; a live artifact must be frozen and reproducible (audit trail: this bot = this commit). A sweep pays no hot-reload tax — params are runtime data (C12), so the cdylib loads once. +**Realization (cycle 0102 — the load boundary; per-invocation reload).** The +authoring-loop half is realized: a project is an external cdylib crate loaded +per invocation through a two-tier `#[repr(C)]` descriptor (`AURA_PROJECT`, +`aura-core::project`) — a C-ABI stamp prefix (rustc version + aura-core +version, baked per consuming build) validated **before** any Rust-ABI field is +touched, then the vocabulary resolver + enumerable type-id list behind the +stamp gate. "Hot-reload" reads, in v1, as **per-invocation load of the +freshest build**: the author (Claude) runs `cargo build`, the next `aura` +invocation locates the artifact via `cargo metadata` (debug default, +`--release` opt-in) and loads it **load-and-hold** (leaked, never unloaded). +Scope boundary: load-and-hold is trivially sound only because the CLI is a +one-shot process — a future long-running host (the open local-server thread, +C22) must re-solve reload (host restart or subprocess isolation), never +in-process unload. Mismatch of either stamp refuses (exit 1) naming both +sides; the project vocabulary is charter-checked at load (`::`-namespaced ids, +no duplicates against std, list↔resolver cross-check) — the invariant-9 +data-plane discipline of the C24 enforcement-shift note, now enforced at the +one seam. ### C14 — Headless core, two faces **Guarantee.** The engine is a UI-agnostic library. Two faces sit on it: a @@ -1145,8 +1163,8 @@ their own repos, pulled as cargo git deps) / **project-local `nodes/`** (experimental, project-specific). A reusable node is an `rlib` dependency; the hot-reload unit stays the project-side `cdylib` that composes it (consistent with C13). Concretely a project is a **Rust crate** — a cdylib library of node / -strategy / experiment blueprints — plus a static `Aura.toml` (project context: -data paths, instrument/pip metadata, default broker & window, runs dir). During +strategy / experiment blueprints — plus a static `Aura.toml` (project context, +paths only: data archive root, runs dir — cycle 0102). During research the `aura` host loads and runs it (C13 hot-reload); for deploy the chosen strategy + broker freeze into a standalone binary. **A project is therefore always a Rust program built on the engine.** Beyond the node-reuse @@ -1207,7 +1225,7 @@ into a reusable domain-agnostic substrate — until then `RunReport` / `RunManif is authored in native Rust through **Claude Code + the skills pipeline**: the human describes, Claude writes the Rust, builds it, runs it via the `aura` CLI, and reports metrics. Declarative config (`Aura.toml`) carries only **static -project context** (data paths, instrument/pip metadata, defaults, runs dir), +project context** (paths only: data archive root, runs dir — cycle 0102), never logic. aura ships **no embedded coding-LLM**. IONOS LLMs are used only as a *runtime data source* (news-agent bias, C11), gated by per-session consent, never in the code path. @@ -2055,12 +2073,12 @@ artifact, and any run is deterministic once instantiated" (C1). atop the atomic sim unit (C12), not yet designed. Search over *params* sits atop the atomic unit; search over *structure* (graph mutation / crossover — NEAT-style) additionally needs topology-as-data (C24). -- **`aura new` scaffolder, the experiment-builder API, and `Aura.toml`'s - static-context schema** — `aura new` scaffolds a Rust project *crate* (node / - strategy / experiment blueprints) against the engine (C16/C20); the - experiment-builder API surface (harness wiring, structural axes, sweep - combinators) and `Aura.toml`'s schema (data paths, instrument/pip metadata, - default broker & window, runs dir) are not yet designed. +- **`aura new` scaffolder and the experiment-builder API** — `aura new` + scaffolds a Rust project *crate* (node / strategy blueprints) against the + engine (C16/C20); the experiment-builder API surface (harness wiring, + structural axes, sweep combinators) is not yet designed. `Aura.toml`'s + schema was settled paths-only in cycle 0102 (data archive root, runs dir — + see the C13 realization note); the load boundary itself shipped there. - **The analysis meta-level — composable orchestration + project-as-crate authoring (tracked: #109).** aura's differentiator (C21) has two unbuilt halves: (1) the orchestration axes (sweep / Monte-Carlo / walk-forward) becoming *composable tools* diff --git a/docs/glossary.md b/docs/glossary.md index 6c9cd7e..c5f5007 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -17,7 +17,7 @@ The primitive `(frozen topology + param-set + data-window + RNG-seed) → determ ### Aura.toml **Avoid:** — -The per-project declarative config holding only static context (data paths, instrument/pip metadata, default broker & window, runs dir), never logic. Its presence marks the project root, the way `Cargo.toml` marks a cargo crate. +The per-project declarative config holding only static context — paths only: the data archive root and the runs dir — never logic, and never instrument geometry (that is the recorded sidecar, C15). Its presence marks the project root, the way `Cargo.toml` marks a cargo crate. ### backtest **Avoid:** — diff --git a/docs/project-layout.md b/docs/project-layout.md index e52aba1..cb59d35 100644 --- a/docs/project-layout.md +++ b/docs/project-layout.md @@ -30,8 +30,9 @@ A project is always a Rust program built on the engine. ``` ger40-lab/ # your research project — a separate Rust crate (cdylib) -├── Aura.toml # STATIC context only: data paths, instrument/pip -│ # metadata, default broker & window, runs dir (no logic) +├── Aura.toml # STATIC context only, paths only: data archive root, +│ # runs dir (no logic; instrument geometry is the +│ # recorded sidecar, C15 — never authored here) ├── Cargo.toml # cdylib; depends on aura-core/aura-std (+ shared node crates); │ # carries an empty [workspace] table (see note below) ├── CLAUDE.md # the project's own skills wiring (authoring discipline)