1use 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#[derive(Debug, Clone)]
29pub struct CampaignConfig {
30 pub space: ConfigurationSpace,
32 pub model_path: String,
34 pub quantization: String,
36 pub prompt: String,
38 pub decode_tokens: u32,
40 pub warm_repeats: u32,
42 pub budget: usize,
44 pub batch_size: usize,
46 pub profile: ApplicationProfileWeight,
48 pub improvement_threshold: f64,
50 pub jsonl_path: Option<PathBuf>,
52 pub belief_path: Option<PathBuf>,
54 pub with_quality: bool,
57 pub max_duration: Option<Duration>,
59}
60
61impl CampaignConfig {
62 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#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct CampaignReport {
121 pub results: Vec<ExperimentResult>,
123 pub frontier: ParetoFrontier,
125 pub beliefs: BeliefGraph,
127 pub best_index: Option<usize>,
129 pub trials_run: usize,
131 pub elapsed_s: f64,
133 pub baseline_tok_s: Option<f64>,
135 pub best_tok_s: Option<f64>,
137 pub summary: String,
139}
140
141impl CampaignReport {
142 pub fn best_result(&self) -> Option<&ExperimentResult> {
144 self.best_index.and_then(|i| self.results.get(i))
145 }
146
147 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 pub fn to_json(&self) -> String {
157 serde_json::to_string_pretty(self).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
158 }
159}
160
161pub 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 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 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 let mut trials_run = 0;
215 while trials_run < cfg.budget {
216 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 let config = match engine.ask() {
229 Some(c) => c,
230 None => break,
231 };
232
233 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 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 engine.tell(&config, &result);
255
256 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 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 let frontier = ParetoFrontier::compute(&results);
285
286 update_beliefs(
288 &mut beliefs,
289 &results,
290 baseline_tok_s,
291 cfg.improvement_threshold,
292 );
293
294 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 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 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
341fn update_beliefs(
344 beliefs: &mut BeliefGraph,
345 results: &[ExperimentResult],
346 baseline_tok_s: Option<f64>,
347 threshold: f64,
348) {
349 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 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 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; beliefs.record_experiment(exp_id, "H-default", r.config_hash, verdict, weight);
387 }
388}
389
390fn 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
401pub 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
419pub 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}