Skip to main content

oxibrain_cli/cmd/
gate.rs

1//! `oxibrain eval --suite gate` — three-arm comparison runner
2//! (ARCHITECTURE.md §17.2).
3//!
4//! Loads `eval/golden/` (manifest + episodes + questions), ingests the
5//! episodes as declarations on a fresh brain, then for each question runs
6//! arm (b) lexical and arm (c) hybrid and scores whether the answer
7//! appears in the rendered top-K statements.
8//!
9//! Per ROADMAP §4, this is the controlled comparison whose outcome decides
10//! whether to proceed with M10 as written, fix extraction, or invoke D19's
11//! pre-commitment (demote the graph). Arms (a) and the frontier tier are
12//! out of scope for the golden-only gate (LongMemEval removed from the
13//! plan, 2026-08-13).
14//!
15//! The runner is deterministic and self-contained: no network, no LLM,
16//! no live model. Tokens/query is approximated by the number of ranked
17//! items + the character length of rendered statements.
18
19use anyhow::{Context, Result, bail};
20use oxibrain::Brain;
21use oxibrain_core::retrieval::{Query, QueryMode};
22use oxibrain_ports::{FakeClock, Timestamp};
23use oxibrain_store::project::{DeclObject, Declaration, EntityRef};
24use serde::Deserialize;
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27use tempfile::TempDir;
28
29// ── Golden corpus types (TOML) ────────────────────────────────────────────
30
31#[derive(Debug, Deserialize)]
32struct Manifest {
33    #[allow(dead_code)]
34    version: String,
35    #[allow(dead_code)]
36    categories: Vec<Category>,
37    episodes: Vec<EpisodeEntry>,
38    questions: Vec<QuestionEntry>,
39}
40
41#[derive(Debug, Deserialize)]
42struct Category {
43    #[allow(dead_code)]
44    name: String,
45    #[allow(dead_code)]
46    description: String,
47}
48
49#[derive(Debug, Deserialize)]
50struct EpisodeEntry {
51    #[allow(dead_code)]
52    id: String,
53    #[allow(dead_code)]
54    shape: String,
55    #[allow(dead_code)]
56    lang: String,
57    file: String,
58}
59
60#[derive(Debug, Deserialize)]
61struct QuestionEntry {
62    #[allow(dead_code)]
63    id: String,
64    #[allow(dead_code)]
65    category: String,
66    file: String,
67}
68
69#[derive(Debug, Deserialize)]
70struct EpisodeFile {
71    #[allow(dead_code)]
72    shape: String,
73    #[allow(dead_code)]
74    lang: String,
75    #[allow(dead_code)]
76    occurred_at: String,
77    #[allow(dead_code)]
78    content: String,
79    entities: Vec<EpisodeEntity>,
80    statements: Vec<EpisodeStatement>,
81}
82
83#[derive(Debug, Deserialize)]
84struct EpisodeEntity {
85    surface: String,
86    #[serde(rename = "type")]
87    ty: String,
88}
89
90#[derive(Debug, Deserialize)]
91struct EpisodeStatement {
92    predicate: String,
93    subject_surface: String,
94    /// Either an entity reference (`object_surface`, type resolved from the
95    /// episode's `[[entities]]`) or a literal (`object_literal_type` +
96    /// `object_literal_value`).
97    object_surface: Option<String>,
98    object_literal_type: Option<String>,
99    object_literal_value: Option<String>,
100    valid_from: String,
101    #[serde(default)]
102    valid_to: Option<String>,
103}
104
105#[derive(Debug, Deserialize)]
106struct QuestionFile {
107    #[allow(dead_code)]
108    id: String,
109    #[allow(dead_code)]
110    category: String,
111    question: String,
112    #[serde(default)]
113    as_of: Option<String>,
114    answer: String,
115    #[allow(dead_code)]
116    supporting_episodes: Vec<String>,
117}
118
119// ── Arm results ───────────────────────────────────────────────────────────
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122enum Arm {
123    /// Lexical only (word + ngram + RRF) — the control.
124    B,
125    /// Full hybrid (lexical ∪ vector ∪ graph) — the treatment.
126    C,
127}
128
129impl Arm {
130    fn label(self) -> &'static str {
131        match self {
132            Arm::B => "b (lexical)",
133            Arm::C => "c (hybrid)",
134        }
135    }
136
137    fn query_mode(self) -> QueryMode {
138        match self {
139            Arm::B => QueryMode::Lexical,
140            Arm::C => QueryMode::Hybrid,
141        }
142    }
143}
144
145#[derive(Debug, Clone)]
146struct ArmResult {
147    passed: bool,
148    /// Approximate token count: characters / 4. This is a coarse proxy
149    /// (real tokenization is model-specific); the gate reports it for
150    /// shape-comparison only, not for a hard budget claim.
151    approx_tokens: usize,
152}
153
154// ── Public entry point ────────────────────────────────────────────────────
155
156pub async fn run(suite: &str, corpus_dir: &Path) -> Result<()> {
157    if suite != "gate" {
158        bail!("gate runner only handles the 'gate' suite (got '{suite}')");
159    }
160    let manifest_path = corpus_dir.join("manifest.toml");
161    let manifest_text = std::fs::read_to_string(&manifest_path)
162        .with_context(|| format!("read {}", manifest_path.display()))?;
163    let manifest: Manifest = toml::from_str(&manifest_text).context("parse manifest.toml")?;
164
165    // 1. Set up a fresh brain and ingest every episode as declarations.
166    //    Use a fake clock pinned to 2024-12-01 (after all episodes) so
167    //    as_of queries for the temporal corpus resolve correctly.
168    let dir = TempDir::new().context("create temp dir")?;
169    let clock_ts = Timestamp::from_millis(1_700_000_000_000); // 2023-11-14
170    let clock = Arc::new(FakeClock::new(clock_ts));
171    let brain = Brain::with_clock(oxibrain::BrainConfig::at(dir.path()), clock)
172        .await
173        .context("open brain")?;
174    let space = "gate";
175    let space_id = brain.ensure_space(space).await.context("ensure space")?;
176
177    let mut ingested = 0usize;
178    // All entity surfaces in the corpus — used to derive keyword queries
179    // from question text (a real agent queries the entity name, not the
180    // whole NL sentence; arm (b) lexical cannot match an entire question).
181    let mut entity_surfaces: Vec<String> = Vec::new();
182    for ep_entry in &manifest.episodes {
183        let ep_path = corpus_dir.join(&ep_entry.file);
184        let ep_text = std::fs::read_to_string(&ep_path)
185            .with_context(|| format!("read {}", ep_path.display()))?;
186        let ep: EpisodeFile =
187            toml::from_str(&ep_text).with_context(|| format!("parse {}", ep_path.display()))?;
188        for e in &ep.entities {
189            entity_surfaces.push(e.surface.clone());
190        }
191        for st in &ep.statements {
192            let decl = statement_to_declaration(st, &ep.entities)?;
193            brain
194                .declare(&space_id, decl)
195                .await
196                .with_context(|| format!("declare from {}", ep_entry.id))?;
197            ingested += 1;
198        }
199    }
200    entity_surfaces.sort();
201    entity_surfaces.dedup();
202
203    // 1.5. Declarations do not auto-index FTS; the query arms need the
204    //      lexical index to surface anything. Rebuild before scoring.
205    brain
206        .rebuild_indexes(&space_id)
207        .await
208        .context("rebuild indexes")?;
209
210    // 2. For each question, run both arms and score.
211    let mut results: Vec<QuestionResult> = Vec::new();
212    for q_entry in &manifest.questions {
213        let q_path = corpus_dir.join(&q_entry.file);
214        let q_text = std::fs::read_to_string(&q_path)
215            .with_context(|| format!("read {}", q_path.display()))?;
216        let q: QuestionFile =
217            toml::from_str(&q_text).with_context(|| format!("parse {}", q_path.display()))?;
218        // Keyword query: the entity surface(s) named in the question.
219        let keyword = extract_keywords(&q.question, &entity_surfaces);
220        let as_of = q.as_of.as_deref().map(parse_iso_to_timestamp).transpose()?;
221        let arm_b = run_arm(&brain, &space_id, &keyword, Arm::B, as_of, &q.answer).await?;
222        let arm_c = run_arm(&brain, &space_id, &keyword, Arm::C, as_of, &q.answer).await?;
223        // M9 exit criterion: tokens per answered question must DECREASE
224        // vs M8's recall-only path (assemble_context). Measure both.
225        let recall = brain
226            .assemble_context(&space_id, &keyword, 3000)
227            .await
228            .context("assemble_context")?;
229        let recall_tokens = recall.total_tokens;
230        let brief_tokens = brief_token_cost(&brain, &space_id, &keyword, &entity_surfaces).await?;
231        results.push(QuestionResult {
232            id: q.id,
233            category: q.category,
234            answer: q.answer,
235            arm_b,
236            arm_c,
237            recall_tokens,
238            brief_tokens,
239        });
240    }
241
242    // 3. Report.
243    print_report(&results, ingested);
244
245    Ok(())
246}
247
248#[derive(Debug, Clone)]
249struct QuestionResult {
250    id: String,
251    category: String,
252    answer: String,
253    arm_b: ArmResult,
254    arm_c: ArmResult,
255    /// M8 recall-only context tokens (assemble_context budget=3000).
256    recall_tokens: usize,
257    /// M9 brief/navigate path tokens (brief pages for the top entities).
258    brief_tokens: usize,
259}
260
261// ── Helpers ───────────────────────────────────────────────────────────────
262
263fn statement_to_declaration(
264    st: &EpisodeStatement,
265    entities: &[EpisodeEntity],
266) -> Result<Declaration> {
267    let valid_from = parse_iso_to_timestamp(&st.valid_from)?.millis();
268    let valid_to = match &st.valid_to {
269        Some(s) => parse_iso_to_timestamp(s)?.millis(),
270        None => oxibrain_ports::TIME_MAX.millis(),
271    };
272    let subject = EntityRef {
273        surface: st.subject_surface.clone(),
274        ty: entity_type(entities, &st.subject_surface)
275            .unwrap_or("Person")
276            .to_string(),
277    };
278    let object = if let Some(surface) = &st.object_surface {
279        let ty = entity_type(entities, surface).ok_or_else(|| {
280            anyhow::anyhow!("object surface '{surface}' not declared in episode entities")
281        })?;
282        DeclObject::Entity {
283            surface: surface.clone(),
284            ty: ty.to_string(),
285        }
286    } else if let (Some(lt), Some(val)) = (&st.object_literal_type, &st.object_literal_value) {
287        DeclObject::Literal {
288            literal_type: lt.clone(),
289            value: val.clone(),
290        }
291    } else {
292        bail!(
293            "statement '{}' has neither object_surface nor object_literal_*",
294            st.subject_surface
295        );
296    };
297    Ok(Declaration::AddStatement {
298        subject,
299        predicate: st.predicate.clone(),
300        object,
301        polarity: "affirm".into(),
302        valid_from,
303        valid_to,
304    })
305}
306
307/// Extract the entity surface(s) named in the question text. Returns the
308/// joined surfaces (space-separated) so both arms query the same keyword
309/// the way an agent would. Falls back to the whole question text if no
310/// entity surface appears (rare — the corpus names entities in questions).
311fn extract_keywords(question: &str, entity_surfaces: &[String]) -> String {
312    let lower = question.to_lowercase();
313    let mut found: Vec<&str> = Vec::new();
314    for surface in entity_surfaces {
315        if lower.contains(&surface.to_lowercase()) {
316            found.push(surface.as_str());
317        }
318    }
319    if found.is_empty() {
320        question.to_string()
321    } else {
322        found.join(" ")
323    }
324}
325
326/// M9 path token cost: render the brief pages for the keyword's entity
327/// surfaces and sum their chars/4 (coarse token proxy, same as the arms).
328/// An agent reading a brief page consumes its rendered text.
329async fn brief_token_cost(
330    brain: &Brain,
331    space_id: &str,
332    keyword: &str,
333    entity_surfaces: &[String],
334) -> Result<usize> {
335    // Resolve the entity surfaces named in the keyword, then brief each.
336    let mut total = 0usize;
337    let mut seen = std::collections::HashSet::new();
338    for surface in entity_surfaces {
339        if !keyword.to_lowercase().contains(&surface.to_lowercase()) {
340            continue;
341        }
342        if !seen.insert(surface.clone()) {
343            continue;
344        }
345        if let Ok(Some(id)) = brain
346            .resolve_entity_id(
347                space_id,
348                &entity_type_for(brain, space_id, surface).await,
349                surface,
350            )
351            .await
352        {
353            let page = brain
354                .brief(space_id, &id)
355                .await
356                .context("brief cost page")?;
357            total += page.len() / 4;
358        }
359    }
360    Ok(total)
361}
362
363/// Resolve the type of an entity surface by trying common types.
364async fn entity_type_for(brain: &Brain, space_id: &str, surface: &str) -> String {
365    for ty in ["Person", "Organization", "Project", "Place", "Concept"] {
366        if brain
367            .resolve_entity_id(space_id, ty, surface)
368            .await
369            .ok()
370            .flatten()
371            .is_some()
372        {
373            return ty.to_string();
374        }
375    }
376    "Concept".to_string()
377}
378
379fn entity_type<'a>(entities: &'a [EpisodeEntity], surface: &str) -> Option<&'a str> {
380    entities
381        .iter()
382        .find(|e| e.surface == surface)
383        .map(|e| e.ty.as_str())
384}
385
386fn parse_iso_to_timestamp(s: &str) -> Result<Timestamp> {
387    // The corpus uses `YYYY-MM-DD`. Convert to millis since epoch (UTC noon
388    // to avoid TZ off-by-one). The Temporal answer correctness depends on
389    // the interval, not the exact instant.
390    let parts: Vec<&str> = s.split('-').collect();
391    if parts.len() != 3 {
392        bail!("not a YYYY-MM-DD date: {s}");
393    }
394    let y: i64 = parts[0]
395        .parse()
396        .with_context(|| format!("year parse: {s}"))?;
397    let m: i64 = parts[1]
398        .parse()
399        .with_context(|| format!("month parse: {s}"))?;
400    let d: i64 = parts[2]
401        .parse()
402        .with_context(|| format!("day parse: {s}"))?;
403    // Days from 1970-01-01 to Y-M-D, civil-date arithmetic.
404    let days = days_from_civil(y, m, d);
405    Ok(Timestamp(days * 86_400_000 + 12 * 3_600_000)) // noon UTC
406}
407
408/// Howard Hinnant's days_from_civil (public domain).
409fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
410    let y = if m <= 2 { y - 1 } else { y };
411    let era = if y >= 0 { y } else { y - 399 } / 400;
412    let yoe = y - era * 400; // [0, 399]
413    let m = if m > 2 { m - 3 } else { m + 9 }; // [0, 11]
414    let doy = (153 * m + 2) / 5 + d - 1; // [0, 365]
415    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
416    era * 146_097 + doe - 719_468
417}
418
419async fn run_arm(
420    brain: &Brain,
421    space_id: &str,
422    text: &str,
423    arm: Arm,
424    as_of: Option<Timestamp>,
425    answer: &str,
426) -> Result<ArmResult> {
427    let query = Query {
428        text: text.to_string(),
429        mode: arm.query_mode(),
430        space: space_id.to_string(),
431        as_of,
432        limit: 15,
433        min_confidence: 0.0,
434    };
435    let result = brain.query(query).await.context(arm.label())?;
436    // Render the top-K ranked statements by id (`id | subject predicate
437    // object`) and check substring match against the answer. This is the
438    // agent-visible surface: the ranked statement's full text, not just
439    // its predicate name.
440    let mut ids: Vec<String> = Vec::new();
441    for item in result.items.iter().take(15) {
442        if let oxibrain_core::rank::TargetId::Statement { id } = &item.target {
443            ids.push(id.clone());
444        }
445    }
446    // For the hybrid arm (C), also resolve Entity targets to their statements.
447    // Graph expansion discovers Entity nodes (e.g., "Grace"); the agent needs
448    // Grace's statements ("Grace has_skill Kubernetes") to answer questions
449    // that require graph traversal. The lexical arm (B) never has Entity
450    // targets from graph expansion, so it is unaffected.
451    if arm == Arm::C {
452        let mut entity_ids: Vec<String> = Vec::new();
453        for item in result.items.iter().take(15) {
454            if let oxibrain_core::rank::TargetId::Entity { id } = &item.target {
455                entity_ids.push(id.clone());
456            }
457        }
458        if !entity_ids.is_empty() {
459            let entity_stmts = brain
460                .statements_for_entities(space_id, &entity_ids)
461                .await
462                .unwrap_or_default();
463            ids.extend(entity_stmts);
464        }
465    }
466    let rendered_lines = brain.render_statements(space_id, &ids).await?;
467    let rendered = rendered_lines.join("\n");
468    let passed = !rendered.is_empty()
469        && answer_matches(answer.to_lowercase().as_str(), &rendered.to_lowercase());
470    let approx_tokens = rendered.len() / 4;
471    Ok(ArmResult {
472        passed,
473        approx_tokens,
474    })
475}
476
477fn answer_matches(needle: &str, haystack_lower: &str) -> bool {
478    // The gate's scoring is a coarse case-insensitive substring match
479    // against the rendered top-K. Multi-word answers: every word must
480    // appear in some rendered line (looser than full-phrase match).
481    let words: Vec<&str> = needle.split_whitespace().collect();
482    if words.is_empty() {
483        return false;
484    }
485    words.iter().all(|w| haystack_lower.contains(w))
486}
487
488fn print_report(results: &[QuestionResult], ingested: usize) {
489    println!("═══ oxibrain gate (golden-only) ═══");
490    println!("Episodes ingested: {ingested} declarations");
491    println!("Questions:         {}", results.len());
492    println!();
493    // Per-question detail.
494    for r in results {
495        let mark_b = if r.arm_b.passed { "✓" } else { "✗" };
496        let mark_c = if r.arm_c.passed { "✓" } else { "✗" };
497        println!(
498            "  {} [{}]  b={} ({:>3} tok)  c={} ({:>3} tok)  answer: {}",
499            r.id,
500            r.category,
501            mark_b,
502            r.arm_b.approx_tokens,
503            mark_c,
504            r.arm_c.approx_tokens,
505            truncate(&r.answer, 40),
506        );
507    }
508    println!();
509    // Per-category delta (c − b accuracy).
510    let categories: std::collections::BTreeSet<&str> =
511        results.iter().map(|r| r.category.as_str()).collect();
512    println!("Per-category accuracy (c − b):");
513    for cat in categories {
514        let in_cat: Vec<&QuestionResult> = results.iter().filter(|r| r.category == cat).collect();
515        let n = in_cat.len() as f64;
516        let b = in_cat.iter().filter(|r| r.arm_b.passed).count() as f64;
517        let c = in_cat.iter().filter(|r| r.arm_c.passed).count() as f64;
518        println!(
519            "  {:<22}  b={:.0}/{:.0}  c={:.0}/{:.0}  delta(c−b) = {:+.0}",
520            cat,
521            b,
522            n,
523            c,
524            n,
525            (c - b),
526        );
527    }
528    println!();
529    // Tokens/query total.
530    let b_tokens: usize = results.iter().map(|r| r.arm_b.approx_tokens).sum();
531    let c_tokens: usize = results.iter().map(|r| r.arm_c.approx_tokens).sum();
532    let n = results.len().max(1);
533    println!(
534        "Tokens/query (approx):  b={:.0}  c={:.0}  delta(c−b) = {:+} tok/q",
535        b_tokens as f64 / n as f64,
536        c_tokens as f64 / n as f64,
537        (c_tokens as isize - b_tokens as isize) / n as isize,
538    );
539    // M9 exit criterion: tokens per answered question vs M8 recall-only.
540    let recall_tokens: usize = results.iter().map(|r| r.recall_tokens).sum();
541    let brief_tokens: usize = results.iter().map(|r| r.brief_tokens).sum();
542    println!(
543        "Tokens/answer:  M8 recall-only={:.0}  M9 brief/navigate={:.0}  delta = {:+} tok/q",
544        recall_tokens as f64 / n as f64,
545        brief_tokens as f64 / n as f64,
546        (brief_tokens as isize - recall_tokens as isize) / n as isize,
547    );
548}
549
550fn truncate(s: &str, n: usize) -> String {
551    if s.chars().count() <= n {
552        s.to_string()
553    } else {
554        let mut out: String = s.chars().take(n).collect();
555        out.push('…');
556        out
557    }
558}
559
560/// Entry point used by the CLI dispatcher — supports the `gate` suite
561/// with a corpus-dir argument. The CLI passes the path to `eval/golden/`
562/// (resolved relative to the manifest via the `--corpus` flag, defaulting
563/// to `eval/golden` from the workspace root).
564pub async fn run_with_dir(suite: &str, corpus_dir: Option<PathBuf>) -> Result<()> {
565    let dir = match corpus_dir {
566        Some(d) => d,
567        None => default_corpus_dir()?,
568    };
569    run(suite, &dir).await
570}
571
572fn default_corpus_dir() -> Result<PathBuf> {
573    // Walk up from CWD until we find `eval/golden/manifest.toml`.
574    let mut here = std::env::current_dir().context("cwd")?;
575    loop {
576        let candidate = here.join("eval").join("golden");
577        if candidate.join("manifest.toml").is_file() {
578            return Ok(candidate);
579        }
580        if !here.pop() {
581            bail!("could not locate eval/golden/manifest.toml from CWD");
582        }
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    #[test]
591    fn days_from_civil_known_dates() {
592        assert_eq!(days_from_civil(1970, 1, 1), 0);
593        // 2023-11-14 is 19675 days after epoch (verified with a reference).
594        assert_eq!(days_from_civil(2023, 11, 14), 19675);
595        assert_eq!(days_from_civil(2023, 6, 1), 19509);
596    }
597
598    #[test]
599    fn answer_matches_substring() {
600        assert!(answer_matches("acme corp", "works for acme corp in 2023"));
601        assert!(answer_matches(
602            "alice smith",
603            "alice smith works on project x"
604        ));
605        assert!(!answer_matches("missing", "alice works on project x"));
606    }
607}