feat(cli): SugarInvocation + generalize goes blueprint-generic (#220 slice 1)

Tasks 1-2 of the verb-axis-generalization plan (spec auto-signed on a
grounding-check PASS, decision log on #220):

- verb_sugar gains the shared SugarInvocation<'_> carrier (#214) with
  VolStop and the factored doc_axes_from/risk_from/probe_params_from
  helpers; translate_sweep/run_sweep_sugar adopt it (no behaviour
  change — sweep argv and family output byte-identical), and
  translate_generalize/run_generalize_sugar follow, dropping their
  too_many_arguments allows (4 remain for wf/mc).
- generalize is de-welded: GeneralizeCmd takes a blueprint positional
  plus repeatable --axis <wrapped-name>=<value> (one value per axis);
  --strategy/--fast/--slow are deleted; dispatch_generalize runs the
  sweep sequence (read, wrapped-namespace validation, canonicalize +
  topology_hash registration, wrapped_to_raw_axis strip) with a new
  usage closure. RGrid is deleted workspace-wide.
- cli_run generalize tests migrate argv-only; the exact-grade anchor
  keeps all pinned floats verbatim; generalize_refuses_a_non_r_sma_
  strategy is deleted (its premise — a strategy gate — dissolves by
  design); the multi-value refusal now fires per --axis.

Verification: full workspace suite green in the loop's independent
verify; build + clippy clean. Held quality finding (kept as a
plan-hold): dispatch_generalize duplicates dispatch_sweep's
validation/strip block verbatim — dedup follows once wf/mc land the
same block (rule-of-three, end of this cycle).

refs #220, refs #214
This commit is contained in:
2026-07-09 13:13:59 +02:00
parent 51b8550237
commit a2294c7c3a
3 changed files with 404 additions and 250 deletions
+96 -84
View File
@@ -666,31 +666,6 @@ fn r_sma_broker_label(pip_size: f64) -> String {
format!("sim-optimal+risk-executor(pip_size={pip_size})")
}
/// The four-knob r-sma candidate grid the dissolved `generalize` verb resolves its
/// candidate from (`generalize_args_from`): the SMA fast / slow lengths and the
/// vol-stop length / `k`-multiplier, each a value list. Absent knobs fall back to the
/// historical defaults (fast `{2,3}`, slow `{6,12}`, stop_length `{3}`, stop_k
/// `{2.0}`). The built-in `--strategy` sweep and its CLI grid flags retired with the
/// demo harnesses (#159 cuts 1b/2/3); this struct now serves `generalize` alone.
#[derive(Clone, Debug, PartialEq)]
struct RGrid {
fast: Vec<i64>,
slow: Vec<i64>,
stop_length: Vec<i64>,
stop_k: Vec<f64>,
}
impl Default for RGrid {
fn default() -> Self {
Self {
fast: vec![2, 3],
slow: vec![6, 12],
stop_length: vec![R_SMA_STOP_LENGTH],
stop_k: vec![R_SMA_STOP_K],
}
}
}
/// In-sample winner-selection objective for walk-forward (cycle 0077). `Argmax` is
/// the bare-best pick deflated for trials (#144, the default); `Plateau` argmaxes
/// the neighbourhood-smoothed surface instead (opt-in via `--select`).
@@ -1979,13 +1954,13 @@ fn parse_scalar_csv(csv: &str) -> Option<Vec<Scalar>> {
// The declarative argument grammar. clap owns argv tokenizing, scoped `--help`,
// `--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 (`RGrid`, `Selection`,
// fns accept, reusing the value helpers (`Selection`,
// `DataSource::from_choice`, `parse_scalar_csv`, `parse_csv_list`, `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
// (#159 demo retirement): without a blueprint the dispatcher prints a usage error and
// exits 2 — except the real-archive r-sma sugar arms of walkforward/mc, which route to
// (#159 demo retirement / #220 axis generalization): without a blueprint the
// dispatcher prints a usage error and exits 2; a blueprint with `--real` routes to
// the campaign path. Usage errors (clap parse + argv-applicability guards) exit 2;
// runtime failures exit 1.
@@ -2100,18 +2075,14 @@ struct GraphIntrospectCmd {
#[derive(Args)]
struct GeneralizeCmd {
/// The candidate strategy; must be r-sma (the candidate must produce R).
#[arg(long)]
strategy: Option<String>,
/// The candidate blueprint (.json, required) — graded across instruments.
blueprint: Option<String>,
/// Comma-separated instrument list (>=2 distinct, required).
#[arg(long)]
real: Option<String>,
/// Candidate fast-MA length (single value; required).
/// Candidate axis `<name>=<value>` (repeatable, >=1; one value per axis).
#[arg(long)]
fast: Option<i64>,
/// Candidate slow-MA length (single value; required).
#[arg(long)]
slow: Option<i64>,
axis: Vec<String>,
/// Candidate stop length (single value; required).
#[arg(long)]
stop_length: Option<i64>,
@@ -2424,18 +2395,13 @@ fn mc_args_from(
}
/// Convert `GeneralizeCmd` into the resolved argument shape the generalize sugar
/// consumes. The candidate is a single cell (clap already types `--fast`/etc. as
/// one `i64`/`f64`), all four knobs required, `--real` a `>=2`-distinct comma
/// list; every refusal reuses the old message string (byte-identical front-end).
/// consumes. `--real` is a `>=2`-distinct comma list; the stop knobs are required
/// single values. Every refusal whose flag survives #220 reuses the old message
/// string (byte-identical front-end).
#[allow(clippy::type_complexity)]
fn generalize_args_from(
a: &GeneralizeCmd,
) -> Result<(String, Vec<String>, RGrid, String, Option<i64>, Option<i64>), String> {
if let Some(s) = a.strategy.as_deref()
&& s != "r-sma"
{
return Err("generalize requires --strategy r-sma (the candidate must produce R)".to_string());
}
) -> Result<(String, Vec<String>, i64, f64, String, Option<i64>, Option<i64>), String> {
let symbols: Vec<String> = match a.real.as_deref() {
None => return Err("generalize requires --real <SYM1,SYM2,...> — a comma list of two or more instruments".to_string()),
Some(v) => {
@@ -2456,16 +2422,12 @@ fn generalize_args_from(
if !symbols.iter().all(|s| seen.insert(s.clone())) {
return Err("generalize: each instrument may appear once; --real has a duplicate symbol".to_string());
}
let knobs = || "generalize requires all four candidate knobs: --fast --slow --stop-length --stop-k, each a single value".to_string();
let grid = RGrid {
fast: vec![a.fast.ok_or_else(knobs)?],
slow: vec![a.slow.ok_or_else(knobs)?],
stop_length: vec![a.stop_length.ok_or_else(knobs)?],
stop_k: vec![a.stop_k.ok_or_else(knobs)?],
};
let knobs = || "generalize requires --stop-length and --stop-k, each a single value".to_string();
let stop_length = a.stop_length.ok_or_else(knobs)?;
let stop_k = a.stop_k.ok_or_else(knobs)?;
let metric = a.metric.clone().unwrap_or_else(|| "expectancy_r".to_string());
let name = a.name.clone().unwrap_or_else(|| "generalize".to_string());
Ok((name, symbols, grid, metric, a.from, a.to))
Ok((name, symbols, stop_length, stop_k, metric, a.from, a.to))
}
/// The shared `--real`/`--from`/`--to` resolution for the family subcommands: a
@@ -2607,32 +2569,74 @@ fn dispatch_graph(a: GraphCmd, env: &project::Env) {
}
fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) {
let (name, symbols, grid, metric, from, to) = generalize_args_from(&a).unwrap_or_else(|m| {
eprintln!("aura: {m}");
let usage = || "Usage: aura generalize <blueprint.json> --real <SYM1,SYM2[,…]> --axis <name>=<value> [--axis …] --stop-length <n> --stop-k <x> [--metric <m>] [--name <n>] [--from <ms>] [--to <ms>]".to_string();
let Some(path) = is_blueprint_file(&a.blueprint) else {
eprintln!("aura: {}", usage());
std::process::exit(2);
};
let doc = std::fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!("aura: {path}: {e}");
std::process::exit(2);
});
// Data-free R-metric refusal, byte-identical to the inline path (exit 2)
// before any archive is touched.
if let Err(msg) = graph_construct::blueprint_slot_prose(&doc, env) {
eprintln!("aura: {path}: {msg}");
std::process::exit(2);
}
let (name, symbols, stop_length, stop_k, metric, from, to) =
generalize_args_from(&a).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
});
// Data-free R-metric refusal, byte-identical to the retired welded path
// (exit 2) before any archive is touched.
if let Err(e) = check_r_metric(&metric) {
eprintln!("aura: {e}");
std::process::exit(2);
}
// Embed the shipped open SMA example as the stored strategy blueprint (the
// same shape the sweep-dissolution fixture `sma_signal_open.json` stores);
// fast/slow become single-value campaign axes and the stop rides the risk
// regime (mapped back to StopRule::Vol by the member runner). topology_hash
// == content_id_of(canonical), so the strategy ref resolves against this one
// blueprint-store write.
let canonical = include_str!("../examples/r_sma_open.json");
let axes = parse_axes(&a.axis, &usage).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(2);
});
if axes.is_empty() {
eprintln!("aura: {}", usage());
std::process::exit(2);
}
// A candidate is a single grid cell, not a sweep: every axis carries
// exactly one value (the refusal the retired `--fast 2,3` grammar made).
for (n, vals) in &axes {
if vals.len() != 1 {
eprintln!(
"aura: generalize: each --axis takes exactly one value; axis \"{n}\" has {}",
vals.len()
);
std::process::exit(2);
}
}
// Validate every --axis name against the WRAPPED probe namespace before
// stripping to the raw campaign namespace (the sweep sequence, #210 c0110).
let space = blueprint_axis_probe(&doc, env).param_space();
for (n, _) in &axes {
if !space.iter().any(|p| &p.name == n) {
eprintln!(
"aura: axis \"{n}\" is not one of this blueprint's \
sweepable axes — run 'aura sweep <bp> --list-axes' \
to see them"
);
std::process::exit(2);
}
}
let blueprint = blueprint_from_json(&doc, &|t| env.resolve(t))
.expect("doc parse-validated at the dispatch boundary");
let canonical = blueprint_to_json(&blueprint).expect("a loaded blueprint re-serializes");
let reg = env.registry();
let topo = content_id(canonical);
reg.put_blueprint(&topo, canonical).unwrap_or_else(|e| {
let topo = topology_hash(&blueprint);
reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(1);
});
// Window: the explicit --from/--to when both present (byte-identical to the
// inline path, verified by the exact-grade anchor); otherwise the first
// symbol's full archive window as the single shared campaign window.
// retired welded path, verified by the exact-grade anchor); otherwise the
// first symbol's full archive window as the single shared campaign window.
let (from_ms, to_ms) = match (from, to) {
(Some(f), Some(t)) => (f, t),
_ => {
@@ -2647,20 +2651,22 @@ fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) {
)
}
};
verb_sugar::run_generalize_sugar(
grid.fast[0],
grid.slow[0],
grid.stop_length[0],
grid.stop_k[0],
&metric,
&name,
&symbols,
// Campaign documents speak the RAW campaign-axis namespace; `--axis` is
// typed against the WRAPPED probe. Strip one segment (the sweep strip).
let raw_axes: Vec<(String, Vec<Scalar>)> = axes
.iter()
.map(|(n, v)| (campaign_run::wrapped_to_raw_axis(n).to_string(), v.clone()))
.collect();
let inv = verb_sugar::SugarInvocation {
axes: &raw_axes,
name,
symbols,
from_ms,
to_ms,
canonical,
env,
)
.unwrap_or_else(|m| {
blueprint_canonical: &canonical,
stop: Some(verb_sugar::VolStop { length: stop_length, k: stop_k }),
};
verb_sugar::run_generalize_sugar(&inv, &metric, env).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(1);
});
@@ -2839,10 +2845,16 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
.iter()
.map(|(n, v)| (campaign_run::wrapped_to_raw_axis(n).to_string(), v.clone()))
.collect();
verb_sugar::run_sweep_sugar(
&raw_axes, &name, &symbol, from_ms, to_ms, &canonical, env,
)
.unwrap_or_else(|m| {
let inv = verb_sugar::SugarInvocation {
axes: &raw_axes,
name: name.clone(),
symbols: vec![symbol.clone()],
from_ms,
to_ms,
blueprint_canonical: &canonical,
stop: None,
};
verb_sugar::run_sweep_sugar(&inv, env).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(1);
});
+137 -121
View File
@@ -23,6 +23,52 @@ pub(crate) struct GeneratedSweep {
pub campaign: CampaignDoc,
}
/// One dissolved-verb invocation's shared shape (#214/#220): arbitrary RAW
/// axes (post `wrapped_to_raw_axis` strip), the family name, the instrument
/// list, the shared window, the canonical blueprint, and the optional single
/// Vol stop regime (`None` binds no regime — sweep's contract). Sweep and
/// generalize route their dispatch args through this shape today;
/// walkforward/mc still build their args directly via their own
/// `--fast`/`--slow` builders, pending their own #220 dissolution.
pub(crate) struct SugarInvocation<'a> {
pub axes: &'a [(String, Vec<Scalar>)],
pub name: String,
/// Instruments: sweep/walkforward/mc carry exactly one; generalize >=2.
pub symbols: Vec<String>,
pub from_ms: i64,
pub to_ms: i64,
pub blueprint_canonical: &'a str,
pub stop: Option<VolStop>,
}
/// The single protective-stop regime a dissolved verb binds (`RiskRegime::Vol`).
#[derive(Clone, Copy)]
pub(crate) struct VolStop {
pub length: i64,
pub k: f64,
}
/// The generic doc-axes map: every invocation axis under its arbitrary raw
/// bind-key (the sweep shape, adopted by all four verbs — #220).
fn doc_axes_from(axes: &[(String, Vec<Scalar>)]) -> Result<BTreeMap<String, Axis>, String> {
let mut doc_axes: BTreeMap<String, Axis> = BTreeMap::new();
for (axis_name, values) in axes {
doc_axes.insert(axis_name.clone(), axis_from_values(axis_name, values)?);
}
Ok(doc_axes)
}
/// The optional single Vol stop regime -> the campaign risk vector.
fn risk_from(stop: Option<VolStop>) -> Vec<RiskRegime> {
stop.map(|s| vec![RiskRegime::Vol { length: s.length, k: s.k }]).unwrap_or_default()
}
/// One probe param per axis (any one grid value — `bind_axes` checks NAME
/// coverage/uniqueness, never the bound value).
fn probe_params_from(axes: &[(String, Vec<Scalar>)]) -> Vec<(String, Scalar)> {
axes.iter().filter_map(|(n, vals)| vals.first().map(|v| (n.clone(), *v))).collect()
}
/// Map one verb axis (`--axis name=csv`, already scalar-parsed) to a
/// document `Axis`: the kind is the first value's kind; a mixed-kind CSV is
/// refused (the document axis declares its kind ONCE — #189).
@@ -52,16 +98,9 @@ fn axis_from_values(name: &str, values: &[Scalar]) -> Result<Axis, String> {
}
/// Translate a real-data blueprint sweep invocation into its two generated
/// documents. `blueprint_canonical` is the canonical blueprint JSON already
/// documents. `inv.blueprint_canonical` is the canonical blueprint JSON already
/// stored by topology hash; its content id is the strategy ref.
pub(crate) fn translate_sweep(
axes: &[(String, Vec<Scalar>)],
name: &str,
symbol: &str,
from_ms: i64,
to_ms: i64,
blueprint_canonical: &str,
) -> Result<GeneratedSweep, String> {
pub(crate) fn translate_sweep(inv: &SugarInvocation) -> Result<GeneratedSweep, String> {
let process = ProcessDoc {
format_version: FORMAT_VERSION,
kind: DocKind::Process,
@@ -69,28 +108,25 @@ pub(crate) fn translate_sweep(
description: None,
pipeline: vec![StageBlock::Sweep { selection: None }],
};
let mut doc_axes: BTreeMap<String, Axis> = BTreeMap::new();
for (axis_name, values) in axes {
doc_axes.insert(axis_name.clone(), axis_from_values(axis_name, values)?);
}
let campaign = CampaignDoc {
format_version: FORMAT_VERSION,
kind: DocKind::Campaign,
name: name.to_string(),
name: inv.name.clone(),
description: None,
data: DataSection {
instruments: vec![symbol.to_string()],
windows: vec![Window { from_ms, to_ms }],
instruments: inv.symbols.clone(),
windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }],
},
strategies: vec![StrategyEntry {
r#ref: DocRef::ContentId(content_id_of(blueprint_canonical)),
axes: doc_axes,
r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)),
axes: doc_axes_from(inv.axes)?,
}],
process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) },
// No stop regime is bound for a dissolved single-sweep member; the
// R-path stop is a wrap_r constant baked outside the signal axes
// (absent risk == empty risk, per the established parity).
risk: vec![],
// No stop regime is bound for a dissolved single-sweep member (the
// dispatch passes `stop: None`; absent risk == empty risk, per the
// established parity). The other verbs bind the single Vol regime
// through this same seam.
risk: risk_from(inv.stop),
// No stage of a selection-free single-sweep pipeline consumes the
// seed; a fixed zero keeps generated bytes deterministic so identical
// invocations dedupe onto identical content ids.
@@ -127,22 +163,12 @@ pub(crate) struct GeneratedGeneralize {
/// a **selection-bearing** process (`[std::sweep(argmax), std::generalize]` —
/// generalize needs a nominee, so the sweep stage is selection-bearing, unlike
/// the selection-free single-sweep translator) and a campaign running the one
/// fixed candidate (single-value raw axes) across all instruments under a single
/// risk regime that carries the stop. `blueprint_canonical` is the bare
/// `sma_signal` blueprint already stored by topology hash; its content id is the
/// strategy ref.
#[allow(clippy::too_many_arguments)]
/// fixed candidate (single-value raw axes) across all instruments under the
/// invocation's single risk regime. `inv.blueprint_canonical` is the user's
/// blueprint already stored by topology hash; its content id is the strategy ref.
pub(crate) fn translate_generalize(
fast: i64,
slow: i64,
stop_length: i64,
stop_k: f64,
inv: &SugarInvocation,
metric: &str,
name: &str,
symbols: &[String],
from_ms: i64,
to_ms: i64,
blueprint_canonical: &str,
) -> Result<GeneratedGeneralize, String> {
let process = ProcessDoc {
format_version: FORMAT_VERSION,
@@ -160,32 +186,23 @@ pub(crate) fn translate_generalize(
StageBlock::Generalize { metric: metric.to_string() },
],
};
let mut doc_axes: BTreeMap<String, Axis> = BTreeMap::new();
doc_axes.insert(
"fast.length".to_string(),
axis_from_values("fast.length", &[Scalar::i64(fast)])?,
);
doc_axes.insert(
"slow.length".to_string(),
axis_from_values("slow.length", &[Scalar::i64(slow)])?,
);
let campaign = CampaignDoc {
format_version: FORMAT_VERSION,
kind: DocKind::Campaign,
name: name.to_string(),
name: inv.name.clone(),
description: None,
data: DataSection {
instruments: symbols.to_vec(),
windows: vec![Window { from_ms, to_ms }],
instruments: inv.symbols.clone(),
windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }],
},
strategies: vec![StrategyEntry {
r#ref: DocRef::ContentId(content_id_of(blueprint_canonical)),
axes: doc_axes,
r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)),
axes: doc_axes_from(inv.axes)?,
}],
process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) },
// The stop is a single structural risk regime (the just-shipped axis);
// the member runner maps it back to StopRule::Vol at run time.
risk: vec![RiskRegime::Vol { length: stop_length, k: stop_k }],
// The stop is a single structural risk regime; the member runner maps
// it back to StopRule::Vol at run time.
risk: risk_from(inv.stop),
seed: 0,
presentation: Presentation {
persist_taps: vec![],
@@ -254,22 +271,12 @@ fn validate_before_register(
/// Run one dissolved sweep invocation end-to-end: register the generated
/// documents, then execute through the one campaign path in sugar mode.
pub(crate) fn run_sweep_sugar(
axes: &[(String, Vec<Scalar>)],
name: &str,
symbol: &str,
from_ms: i64,
to_ms: i64,
blueprint_canonical: &str,
inv: &SugarInvocation,
env: &crate::project::Env,
) -> Result<(), String> {
let generated = translate_sweep(axes, name, symbol, from_ms, to_ms, blueprint_canonical)?;
let probe_params: Vec<(String, Scalar)> = axes
.iter()
.filter_map(|(n, vals)| vals.first().map(|v| (n.clone(), *v)))
.collect();
validate_before_register(&generated.process, &generated.campaign, blueprint_canonical, &probe_params, env)?;
let generated = translate_sweep(inv)?;
let probe_params = probe_params_from(inv.axes);
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
let reg = env.registry();
let (_process_id, campaign_id) = register_generated(&reg, &generated)?;
crate::campaign_run::run_campaign_by_id(&campaign_id, env, crate::campaign_run::RunPresentation::MemberLinesOnly)
@@ -303,37 +310,21 @@ fn cross_instrument_members(
/// documents, run through the one campaign path, then reprint today's exact
/// `{"generalize":{…}}` + `{"family_id":…}` lines from the recorded outcome and
/// persist the `CrossInstrument` family. The stdout is byte-identical to the
/// inline path (the committed exact-grade anchor is the gate).
#[allow(clippy::too_many_arguments)]
/// retired welded path (the committed exact-grade anchor is the gate).
pub(crate) fn run_generalize_sugar(
fast: i64,
slow: i64,
stop_length: i64,
stop_k: f64,
inv: &SugarInvocation,
metric: &str,
name: &str,
symbols: &[String],
from_ms: i64,
to_ms: i64,
blueprint_canonical: &str,
env: &crate::project::Env,
) -> Result<(), String> {
let generated = translate_generalize(
fast, slow, stop_length, stop_k, metric, name, symbols, from_ms, to_ms, blueprint_canonical,
)?;
let probe_params: Vec<(String, Scalar)> = vec![
("fast.length".to_string(), Scalar::i64(fast)),
("slow.length".to_string(), Scalar::i64(slow)),
];
validate_before_register(&generated.process, &generated.campaign, blueprint_canonical, &probe_params, env)?;
let generated = translate_generalize(inv, metric)?;
let probe_params = probe_params_from(inv.axes);
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
let reg = env.registry();
let (_process_id, campaign_id) = register_generated_g(&reg, &generated)?;
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
// Reprint the verb's aggregate line from the recorded cross-instrument
// grade — byte-identical to the inline `generalize_json(&agg)`.
// grade — byte-identical to the retired welded path's `generalize_json(&agg)`.
let cg = run
.outcome
.record
@@ -345,7 +336,7 @@ pub(crate) fn run_generalize_sugar(
let members = cross_instrument_members(&run.outcome);
let family_id = reg
.append_family(name, aura_registry::FamilyKind::CrossInstrument, &members)
.append_family(&inv.name, aura_registry::FamilyKind::CrossInstrument, &members)
.map_err(|e| e.to_string())?;
println!("{{\"family_id\":\"{family_id}\"}}");
Ok(())
@@ -677,10 +668,37 @@ mod tests {
]
}
fn inv<'a>(
axes: &'a [(String, Vec<Scalar>)],
name: &str,
symbols: &[&str],
stop: Option<VolStop>,
bp: &'a str,
) -> SugarInvocation<'a> {
SugarInvocation {
axes,
name: name.to_string(),
symbols: symbols.iter().map(|s| s.to_string()).collect(),
from_ms: 100,
to_ms: 200,
blueprint_canonical: bp,
stop,
}
}
fn g_axes() -> Vec<(String, Vec<Scalar>)> {
vec![
("fast.length".to_string(), vec![Scalar::i64(3)]),
("slow.length".to_string(), vec![Scalar::i64(12)]),
]
}
#[test]
fn translate_sweep_is_deterministic_and_names_flow() {
let a = translate_sweep(&axes(), "probe", "GER40", 100, 200, "{\"bp\":1}").unwrap();
let b = translate_sweep(&axes(), "probe", "GER40", 100, 200, "{\"bp\":1}").unwrap();
let ax = axes();
let i = inv(&ax, "probe", &["GER40"], None, "{\"bp\":1}");
let a = translate_sweep(&i).unwrap();
let b = translate_sweep(&i).unwrap();
assert_eq!(
content_id_of(&campaign_to_json(&a.campaign)),
content_id_of(&campaign_to_json(&b.campaign)),
@@ -704,30 +722,38 @@ mod tests {
#[test]
fn translate_sweep_refuses_a_mixed_kind_axis() {
let mixed = vec![("k".to_string(), vec![Scalar::I64(1), Scalar::F64(2.5)])];
let err = translate_sweep(&mixed, "n", "GER40", 1, 2, "{}").unwrap_err();
let err = translate_sweep(&inv(&mixed, "n", &["GER40"], None, "{}")).unwrap_err();
assert!(err.contains("mixed value kinds"), "{err}");
}
#[test]
fn generated_campaign_validates_intrinsically_and_preflights() {
let g = translate_sweep(&axes(), "probe", "GER40", 100, 200, "{\"bp\":1}").unwrap();
let ax = axes();
let g = translate_sweep(&inv(&ax, "probe", &["GER40"], None, "{\"bp\":1}")).unwrap();
assert!(aura_research::validate_campaign(&g.campaign).is_empty());
assert!(aura_research::validate_process(&g.process).is_empty());
assert!(aura_campaign::preflight(&g.process, &g.campaign).is_ok());
}
/// The one seam all four verbs share (#220): `stop: Some(VolStop)` binds the
/// single Vol regime; `stop: None` binds no regime (sweep's contract).
#[test]
fn sugar_invocation_stop_maps_to_the_vol_regime() {
let ax = axes();
let mut i = inv(&ax, "n", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}");
let with = translate_sweep(&i).unwrap();
assert_eq!(with.campaign.risk, vec![RiskRegime::Vol { length: 14, k: 2.0 }]);
i.stop = None;
let without = translate_sweep(&i).unwrap();
assert!(without.campaign.risk.is_empty());
}
#[test]
fn translate_generalize_is_deterministic_and_carries_the_regime() {
let a = translate_generalize(
3, 12, 14, 2.0, "expectancy_r", "generalize",
&["GER40".to_string(), "USDJPY".to_string()], 100, 200, "{\"bp\":1}",
)
.unwrap();
let b = translate_generalize(
3, 12, 14, 2.0, "expectancy_r", "generalize",
&["GER40".to_string(), "USDJPY".to_string()], 100, 200, "{\"bp\":1}",
)
.unwrap();
let ax = g_axes();
let i = inv(&ax, "generalize", &["GER40", "USDJPY"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}");
let a = translate_generalize(&i, "expectancy_r").unwrap();
let b = translate_generalize(&i, "expectancy_r").unwrap();
assert_eq!(
content_id_of(&campaign_to_json(&a.campaign)),
content_id_of(&campaign_to_json(&b.campaign)),
@@ -777,21 +803,10 @@ mod tests {
/// different-k pair and a different-length, same-k pair.
#[test]
fn translate_generalize_diverging_regimes_do_not_collide_content_ids() {
let base = translate_generalize(
3, 12, 14, 2.0, "expectancy_r", "generalize",
&["GER40".to_string(), "USDJPY".to_string()], 100, 200, "{\"bp\":1}",
)
.unwrap();
let different_k = translate_generalize(
3, 12, 14, 3.0, "expectancy_r", "generalize",
&["GER40".to_string(), "USDJPY".to_string()], 100, 200, "{\"bp\":1}",
)
.unwrap();
let different_length = translate_generalize(
3, 12, 20, 2.0, "expectancy_r", "generalize",
&["GER40".to_string(), "USDJPY".to_string()], 100, 200, "{\"bp\":1}",
)
.unwrap();
let ax = g_axes();
let base = translate_generalize(&inv(&ax, "generalize", &["GER40", "USDJPY"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"), "expectancy_r").unwrap();
let different_k = translate_generalize(&inv(&ax, "generalize", &["GER40", "USDJPY"], Some(VolStop { length: 14, k: 3.0 }), "{\"bp\":1}"), "expectancy_r").unwrap();
let different_length = translate_generalize(&inv(&ax, "generalize", &["GER40", "USDJPY"], Some(VolStop { length: 20, k: 2.0 }), "{\"bp\":1}"), "expectancy_r").unwrap();
let base_id = content_id_of(&campaign_to_json(&base.campaign));
let k_id = content_id_of(&campaign_to_json(&different_k.campaign));
let length_id = content_id_of(&campaign_to_json(&different_length.campaign));
@@ -822,9 +837,10 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap();
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
let ax = g_axes();
let generated = translate_generalize(
3, 12, 14, 2.0, "expectancy_r", "generalize",
&["GER40".to_string(), "USDJPY".to_string()], 100, 200, "{\"bp\":1}",
&inv(&ax, "generalize", &["GER40", "USDJPY"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"),
"expectancy_r",
)
.unwrap();
let (process_id, campaign_id) = register_generated_g(&reg, &generated).unwrap();
+171 -45
View File
@@ -1306,6 +1306,33 @@ fn sweep_channel_flag_is_removed_from_the_grammar() {
let _ = std::fs::remove_dir_all(&cwd);
}
/// Property (#220 generalize vertical): `--strategy`/`--fast`/`--slow` are
/// genuinely removed from the generalize grammar, not merely unused — clap
/// rejects `--strategy` as a structurally unknown argument (exit 2) rather
/// than silently accepting and ignoring it now that the candidate is an
/// arbitrary blueprint positional + generic `--axis`. A regression that left
/// the old flags on `GeneralizeCmd` while deleting only their plumbing would
/// parse successfully here and this test would fail on the exit code.
#[test]
fn generalize_strategy_flag_is_removed_from_the_grammar() {
let cwd = temp_cwd("generalize-strategy-flag-retired");
let out = Command::new(BIN)
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
])
.current_dir(&cwd)
.output()
.expect("spawn aura generalize --strategy");
assert_eq!(out.status.code(), Some(2), "--strategy must be an unrecognized clap argument: {:?}", out.status);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--strategy") || stderr.to_lowercase().contains("unexpected argument"),
"clap must name the unknown flag: {stderr:?}"
);
let _ = std::fs::remove_dir_all(&cwd);
}
/// Property: `aura generalize` grades a single r-sma candidate across two
/// instruments — its stdout carries the per-instrument breakdown + the worst-case
/// floor + the sign-agreement, then the persisted CrossInstrument family handle.
@@ -1316,10 +1343,12 @@ fn generalize_grades_a_candidate_across_two_instruments() {
const FROM_MS: &str = "1725148800000";
const TO_MS: &str = "1727740799999";
let cwd = temp_cwd("generalize-two");
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
@@ -1360,10 +1389,12 @@ fn generalize_real_e2e_pins_the_exact_current_grade() {
const FROM_MS: &str = "1725148800000";
const TO_MS: &str = "1727740799999";
let cwd = temp_cwd("generalize-exact-grade");
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
@@ -1399,6 +1430,53 @@ fn generalize_real_e2e_pins_the_exact_current_grade() {
assert_eq!(per[1][1].as_f64(), Some(0.005795903617609842), "USDJPY expectancy R: {grade_line}");
}
/// Property (#220 generalize vertical): `aura generalize` grades ANY sweepable
/// blueprint, not just the historical r-sma candidate — the whole point of
/// dropping `--strategy r-sma`/`--fast`/`--slow` for a positional blueprint +
/// generic `--axis`. Every other generalize E2E test here still exercises
/// `r_sma_open.json`, so a regression that silently special-cased r-sma paths
/// back in (e.g. resolving axes against a hardcoded `sma_signal.*` namespace)
/// would ship green there while breaking every other blueprint. This runs the
/// unrelated r-breakout candidate (`channel_hi`/`channel_lo` axes, no
/// `sma_signal` node in sight) end-to-end and asserts a well-formed two-
/// instrument grade. Gated on the shared GER40/USDJPY Sept-2024 archive; skips
/// cleanly on a data refusal.
#[test]
fn generalize_grades_a_non_r_sma_blueprint() {
const FROM_MS: &str = "1725148800000";
const TO_MS: &str = "1727740799999";
let cwd = temp_cwd("generalize-non-r-sma");
let fixture = format!("{}/examples/r_breakout_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "r_breakout_signal.channel_hi.length=20",
"--axis", "r_breakout_signal.channel_lo.length=20",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
.output()
.expect("spawn aura");
if out.status.code() == Some(1) {
let stderr = String::from_utf8_lossy(&out.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/USDJPY data");
return;
}
assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
assert!(stdout.contains("\"generalize\":"), "aggregate object: {stdout}");
assert!(stdout.contains("\"n_instruments\":2"), "two instruments: {stdout}");
assert!(stdout.contains("\"worst_case\":"), "worst_case present: {stdout}");
assert!(stdout.contains("\"sign_agreement\":"), "sign_agreement present: {stdout}");
assert!(stdout.contains("GER40") && stdout.contains("USDJPY"), "per-instrument breakdown: {stdout}");
assert!(stdout.contains("\"family_id\":"), "family handle: {stdout}");
}
/// Characterization pin (byte-identity anchor for the walkforward dissolution,
/// #210). The current `aura walkforward` runs its per-window IS-refit inline
/// (`walkforward_family` -> `select_winner` -> OOS run -> stitch/pool); the
@@ -1494,9 +1572,8 @@ fn walkforward_dissolved_refuses_select_plateau() {
assert!(stderr.contains("#215"), "forward pointer to the plateau follow-up: {stderr}");
}
/// `walkforward_args_from` requires all four grid knobs on the dissolved r-sma-real
/// path (unlike the inline path, which defaults an absent flag via `RGrid::default()`)
/// — a missing knob is a usage refusal (exit 2), not a silent default.
/// `walkforward_args_from` requires both stop knobs on the campaign path — a
/// missing knob is a usage refusal (exit 2), not a silent default.
#[test]
fn walkforward_dissolved_refuses_missing_knobs() {
let cwd = temp_cwd("walkforward-missing-knobs");
@@ -1665,10 +1742,12 @@ fn generalize_dissolves_through_the_campaign_path() {
const FROM_MS: &str = "1725148800000";
const TO_MS: &str = "1727740799999";
let cwd = temp_cwd("generalize-dissolves");
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
@@ -1754,11 +1833,13 @@ fn generalize_repeated_identical_invocation_does_not_litter_the_store() {
const FROM_MS: &str = "1725148800000";
const TO_MS: &str = "1727740799999";
let cwd = temp_cwd("generalize-repeat-idempotent");
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let invoke = || {
std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
@@ -1807,11 +1888,14 @@ fn generalize_distinct_invocations_persist_distinct_campaign_documents() {
const FROM_MS: &str = "1725148800000";
const TO_MS: &str = "1727740799999";
let cwd = temp_cwd("generalize-distinct-content");
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let invoke = |fast: &str| {
let fast_axis = format!("sma_signal.fast.length={fast}");
std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", fast, "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", &fast_axis, "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
@@ -1853,10 +1937,12 @@ fn generalize_distinct_invocations_persist_distinct_campaign_documents() {
#[test]
fn generalize_without_explicit_window_falls_back_to_the_first_symbols_full_window() {
let cwd = temp_cwd("generalize-default-window");
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
])
.current_dir(&cwd)
.output()
@@ -1883,10 +1969,12 @@ fn generalize_without_explicit_window_falls_back_to_the_first_symbols_full_windo
/// before any data access — so it asserts the arity refusal on any machine.
#[test]
fn generalize_refuses_a_single_instrument() {
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
])
.output()
.expect("spawn aura");
@@ -1897,10 +1985,12 @@ fn generalize_refuses_a_single_instrument() {
/// `check_r_metric` pre-check), so this asserts the R-only refusal on any machine.
#[test]
fn generalize_refuses_a_non_r_metric() {
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
"--metric", "total_pips",
])
.output()
@@ -1920,10 +2010,12 @@ fn generalize_refuses_a_non_r_metric() {
/// check precedes any geometry/archive lookup, so it asserts on any machine.
#[test]
fn generalize_refuses_a_duplicate_instrument() {
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,GER40",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,GER40",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
])
.output()
.expect("spawn aura");
@@ -1937,22 +2029,24 @@ fn generalize_refuses_a_duplicate_instrument() {
assert!(stderr.contains("generalize"), "must surface the generalize usage, got: {stderr}");
}
/// Property: a non-`r-sma` strategy is refused at the binary boundary (exit 2,
/// no run, data-free). The candidate must produce R (C10) — the cross-instrument
/// reduction is R-only — so only the r-sma grid is an admissible candidate. The
/// parser unit test pins the grammar refusal; this pins that `--strategy sma`
/// reaches `exit(2)` through the dispatch arm rather than silently defaulting to a
/// running candidate. Asserts on any machine (the strategy check precedes any run).
/// Property: a multi-value axis is refused at the binary boundary (exit 2, no
/// run, data-free). A *candidate* is a single grid cell, not a sweep — so any
/// `--axis` carrying more than one value is refused, never silently widened
/// into a multi-cell run whose generalization grade would be ill-defined.
#[test]
fn generalize_refuses_a_non_r_sma_strategy() {
fn generalize_refuses_a_multi_value_axis() {
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=2,3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
])
.output()
.expect("spawn aura");
assert_eq!(out.status.code(), Some(2), "a non-r-sma strategy must exit 2");
assert_eq!(out.status.code(), Some(2), "a multi-value axis must exit 2");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("each --axis takes exactly one value"), "arity refusal: {stderr}");
assert!(
out.stdout.is_empty(),
"the refusal must not leak an aggregate to stdout, got: {:?}",
@@ -1960,22 +2054,50 @@ fn generalize_refuses_a_non_r_sma_strategy() {
);
}
/// Property: a multi-value candidate flag is refused at the binary boundary (exit 2,
/// no run, data-free). A *candidate* is a single grid cell, not a sweep — so any of
/// `--fast/--slow/--stop-length/--stop-k` carrying more than one value (`--fast 2,3`)
/// is refused, never silently widened into a multi-cell run whose generalization
/// grade would be ill-defined (the reduction grades one candidate, not a family).
/// The parser unit test pins the grammar; this pins the wiring to `exit(2)`.
/// Property: a `--axis` with no `--axis` flags at all is a usage error (exit 2,
/// no run) — generalize needs >= 1 axis to name a candidate, mirroring the sibling
/// sweep/walkforward "no axis" refusals. Data-free (fires before any archive access).
#[test]
fn generalize_refuses_a_multi_value_candidate_flag() {
fn generalize_refuses_no_axis() {
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "2,3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--stop-length", "14", "--stop-k", "2.0",
])
.output()
.expect("spawn aura");
assert_eq!(out.status.code(), Some(2), "a multi-value candidate flag must exit 2");
assert_eq!(out.status.code(), Some(2), "zero axes must exit 2");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("Usage: aura generalize"), "must print the generalize usage: {stderr}");
assert!(
out.stdout.is_empty(),
"the refusal must not leak an aggregate to stdout, got: {:?}",
String::from_utf8_lossy(&out.stdout)
);
}
/// Property: an `--axis` name that is not among the loaded blueprint's sweepable
/// axes is refused before the raw-namespace strip or any archive access (exit 2),
/// echoing exactly the name the user typed — the same WRAPPED-probe preflight
/// `aura sweep` enforces (`aura_sweep_real_blueprint_refuses_a_raw_form_axis_name_before_data_access`).
#[test]
fn generalize_refuses_an_unknown_axis_name() {
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.nope=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
])
.output()
.expect("spawn aura");
assert_eq!(out.status.code(), Some(2), "an unknown axis name must exit 2");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("axis \"sma_signal.nope\"") && stderr.contains("sweepable axes"),
"must name the unresolved axis exactly as typed: {stderr}"
);
assert!(
out.stdout.is_empty(),
"the refusal must not leak an aggregate to stdout, got: {:?}",
@@ -1998,10 +2120,12 @@ fn generalize_persists_a_discoverable_cross_instrument_family() {
const FROM_MS: &str = "1725148800000";
const TO_MS: &str = "1727740799999";
let cwd = temp_cwd("generalize-family");
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)
@@ -2061,10 +2185,12 @@ fn generalize_family_members_are_attributed_to_the_right_instrument() {
const FROM_MS: &str = "1725148800000";
const TO_MS: &str = "1727740799999";
let cwd = temp_cwd("generalize-member-attribution");
let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
"--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
"generalize", &fixture, "--real", "GER40,USDJPY",
"--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12",
"--stop-length", "14", "--stop-k", "2.0",
"--from", FROM_MS, "--to", TO_MS,
])
.current_dir(&cwd)