feat(cli): dissolve the real-data blueprint sweep into the campaign path (#210 T4)

aura sweep <bp.json> --real ... --axis ... now runs as thin sugar over
its generated campaign document through the one campaign executor: the
dispatch registers the canonical blueprint (topology_hash IS
content_id_of over the same canonical bytes, so the strategy ref
resolves against the existing single store write), strips the CLI's
wrapped axis names to the raw campaign namespace (#203 bind
convention), converts the probed archive window back through the
ingest seam's own epoch_ns_to_unix_ms (made pub; C3 — the seam owns
the ms/ns convention, never an inline division), and hands off to
verb_sugar::run_sweep_sugar. The inline real-data arm is gone; the
synthetic arm and --list-axes are untouched.

campaign_run gains RunPresentation {Full, MemberLinesOnly}:
run_campaign keeps Full (record line unchanged, project gate
unchanged); the sugar path runs MemberLinesOnly — the generated doc's
emit already limits stdout to family_table member lines, the mode
suppresses only the final record line. The record append is identical
in both modes; no project gate on the by-id path (the dissolved verb
works project-less, exactly as before).

The characterization pin survives the re-cut with exactly the one
sanctioned assertion flip (instrument-absence -> instrument==GER40)
and now also pins the generated artifacts: one process + one campaign
document auto-registered (dedup on a second identical invocation),
raw axis keys in the stored document, the stored window a ms sub-range
of the request, C1 at the member-report level with the registry's
run-suffix increment isolated.

Suite 1055/0, clippy clean, gated real-data paths executed locally.

