Skip to main content

qualia_core_db/inference/lab/
campaign.rs

1//! End-to-end optimization campaign orchestration.
2//!
3//! Ties together the five lab layers into a single pipeline:
4//! 1. `SearchEngine` proposes configurations (Sobol + Bayesian EI)
5//! 2. `run_experiment_with_quality()` executes each trial
6//! 3. `ParetoFrontier` computes the 6-dimensional non-dominated set
7//! 4. `BeliefGraph` records verdicts and cascades confidence
8//! 5. Results are persisted to JSONL for reproducibility
9//!
10//! The campaign runs in batches: after each batch, the frontier is recomputed
11//! and the belief graph is updated. The search engine adapts based on all
12//! observations so far.
13
14use std::path::{Path, PathBuf};
15use std::time::{Duration, Instant};
16
17use serde::{Deserialize, Serialize};
18
19use super::config_space::{Configuration, ConfigurationSpace, ParameterDef};
20use super::experiment::{
21    append_experiment_jsonl, load_experiment_log, ExperimentConfig, ExperimentResult,
22};
23use super::hypothesis::{evaluate_verdict, BeliefGraph, Hypothesis};
24use super::pareto::{ApplicationProfileWeight, ParetoFrontier};
25use super::search::SearchEngine;
26
27/// Configuration for an optimization campaign.
28#[derive(Debug, Clone)]
29pub struct CampaignConfig {
30    /// The configuration space to search over.
31    pub space: ConfigurationSpace,
32    /// Model path for benchmarking.
33    pub model_path: String,
34    /// Quantization label.
35    pub quantization: String,
36    /// Prompt for quality evaluation.
37    pub prompt: String,
38    /// Decode token budget (0 = production default).
39    pub decode_tokens: u32,
40    /// Warm repeats for benchmark.
41    pub warm_repeats: u32,
42    /// Total evaluation budget (number of trials).
43    pub budget: usize,
44    /// Batch size: after each batch, recompute frontier + update beliefs.
45    pub batch_size: usize,
46    /// Application profile for best-config selection.
47    pub profile: ApplicationProfileWeight,
48    /// Improvement threshold for verdict evaluation (e.g. 0.20 = 20%).
49    pub improvement_threshold: f64,
50    /// Optional path to persist JSONL experiment log.
51    pub jsonl_path: Option<PathBuf>,
52    /// Optional path to persist belief graph JSON.
53    pub belief_path: Option<PathBuf>,
54    /// Use quality verification (decode_with_metrics + verify_and_heal_turn).
55    /// If false, uses run_bench only (faster but no quality score).
56    pub with_quality: bool,
57    /// Max wall-clock duration for the campaign.
58    pub max_duration: Option<Duration>,
59}
60
61impl CampaignConfig {
62    /// Create a campaign config with sensible defaults.
63    pub fn new(space: ConfigurationSpace, model_path: impl Into<String>, budget: usize) -> Self {
64        Self {
65            space,
66            model_path: model_path.into(),
67            quantization: "q4_k".into(),
68            prompt: "What is the capital of France?".into(),
69            decode_tokens: 32,
70            warm_repeats: 3,
71            budget,
72            batch_size: 5,
73            profile: ApplicationProfileWeight::Interactive,
74            improvement_threshold: 0.20,
75            jsonl_path: None,
76            belief_path: None,
77            with_quality: true,
78            max_duration: None,
79        }
80    }
81
82    pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
83        self.prompt = prompt.into();
84        self
85    }
86
87    pub fn with_profile(mut self, profile: ApplicationProfileWeight) -> Self {
88        self.profile = profile;
89        self
90    }
91
92    pub fn with_quality(mut self, quality: bool) -> Self {
93        self.with_quality = quality;
94        self
95    }
96
97    pub fn with_jsonl_log(mut self, path: impl Into<PathBuf>) -> Self {
98        self.jsonl_path = Some(path.into());
99        self
100    }
101
102    pub fn with_belief_log(mut self, path: impl Into<PathBuf>) -> Self {
103        self.belief_path = Some(path.into());
104        self
105    }
106
107    pub fn with_batch_size(mut self, batch: usize) -> Self {
108        self.batch_size = batch.max(1);
109        self
110    }
111
112    pub fn with_max_duration(mut self, dur: Duration) -> Self {
113        self.max_duration = Some(dur);
114        self
115    }
116}
117
118/// The result of an optimization campaign.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct CampaignReport {
121    /// All experiment results from the campaign.
122    pub results: Vec<ExperimentResult>,
123    /// The Pareto frontier (indices into `results`).
124    pub frontier: ParetoFrontier,
125    /// The belief graph after the campaign.
126    pub beliefs: BeliefGraph,
127    /// Index of the best result (selected by profile).
128    pub best_index: Option<usize>,
129    /// Number of trials actually run (may be less than budget if time-limited).
130    pub trials_run: usize,
131    /// Wall-clock duration of the campaign in seconds.
132    pub elapsed_s: f64,
133    /// Baseline throughput (tok/s) for comparison.
134    pub baseline_tok_s: Option<f64>,
135    /// Best throughput achieved (tok/s).
136    pub best_tok_s: Option<f64>,
137    /// Summary message for logging.
138    pub summary: String,
139}
140
141impl CampaignReport {
142    /// The best experiment result (selected by application profile).
143    pub fn best_result(&self) -> Option<&ExperimentResult> {
144        self.best_index.and_then(|i| self.results.get(i))
145    }
146
147    /// Improvement ratio: best / baseline.
148    pub fn improvement_ratio(&self) -> Option<f64> {
149        match (self.best_tok_s, self.baseline_tok_s) {
150            (Some(best), Some(baseline)) if baseline > 0.0 => Some(best / baseline),
151            _ => None,
152        }
153    }
154
155    /// Serialize the report to JSON.
156    pub fn to_json(&self) -> String {
157        serde_json::to_string_pretty(self).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
158    }
159}
160
161/// Run an optimization campaign end-to-end.
162///
163/// This is the main entry point for the AI Inference Optimization Lab.
164/// It runs `budget` trials, computing the Pareto frontier and updating the
165/// belief graph after each batch.
166pub fn run_optimization_campaign(cfg: &CampaignConfig) -> CampaignReport {
167    let t_start = Instant::now();
168    let mut engine = SearchEngine::new(cfg.space.clone(), cfg.budget);
169    let mut beliefs = BeliefGraph::new();
170    let mut results: Vec<ExperimentResult> = Vec::new();
171
172    // Load existing results from JSONL if present (resume capability).
173    if let Some(ref jsonl) = cfg.jsonl_path {
174        if let Ok(existing) = load_experiment_log(jsonl) {
175            for r in &existing {
176                if let Ok(config) = Configuration::from_cbor(&r.config_cbor) {
177                    let normalized = cfg.space.normalize_config(&config);
178                    let objective = compute_objective(r);
179                    engine.surrogate_add(normalized, objective, r.config_hash);
180                }
181            }
182            results = existing;
183            log::info!("campaign|resumed|{} existing results", results.len());
184        }
185    }
186
187    // Run a baseline measurement if we have no results yet.
188    let baseline_tok_s = if results.is_empty() {
189        let baseline_cfg = cfg.space.default_config();
190        let exp_cfg = ExperimentConfig {
191            space: cfg.space.clone(),
192            config: baseline_cfg,
193            model_path: cfg.model_path.clone(),
194            quantization: cfg.quantization.clone(),
195            prompt: cfg.prompt.clone(),
196            decode_tokens: cfg.decode_tokens,
197            warm_repeats: cfg.warm_repeats,
198            seed: 0,
199            hypothesis_id: None,
200        };
201        let baseline_result = if cfg.with_quality {
202            super::experiment::run_experiment_with_quality(&exp_cfg)
203        } else {
204            super::experiment::run_experiment(&exp_cfg)
205        };
206        let bt = baseline_result.bench.as_ref().map(|b| b.decode_tok_s);
207        results.push(baseline_result);
208        bt
209    } else {
210        results[0].bench.as_ref().map(|b| b.decode_tok_s)
211    };
212
213    // Main search loop.
214    let mut trials_run = 0;
215    while trials_run < cfg.budget {
216        // Check time budget.
217        if let Some(max_dur) = cfg.max_duration {
218            if t_start.elapsed() >= max_dur {
219                log::info!(
220                    "campaign|time_budget_exhausted|{}s",
221                    t_start.elapsed().as_secs_f64()
222                );
223                break;
224            }
225        }
226
227        // Ask the search engine for the next configuration.
228        let config = match engine.ask() {
229            Some(c) => c,
230            None => break,
231        };
232
233        // Build the experiment config.
234        let exp_cfg = ExperimentConfig {
235            space: cfg.space.clone(),
236            config: config.clone(),
237            model_path: cfg.model_path.clone(),
238            quantization: cfg.quantization.clone(),
239            prompt: cfg.prompt.clone(),
240            decode_tokens: cfg.decode_tokens,
241            warm_repeats: cfg.warm_repeats,
242            seed: trials_run as u64,
243            hypothesis_id: None,
244        };
245
246        // Run the experiment.
247        let result = if cfg.with_quality {
248            super::experiment::run_experiment_with_quality(&exp_cfg)
249        } else {
250            super::experiment::run_experiment(&exp_cfg)
251        };
252
253        // Tell the search engine the outcome.
254        engine.tell(&config, &result);
255
256        // Persist to JSONL.
257        if let Some(ref jsonl) = cfg.jsonl_path {
258            if let Err(e) = append_experiment_jsonl(jsonl, &result) {
259                log::warn!("campaign|jsonl_append_failed|{e}");
260            }
261        }
262
263        results.push(result);
264        trials_run += 1;
265
266        // After each batch: recompute frontier + update beliefs.
267        if trials_run % cfg.batch_size == 0 {
268            update_beliefs(
269                &mut beliefs,
270                &results,
271                baseline_tok_s,
272                cfg.improvement_threshold,
273            );
274            log::info!(
275                "campaign|batch_complete|trials={}|frontier={}|beliefs={}",
276                trials_run,
277                ParetoFrontier::compute(&results).frontier_size(),
278                beliefs.hypotheses.len(),
279            );
280        }
281    }
282
283    // Final frontier computation.
284    let frontier = ParetoFrontier::compute(&results);
285
286    // Final belief update.
287    update_beliefs(
288        &mut beliefs,
289        &results,
290        baseline_tok_s,
291        cfg.improvement_threshold,
292    );
293
294    // Select best result by application profile.
295    let best_index = cfg
296        .profile
297        .select_best(&results, &frontier)
298        .and_then(|r| results.iter().position(|x| x.config_hash == r.config_hash));
299
300    let best_tok_s = best_index
301        .and_then(|i| results[i].bench.as_ref())
302        .map(|b| b.decode_tok_s);
303
304    // Persist belief graph.
305    if let Some(ref belief_path) = cfg.belief_path {
306        if let Err(e) = beliefs.save(belief_path) {
307            log::warn!("campaign|belief_save_failed|{e}");
308        }
309    }
310
311    let elapsed_s = t_start.elapsed().as_secs_f64();
312
313    // Build summary.
314    let improvement = match (best_tok_s, baseline_tok_s) {
315        (Some(best), Some(base)) if base > 0.0 => {
316            format!("{:.1}% improvement", (best / base - 1.0) * 100.0)
317        }
318        _ => "no baseline".to_string(),
319    };
320    let summary = format!(
321        "trials={}, frontier={}, beliefs={}, {}",
322        trials_run,
323        frontier.frontier_size(),
324        beliefs.hypotheses.len(),
325        improvement,
326    );
327
328    CampaignReport {
329        results,
330        frontier,
331        beliefs,
332        best_index,
333        trials_run,
334        elapsed_s,
335        baseline_tok_s,
336        best_tok_s,
337        summary,
338    }
339}
340
341/// Update the belief graph from experiment results.
342/// For each result, evaluate the verdict against the baseline and record it.
343fn update_beliefs(
344    beliefs: &mut BeliefGraph,
345    results: &[ExperimentResult],
346    baseline_tok_s: Option<f64>,
347    threshold: f64,
348) {
349    // Create a default hypothesis if none exist.
350    if beliefs.hypotheses.is_empty() {
351        let h = Hypothesis::new(
352            "H-default",
353            "Search finds configurations that improve decode throughput",
354            "campaign",
355        );
356        beliefs.add_hypothesis(h);
357    }
358
359    let baseline = match baseline_tok_s {
360        Some(b) if b > 0.0 => b,
361        _ => return,
362    };
363
364    // Evaluate each result (skip the baseline, which is results[0]).
365    for (i, r) in results.iter().enumerate().skip(1) {
366        if r.error.is_some() {
367            continue;
368        }
369        let tok_s = match r.bench.as_ref() {
370            Some(b) => b.decode_tok_s,
371            None => continue,
372        };
373        if tok_s <= 0.0 {
374            continue;
375        }
376
377        // Check if we already recorded this experiment.
378        let exp_id = format!("E-campaign-{i}");
379        if beliefs.experiments.contains_key(&exp_id) {
380            continue;
381        }
382
383        let h = beliefs.hypotheses.get("H-default").unwrap();
384        let verdict = evaluate_verdict(h, tok_s, baseline, threshold);
385        let weight = 0.5 + (tok_s / baseline).min(2.0) * 0.25; // weight scales with improvement
386        beliefs.record_experiment(exp_id, "H-default", r.config_hash, verdict, weight);
387    }
388}
389
390/// Compute a scalar objective from an experiment result.
391/// Used by the search engine's surrogate model.
392fn compute_objective(r: &ExperimentResult) -> f64 {
393    if r.error.is_some() {
394        return f64::NEG_INFINITY;
395    }
396    let tok_s = r.bench.as_ref().map(|b| b.decode_tok_s).unwrap_or(0.0);
397    let quality = r.quality.composite();
398    tok_s * quality
399}
400
401/// Build a default configuration space for inference toggle optimization.
402/// This covers the main toggle parameters that `ExperimentConfig::apply_toggles` supports.
403pub fn default_toggle_space() -> ConfigurationSpace {
404    ConfigurationSpace::new("inference_toggles")
405        .with("coop_gemv", ParameterDef::Bool)
406        .with("ffn_fusion", ParameterDef::Bool)
407        .with("kv_int8", ParameterDef::Bool)
408        .with("kv_dict", ParameterDef::Bool)
409        .with("resident_decode", ParameterDef::Bool)
410        .with("resident_prefill", ParameterDef::Bool)
411        .with("resident_weights", ParameterDef::Bool)
412        .with("spec_decode", ParameterDef::Bool)
413        .with("ternary_ffn", ParameterDef::Bool)
414        .with("attention_preproject", ParameterDef::Bool)
415        .with("attention_o_fuse", ParameterDef::Bool)
416        .with("gpu_topk", ParameterDef::Bool)
417}
418
419/// Save a campaign report to a JSON file.
420pub fn save_campaign_report(report: &CampaignReport, path: &Path) -> Result<(), String> {
421    if let Some(parent) = path.parent() {
422        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
423    }
424    let json = report.to_json();
425    std::fs::write(path, json).map_err(|e| e.to_string())
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn default_toggle_space_has_dims() {
434        let space = default_toggle_space();
435        assert!(space.dims() >= 10);
436    }
437
438    #[test]
439    fn campaign_config_builder() {
440        let space = default_toggle_space();
441        let cfg = CampaignConfig::new(space, "model.gguf", 20)
442            .with_prompt("Hello")
443            .with_profile(ApplicationProfileWeight::LiveFast)
444            .with_batch_size(4);
445
446        assert_eq!(cfg.budget, 20);
447        assert_eq!(cfg.batch_size, 4);
448        assert_eq!(cfg.profile, ApplicationProfileWeight::LiveFast);
449        assert_eq!(cfg.prompt, "Hello");
450    }
451
452    #[test]
453    fn campaign_report_json_serializes() {
454        let report = CampaignReport {
455            results: Vec::new(),
456            frontier: ParetoFrontier::default(),
457            beliefs: BeliefGraph::new(),
458            best_index: None,
459            trials_run: 0,
460            elapsed_s: 0.0,
461            baseline_tok_s: Some(50.0),
462            best_tok_s: Some(75.0),
463            summary: "test".into(),
464        };
465        let json = report.to_json();
466        assert!(json.contains("trials_run"));
467        assert!(json.contains("baseline_tok_s"));
468    }
469
470    #[test]
471    fn improvement_ratio_computes() {
472        let report = CampaignReport {
473            results: Vec::new(),
474            frontier: ParetoFrontier::default(),
475            beliefs: BeliefGraph::new(),
476            best_index: None,
477            trials_run: 0,
478            elapsed_s: 0.0,
479            baseline_tok_s: Some(50.0),
480            best_tok_s: Some(75.0),
481            summary: "test".into(),
482        };
483        assert_eq!(report.improvement_ratio(), Some(1.5));
484    }
485
486    #[test]
487    fn save_and_load_report() {
488        let report = CampaignReport {
489            results: Vec::new(),
490            frontier: ParetoFrontier::default(),
491            beliefs: BeliefGraph::new(),
492            best_index: None,
493            trials_run: 5,
494            elapsed_s: 12.5,
495            baseline_tok_s: Some(40.0),
496            best_tok_s: Some(60.0),
497            summary: "test save".into(),
498        };
499        let tmp =
500            std::env::temp_dir().join(format!("qualia_campaign_test_{}.json", std::process::id()));
501        save_campaign_report(&report, &tmp).unwrap();
502        let content = std::fs::read_to_string(&tmp).unwrap();
503        assert!(content.contains("trials_run"));
504        assert!(content.contains("12.5"));
505        let _ = std::fs::remove_file(&tmp);
506    }
507}