1use 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#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122enum Arm {
123 B,
125 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 approx_tokens: usize,
152}
153
154pub 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 let dir = TempDir::new().context("create temp dir")?;
169 let clock_ts = Timestamp::from_millis(1_700_000_000_000); 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 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 brain
206 .rebuild_indexes(&space_id)
207 .await
208 .context("rebuild indexes")?;
209
210 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 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 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 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 recall_tokens: usize,
257 brief_tokens: usize,
259}
260
261fn 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
307fn 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
326async fn brief_token_cost(
330 brain: &Brain,
331 space_id: &str,
332 keyword: &str,
333 entity_surfaces: &[String],
334) -> Result<usize> {
335 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
363async 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 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 let days = days_from_civil(y, m, d);
405 Ok(Timestamp(days * 86_400_000 + 12 * 3_600_000)) }
407
408fn 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; let m = if m > 2 { m - 3 } else { m + 9 }; let doy = (153 * m + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; 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 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 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 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 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 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 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 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
560pub 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 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 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}