refs #210
This commit is contained in:
2026-07-04 19:01:27 +02:00
parent fd0b21d070
commit b7aaa0ba59
5 changed files with 284 additions and 35 deletions
+40 -10
View File
@@ -262,6 +262,19 @@ impl MemberRunner for CliMemberRunner<'_> {
}
}
/// Stdout shape of a campaign run. `Full` is `aura campaign run` (emit-gated
/// member/selection lines + the always-on final `campaign_run` record line).
/// `MemberLinesOnly` is dissolved-verb sugar: the generated document's
/// `emit: ["family_table"]` already limits emission to member lines; the mode
/// additionally suppresses the final record line so the verb's stdout
/// contract is reproduced byte-for-byte. The record append is identical in
/// both modes — presentation changes, the record does not.
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum RunPresentation {
Full,
MemberLinesOnly,
}
/// `aura campaign run <target>`: resolve, gate, execute, emit. Every `Err`
/// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1.
pub(crate) fn run_campaign(target: &str, env: &Env) -> Result<(), String> {
@@ -297,11 +310,26 @@ pub(crate) fn run_campaign(target: &str, env: &Env) -> Result<(), String> {
));
};
// One uniform path from here: fetch the stored canonical bytes by id (so
// file addressing and id addressing produce the same realization by
// construction) and re-run the intrinsic tier on them.
run_campaign_by_id(&campaign_id, env, RunPresentation::Full)
}
/// The one campaign executor path from a resolved content id onward: fetch
/// the stored canonical bytes by id (so file addressing and id addressing
/// produce the same realization by construction) and re-run the intrinsic
/// tier on them, execute, and emit per `presentation`. Shared by
/// `run_campaign` (`RunPresentation::Full`) and the dissolved-verb sugar path
/// (`verb_sugar::run_sweep_sugar`, `RunPresentation::MemberLinesOnly`) — no
/// project gate here: the sugar path must work project-less exactly as the
/// inline verb it replaces did (store mechanics run against `env.registry()`
/// in both cases).
pub(crate) fn run_campaign_by_id(
campaign_id: &str,
env: &Env,
presentation: RunPresentation,
) -> Result<(), String> {
let registry = env.registry();
let campaign_text = registry
.get_campaign(&campaign_id)
.get_campaign(campaign_id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("no campaign {campaign_id} in the project store"))?;
let campaign = parse_campaign(&campaign_text)
@@ -369,7 +397,7 @@ pub(crate) fn run_campaign(target: &str, env: &Env) -> Result<(), String> {
server: Arc::new(data_server::DataServer::new(env.data_path())),
};
let outcome = aura_campaign::execute(
&campaign_id,
campaign_id,
&campaign,
&process,
&strategies,
@@ -437,11 +465,13 @@ pub(crate) fn run_campaign(target: &str, env: &Env) -> Result<(), String> {
}
}
}
println!(
"{}",
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
.expect("campaign run record serializes")
);
if presentation == RunPresentation::Full {
println!(
"{}",
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
.expect("campaign run record serializes")
);
}
// Trace persistence runs LAST, after the always-on record line (0109):
// stdout stays data-pure (the record line is the wire contract) and the
+77 -1
View File
@@ -3201,6 +3201,10 @@ fn blueprint_mc_family(
/// valid sweep on a name collision). `persist` is therefore not yet load-bearing here;
/// it is retained for the deferred per-member trace path. The family record itself is
/// written unconditionally, so lineage (C18/C21) holds whether or not `--trace` is given.
///
/// Synthetic-only: real-data invocations route through
/// `verb_sugar::run_sweep_sugar` at the dispatch boundary and never reach
/// this fn.
fn run_blueprint_sweep(
doc: &str, axes: &[(String, Vec<Scalar>)], name: &str, persist: bool, data: DataSource,
env: &project::Env,
@@ -4473,7 +4477,79 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
eprintln!("aura: {m}");
std::process::exit(2);
});
run_blueprint_sweep(&doc, &axes, &name, persist, DataSource::from_choice(data, env), env);
match &data {
DataChoice::Real { symbol, .. } => {
// The dissolved branch: real-data blueprint sweeps run as
// sugar over a generated campaign document through the
// one campaign executor. The blueprint store write (by
// topology hash) is kept — the
// canonical bytes are also the strategy ref's content, and
// `topology_hash` IS `content_id_of` applied to the same
// canonical bytes (verified by reading: `topology_hash` ==
// `content_id` == `aura_research::content_id_of`), so the
// strategy ref resolves against this one write; a second
// put under `content_id_of(&canonical)` would target the
// identical key and is not needed.
let symbol = symbol.clone();
let source = DataSource::from_choice(data, env);
let (from_ts, to_ts) = source.full_window(env);
// `full_window` probes the ARCHIVE's actual first/last bar
// and returns aura's native epoch-ns `Timestamp` (the
// ms->ns crossing happens once, at the ingest seam — C3);
// the campaign document's `Window` field is Unix-ms (same
// currency as `--from`/`--to` and every existing campaign
// fixture, e.g. `campaign_doc_json` in research_docs.rs).
// Convert back through the seam's own
// `aura_ingest::epoch_ns_to_unix_ms` at this one
// seam-crossing (never reimplement the division inline) so
// the executor's `unix_ms_to_epoch_ns` re-normalizes
// exactly once downstream, not twice.
let (from_ms, to_ms) = (
aura_ingest::epoch_ns_to_unix_ms(from_ts),
aura_ingest::epoch_ns_to_unix_ms(to_ts),
);
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 = topology_hash(&blueprint);
reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(1);
});
// Campaign documents speak the RAW
// campaign-axis namespace (`bind_axes`'s convention, #203) —
// everything after the wrapper's one node segment — while
// `--axis` on a blueprint file is typed against the WRAPPED
// probe (`blueprint_axis_probe`/`list_blueprint_axes`, e.g.
// `sma_signal.fast.length`). Strip that one segment before
// generating the campaign doc, or `validate_campaign_refs`
// (which checks axes against the UNWRAPPED blueprint's own
// `param_space`) refuses every axis as unknown.
let raw_axes: Vec<(String, Vec<Scalar>)> = axes
.iter()
.map(|(n, v)| {
let raw =
n.split_once('.').map(|(_, rest)| rest.to_string()).unwrap_or_else(|| n.clone());
(raw, v.clone())
})
.collect();
verb_sugar::run_sweep_sugar(
&raw_axes, &name, &symbol, from_ms, to_ms, &canonical, env,
)
.unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(1);
});
}
DataChoice::Synthetic => {
run_blueprint_sweep(
&doc, &axes, &name, persist,
DataSource::from_choice(data, env), env,
);
}
}
}
None => {
let usage = || "Usage: aura sweep [--strategy <sma|momentum|r-sma|r-breakout|r-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--channel <csv>] [--window <csv>] [--band-k <csv>]".to_string();
+17 -4
View File
@@ -7,10 +7,6 @@
//! becomes durable, diffable, reproducible intent), then run through the one
//! campaign executor in sugar presentation mode (member lines only).
// Temporary until the dispatch rewire wires this module (plan Task 4, which
// removes this line): the translator lands one commit ahead of its caller.
#![allow(dead_code)]
use std::collections::BTreeMap;
use aura_core::{Scalar, ScalarKind};
@@ -116,6 +112,23 @@ pub(crate) fn register_generated(
Ok((process_id, campaign_id))
}
/// 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,
env: &crate::project::Env,
) -> Result<(), String> {
let generated = translate_sweep(axes, name, symbol, from_ms, to_ms, blueprint_canonical)?;
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)
}
#[cfg(test)]
mod tests {
use super::*;