From ed8b179e342aa4dddd45eee6c0e50b65682632c4 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 6 Jul 2026 17:04:56 +0200 Subject: [PATCH] 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 --- crates/aura-cli/src/verb_sugar.rs | 228 +++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 2 deletions(-) diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index 4e2bb94..3ac01f3 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -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 { + 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 = 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); + } }