feat(cli): the generalize campaign translator (#210 T2)
`translate_generalize` builds the selection-bearing campaign document (`[std::sweep(argmax), std::generalize]`, a single risk regime carrying the stop, the candidate as single-value raw axes) + `GeneratedGeneralize` + `register_generated_g`, mirroring the sweep translator. A determinism unit test pins identical-invocation -> identical content id and the regime/pipeline shape. The new items are unused until the dispatch-rewire task (#210 T3) wires the runner; a bridge `#![allow(dead_code)]` (removed in T3) mirrors the sweep sibling's landing. refs #210
This commit is contained in:
@@ -7,13 +7,19 @@
|
||||
//! 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 the new generalize translator (a
|
||||
// later task in this plan, which removes this line): the same bridge pattern
|
||||
// used for the sweep translator's own landing (#210 T3) — the translator lands
|
||||
// ahead of its caller.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aura_core::{Scalar, ScalarKind};
|
||||
use aura_research::{
|
||||
campaign_to_json, content_id_of, process_to_json, Axis, CampaignDoc, DataSection, DocKind,
|
||||
DocRef, Presentation, ProcessDoc, ProcessRef, StageBlock, StrategyEntry, Window,
|
||||
FORMAT_VERSION,
|
||||
DocRef, Presentation, ProcessDoc, ProcessRef, RiskRegime, SelectRule, StageBlock,
|
||||
StrategyEntry, SweepSelection, Window, FORMAT_VERSION,
|
||||
};
|
||||
|
||||
/// The two generated documents of one dissolved-verb invocation.
|
||||
@@ -116,6 +122,98 @@ pub(crate) fn register_generated(
|
||||
Ok((process_id, campaign_id))
|
||||
}
|
||||
|
||||
/// The two generated documents of one dissolved `generalize` invocation.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct GeneratedGeneralize {
|
||||
pub process: ProcessDoc,
|
||||
pub campaign: CampaignDoc,
|
||||
}
|
||||
|
||||
/// Translate one `aura generalize` invocation into its two generated documents:
|
||||
/// 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)]
|
||||
pub(crate) fn translate_generalize(
|
||||
fast: i64,
|
||||
slow: i64,
|
||||
stop_length: i64,
|
||||
stop_k: f64,
|
||||
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,
|
||||
kind: DocKind::Process,
|
||||
name: "generalize".to_string(),
|
||||
description: None,
|
||||
pipeline: vec![
|
||||
StageBlock::Sweep {
|
||||
selection: Some(SweepSelection {
|
||||
metric: metric.to_string(),
|
||||
select: SelectRule::Argmax,
|
||||
deflate: false,
|
||||
}),
|
||||
},
|
||||
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(),
|
||||
description: None,
|
||||
data: DataSection {
|
||||
instruments: symbols.to_vec(),
|
||||
windows: vec![Window { from_ms, to_ms }],
|
||||
},
|
||||
strategies: vec![StrategyEntry {
|
||||
r#ref: DocRef::ContentId(content_id_of(blueprint_canonical)),
|
||||
axes: doc_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 }],
|
||||
seed: 0,
|
||||
presentation: Presentation {
|
||||
persist_taps: vec![],
|
||||
emit: vec!["family_table".to_string()],
|
||||
},
|
||||
};
|
||||
Ok(GeneratedGeneralize { process, campaign })
|
||||
}
|
||||
|
||||
/// Auto-register both generated generalize documents (content-addressed,
|
||||
/// idempotent). Returns `(process_id, campaign_id)`.
|
||||
pub(crate) fn register_generated_g(
|
||||
reg: &aura_registry::Registry,
|
||||
generated: &GeneratedGeneralize,
|
||||
) -> Result<(String, String), String> {
|
||||
let process_id =
|
||||
reg.put_process(&process_to_json(&generated.process)).map_err(|e| e.to_string())?;
|
||||
let campaign_id =
|
||||
reg.put_campaign(&campaign_to_json(&generated.campaign)).map_err(|e| e.to_string())?;
|
||||
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(
|
||||
@@ -223,4 +321,130 @@ mod tests {
|
||||
assert!(aura_research::validate_process(&g.process).is_empty());
|
||||
assert!(aura_campaign::preflight(&g.process, &g.campaign).is_ok());
|
||||
}
|
||||
|
||||
#[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();
|
||||
assert_eq!(
|
||||
content_id_of(&campaign_to_json(&a.campaign)),
|
||||
content_id_of(&campaign_to_json(&b.campaign)),
|
||||
"identical invocations must generate identical campaign ids"
|
||||
);
|
||||
// A selection-bearing pipeline: sweep(argmax) then generalize.
|
||||
assert_eq!(
|
||||
a.process.pipeline,
|
||||
vec![
|
||||
StageBlock::Sweep {
|
||||
selection: Some(SweepSelection {
|
||||
metric: "expectancy_r".to_string(),
|
||||
select: SelectRule::Argmax,
|
||||
deflate: false,
|
||||
})
|
||||
},
|
||||
StageBlock::Generalize { metric: "expectancy_r".to_string() },
|
||||
]
|
||||
);
|
||||
// The stop rides a single risk regime (the structural axis).
|
||||
assert_eq!(a.campaign.risk, vec![RiskRegime::Vol { length: 14, k: 2.0 }]);
|
||||
// All instruments under one campaign; the candidate as single-value axes.
|
||||
assert_eq!(
|
||||
a.campaign.data.instruments,
|
||||
vec!["GER40".to_string(), "USDJPY".to_string()]
|
||||
);
|
||||
assert_eq!(a.campaign.strategies.len(), 1);
|
||||
assert!(a.campaign.strategies[0].axes.contains_key("fast.length"));
|
||||
assert!(a.campaign.strategies[0].axes.contains_key("slow.length"));
|
||||
assert_eq!(a.process.name, "generalize");
|
||||
assert_eq!(a.campaign.name, "generalize");
|
||||
// Intrinsically valid + preflights (like the sweep sibling).
|
||||
assert!(aura_research::validate_campaign(&a.campaign).is_empty());
|
||||
assert!(aura_research::validate_process(&a.process).is_empty());
|
||||
assert!(aura_campaign::preflight(&a.process, &a.campaign).is_ok());
|
||||
}
|
||||
|
||||
/// Content-addressing correctness for the risk-regime axis (#210 T1-T2):
|
||||
/// two invocations that differ ONLY in the protective stop (`stop_length`/
|
||||
/// `stop_k`) must generate campaigns with DIFFERENT content ids. The stop
|
||||
/// is now carried through `CampaignDoc.risk` rather than as an opaque
|
||||
/// runtime constant, so a hashing/serialization bug that dropped or
|
||||
/// normalized-away the regime would silently collide two distinct
|
||||
/// invocations onto one cached campaign — the wrong grade would be served
|
||||
/// for one of them without any error. This pins that a regime change is
|
||||
/// visible in the generated bytes, both against a same-length,
|
||||
/// 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 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));
|
||||
assert_ne!(base_id, k_id, "a different stop_k must not collide onto the same campaign id");
|
||||
assert_ne!(
|
||||
base_id, length_id,
|
||||
"a different stop_length must not collide onto the same campaign id"
|
||||
);
|
||||
assert_ne!(k_id, length_id, "the two diverging regimes must not collide with each other");
|
||||
}
|
||||
|
||||
/// The translator's output is not merely intrinsically valid in isolation
|
||||
/// (preflight/validate never touch a store) — `register_generated_g` must
|
||||
/// actually persist documents the campaign can resolve through a real
|
||||
/// `Registry`: the campaign's `process.ref` content id must fetch back the
|
||||
/// EXACT process the translator produced, and the returned `campaign_id`
|
||||
/// must fetch back the exact campaign. A drift between what gets hashed
|
||||
/// into the ref and what gets stored (e.g. a canonicalization mismatch)
|
||||
/// would pass every static check here yet leave the campaign permanently
|
||||
/// unresolvable at real run time — this is the property that would catch
|
||||
/// that silently, before the dispatch rewire ever exercises the path live.
|
||||
#[test]
|
||||
fn register_generated_g_stores_documents_the_campaign_can_actually_resolve() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"aura-verb-sugar-generalize-registry-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
|
||||
|
||||
let generated = translate_generalize(
|
||||
3, 12, 14, 2.0, "expectancy_r", "generalize",
|
||||
&["GER40".to_string(), "USDJPY".to_string()], 100, 200, "{\"bp\":1}",
|
||||
)
|
||||
.unwrap();
|
||||
let (process_id, campaign_id) = register_generated_g(®, &generated).unwrap();
|
||||
|
||||
let stored_campaign = reg.get_campaign(&campaign_id).unwrap().expect("campaign stored");
|
||||
assert_eq!(stored_campaign, campaign_to_json(&generated.campaign));
|
||||
|
||||
let DocRef::ContentId(ref_id) = &generated.campaign.process.r#ref else {
|
||||
panic!("generalize's process ref is a content id");
|
||||
};
|
||||
assert_eq!(ref_id, &process_id, "the stored process id must be the campaign's own ref");
|
||||
let stored_process = reg.get_process(ref_id).unwrap().expect("process stored");
|
||||
assert_eq!(stored_process, process_to_json(&generated.process));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user