Skip to main content

bench_kernel/
bench_kernel.rs

1//! Offline measurements against the real kernel. Synthetic workloads are explicitly labelled.
2use clap::{Parser, Subcommand};
3use hikmah_kernel::decision::{evaluate, Criterion, DecisionFrame, DecisionOption};
4use hikmah_kernel::hook::rules_verdict;
5use hikmah_kernel::policy::KernelPolicy;
6use hikmah_kernel::recall::RecallQuery;
7use hikmah_kernel::trace::{Trace, TraceKind};
8use hikmah_kernel::MemoryStore;
9use serde_json::{json, Value};
10use std::collections::BTreeMap;
11use std::error::Error;
12use std::fs::{self, OpenOptions};
13use std::io::{self, BufRead, Write};
14use std::path::{Path, PathBuf};
15use std::process::{Command, Stdio};
16use std::time::Instant;
17
18type Result<T> = std::result::Result<T, Box<dyn Error>>;
19const CLOCK: u64 = 1_700_000_000_000;
20
21#[derive(Parser)]
22struct Args {
23    #[command(subcommand)]
24    command: Mode,
25}
26
27#[derive(Subcommand)]
28enum Mode {
29    Scale {
30        #[arg(long)]
31        dir: PathBuf,
32        #[arg(long, default_value_t = 1000)]
33        records: usize,
34        #[arg(long, default_value_t = 30)]
35        queries: usize,
36    },
37    Decisions {
38        #[arg(long, default_value_t = 10000)]
39        cases: usize,
40        #[arg(long, default_value_t = 20260926)]
41        seed: u64,
42    },
43    Recovery {
44        #[arg(long)]
45        dir: PathBuf,
46    },
47    /// Internal process killed after acknowledging a committed batch, not during a write.
48    #[command(hide = true)]
49    CommittedChild {
50        #[arg(long)]
51        dir: PathBuf,
52    },
53    /// JSONL {id, message} -> {id, blocked, ms}; no labels enter the kernel.
54    Gate,
55}
56
57fn ms(start: Instant) -> f64 {
58    start.elapsed().as_secs_f64() * 1000.0
59}
60
61fn quantiles(mut values: Vec<f64>) -> Value {
62    values.sort_by(f64::total_cmp);
63    let q = |p: f64| {
64        if values.is_empty() {
65            return None;
66        }
67        let x = (values.len() - 1) as f64 * p;
68        let lo = x.floor() as usize;
69        let hi = x.ceil() as usize;
70        Some(values[lo] + (values[hi] - values[lo]) * x.fract())
71    };
72    json!({"n": values.len(), "p50": q(0.5), "p95": q(0.95), "p99": q(0.99)})
73}
74
75fn rss() -> Value {
76    let status = fs::read_to_string("/proc/self/status").unwrap_or_default();
77    let field = |key: &str| {
78        status.lines().find_map(|line| {
79            line.strip_prefix(key)
80                .and_then(|s| s.split_whitespace().next())
81                .and_then(|n| n.parse::<u64>().ok())
82        })
83    };
84    json!({"rss_kib": field("VmRSS:"), "process_peak_rss_kib": field("VmHWM:")})
85}
86
87fn token(mut n: usize) -> String {
88    let mut out = String::from("event");
89    for _ in 0..8 {
90        out.push((b'a' + (n % 26) as u8) as char);
91        n /= 26;
92    }
93    out
94}
95
96fn trace(i: usize) -> Trace {
97    let mut t = Trace::new(
98        TraceKind::Observation,
99        format!("{} deployment observation", token(i)),
100        "synthetic-kernel-benchmark",
101    );
102    t.id = format!("sample-{i}");
103    t.created_at_ms = CLOCK;
104    t.provenance.observed_at_ms = CLOCK;
105    t
106}
107
108fn fresh(dir: &Path) -> Result<PathBuf> {
109    // Never open or modify an existing user's memory, even when it is empty.
110    fs::create_dir(dir)?;
111    Ok(dir.join("memory.jsonl"))
112}
113
114fn scale(dir: &Path, records: usize, queries: usize) -> Result<Value> {
115    if records == 0 || queries == 0 {
116        return Err("records and queries must be positive".into());
117    }
118    let path = fresh(dir)?;
119    let mut store = MemoryStore::open(&path, KernelPolicy::default())?;
120    let single_count = records.min(50);
121    let batched = records - single_count;
122    let mut batches = Vec::new();
123    let seed_started = Instant::now();
124    for begin in (0..batched).step_by(1000) {
125        let traces = (begin..(begin + 1000).min(batched)).map(trace).collect();
126        let start = Instant::now();
127        store.remember_many(traces)?;
128        batches.push(ms(start));
129    }
130    let mut appends = Vec::new();
131    for i in batched..records {
132        let t = trace(i);
133        let start = Instant::now();
134        store.remember(t)?;
135        appends.push(ms(start));
136    }
137    let seed_ms = ms(seed_started);
138    let memory_after_seed = rss();
139    let mut recall_ms = Vec::new();
140    let mut hits = 0;
141    let mut returned = 0;
142    let mut result_ids = Vec::new();
143    // Warm up with an exact cue; quality here is a synthetic exact-cue check, not field recall.
144    std::hint::black_box(store.recall(&RecallQuery::new(token(0))));
145    for i in 0..queries {
146        let selected = i * records / queries;
147        let mut query = RecallQuery::new(token(selected));
148        query.now_ms = CLOCK;
149        query.limit = 1;
150        let start = Instant::now();
151        let found = store.recall(&query);
152        recall_ms.push(ms(start));
153        returned += found.len();
154        hits += usize::from(
155            found
156                .first()
157                .is_some_and(|r| r.trace.id == format!("sample-{selected}")),
158        );
159        result_ids.push(found.iter().map(|r| r.trace.id.clone()).collect::<Vec<_>>());
160    }
161    let start = Instant::now();
162    store.verify()?;
163    let verify_ms = ms(start);
164    let before = store.head();
165    drop(store);
166    let start = Instant::now();
167    let store = MemoryStore::open_existing(&path, KernelPolicy::default())?;
168    let replay_ms = ms(start);
169    store.verify()?;
170    let retained = store
171        .all()
172        .filter(|e| {
173            e.trace.provenance.source == "synthetic-kernel-benchmark"
174                && e.trace.provenance.observed_at_ms == CLOCK
175                && !e.trace.provenance.verified
176        })
177        .count();
178    let mut replay_matches = 0;
179    for (i, expected) in result_ids.iter().enumerate() {
180        let mut query = RecallQuery::new(token(i * records / queries));
181        query.now_ms = CLOCK;
182        query.limit = 1;
183        let ids: Vec<_> = store
184            .recall(&query)
185            .into_iter()
186            .map(|r| r.trace.id)
187            .collect();
188        replay_matches += usize::from(ids == *expected);
189    }
190    let head_same = serde_json::to_value(before)? == serde_json::to_value(store.head())?;
191    let ok = store.record_count() == records
192        && retained == records
193        && hits == queries
194        && replay_matches == queries
195        && head_same;
196    Ok(json!({
197        "kind": "synthetic_scale", "ok": ok, "records": records, "queries": queries,
198        "workload": "unclaimed observation traces, unique exact lexical cues, fixed clock",
199        "seed_ms": seed_ms, "batch_size": 1000, "batched_records": batched,
200        "batch_append_ms": quantiles(batches), "single_append_ms": quantiles(appends),
201        "recall_ms": quantiles(recall_ms), "verify_ms": verify_ms, "replay_ms": replay_ms,
202        "exact_cue_hits": hits, "returned": returned, "recall_at_1": hits as f64 / queries as f64,
203        "provenance_retained": retained, "replayed_records": store.record_count(),
204        "recall_replay_matches": replay_matches, "head_unchanged": head_same,
205        "ledger_bytes": fs::metadata(path)?.len(), "memory_after_seed": memory_after_seed,
206        "memory_after_replay": rss(), "policy": store.policy(),
207        "limits": ["Batched seeding does not measure per-record durable append latency.",
208          "Warm filesystem cache; reopening in the same process is not a cold-start measurement.",
209          "No structured claims in this workload; conflict-heavy stores need a separate workload.",
210          "Synthetic exact-cue quality is not semantic or real-world retrieval accuracy.",
211          "RSS is process-wide Linux /proc data; unavailable platforms report null."]
212    }))
213}
214
215// Specified, platform-independent SplitMix64; never a cryptographic RNG.
216struct Rng(u64);
217impl Rng {
218    fn next(&mut self) -> u64 {
219        self.0 = self.0.wrapping_add(0x9e3779b97f4a7c15);
220        let mut x = self.0;
221        x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
222        x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb);
223        x ^ (x >> 31)
224    }
225    fn unit(&mut self) -> f64 {
226        (self.next() % 10001) as f64 / 10000.0
227    }
228}
229
230fn option(name: &str, a: f64, b: f64) -> DecisionOption {
231    DecisionOption {
232        name: name.into(),
233        description: None,
234        scores: BTreeMap::from([("a".into(), a), ("b".into(), b)]),
235        model_scores: BTreeMap::new(),
236        evidence_confidence: 1.0,
237        hard_blocks: vec![],
238        reversible: false,
239    }
240}
241
242fn decisions(cases: usize, seed: u64) -> Result<Value> {
243    if cases < 6 {
244        return Err("at least 6 cases required to exercise every family".into());
245    }
246    let mut rng = Rng(seed);
247    let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
248    let mut failures = Vec::new();
249    let mut violations = 0;
250    let start = Instant::now();
251    for i in 0..cases {
252        let mut a = option("a", rng.unit(), rng.unit());
253        let mut b = option("b", rng.unit(), rng.unit());
254        let family;
255        let mut expected: Option<Option<String>> = None;
256        match i % 6 {
257            0 => {
258                family = "high_score_hard_block";
259                a.scores.values_mut().for_each(|s| *s = 1.0);
260                a.hard_blocks.push("no authorization".into());
261                expected = Some(Some("b".into()));
262            }
263            1 => {
264                family = "all_blocked";
265                a.hard_blocks.push("privacy".into());
266                b.hard_blocks.push("consent".into());
267                expected = Some(None);
268            }
269            2 => {
270                family = "complete_score_oracle";
271                let sum_a: f64 = a.scores.values().sum();
272                let sum_b: f64 = b.scores.values().sum();
273                expected = Some(Some(if sum_a >= sum_b { "a" } else { "b" }.into()));
274            }
275            3 => {
276                family = "unknowns_and_model_estimates";
277                a.scores.clear();
278                a.model_scores.insert("a".into(), rng.unit());
279            }
280            4 => {
281                family = "reversibility_band";
282                let top = 0.5 + rng.unit() * 0.4;
283                let gap = if rng.next().is_multiple_of(2) {
284                    0.01
285                } else {
286                    0.03
287                };
288                a.scores.values_mut().for_each(|s| *s = top);
289                a.evidence_confidence = 0.1;
290                b.scores.values_mut().for_each(|s| *s = top - gap);
291                b.reversible = true;
292                expected = Some(Some(if gap < 0.02 { "b" } else { "a" }.into()));
293            }
294            _ => {
295                family = "invalid_numeric_input";
296                a.scores.insert("a".into(), 1.01 + rng.unit());
297            }
298        }
299        *counts.entry(family).or_default() += 1;
300        let frame = DecisionFrame {
301            question: "synthetic decision".into(),
302            criteria: ["a", "b"]
303                .into_iter()
304                .map(|id| Criterion {
305                    id: id.into(),
306                    weight: 1.0,
307                    description: id.into(),
308                })
309                .collect(),
310            options: vec![a, b],
311        };
312        let actual = evaluate(&frame);
313        let mut reasons = Vec::new();
314        if family == "invalid_numeric_input" {
315            if actual.is_ok() {
316                reasons.push("invalid input admitted");
317            }
318        } else if let Ok(answer) = &actual {
319            if let Some(expected) = expected {
320                if answer.recommended != expected {
321                    reasons.push("wrong recommendation");
322                }
323            }
324            if let Some(name) = &answer.recommended {
325                if frame
326                    .options
327                    .iter()
328                    .any(|o| &o.name == name && !o.hard_blocks.is_empty())
329                {
330                    reasons.push("hard-blocked option selected");
331                }
332            }
333            if family == "unknowns_and_model_estimates" {
334                let ranked = answer.ranking.iter().find(|o| o.name == "a").unwrap();
335                if ranked.coverage != 0.0
336                    || ranked.evidence_interval != [0.0, 1.0]
337                    || ranked.missing_criteria != ["b"]
338                    || answer.decisive
339                {
340                    reasons.push("unknown evidence hidden or model guess counted as evidence");
341                }
342            }
343            let mut permuted = frame.clone();
344            permuted.options.reverse();
345            permuted.question = "irrelevant wording changed".into();
346            let other = evaluate(&permuted)?;
347            if answer.recommended != other.recommended
348                || serde_json::to_value(&answer.ranking)? != serde_json::to_value(&other.ranking)?
349            {
350                reasons.push("order or irrelevant text changed result");
351            }
352        } else {
353            reasons.push("valid frame rejected");
354        }
355        if !reasons.is_empty() {
356            violations += 1;
357            if failures.len() < 20 {
358                failures.push(
359                    json!({"case_index": i, "family": family, "reasons": reasons,
360                    "frame": frame, "actual": actual.as_ref().ok()}),
361                );
362            }
363        }
364    }
365    Ok(
366        json!({"kind": "synthetic_decision_invariants", "seed": seed, "rng": "splitmix64",
367        "cases": cases, "families": counts, "violations": violations,
368        "violation_rate": violations as f64 / cases as f64, "ok": violations == 0,
369        "elapsed_ms": ms(start), "counterexamples": failures,
370        "limits": ["Generated families are not real decision-quality labels or a proof over all inputs.",
371          "Metamorphic checks keep criteria fixed; weight perturbations require separate expectations."]}),
372    )
373}
374
375fn committed_child(dir: &Path) -> Result<()> {
376    let path = fresh(dir)?;
377    let mut store = MemoryStore::open(path, KernelPolicy::default())?;
378    store.remember_many((0..50).map(trace).collect())?;
379    println!("committed:50");
380    io::stdout().flush()?;
381    // Parent kills us after the acknowledged commit; this is not an in-flight crash claim.
382    loop {
383        std::thread::park();
384    }
385}
386
387fn recovery(dir: &Path) -> Result<Value> {
388    fs::create_dir(dir)?;
389    let child_dir = dir.join("committed");
390    let mut child = Command::new(std::env::current_exe()?)
391        .args(["committed-child", "--dir"])
392        .arg(&child_dir)
393        .stdin(Stdio::null())
394        .stdout(Stdio::piped())
395        .spawn()?;
396    let mut ack = String::new();
397    let read_result = io::BufReader::new(child.stdout.take().unwrap()).read_line(&mut ack);
398    let kill_result = child.kill();
399    let wait_result = child.wait();
400    read_result?;
401    kill_result?;
402    wait_result?;
403    if ack.trim() != "committed:50" {
404        return Err("child did not acknowledge commit".into());
405    }
406    let path = child_dir.join("memory.jsonl");
407    let start = Instant::now();
408    let store = MemoryStore::open_existing(&path, KernelPolicy::default())?;
409    store.verify()?;
410    let restart_ms = ms(start);
411    let acknowledged_survived =
412        store.record_count() == 50 && (0..50).all(|i| store.get(&format!("sample-{i}")).is_some());
413    let head_path = store.head_path();
414    drop(store);
415    let original = fs::read(&path)?;
416    let original_head = fs::read(head_path)?;
417    let copied_store = |name: &str, bytes: &[u8]| -> Result<PathBuf> {
418        let target = fresh(&dir.join(name))?;
419        let store = MemoryStore::open(&target, KernelPolicy::default())?;
420        let target_head = store.head_path();
421        drop(store);
422        fs::write(&target, bytes)?;
423        fs::write(target_head, &original_head)?;
424        Ok(target)
425    };
426    let torn = copied_store("torn", &original)?;
427    OpenOptions::new()
428        .append(true)
429        .open(&torn)?
430        .write_all(b"{\"seq\":")?;
431    let mut store = MemoryStore::open_existing(&torn, KernelPolicy::default())?;
432    store.remember(trace(50))?;
433    store.verify()?;
434    let torn_repaired = store.record_count() == 51;
435    let mut edited = String::from_utf8(original.clone())?;
436    edited = edited.replacen("deployment observation", "deployment fabrication", 1);
437    let tampered = copied_store("edited", edited.as_bytes())?;
438    let rejected = |path: &Path| match MemoryStore::open_existing(path, KernelPolicy::default()) {
439        Err(_) => true,
440        Ok(store) => store.verify().is_err(),
441    };
442    let edit_detected = rejected(&tampered);
443    let last = original[..original.len() - 1]
444        .iter()
445        .rposition(|b| *b == b'\n')
446        .unwrap()
447        + 1;
448    let truncated = copied_store("truncated", &original[..last])?;
449    let truncation_detected = rejected(&truncated);
450    Ok(
451        json!({"kind": "synthetic_recovery", "acknowledged_records": 50,
452        "acknowledged_survived": acknowledged_survived, "restart_ms": restart_ms,
453        "torn_tail_repaired": torn_repaired, "edit_detected": edit_detected,
454        "truncation_detected": truncation_detected,
455        "ok": acknowledged_survived && torn_repaired && edit_detected && truncation_detected,
456        "limits": ["Process killed after an acknowledged commit, not during fsync or head replacement.",
457          "Torn tail, edit and truncation are injected into separate copies.",
458          "This does not simulate power loss or an attacker rewriting both ledger and head."]}),
459    )
460}
461
462fn gate() -> Result<()> {
463    let mut out = io::BufWriter::new(io::stdout().lock());
464    // Exclude one-time regex compilation from per-message timings.
465    std::hint::black_box(rules_verdict("Done. TODO: tests"));
466    for line in io::stdin().lock().lines() {
467        let input: Value = serde_json::from_str(&line?)?;
468        let id = input["id"].as_str().ok_or("id must be a string")?;
469        let message = input["message"]
470            .as_str()
471            .ok_or("message must be a string")?;
472        let start = Instant::now();
473        let blocked = rules_verdict(message);
474        writeln!(
475            out,
476            "{}",
477            json!({"id": id, "blocked": blocked, "ms": ms(start)})
478        )?;
479    }
480    out.flush()?;
481    Ok(())
482}
483
484fn main() -> Result<()> {
485    let result = match Args::parse().command {
486        Mode::Scale {
487            dir,
488            records,
489            queries,
490        } => scale(&dir, records, queries)?,
491        Mode::Decisions { cases, seed } => decisions(cases, seed)?,
492        Mode::Recovery { dir } => recovery(&dir)?,
493        Mode::CommittedChild { dir } => return committed_child(&dir),
494        Mode::Gate => return gate(),
495    };
496    println!("{}", serde_json::to_string_pretty(&result)?);
497    if result["ok"] != true {
498        std::process::exit(1);
499    }
500    Ok(())
501}