qualia_core_db/inference/lab/
experiment_log.rs1use 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 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
98pub 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}