diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index fdd6b3c..1fead8e 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -487,12 +487,13 @@ fn resolve_campaign_name(name: &str, env: &aura_runner::project::Env) -> NameRes // `aura_runner::family` (#295 Task 8) — imported below by plain name so // call sites are unchanged. -/// Parse a `--select` token: `argmax` | `plateau:mean` | `plateau:worst`. Unknown -/// tokens are a usage error (the caller maps `Err(())` to exit 2). +/// Parse a `--select` token: `argmax` | `plateau` (alias for `plateau:mean`) | +/// `plateau:mean` | `plateau:worst`. Unknown tokens are a usage error (the +/// caller maps `Err(())` to exit 2). fn parse_select(s: &str) -> Result { match s { "argmax" => Ok(Selection::Argmax), - "plateau:mean" => Ok(Selection::Plateau(PlateauMode::Mean)), + "plateau" | "plateau:mean" => Ok(Selection::Plateau(PlateauMode::Mean)), "plateau:worst" => Ok(Selection::Plateau(PlateauMode::Worst)), _ => Err(()), } @@ -508,22 +509,6 @@ fn select_rule_of(sel: Selection) -> aura_research::SelectRule { } } -/// Parse a comma-separated list of `T` (each item parsed via `FromStr`), rejecting -/// any item that fails to parse — the shared validator for the r-sma grid flags -/// (#137). The caller folds the `Err` into the subcommand `usage()` so a malformed -/// `--fast 2,x` is the strict usage path, not a downstream panic. `str::split(',')` -/// always yields at least one item, and an empty item (`""`, or a trailing comma) -/// fails its per-item `parse`, so a non-empty result needs no separate empty-list -/// check — the empty/blank inputs are rejected by the per-item parse itself. -fn parse_csv_list(s: &str) -> Result, ()> { - let items: Vec<&str> = s.split(',').collect(); - let mut out = Vec::with_capacity(items.len()); - for item in items { - out.push(item.parse::().map_err(|_| ())?); - } - Ok(out) -} - /// Render a family-member stdout line: the assigned `family_id` plus the embedded /// `RunReport`. The report is emitted in its own declaration key order (manifest /// leads with `commit`, C18) so the line is byte-identical to the stored @@ -1001,7 +986,7 @@ fn parse_scalar_csv(csv: &str) -> Option> { // `--version`, `--flag=value`, `--`, and long-option abbreviation; the `dispatch_*` // handlers below convert each `*Cmd` into the argument shapes the existing execution // fns accept, reusing the value helpers (`Selection`, -// `DataSource::from_choice`, `parse_scalar_csv`, `parse_csv_list`, `parse_select`, +// `DataSource::from_choice`, `parse_scalar_csv`, `parse_select`, // `parse_param_cells`). The four subcommands with an optional `[blueprint]` positional // dispatch on `is_blueprint_file`: a first-positional naming an existing `.json` file // selects the loaded-blueprint branch. There is no second built-in grammar anymore @@ -1358,13 +1343,14 @@ struct WalkforwardCmd { /// mutually exclusive with --name). #[arg(long)] trace: Option, - /// Campaign-path stop length (single value; --real mode). + /// Campaign-path stop length (--real mode). #[arg(long)] - stop_length: Option, - /// Campaign-path stop-k multiple (single value; --real mode). + stop_length: Option, + /// Campaign-path stop-k multiple (--real mode). #[arg(long)] - stop_k: Option, - /// In-sample winner selection: argmax | plateau:mean | plateau:worst (default argmax). + stop_k: Option, + /// In-sample winner selection: argmax | plateau (alias for plateau:mean) | + /// plateau:mean | plateau:worst (default argmax). #[arg(long)] select: Option, /// Blueprint IS-refit axis `=` (repeatable, >=1 required; .json mode). @@ -1395,12 +1381,12 @@ struct McCmd { /// Blueprint IS-refit axis `=` (repeatable, >=1 required; --real mode). #[arg(long)] axis: Vec, - /// Campaign-path stop length (single value; --real mode). + /// Campaign-path stop length (--real mode). #[arg(long)] - stop_length: Option, - /// Campaign-path stop-k multiple (single value; --real mode). + stop_length: Option, + /// Campaign-path stop-k multiple (--real mode). #[arg(long)] - stop_k: Option, + stop_k: Option, /// Moving-block bootstrap block length (R-bootstrap; default 1). #[arg(long)] block_len: Option, @@ -1442,41 +1428,16 @@ fn run_data_from(real: Option<&str>, from: Option, to: Option) -> RunD } } -/// Resolve a single optional stop knob (`--stop-length`/`--stop-k`): an absent flag -/// defaults to `default`; a present one parses via [`parse_csv_list`] and refuses -/// unless it holds exactly one value (the stop is a risk regime, not a swept axis). -/// Single-sourced across `walkforward_args_from`/`mc_args_from` (#217 follow-up) — -/// `generalize_args_from`'s typed `Option`/`Option` fields need no parse -/// step, so it keeps its own plain `unwrap_or`. -fn stop_knob_or( - raw: Option<&str>, - default: T, - regime: &impl Fn() -> String, -) -> Result { - match raw { - None => Ok(default), - Some(s) => { - let v: Vec = parse_csv_list(s).map_err(|()| regime())?; - if v.len() != 1 { - return Err(regime()); - } - Ok(v[0]) - } - } -} - /// Convert `WalkforwardCmd` into the resolved argument shape the walkforward sugar /// consumes (the dissolved `.json --real` branch). Single instrument, single-value -/// stop (Fork A: the stop is a risk regime, not a swept axis); `parse_csv_list` is -/// generic over `T: FromStr`, so the same helper parses the i64 length and the f64 -/// k. `--stop-length`/`--stop-k` are each OPTIONAL (#217): a missing flag defaults -/// independently to the single-sourced [`R_SMA_STOP_LENGTH`]/[`R_SMA_STOP_K`] -/// regime; a present-but-multi-value stop still refuses (the local `regime` -/// closure, captureless → `Copy`, is reused across both arms). `--name`/`--trace` -/// are mutually exclusive, matching the flag's own documented contract and the -/// inline path's `name_persist` refusal; an omitted flag defaults the family name -/// to "walkforward". The IS-refit `--axis` grid is parsed separately at the -/// dispatch site, not here. +/// stop (Fork A: the stop is a risk regime, not a swept axis); `--stop-length`/ +/// `--stop-k` are typed `Option`/`Option` (clap rejects a multi-value +/// spelling itself, "invalid value") and each OPTIONAL (#217): a missing flag +/// defaults independently to the single-sourced [`R_SMA_STOP_LENGTH`]/ +/// [`R_SMA_STOP_K`] regime. `--name`/`--trace` are mutually exclusive, matching +/// the flag's own documented contract and the inline path's `name_persist` +/// refusal; an omitted flag defaults the family name to "walkforward". The +/// IS-refit `--axis` grid is parsed separately at the dispatch site, not here. #[allow(clippy::type_complexity)] fn walkforward_args_from( a: &WalkforwardCmd, @@ -1485,9 +1446,8 @@ fn walkforward_args_from( None | Some("") => return Err("walkforward dissolves only over --real ".to_string()), Some(s) => s.to_string(), }; - let regime = || "walkforward: the stop is a single risk regime; --stop-length and --stop-k take one value each".to_string(); - let stop_length = stop_knob_or(a.stop_length.as_deref(), R_SMA_STOP_LENGTH, ®ime)?; - let stop_k = stop_knob_or(a.stop_k.as_deref(), R_SMA_STOP_K, ®ime)?; + let stop_length = a.stop_length.unwrap_or(R_SMA_STOP_LENGTH); + let stop_k = a.stop_k.unwrap_or(R_SMA_STOP_K); let (name, trace) = match (a.name.as_deref(), a.trace.as_deref()) { (Some(_), Some(_)) => { return Err("walkforward: --name and --trace are mutually exclusive".to_string()); @@ -1501,16 +1461,16 @@ fn walkforward_args_from( /// Convert `McCmd` into the resolved argument shape the mc sugar consumes (the /// `--real` campaign branch). Single instrument, single-value stop (Fork A: the -/// stop is a risk regime, not a swept axis); `--stop-length`/`--stop-k` are each -/// OPTIONAL (#217), independently defaulting to the single-sourced -/// [`R_SMA_STOP_LENGTH`]/[`R_SMA_STOP_K`] regime when omitted — a present-but- -/// multi-value stop still refuses. `--block-len`/`--resamples`/`--seed` -/// default to `1`/`1000`/`1` — the same defaults the retired real-R dispatch -/// used, so an omitted flag produces the same document either way. The -/// usize->u32 conversion lands HERE, at the argv boundary where the CLI's -/// `usize` meets the document's `u32` vocabulary (`StageBlock::MonteCarlo`). -/// `--name`/`--trace` are rejected: the R-bootstrap records without a family -/// name (the campaign name is a constant "mc"). +/// stop is a risk regime, not a swept axis); `--stop-length`/`--stop-k` are typed +/// `Option`/`Option` (clap rejects a multi-value spelling itself, +/// "invalid value") and each OPTIONAL (#217), independently defaulting to the +/// single-sourced [`R_SMA_STOP_LENGTH`]/[`R_SMA_STOP_K`] regime when omitted. +/// `--block-len`/`--resamples`/`--seed` default to `1`/`1000`/`1` — the same +/// defaults the retired real-R dispatch used, so an omitted flag produces the +/// same document either way. The usize->u32 conversion lands HERE, at the argv +/// boundary where the CLI's `usize` meets the document's `u32` vocabulary +/// (`StageBlock::MonteCarlo`). `--name`/`--trace` are rejected: the R-bootstrap +/// records without a family name (the campaign name is a constant "mc"). #[allow(clippy::type_complexity)] fn mc_args_from( a: &McCmd, @@ -1522,9 +1482,8 @@ fn mc_args_from( if a.name.is_some() || a.trace.is_some() { return Err("mc --real: --name/--trace are not accepted (the R-bootstrap records without a family name)".to_string()); } - let regime = || "mc: the stop is a single risk regime; --stop-length and --stop-k take one value each".to_string(); - let stop_length = stop_knob_or(a.stop_length.as_deref(), R_SMA_STOP_LENGTH, ®ime)?; - let stop_k = stop_knob_or(a.stop_k.as_deref(), R_SMA_STOP_K, ®ime)?; + let stop_length = a.stop_length.unwrap_or(R_SMA_STOP_LENGTH); + let stop_k = a.stop_k.unwrap_or(R_SMA_STOP_K); let block_len = a.block_len.map(|v| v as u32).unwrap_or(1); let resamples = a.resamples.map(|v| v as u32).unwrap_or(1000); let seed = a.seed.unwrap_or(1); @@ -2319,7 +2278,7 @@ fn dispatch_sweep(a: SweepCmd, env: &aura_runner::project::Env) { /// #220: arbitrary user blueprint + axes, formerly the welded r-sma branch). fn dispatch_walkforward(a: WalkforwardCmd, env: &aura_runner::project::Env) { // Single-sourced: both arms below read this one closure (house style, #179). - let usage = || format!("Usage: aura walkforward --axis = [--axis …] [--select ] [--name ] | aura walkforward --real --axis = [--axis …] [--stop-length (default {R_SMA_STOP_LENGTH})] [--stop-k (default {R_SMA_STOP_K:.1})] [--from ] [--to ] [--name | --trace ]"); + let usage = || format!("Usage: aura walkforward --axis = [--axis …] [--select ] [--name ] | aura walkforward --real --axis = [--axis …] [--stop-length (default {R_SMA_STOP_LENGTH})] [--stop-k (default {R_SMA_STOP_K:.1})] [--from ] [--to ] [--name | --trace ]"); match is_blueprint_file(&a.blueprint) { Some(path) => { let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { @@ -2763,6 +2722,10 @@ mod tests { assert!(matches!(parse_select("argmax").unwrap(), Selection::Argmax), "default is argmax"); assert!(matches!(parse_select("plateau:mean").unwrap(), Selection::Plateau(PlateauMode::Mean))); assert!(matches!(parse_select("plateau:worst").unwrap(), Selection::Plateau(PlateauMode::Worst))); + assert!(matches!( + parse_select("plateau").unwrap(), + Selection::Plateau(PlateauMode::Mean) + )); assert!(parse_select("bogus").is_err(), "unknown --select token is a usage error"); } @@ -3820,18 +3783,6 @@ mod tests { assert_eq!(stop_k, R_SMA_STOP_K, "omitted --stop-k defaults to the regime"); } - #[test] - fn mc_args_from_refuses_a_multi_value_stop() { - let a = McCmd { - real: Some("GER40".to_string()), - stop_length: Some("14,20".to_string()), - stop_k: Some("2.0".to_string()), - ..bare_mc_cmd() - }; - let err = mc_args_from(&a).unwrap_err(); - assert!(err.contains("single risk regime"), "stop-regime refusal: {err}"); - } - /// Property (#217): a missing `--stop-length`/`--stop-k` no longer refuses — /// each independently defaults to the single-sourced [`R_SMA_STOP_LENGTH`]/ /// [`R_SMA_STOP_K`] regime (length 3, k 2.0). diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index 16a314c..ea527e1 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -29,6 +29,8 @@ pub enum ProcessSub { Introspect(DocIntrospectCmd), /// Register a valid process document into the store under the runs root. Register { file: PathBuf }, + /// Print a registered process document's canonical bytes to stdout. + Show { id: String }, } #[derive(clap::Args)] @@ -208,6 +210,7 @@ pub fn process_cmd(cmd: ProcessCmd, env: &Env) { introspect_process(i) } ProcessSub::Register { file } => register_process(file, env), + ProcessSub::Show { id } => show_process(id, env), }; if let Err(m) = result { eprintln!("aura: {m}"); @@ -259,6 +262,21 @@ fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> { Ok(()) } +fn show_process(id: &str, env: &Env) -> Result<(), String> { + let registry = env.registry(); + match registry.get_process(id).map_err(|e| e.to_string())? { + Some(bytes) => { + // `print!`, not `println!`: `bytes` is the content-addressed canonical + // form the store keyed `id` on — the CLI is a transport and must not + // frame the canonical bytes with a trailing newline (#164, mirroring + // `graph_construct::build_cmd`). + print!("{bytes}"); + Ok(()) + } + None => Err(format!("process {id} not found in the project store{}", prefix_hint(id))), + } +} + fn introspect_process(cmd: &DocIntrospectCmd) -> Result<(), String> { if cmd.metrics { print_metric_roster(); @@ -341,6 +359,8 @@ pub enum CampaignSub { /// List stored campaign realizations, or dump one campaign's records /// (the bare store lines, not the run-emit wrapper). Runs { campaign: Option, run: Option }, + /// Print a registered campaign document's canonical bytes to stdout. + Show { id: String }, } /// The one authoring trap the referential tier can name outright: a ref that @@ -460,6 +480,7 @@ pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) { } CampaignSub::Register { file } => register_campaign(file, env), CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env), + CampaignSub::Show { id } => show_campaign(id, env), CampaignSub::Run { .. } => unreachable!("handled above"), }; if let Err(m) = result { @@ -562,6 +583,19 @@ fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> { Ok(()) } +fn show_campaign(id: &str, env: &Env) -> Result<(), String> { + let registry = env.registry(); + match registry.get_campaign(id).map_err(|e| e.to_string())? { + Some(bytes) => { + // `print!`, not `println!`: see `show_process` — the CLI must not + // frame the canonical bytes with a trailing newline (#164). + print!("{bytes}"); + Ok(()) + } + None => Err(format!("campaign {id} not found in the project store{}", prefix_hint(id))), + } +} + fn introspect_campaign(cmd: &DocIntrospectCmd) -> Result<(), String> { if cmd.metrics { print_metric_roster(); diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index b63c550..33dac96 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -3220,7 +3220,7 @@ fn walkforward_dissolved_refuses_a_multi_value_stop() { .expect("spawn aura"); assert_eq!(out.status.code(), Some(2), "multi-value stop is a usage refusal"); let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("single risk regime"), "stop-regime refusal: {stderr}"); + assert!(stderr.contains("invalid value"), "stop-regime refusal: {stderr}"); } /// #215: --select plateau is no longer refused on the dissolved path — it now @@ -3647,6 +3647,96 @@ fn walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2() { ); } +/// #227: bare `--select plateau` is the documented default aggregation +/// (`plateau:mean`), not a distinct spelling. Same dedup-pin idiom as +/// `walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2`: +/// running the same grid-sweep blueprint in the same store first with +/// `--select plateau`, then with `--select plateau:mean`, must produce +/// byte-identical campaign documents — content-addressed storage collapses +/// them onto EXACTLY ONE stored campaign — while each invocation still +/// records its own campaign-run line. Gated on the shared GER40 archive; +/// skips cleanly on a data refusal. +#[test] +fn walkforward_bare_plateau_generates_the_same_campaign_as_plateau_mean() { + const FROM_MS: &str = "1735689600000"; + const TO_MS: &str = "1747353600000"; + let (cwd, _g) = fresh_project(); + let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + + // Bare `plateau`: today this is an unknown-token usage refusal (exit 2); + // the feature makes it an alias for `plateau:mean`. + let bare = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "walkforward", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20", + "--stop-length", "14", "--stop-k", "2.0", + "--select", "plateau", + "--from", FROM_MS, "--to", TO_MS, + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + if bare.status.code() == Some(1) { + let stderr = String::from_utf8_lossy(&bare.stderr); + assert!( + stderr.contains("no local data") || stderr.contains("no recorded geometry"), + "exit 1 must be a data refusal, got: {stderr}" + ); + eprintln!("skip: no local GER40 data"); + return; + } + assert_eq!( + bare.status.code(), + Some(0), + "bare --select plateau must no longer be refused (it aliases plateau:mean): {}", + String::from_utf8_lossy(&bare.stderr) + ); + + // Explicit `plateau:mean`: names the very aggregation the bare spelling + // resolves to. Same blueprint, same axes, same window, same stop. + let explicit = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "walkforward", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20", + "--stop-length", "14", "--stop-k", "2.0", + "--select", "plateau:mean", + "--from", FROM_MS, "--to", TO_MS, + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + assert_eq!( + explicit.status.code(), + Some(0), + "the explicit --select plateau:mean walkforward must succeed: {}", + String::from_utf8_lossy(&explicit.stderr) + ); + + // Byte-identity: bare `plateau` IS `plateau:mean`, so both invocations + // generate the same campaign document and content-addressed storage + // collapses them onto one content id — exactly one campaign document in + // the store, but two independent campaign-run lines. + let count = |sub: &str| { + std::fs::read_dir(cwd.join("runs").join(sub)) + .map(|d| d.count()) + .unwrap_or(0) + }; + assert_eq!( + count("campaigns"), + 1, + "bare plateau binds the SAME aggregation as plateau:mean, so the two campaign \ + documents are byte-identical and dedup to one content id" + ); + let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl")) + .expect("campaign_runs.jsonl exists after two sugar runs"); + assert_eq!( + runs_log.lines().count(), + 2, + "each invocation still records its own campaign run (a run is an event, a document \ + is content)" + ); +} + /// Property: `aura generalize` is now thin sugar over the one campaign path — a /// successful run durably auto-registers exactly one generated process document, /// one generated campaign document (carrying the `--name` handle and the stop as @@ -5665,7 +5755,7 @@ fn mc_dissolved_refuses_a_multi_value_stop() { .expect("spawn aura"); assert_eq!(out.status.code(), Some(2), "multi-value stop is a usage refusal"); let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("single risk regime"), "stop-regime refusal: {stderr}"); + assert!(stderr.contains("invalid value"), "stop-regime refusal: {stderr}"); } /// Property: an `--axis` name that is not among the loaded blueprint's sweepable @@ -6286,3 +6376,334 @@ fn data_list_enumerates_archive_symbols_one_per_line_sorted() { // scoping an instrument matrix. assert_eq!(stdout, "SYMA\nSYMB\n", "sorted symbol enumeration: {stdout:?}"); } + +/// Property (#300): `aura campaign show ` prints the registered +/// document's exact canonical bytes to stdout — round-tripping them back +/// through `aura campaign register` re-derives the SAME content id (canonical +/// bytes are content-id-identical), and the store stays deduplicated at one +/// document. Gated on the shared GER40 archive; skips cleanly on a data +/// refusal (same guard shape as +/// `walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2`). +#[test] +fn campaign_show_round_trips_the_registered_document() { + const FROM_MS: &str = "1735689600000"; + const TO_MS: &str = "1767225599000"; + let (cwd, _g) = fresh_project(); + let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + + let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "walkforward", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--select", "plateau:mean", + "--from", FROM_MS, "--to", TO_MS, + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + if run.status.code() == Some(1) { + let stderr = String::from_utf8_lossy(&run.stderr); + assert!( + stderr.contains("no local data") || stderr.contains("no recorded geometry"), + "exit 1 must be a data refusal, got: {stderr}" + ); + eprintln!("skip: no local GER40 data"); + return; + } + assert_eq!( + run.status.code(), + Some(0), + "walkforward must succeed: {}", + String::from_utf8_lossy(&run.stderr) + ); + + let campaigns_dir = cwd.join("runs").join("campaigns"); + let cid = std::fs::read_dir(&campaigns_dir) + .expect("campaigns dir exists") + .next() + .expect("exactly one campaign document") + .expect("readable dir entry") + .path() + .file_stem() + .expect("campaign filename has a stem") + .to_string_lossy() + .into_owned(); + + let show = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["campaign", "show", &cid]) + .current_dir(&cwd) + .output() + .expect("spawn aura campaign show"); + assert_eq!( + show.status.code(), + Some(0), + "campaign show must succeed: {}", + String::from_utf8_lossy(&show.stderr) + ); + + // Directly pin the #164 no-framing invariant: `show` must print the + // stored canonical bytes byte-for-byte, with no added trailing newline + // (a `println!` regression in `show_campaign` would still pass the + // re-register check below via `campaign_to_json` -> `content_id_of` + // stripping it back out before hashing, so this comparison is the only + // thing that would catch it). + let stored_bytes = std::fs::read(campaigns_dir.join(format!("{cid}.json"))).expect("read stored campaign document"); + assert_eq!( + show.stdout, stored_bytes, + "campaign show must print the stored canonical bytes byte-for-byte, no framing" + ); + + let roundtrip_path = cwd.join("roundtrip.campaign.json"); + std::fs::write(&roundtrip_path, &show.stdout).expect("write roundtrip campaign doc"); + + let register = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["campaign", "register", roundtrip_path.to_str().expect("utf-8 path")]) + .current_dir(&cwd) + .output() + .expect("spawn aura campaign register"); + assert_eq!( + register.status.code(), + Some(0), + "re-registering the shown document must succeed: {}", + String::from_utf8_lossy(®ister.stderr) + ); + let register_out = String::from_utf8_lossy(®ister.stdout).into_owned(); + assert!( + register_out.contains(&format!("registered campaign {cid}")), + "canonical bytes must re-register content-id-identically to {cid}: {register_out}" + ); + + let count = std::fs::read_dir(&campaigns_dir).expect("campaigns dir exists").count(); + assert_eq!(count, 1, "the shown bytes dedup onto the same one stored document"); +} + +/// Property (#300): identical to `campaign_show_round_trips_the_registered_document` +/// but over the generated process document — `aura process show ` prints +/// canonical bytes that re-register content-id-identically through `aura +/// process register`, with the store staying deduplicated at one document. +#[test] +fn process_show_round_trips_the_registered_document() { + const FROM_MS: &str = "1735689600000"; + const TO_MS: &str = "1767225599000"; + let (cwd, _g) = fresh_project(); + let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + + let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "walkforward", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--select", "plateau:mean", + "--from", FROM_MS, "--to", TO_MS, + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + if run.status.code() == Some(1) { + let stderr = String::from_utf8_lossy(&run.stderr); + assert!( + stderr.contains("no local data") || stderr.contains("no recorded geometry"), + "exit 1 must be a data refusal, got: {stderr}" + ); + eprintln!("skip: no local GER40 data"); + return; + } + assert_eq!( + run.status.code(), + Some(0), + "walkforward must succeed: {}", + String::from_utf8_lossy(&run.stderr) + ); + + let processes_dir = cwd.join("runs").join("processes"); + let pid = std::fs::read_dir(&processes_dir) + .expect("processes dir exists") + .next() + .expect("exactly one process document") + .expect("readable dir entry") + .path() + .file_stem() + .expect("process filename has a stem") + .to_string_lossy() + .into_owned(); + + let show = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["process", "show", &pid]) + .current_dir(&cwd) + .output() + .expect("spawn aura process show"); + assert_eq!( + show.status.code(), + Some(0), + "process show must succeed: {}", + String::from_utf8_lossy(&show.stderr) + ); + + // Directly pin the #164 no-framing invariant (see the sibling campaign + // test's comment): `show` must print the stored canonical bytes + // byte-for-byte, with no added trailing newline. + let stored_bytes = std::fs::read(processes_dir.join(format!("{pid}.json"))).expect("read stored process document"); + assert_eq!( + show.stdout, stored_bytes, + "process show must print the stored canonical bytes byte-for-byte, no framing" + ); + + let roundtrip_path = cwd.join("roundtrip.process.json"); + std::fs::write(&roundtrip_path, &show.stdout).expect("write roundtrip process doc"); + + let register = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["process", "register", roundtrip_path.to_str().expect("utf-8 path")]) + .current_dir(&cwd) + .output() + .expect("spawn aura process register"); + assert_eq!( + register.status.code(), + Some(0), + "re-registering the shown document must succeed: {}", + String::from_utf8_lossy(®ister.stderr) + ); + let register_out = String::from_utf8_lossy(®ister.stdout).into_owned(); + assert!( + register_out.contains(&format!("registered process {pid}")), + "canonical bytes must re-register content-id-identically to {pid}: {register_out}" + ); + + let count = std::fs::read_dir(&processes_dir).expect("processes dir exists").count(); + assert_eq!(count, 1, "the shown bytes dedup onto the same one stored document"); +} + +/// Property (#300): `show` over an unknown id refuses with the same +/// project-store-not-found prose the referential tier already uses for a +/// missing `process.ref`/`strategy.ref` (`ref_fault_prose`'s +/// `ProcessNotFound`/campaign-equivalent hint shape) — no project run needed, +/// so this test needs no archive gate. +#[test] +fn show_refuses_an_unknown_id_with_the_store_hint() { + let (cwd, _g) = fresh_project(); + + let campaign_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["campaign", "show", "deadbeef"]) + .current_dir(&cwd) + .output() + .expect("spawn aura campaign show"); + assert_eq!(campaign_show.status.code(), Some(1), "unknown campaign id is a runtime refusal"); + let campaign_stderr = String::from_utf8_lossy(&campaign_show.stderr); + assert!( + campaign_stderr.contains("campaign deadbeef not found in the project store"), + "campaign show stderr: {campaign_stderr}" + ); + + let process_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["process", "show", "deadbeef"]) + .current_dir(&cwd) + .output() + .expect("spawn aura process show"); + assert_eq!(process_show.status.code(), Some(1), "unknown process id is a runtime refusal"); + let process_stderr = String::from_utf8_lossy(&process_show.stderr); + assert!( + process_stderr.contains("process deadbeef not found in the project store"), + "process show stderr: {process_stderr}" + ); +} + +/// Property (#300): `show`'s not-found prose reuses the existing `prefix_hint` +/// authoring-trap detector (#194) — an id pasted WITH the display-only +/// `content:` prefix (the form `ref_fault_prose` already prints elsewhere for +/// an unresolved `process.ref`/`strategy.ref`) still gets the "drop the +/// 'content:' prefix" hint through the new `show` code path, not a bare +/// not-found. This is the one authoring mistake the store can name outright, +/// and it must survive `show` reusing `ref_fault_prose`'s helper rather than +/// re-deriving its own prose. +#[test] +fn show_hints_the_content_prefix_authoring_mistake() { + let (cwd, _g) = fresh_project(); + + let campaign_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["campaign", "show", "content:deadbeef"]) + .current_dir(&cwd) + .output() + .expect("spawn aura campaign show"); + assert_eq!(campaign_show.status.code(), Some(1), "unresolvable id is a runtime refusal"); + let campaign_stderr = String::from_utf8_lossy(&campaign_show.stderr); + assert!( + campaign_stderr.contains("drop the 'content:' prefix"), + "campaign show must hint the content: authoring mistake: {campaign_stderr}" + ); + + let process_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["process", "show", "content:deadbeef"]) + .current_dir(&cwd) + .output() + .expect("spawn aura process show"); + assert_eq!(process_show.status.code(), Some(1), "unresolvable id is a runtime refusal"); + let process_stderr = String::from_utf8_lossy(&process_show.stderr); + assert!( + process_stderr.contains("drop the 'content:' prefix"), + "process show must hint the content: authoring mistake: {process_stderr}" + ); +} + +/// Property (#300): `show` is scoped to its own document-kind store — a +/// content id that IS a real, registered document in one store (here: a +/// minimal sweep-only process) does not resolve through the OTHER verb +/// (`campaign show`). `show_process`/`show_campaign` are two near-identical +/// functions written in the same change (both `registry.get_*(id) -> Option +/// -> print! the bytes or format! a not-found`); this pins that they read +/// from their own store method and neither accidentally shares the other's +/// namespace or falls back to a generic content-blob lookup. +#[test] +fn show_is_scoped_to_its_own_document_store() { + let (cwd, _g) = fresh_project(); + const SWEEP_ONLY_PROCESS: &str = r#"{"format_version":1,"kind":"process","name":"knob-sweep-only","pipeline":[{"block":"std::sweep","metric":"sqn_normalized","select":"argmax"}]}"#; + std::fs::write(cwd.join("knob.process.json"), SWEEP_ONLY_PROCESS).expect("write process doc"); + + let register = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["process", "register", "knob.process.json"]) + .current_dir(&cwd) + .output() + .expect("spawn aura process register"); + assert_eq!( + register.status.code(), + Some(0), + "process register must succeed: {}", + String::from_utf8_lossy(®ister.stderr) + ); + let register_out = String::from_utf8_lossy(®ister.stdout).into_owned(); + let pid = register_out + .lines() + .find(|l| l.starts_with("registered process ")) + .expect("register line") + .trim_start_matches("registered process ") + .split(' ') + .next() + .expect("process id") + .to_string(); + + let process_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["process", "show", &pid]) + .current_dir(&cwd) + .output() + .expect("spawn aura process show"); + assert_eq!( + process_show.status.code(), + Some(0), + "process show must find the just-registered process: {}", + String::from_utf8_lossy(&process_show.stderr) + ); + + let campaign_show = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["campaign", "show", &pid]) + .current_dir(&cwd) + .output() + .expect("spawn aura campaign show"); + assert_eq!( + campaign_show.status.code(), + Some(1), + "a process id must NOT resolve through campaign show: {}", + String::from_utf8_lossy(&campaign_show.stdout) + ); + let campaign_stderr = String::from_utf8_lossy(&campaign_show.stderr); + assert!( + campaign_stderr.contains(&format!("campaign {pid} not found in the project store")), + "campaign show stderr: {campaign_stderr}" + ); +} diff --git a/crates/aura-runner/src/family.rs b/crates/aura-runner/src/family.rs index c3a3fcd..c53b2bc 100644 --- a/crates/aura-runner/src/family.rs +++ b/crates/aura-runner/src/family.rs @@ -40,6 +40,9 @@ use crate::member::{ use crate::project::Env; use crate::translate::{R_SMA_STOP_K, R_SMA_STOP_LENGTH}; +/// The demo default stop regime, shared by every family builder in this module. +const DEFAULT_STOP: StopRule = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }; + /// The in-sample winner-selection objective for walk-forward's per-window IS /// refit (cycle 0077). `Argmax` is the bare-best pick deflated for trials /// (#144, the default); `Plateau` argmaxes the neighbourhood-smoothed surface @@ -427,7 +430,7 @@ pub 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(env, &binding.columns()), window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], NO_INSTRUMENT_CONTEXT) + run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT) }) // render the sweep terminal's BindError to prose (#247), the fn's String error // contract — never the raw Debug struct. @@ -476,7 +479,7 @@ pub fn blueprint_sweep_over( binder.sweep_with_lattice(|point| { let sources = data.windowed_sources(from, to, env, &binding.columns()); let window = window_of(&sources).expect("non-empty in-sample window"); - run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[], NO_INSTRUMENT_CONTEXT) + run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT) }) } @@ -502,7 +505,7 @@ pub fn run_oos_blueprint( let pip = data.pip_size(); let sources = data.windowed_sources(from, to, env, &binding.columns()); let window = window_of(&sources).expect("non-empty out-of-sample window"); - let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[], NO_INSTRUMENT_CONTEXT); + let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT); (Vec::new(), report) } @@ -642,7 +645,7 @@ pub fn blueprint_mc_family( let family = monte_carlo(&base_point, &seeds, |seed, _base| { let sources = synthetic_walk_sources(seed); let window = window_of(&sources).expect("non-empty synthetic walk"); - run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], NO_INSTRUMENT_CONTEXT) + run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT) }); // Silent-vacuous MC guard (refuse-don't-guess, C10): with >= 2 seeds, if every draw's // metrics are bit-identical to the first, no seed reached a distinguishable realization — diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index f6d4d79..7414138 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -1598,6 +1598,10 @@ by design (the 0106 fieldtest corpus stays as the historical record). Known debt metric-roster triplication (still hand-maintained, but drift from the shipped `aura-analysis` types is now test-caught by a cross-crate guard, #190; single-source removal waits on #147), deflation-constant duplication (#199). +#300 (2026-07-21) closed the store's read loop: `aura process|campaign show +` prints a registered document's canonical bytes — the +generate → retrieve → hand-extend → re-register cycle needs no direct store +filesystem access. **Realization (cycle 0108, #200 — the annotator stages execute).** The v2 executable shape is `std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]? [std::generalize]?` — an ordered optional annotator suffix, each at most once, @@ -2526,9 +2530,13 @@ vocabulary (blueprints, process/campaign documents, registry records) is the canonical layer, and every control surface — the one-shot CLI's executor verbs, any future long-running host, any MCP face, a World program — is a projection/executor over those artifacts, never a second home for intent. -Which projection is built next (document-first flag-vocabulary completion, a -typed-protocol host, an MCP face, or none until demand) is deliberately open -— recorded as an open direction fork on #295. +The owner resolved the direction 2026-07-21 (minuted on #295): **document-first +completion first** — delivered by #300: the executor verb set is settled +(`run`, the four thin per-verb generators, and the document verbs +validate/introspect/register/show/run/runs, `show` being #300's read-back +addition), the verbs' per-verb identity re-ratified (#300 F8 — reduction, not +grammar collapse), and a typed-protocol host or MCP face remains demand-driven, +unbuilt. ### C26 — Harness input binding: role names bind archive columns **Guarantee.** A strategy blueprint declares WHICH data it consumes as the NAMES