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:
+96
-84
@@ -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
@@ -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(®, &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(®, &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(®, &generated).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user