Skip to main content

qualia_core_db/inference/lab/
experiment_log.rs

1//! Append-only experiment CSV (plan ยง5.1).
2
3use std::fs::OpenOptions;
4use std::io::Write;
5use std::path::Path;
6
7pub const CSV_HEADER: &str = "run_id,utc,git_sha,host_passport_key,adapter,backend,model_id,model_hash,layout,mode,profile,toggles_json,qualia_decode_tok_s,ollama_decode_tok_s,a_gap,prefill_tok_s,phase_ns_json,n_ulp_max,c_score,notes";
8
9#[derive(Debug, Clone, Default)]
10pub struct ExperimentRun {
11    pub run_id: String,
12    pub utc: String,
13    pub git_sha: String,
14    pub host_passport_key: String,
15    pub adapter: String,
16    pub backend: String,
17    pub model_id: String,
18    pub model_hash: String,
19    pub layout: String,
20    pub mode: String,
21    pub profile: String,
22    pub toggles_json: String,
23    pub qualia_decode_tok_s: Option<f64>,
24    pub ollama_decode_tok_s: Option<f64>,
25    pub a_gap: Option<f64>,
26    pub prefill_tok_s: Option<f64>,
27    pub phase_ns_json: String,
28    pub n_ulp_max: Option<u64>,
29    pub c_score: Option<f64>,
30    pub notes: String,
31}
32
33impl ExperimentRun {
34    pub fn new_id() -> String {
35        use std::time::{SystemTime, UNIX_EPOCH};
36        let t = SystemTime::now()
37            .duration_since(UNIX_EPOCH)
38            .map(|d| d.as_millis())
39            .unwrap_or(0);
40        format!("run-{t}")
41    }
42
43    pub fn utc_now() -> String {
44        // ISO-ish without chrono dep: unix ms is enough for lab correlation.
45        use std::time::{SystemTime, UNIX_EPOCH};
46        let t = SystemTime::now()
47            .duration_since(UNIX_EPOCH)
48            .map(|d| d.as_secs())
49            .unwrap_or(0);
50        format!("{t}")
51    }
52
53    pub fn compute_a_gap(&mut self) {
54        if let (Some(q), Some(o)) = (self.qualia_decode_tok_s, self.ollama_decode_tok_s) {
55            if q > 0.0 {
56                self.a_gap = Some(o / q);
57            }
58        }
59    }
60
61    fn csv_escape(s: &str) -> String {
62        if s.contains(',') || s.contains('"') || s.contains('\n') {
63            format!("\"{}\"", s.replace('"', "\"\""))
64        } else {
65            s.to_string()
66        }
67    }
68
69    pub fn to_csv_line(&self) -> String {
70        let fopt = |o: Option<f64>| o.map(|v| format!("{v:.6}")).unwrap_or_default();
71        let uopt = |o: Option<u64>| o.map(|v| v.to_string()).unwrap_or_default();
72        [
73            Self::csv_escape(&self.run_id),
74            Self::csv_escape(&self.utc),
75            Self::csv_escape(&self.git_sha),
76            Self::csv_escape(&self.host_passport_key),
77            Self::csv_escape(&self.adapter),
78            Self::csv_escape(&self.backend),
79            Self::csv_escape(&self.model_id),
80            Self::csv_escape(&self.model_hash),
81            Self::csv_escape(&self.layout),
82            Self::csv_escape(&self.mode),
83            Self::csv_escape(&self.profile),
84            Self::csv_escape(&self.toggles_json),
85            fopt(self.qualia_decode_tok_s),
86            fopt(self.ollama_decode_tok_s),
87            fopt(self.a_gap),
88            fopt(self.prefill_tok_s),
89            Self::csv_escape(&self.phase_ns_json),
90            uopt(self.n_ulp_max),
91            fopt(self.c_score),
92            Self::csv_escape(&self.notes),
93        ]
94        .join(",")
95    }
96}
97
98/// Append one run; create file with header if missing.
99pub fn append_run_csv(path: &Path, run: &ExperimentRun) -> Result<(), String> {
100    if let Some(parent) = path.parent() {
101        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
102    }
103    let need_header = !path.exists() || path.metadata().map(|m| m.len() == 0).unwrap_or(true);
104    let mut f = OpenOptions::new()
105        .create(true)
106        .append(true)
107        .open(path)
108        .map_err(|e| format!("open {}: {e}", path.display()))?;
109    if need_header {
110        writeln!(f, "{CSV_HEADER}").map_err(|e| e.to_string())?;
111    }
112    writeln!(f, "{}", run.to_csv_line()).map_err(|e| e.to_string())?;
113    Ok(())
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn csv_line_roundtrip_fields() {
122        let mut r = ExperimentRun {
123            run_id: "r1".into(),
124            utc: "1".into(),
125            git_sha: "abc".into(),
126            host_passport_key: "k".into(),
127            adapter: "A2000".into(),
128            backend: "vulkan".into(),
129            model_id: "m".into(),
130            model_hash: "0".into(),
131            layout: "soa".into(),
132            mode: "portable".into(),
133            profile: "interactive".into(),
134            toggles_json: "{}".into(),
135            qualia_decode_tok_s: Some(2.0),
136            ollama_decode_tok_s: Some(80.0),
137            a_gap: None,
138            prefill_tok_s: None,
139            phase_ns_json: "{}".into(),
140            n_ulp_max: Some(0),
141            c_score: None,
142            notes: "test".into(),
143        };
144        r.compute_a_gap();
145        assert!((r.a_gap.unwrap() - 40.0).abs() < 1e-9);
146        let line = r.to_csv_line();
147        assert!(line.contains("80.000000"));
148        assert!(line.starts_with("r1,"));
149    }
150}