1use std::collections::HashMap;
13
14use serde::{Deserialize, Serialize};
15
16pub type NodeId = String;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Hypothesis {
22 pub id: NodeId,
23 pub statement: String,
25 pub space_name: String,
27 pub expects_improvement: bool,
29 pub confidence: f64,
31 pub depends_on: Vec<NodeId>,
33 pub experiments: Vec<NodeId>,
35 pub active: bool,
37 pub created_ms: u64,
39}
40
41impl Hypothesis {
42 pub fn new(
43 id: impl Into<String>,
44 statement: impl Into<String>,
45 space_name: impl Into<String>,
46 ) -> Self {
47 let now = std::time::SystemTime::now()
48 .duration_since(std::time::UNIX_EPOCH)
49 .map(|d| d.as_millis() as u64)
50 .unwrap_or(0);
51 Self {
52 id: id.into(),
53 statement: statement.into(),
54 space_name: space_name.into(),
55 expects_improvement: true,
56 confidence: 0.0,
57 depends_on: Vec::new(),
58 experiments: Vec::new(),
59 active: true,
60 created_ms: now,
61 }
62 }
63
64 pub fn with_dependency(mut self, dep: impl Into<String>) -> Self {
65 self.depends_on.push(dep.into());
66 self
67 }
68
69 pub fn expects_regression(mut self) -> Self {
70 self.expects_improvement = false;
71 self
72 }
73}
74
75#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
77pub enum ExperimentVerdict {
78 Confirmed,
80 Refuted,
82 Inconclusive,
84 Failed,
86}
87
88impl ExperimentVerdict {
89 pub fn confidence_delta(&self, weight: f64) -> f64 {
91 match self {
92 Self::Confirmed => weight,
93 Self::Refuted => -weight,
94 Self::Inconclusive => 0.0,
95 Self::Failed => 0.0,
96 }
97 }
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct ExperimentNode {
103 pub id: NodeId,
104 pub hypothesis_id: NodeId,
106 pub config_hash: u64,
108 pub verdict: ExperimentVerdict,
110 pub weight: f64,
112 pub timestamp_ms: u64,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct Observation {
119 pub id: NodeId,
120 pub experiment_id: NodeId,
121 pub metric: String,
122 pub value: f64,
123 pub unit: String,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct Claim {
129 pub id: NodeId,
130 pub statement: String,
131 pub supported_by: Vec<NodeId>,
133 pub confidence: f64,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, Default)]
138pub struct BeliefGraph {
139 pub hypotheses: HashMap<NodeId, Hypothesis>,
140 pub experiments: HashMap<NodeId, ExperimentNode>,
141 pub observations: HashMap<NodeId, Observation>,
142 pub claims: HashMap<NodeId, Claim>,
143}
144
145impl BeliefGraph {
146 pub fn new() -> Self {
147 Self::default()
148 }
149
150 pub fn add_hypothesis(&mut self, h: Hypothesis) {
152 self.hypotheses.insert(h.id.clone(), h);
153 }
154
155 pub fn record_experiment(
157 &mut self,
158 experiment_id: impl Into<String>,
159 hypothesis_id: &str,
160 config_hash: u64,
161 verdict: ExperimentVerdict,
162 weight: f64,
163 ) {
164 let now = std::time::SystemTime::now()
165 .duration_since(std::time::UNIX_EPOCH)
166 .map(|d| d.as_millis() as u64)
167 .unwrap_or(0);
168
169 let exp = ExperimentNode {
170 id: experiment_id.into(),
171 hypothesis_id: hypothesis_id.to_string(),
172 config_hash,
173 verdict,
174 weight,
175 timestamp_ms: now,
176 };
177
178 if let Some(h) = self.hypotheses.get_mut(hypothesis_id) {
180 let delta = verdict.confidence_delta(weight);
181 let alpha = 0.3;
183 h.confidence = h.confidence * (1.0 - alpha) + delta * alpha;
184 h.confidence = h.confidence.clamp(-1.0, 1.0);
185 h.experiments.push(exp.id.clone());
186
187 if h.confidence < -0.5 {
189 h.active = false;
190 }
191 }
192
193 self.experiments.insert(exp.id.clone(), exp);
194
195 self.cascade_confidence(hypothesis_id);
197 }
198
199 fn cascade_confidence(&mut self, source_id: &str) {
201 let dependents: Vec<NodeId> = self
203 .hypotheses
204 .values()
205 .filter(|h| h.depends_on.iter().any(|d| d == source_id))
206 .map(|h| h.id.clone())
207 .collect();
208
209 for dep_id in dependents {
210 let source_confidence = self
212 .hypotheses
213 .get(source_id)
214 .map(|s| s.confidence)
215 .unwrap_or(0.0);
216 if let Some(dep) = self.hypotheses.get_mut(&dep_id) {
217 let influence = source_confidence * 0.2;
220 dep.confidence = (dep.confidence + influence).clamp(-1.0, 1.0);
221 }
222 }
223 }
224
225 pub fn add_observation(
227 &mut self,
228 id: impl Into<String>,
229 experiment_id: impl Into<String>,
230 metric: impl Into<String>,
231 value: f64,
232 unit: impl Into<String>,
233 ) {
234 let obs = Observation {
235 id: id.into(),
236 experiment_id: experiment_id.into(),
237 metric: metric.into(),
238 value,
239 unit: unit.into(),
240 };
241 self.observations.insert(obs.id.clone(), obs);
242 }
243
244 pub fn add_claim(
246 &mut self,
247 id: impl Into<String>,
248 statement: impl Into<String>,
249 supported_by: Vec<NodeId>,
250 ) {
251 let confidence = self.compute_claim_confidence(&supported_by);
252 let claim = Claim {
253 id: id.into(),
254 statement: statement.into(),
255 supported_by,
256 confidence,
257 };
258 self.claims.insert(claim.id.clone(), claim);
259 }
260
261 fn compute_claim_confidence(&self, obs_ids: &[NodeId]) -> f64 {
263 if obs_ids.is_empty() {
264 return 0.0;
265 }
266 let mut total = 0.0;
268 let mut count = 0;
269 for obs_id in obs_ids {
270 if let Some(obs) = self.observations.get(obs_id) {
271 if let Some(exp) = self.experiments.get(&obs.experiment_id) {
272 if let Some(h) = self.hypotheses.get(&exp.hypothesis_id) {
273 total += h.confidence;
274 count += 1;
275 }
276 }
277 }
278 }
279 if count == 0 {
280 0.0
281 } else {
282 (total / count as f64).clamp(-1.0, 1.0)
283 }
284 }
285
286 pub fn active_hypotheses(&self) -> Vec<&Hypothesis> {
288 let mut active: Vec<&Hypothesis> = self.hypotheses.values().filter(|h| h.active).collect();
289 active.sort_by(|a, b| {
290 b.confidence
291 .partial_cmp(&a.confidence)
292 .unwrap_or(std::cmp::Ordering::Equal)
293 });
294 active
295 }
296
297 pub fn next_to_test(&self) -> Option<&Hypothesis> {
299 self.active_hypotheses().into_iter().min_by(|a, b| {
300 a.confidence
301 .abs()
302 .partial_cmp(&b.confidence.abs())
303 .unwrap_or(std::cmp::Ordering::Equal)
304 })
305 }
306
307 pub fn to_json(&self) -> String {
309 serde_json::to_string_pretty(self).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
310 }
311
312 pub fn save(&self, path: &std::path::Path) -> Result<(), String> {
314 if let Some(parent) = path.parent() {
315 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
316 }
317 let json = self.to_json();
318 std::fs::write(path, json).map_err(|e| e.to_string())
319 }
320
321 pub fn load(path: &std::path::Path) -> Result<Self, String> {
323 let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
324 serde_json::from_str(&content).map_err(|e| e.to_string())
325 }
326}
327
328pub fn evaluate_verdict(
331 hypothesis: &Hypothesis,
332 treatment_tok_s: f64,
333 baseline_tok_s: f64,
334 improvement_threshold: f64,
335) -> ExperimentVerdict {
336 if baseline_tok_s <= 0.0 || treatment_tok_s <= 0.0 {
337 return ExperimentVerdict::Failed;
338 }
339 let relative = (treatment_tok_s - baseline_tok_s) / baseline_tok_s;
340 let improved = relative > improvement_threshold;
341 let regressed = relative < -improvement_threshold;
342
343 match (hypothesis.expects_improvement, improved, regressed) {
344 (true, true, false) => ExperimentVerdict::Confirmed,
345 (true, false, true) => ExperimentVerdict::Refuted,
346 (false, false, true) => ExperimentVerdict::Confirmed,
347 (false, true, false) => ExperimentVerdict::Refuted,
348 _ => ExperimentVerdict::Inconclusive,
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn hypothesis_confidence_updates() {
358 let mut graph = BeliefGraph::new();
359 let h = Hypothesis::new("H-001", "coop_gemv improves decode by >20%", "toggle_space");
360 graph.add_hypothesis(h);
361
362 graph.record_experiment("E-001", "H-001", 42, ExperimentVerdict::Confirmed, 0.8);
363 let h = graph.hypotheses.get("H-001").unwrap();
364 assert!(h.confidence > 0.0);
365 assert_eq!(h.experiments.len(), 1);
366 }
367
368 #[test]
369 fn confidence_cascade() {
370 let mut graph = BeliefGraph::new();
371 let h1 = Hypothesis::new("H-001", "coop_gemv improves decode", "space");
372 graph.add_hypothesis(h1);
373 let h2 =
374 Hypothesis::new("H-002", "fused_ffn improves decode", "space").with_dependency("H-001");
375 graph.add_hypothesis(h2);
376
377 graph.record_experiment("E-001", "H-001", 42, ExperimentVerdict::Confirmed, 1.0);
379 graph.record_experiment("E-002", "H-001", 43, ExperimentVerdict::Confirmed, 1.0);
380
381 let h2 = graph.hypotheses.get("H-002").unwrap();
383 assert!(h2.confidence > 0.0);
384 }
385
386 #[test]
387 fn refuted_hypothesis_deactivates() {
388 let mut graph = BeliefGraph::new();
389 graph.add_hypothesis(Hypothesis::new("H-001", "X improves Y", "space"));
390
391 graph.record_experiment("E-001", "H-001", 1, ExperimentVerdict::Refuted, 1.0);
393 graph.record_experiment("E-002", "H-001", 2, ExperimentVerdict::Refuted, 1.0);
394 graph.record_experiment("E-003", "H-001", 3, ExperimentVerdict::Refuted, 1.0);
395
396 let h = graph.hypotheses.get("H-001").unwrap();
397 assert!(!h.active);
398 }
399
400 #[test]
401 fn evaluate_verdict_improvement() {
402 let h = Hypothesis::new("H-001", "coop improves tok/s", "space");
403 let v = evaluate_verdict(&h, 60.0, 40.0, 0.20);
404 assert_eq!(v, ExperimentVerdict::Confirmed);
405
406 let v = evaluate_verdict(&h, 41.0, 40.0, 0.20);
407 assert_eq!(v, ExperimentVerdict::Inconclusive);
408
409 let v = evaluate_verdict(&h, 30.0, 40.0, 0.20);
410 assert_eq!(v, ExperimentVerdict::Refuted);
411 }
412
413 #[test]
414 fn evaluate_verdict_regression() {
415 let h =
416 Hypothesis::new("H-001", "naive GEMV regresses tok/s", "space").expects_regression();
417 let v = evaluate_verdict(&h, 30.0, 40.0, 0.20);
418 assert_eq!(v, ExperimentVerdict::Confirmed);
419
420 let v = evaluate_verdict(&h, 60.0, 40.0, 0.20);
421 assert_eq!(v, ExperimentVerdict::Refuted);
422 }
423
424 #[test]
425 fn next_to_test_picks_uncertain() {
426 let mut graph = BeliefGraph::new();
427 graph.add_hypothesis(Hypothesis::new("H-001", "certain claim", "space"));
428 graph.add_hypothesis(Hypothesis::new("H-002", "uncertain claim", "space"));
429
430 graph.record_experiment("E-001", "H-001", 1, ExperimentVerdict::Confirmed, 1.0);
432 graph.record_experiment("E-002", "H-001", 2, ExperimentVerdict::Confirmed, 1.0);
433
434 let next = graph.next_to_test();
436 assert!(next.is_some());
437 assert_eq!(next.unwrap().id, "H-002");
438 }
439
440 #[test]
441 fn belief_graph_save_load() {
442 let mut graph = BeliefGraph::new();
443 graph.add_hypothesis(Hypothesis::new("H-001", "test", "space"));
444 graph.record_experiment("E-001", "H-001", 42, ExperimentVerdict::Confirmed, 0.5);
445
446 let tmp =
447 std::env::temp_dir().join(format!("qualia_belief_test_{}.json", std::process::id()));
448 graph.save(&tmp).unwrap();
449 let loaded = BeliefGraph::load(&tmp).unwrap();
450 assert!(loaded.hypotheses.contains_key("H-001"));
451 assert!(loaded.experiments.contains_key("E-001"));
452 let _ = std::fs::remove_file(&tmp);
453 }
454}