1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt::{Display, Formatter, Result as FmtResult};
3use std::time::Instant;
4
5use anyhow::{bail, Context, Result};
6use rusqlite::Connection;
7use serde::Serialize;
8
9use super::golden::{self, CategoryEvaluation, GoldenDataset, MetricAverages};
10
11pub const DEFAULT_DATASET_PATH: &str = "eval/golden.json";
12pub const DEFAULT_REPORT_PATH: &str = "eval/graph-decision/report.json";
13const BENEFIT_THRESHOLD: f64 = 0.05;
14const LATENCY_BUDGET_P95_MS: f64 = 1000.0;
15const EPSILON: f64 = 0.000_001;
16
17#[derive(Debug, Clone)]
18pub struct GraphDecisionEvalOptions {
19 pub dataset_path: String,
20 pub k: usize,
21}
22
23impl Default for GraphDecisionEvalOptions {
24 fn default() -> Self {
25 Self {
26 dataset_path: DEFAULT_DATASET_PATH.to_string(),
27 k: 5,
28 }
29 }
30}
31
32#[derive(Debug, Clone, Serialize)]
33pub struct GraphDecisionReport {
34 pub version: String,
35 pub dataset_path: String,
36 pub evidence_fingerprint: evidence_fingerprint::GraphEvidenceFingerprint,
37 pub k: usize,
38 pub benefit_threshold: f64,
39 pub latency_budget_p95_ms: f64,
40 pub evaluated_channel: EvaluatedGraphChannel,
41 pub graph_edges_evaluated: bool,
42 pub graph_edges_retrieval_decision: GraphEdgesRetrievalDecision,
43 pub decision: GraphDecision,
44 pub decision_reason: String,
45 pub standard: GraphDecisionArmReport,
46 pub entity_bfs: GraphDecisionArmReport,
47 pub literal_graph: GraphDecisionArmReport,
48 pub deltas: GraphDecisionDeltas,
49 pub checks: GraphDecisionChecks,
50 pub notes: Vec<String>,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
54#[serde(rename_all = "snake_case")]
55pub enum GraphDecision {
56 WireLiteralGraphTraversal,
57 KeepGraphEdgesFrozenPendingLiteralEval,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
61#[serde(rename_all = "snake_case")]
62pub enum EvaluatedGraphChannel {
63 LiteralGraphEdges,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
67#[serde(rename_all = "snake_case")]
68pub enum GraphEdgesRetrievalDecision {
69 WireProductionChannel,
70 RemainFrozenPendingLiteralEval,
71}
72
73#[derive(Debug, Clone, Serialize)]
74pub struct GraphDecisionArmReport {
75 pub mode: GraphDecisionMode,
76 pub overall: CategoryEvaluation,
77 pub associative_slice: CategoryEvaluation,
78 pub non_associative_slices: CategoryEvaluation,
79 pub non_associative_by_slice: BTreeMap<String, CategoryEvaluation>,
80 pub associative_queries_with_two_or_more_hops: usize,
81 pub scope_leak_count: usize,
82 pub query_summaries: Vec<GraphDecisionQuerySummary>,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
86#[serde(rename_all = "snake_case")]
87pub enum GraphDecisionMode {
88 Standard,
89 EntityBfs,
90 LiteralGraph,
91}
92
93#[derive(Debug, Clone, Serialize)]
94pub struct GraphDecisionQuerySummary {
95 pub id: String,
96 pub slice: String,
97 pub status: String,
98 pub result_count: usize,
99 pub retrieved_ids: Vec<i64>,
100 pub matched_refs: usize,
101 pub expected_refs: usize,
102 pub retrieval_latency_ms: f64,
103 pub hops: Option<u8>,
104 pub entities_discovered: Vec<String>,
105 pub graph_result_count: usize,
106}
107
108#[derive(Debug, Clone, Serialize)]
109pub struct GraphDecisionDeltas {
110 pub associative_recall_at_k: f64,
111 pub associative_evidence_recall_at_k: f64,
112 pub associative_ndcg_at_10: f64,
113 pub non_associative_recall_at_k: f64,
114 pub non_associative_evidence_recall_at_k: f64,
115 pub non_associative_ndcg_at_10: f64,
116 pub p95_latency_ms: f64,
117}
118
119#[derive(Debug, Clone, Serialize)]
120pub struct GraphDecisionChecks {
121 pub associative_slice_present: bool,
122 pub literal_two_hop_observed: bool,
123 pub benefit_threshold_met: bool,
124 pub non_associative_zero_regression: bool,
125 pub zero_scope_leak: bool,
126 pub p95_latency_within_budget: bool,
127 pub safe_to_wire_literal_graph: bool,
128 pub all_checks_passed: bool,
129}
130
131pub fn run_graph_decision_eval(options: GraphDecisionEvalOptions) -> Result<GraphDecisionReport> {
132 let dataset = golden::load_dataset(&options.dataset_path)?;
133 run_graph_decision_dataset(dataset, options.dataset_path, options.k)
134}
135
136fn run_graph_decision_dataset(
137 dataset: GoldenDataset,
138 dataset_path: String,
139 requested_k: usize,
140) -> Result<GraphDecisionReport> {
141 if !dataset.has_fixture_corpus() {
142 bail!("graph decision eval requires a fixture-backed golden dataset");
143 }
144
145 let k = requested_k.max(1);
146 let standard = evaluate_arm(&dataset, k, GraphDecisionMode::Standard)?;
147 let entity_bfs = evaluate_arm(&dataset, k, GraphDecisionMode::EntityBfs)?;
148 let literal_graph = evaluate_arm(&dataset, k, GraphDecisionMode::LiteralGraph)?;
149 ensure_required_slices(&standard, &literal_graph)?;
150 let deltas = build_deltas(&standard, &literal_graph);
151 let checks = build_checks(&standard, &literal_graph, &deltas);
152 let decision = if checks.safe_to_wire_literal_graph {
153 GraphDecision::WireLiteralGraphTraversal
154 } else {
155 GraphDecision::KeepGraphEdgesFrozenPendingLiteralEval
156 };
157 let decision_reason = match decision {
158 GraphDecision::WireLiteralGraphTraversal => format!(
159 "Literal graph_edges traversal is safe to wire: it improved associative evidence recall by at least {:.0}%, preserved non-associative quality, produced no scope leak, stayed within the p95 latency budget, and exercised a real two-edge path.",
160 BENEFIT_THRESHOLD * 100.0
161 ),
162 GraphDecision::KeepGraphEdgesFrozenPendingLiteralEval => format!(
163 "Literal graph_edges traversal did not satisfy all wire requirements: >= {:.0}% associative evidence-recall gain, non-associative zero regression, zero scope leak, p95 latency <= {:.0}ms, and an observed two-edge expansion. Keep graph_edges retrieval frozen.",
164 BENEFIT_THRESHOLD * 100.0,
165 LATENCY_BUDGET_P95_MS
166 ),
167 };
168
169 Ok(GraphDecisionReport {
170 version: "2026-07-19".to_string(),
171 evidence_fingerprint: evidence_fingerprint::compute(&dataset_path)?,
172 dataset_path,
173 k,
174 benefit_threshold: BENEFIT_THRESHOLD,
175 latency_budget_p95_ms: LATENCY_BUDGET_P95_MS,
176 evaluated_channel: EvaluatedGraphChannel::LiteralGraphEdges,
177 graph_edges_evaluated: true,
178 graph_edges_retrieval_decision: if checks.safe_to_wire_literal_graph {
179 GraphEdgesRetrievalDecision::WireProductionChannel
180 } else {
181 GraphEdgesRetrievalDecision::RemainFrozenPendingLiteralEval
182 },
183 decision,
184 decision_reason,
185 standard,
186 entity_bfs,
187 literal_graph,
188 deltas,
189 checks,
190 notes: vec![
191 "The standard and literal arms use the same golden dataset and search implementation; the standard arm sets graph weight to zero.".to_string(),
192 "Associative hop_path metadata seeds trusted mentions/touches_file edges through the typed provenance contract before literal-arm queries run.".to_string(),
193 "Entity BFS remains informational and does not decide whether literal graph_edges traversal is wired.".to_string(),
194 ],
195 })
196}
197
198pub fn ensure_graph_decision_gate(report: &GraphDecisionReport) -> Result<()> {
199 if report.checks.all_checks_passed {
200 return Ok(());
201 }
202 bail!(
203 "graph decision eval failed: associative_slice_present={} non_associative_zero_regression={} zero_scope_leak={} p95_latency_within_budget={}",
204 report.checks.associative_slice_present,
205 report.checks.non_associative_zero_regression,
206 report.checks.zero_scope_leak,
207 report.checks.p95_latency_within_budget
208 )
209}
210
211fn evaluate_arm(
212 dataset: &GoldenDataset,
213 k: usize,
214 mode: GraphDecisionMode,
215) -> Result<GraphDecisionArmReport> {
216 let conn = Connection::open_in_memory().context("open in-memory graph decision eval DB")?;
217 crate::migrate::run_migrations(&conn).context("migrate graph decision eval DB")?;
218 golden::run::seed_fixture_corpus(&conn, &dataset.corpus)?;
219 if mode == GraphDecisionMode::LiteralGraph {
220 seed_fixture_graph_edges(&conn, dataset)?;
221 }
222
223 let mut overall = golden::run::CategoryAccumulator::default();
224 let mut associative_slice = golden::run::CategoryAccumulator::default();
225 let mut non_associative_slices = golden::run::CategoryAccumulator::default();
226 let mut non_associative_by_slice = BTreeMap::<String, golden::run::CategoryAccumulator>::new();
227 let mut query_summaries = Vec::with_capacity(dataset.queries.len());
228 let mut scope_leak_count = 0;
229
230 for query in &dataset.queries {
231 let started = Instant::now();
232 let (results, hops, entities_discovered, graph_result_count) = match mode {
233 GraphDecisionMode::Standard => (
234 crate::retrieval::search::search_with_branch_weights(
235 &conn,
236 Some(&query.query),
237 query.project.as_deref(),
238 query.memory_type.as_deref(),
239 k.max(10) as i64,
240 0,
241 false,
242 query.branch.as_deref(),
243 crate::retrieval::search::SearchWeights {
244 graph: 0.0,
245 ..crate::retrieval::search::SearchWeights::default()
246 },
247 )?,
248 None,
249 Vec::new(),
250 0,
251 ),
252 GraphDecisionMode::EntityBfs => {
253 let multi_hop = crate::retrieval::search_multihop::search_multi_hop(
254 &conn,
255 &query.query,
256 query.project.as_deref(),
257 k.max(10) as i64,
258 0,
259 query.memory_type.as_deref(),
260 query.branch.as_deref(),
261 false,
262 false,
263 )?;
264 (
265 multi_hop.memories,
266 Some(multi_hop.hops),
267 multi_hop.entities_discovered,
268 0,
269 )
270 }
271 GraphDecisionMode::LiteralGraph => {
272 let (results, explain) = crate::retrieval::search::search_with_branch_explain(
273 &conn,
274 Some(&query.query),
275 query.project.as_deref(),
276 query.memory_type.as_deref(),
277 k.max(10) as i64,
278 0,
279 false,
280 query.branch.as_deref(),
281 )?;
282 let (hops, graph_result_count) = literal_path_summary(
283 &conn,
284 query,
285 &results,
286 explain
287 .as_ref()
288 .context("literal graph search missing explain")?,
289 )?;
290 (results, hops, Vec::new(), graph_result_count)
291 }
292 };
293 let retrieval_latency_ms = started.elapsed().as_secs_f64() * 1000.0;
294 let query_tokens = golden::run::estimate_query_tokens(&query.query);
295 let evaluation =
296 golden::run::evaluate_query(query, &results, k, query_tokens, retrieval_latency_ms);
297
298 golden::run::record_bucket(&mut overall, query, &evaluation);
299 if query.slice_label() == "associative" {
300 golden::run::record_bucket(&mut associative_slice, query, &evaluation);
301 } else {
302 golden::run::record_bucket(&mut non_associative_slices, query, &evaluation);
303 golden::run::record_bucket(
304 non_associative_by_slice
305 .entry(query.slice_label().to_string())
306 .or_default(),
307 query,
308 &evaluation,
309 );
310 }
311 scope_leak_count += results
312 .iter()
313 .filter(|memory| memory.scope != "global")
314 .filter(|memory| {
315 query.project.as_deref().is_some_and(|project| {
316 !crate::project_id::project_matches(Some(&memory.project), project)
317 })
318 })
319 .count();
320 query_summaries.push(GraphDecisionQuerySummary {
321 id: evaluation.id.clone(),
322 slice: evaluation.slice.clone(),
323 status: evaluation.status.label().to_string(),
324 result_count: evaluation.result_count,
325 retrieved_ids: evaluation.retrieved_ids.clone(),
326 matched_refs: evaluation.matched_refs,
327 expected_refs: evaluation.expected_refs,
328 retrieval_latency_ms,
329 hops,
330 entities_discovered,
331 graph_result_count,
332 });
333 }
334
335 let associative_queries_with_two_or_more_hops = query_summaries
336 .iter()
337 .filter(|summary| {
338 summary.slice == "associative" && summary.hops.is_some_and(|hops| hops >= 2)
339 })
340 .count();
341
342 Ok(GraphDecisionArmReport {
343 mode,
344 overall: golden::run::bucket_evaluation(overall),
345 associative_slice: golden::run::bucket_evaluation(associative_slice),
346 non_associative_slices: golden::run::bucket_evaluation(non_associative_slices),
347 non_associative_by_slice: non_associative_by_slice
348 .into_iter()
349 .map(|(name, bucket)| (name, golden::run::bucket_evaluation(bucket)))
350 .collect(),
351 associative_queries_with_two_or_more_hops,
352 scope_leak_count,
353 query_summaries,
354 })
355}
356
357fn literal_path_summary(
358 conn: &Connection,
359 query: &golden::GoldenQuery,
360 results: &[crate::memory::Memory],
361 explain: &crate::retrieval::search::SearchExplain,
362) -> Result<(Option<u8>, usize)> {
363 let seed_ids = explain
364 .channels
365 .iter()
366 .filter(|channel| channel.name == "fts" || channel.name == "vector")
367 .flat_map(|channel| channel.hits.iter().map(|hit| hit.memory_id))
368 .take(32)
369 .collect::<Vec<_>>();
370 let outcome = crate::retrieval::graph::traverse_trusted_graph(
371 conn,
372 crate::retrieval::graph::GraphTraversalRequest {
373 seed_memory_ids: &seed_ids,
374 project: query.project.as_deref(),
375 memory_type: query.memory_type.as_deref(),
376 branch: query.branch.as_deref(),
377 include_inactive: false,
378 reference_time_epoch: chrono::Utc::now().timestamp(),
379 limits: crate::retrieval::graph::GraphTraversalLimits::default(),
380 },
381 )?;
382 let result_ids = results
383 .iter()
384 .map(|memory| memory.id)
385 .collect::<BTreeSet<_>>();
386 let graph_hits = outcome
387 .hits
388 .iter()
389 .filter(|hit| result_ids.contains(&hit.memory_id))
390 .collect::<Vec<_>>();
391 Ok((
392 graph_hits.iter().map(|hit| hit.hop_count).max(),
393 graph_hits.len(),
394 ))
395}
396
397fn ensure_required_slices(
398 standard: &GraphDecisionArmReport,
399 literal_graph: &GraphDecisionArmReport,
400) -> Result<()> {
401 if standard.associative_slice.scored_queries == 0
402 || literal_graph.associative_slice.scored_queries == 0
403 {
404 bail!("graph decision eval requires scored associative queries in both arms");
405 }
406 Ok(())
407}
408
409fn build_deltas(
410 standard: &GraphDecisionArmReport,
411 literal_graph: &GraphDecisionArmReport,
412) -> GraphDecisionDeltas {
413 GraphDecisionDeltas {
414 associative_recall_at_k: metric_delta(
415 standard.associative_slice.metrics.as_ref(),
416 literal_graph.associative_slice.metrics.as_ref(),
417 |m| m.recall_at_k,
418 ),
419 associative_evidence_recall_at_k: metric_delta(
420 standard.associative_slice.metrics.as_ref(),
421 literal_graph.associative_slice.metrics.as_ref(),
422 |m| m.evidence_recall_at_k,
423 ),
424 associative_ndcg_at_10: metric_delta(
425 standard.associative_slice.metrics.as_ref(),
426 literal_graph.associative_slice.metrics.as_ref(),
427 |m| m.ndcg_at_10,
428 ),
429 non_associative_recall_at_k: metric_delta(
430 standard.non_associative_slices.metrics.as_ref(),
431 literal_graph.non_associative_slices.metrics.as_ref(),
432 |m| m.recall_at_k,
433 ),
434 non_associative_evidence_recall_at_k: metric_delta(
435 standard.non_associative_slices.metrics.as_ref(),
436 literal_graph.non_associative_slices.metrics.as_ref(),
437 |m| m.evidence_recall_at_k,
438 ),
439 non_associative_ndcg_at_10: metric_delta(
440 standard.non_associative_slices.metrics.as_ref(),
441 literal_graph.non_associative_slices.metrics.as_ref(),
442 |m| m.ndcg_at_10,
443 ),
444 p95_latency_ms: literal_graph.overall.retrieval_latency_p95_ms
445 - standard.overall.retrieval_latency_p95_ms,
446 }
447}
448
449fn metric_delta(
450 standard: Option<&MetricAverages>,
451 candidate: Option<&MetricAverages>,
452 value: impl Fn(&MetricAverages) -> f64,
453) -> f64 {
454 match (standard, candidate) {
455 (Some(standard), Some(candidate)) => value(candidate) - value(standard),
456 _ => 0.0,
457 }
458}
459
460fn build_checks(
461 standard: &GraphDecisionArmReport,
462 literal_graph: &GraphDecisionArmReport,
463 deltas: &GraphDecisionDeltas,
464) -> GraphDecisionChecks {
465 let associative_slice_present = standard.associative_slice.scored_queries > 0
466 && literal_graph.associative_slice.scored_queries > 0;
467 let literal_two_hop_observed = literal_graph.associative_queries_with_two_or_more_hops > 0;
468 let benefit_threshold_met = deltas.associative_evidence_recall_at_k >= BENEFIT_THRESHOLD;
469 let non_associative_zero_regression = non_associative_slices_not_lower(
470 &standard.non_associative_by_slice,
471 &literal_graph.non_associative_by_slice,
472 );
473 let zero_scope_leak = literal_graph.scope_leak_count == 0;
474 let p95_latency_within_budget =
475 literal_graph.overall.retrieval_latency_p95_ms <= LATENCY_BUDGET_P95_MS;
476 let safe_to_wire_literal_graph = benefit_threshold_met
477 && non_associative_zero_regression
478 && zero_scope_leak
479 && p95_latency_within_budget
480 && literal_two_hop_observed;
481
482 GraphDecisionChecks {
483 associative_slice_present,
484 literal_two_hop_observed,
485 benefit_threshold_met,
486 non_associative_zero_regression,
487 zero_scope_leak,
488 p95_latency_within_budget,
489 safe_to_wire_literal_graph,
490 all_checks_passed: associative_slice_present && safe_to_wire_literal_graph,
491 }
492}
493
494fn metrics_not_lower(
495 standard: Option<&MetricAverages>,
496 candidate: Option<&MetricAverages>,
497) -> bool {
498 match (standard, candidate) {
499 (Some(standard), Some(candidate)) => {
500 candidate.hit_at_k + EPSILON >= standard.hit_at_k
501 && candidate.mrr_at_10 + EPSILON >= standard.mrr_at_10
502 && candidate.precision_at_k + EPSILON >= standard.precision_at_k
503 && candidate.recall_at_k + EPSILON >= standard.recall_at_k
504 && candidate.ndcg_at_10 + EPSILON >= standard.ndcg_at_10
505 && candidate.evidence_recall_at_k + EPSILON >= standard.evidence_recall_at_k
506 }
507 (None, None) => true,
508 _ => false,
509 }
510}
511
512fn non_associative_slices_not_lower(
513 standard: &BTreeMap<String, CategoryEvaluation>,
514 candidate: &BTreeMap<String, CategoryEvaluation>,
515) -> bool {
516 standard.len() == candidate.len()
517 && standard.iter().all(|(slice, standard)| {
518 candidate.get(slice).is_some_and(|candidate| {
519 metrics_not_lower(standard.metrics.as_ref(), candidate.metrics.as_ref())
520 && candidate.abstention_passed >= standard.abstention_passed
521 })
522 })
523}
524
525fn seed_fixture_graph_edges(conn: &Connection, dataset: &GoldenDataset) -> Result<()> {
526 use crate::memory::graph_contract::{
527 insert_graph_edge, GraphEdgeInput, GraphEdgeProvenance, GraphEdgeType, GraphNodeRef,
528 };
529
530 let (event_id, candidate_id, operation_id) = seed_graph_provenance(conn)?;
531 let event_ids = [event_id];
532 let provenance = GraphEdgeProvenance {
533 source_event_ids: &event_ids,
534 source_candidate_id: Some(candidate_id),
535 source_operation_id: Some(operation_id),
536 confidence: Some(1.0),
537 reason: Some("pre-registered associative hop_path"),
538 };
539 let mut bridges = BTreeMap::<(String, String, String), GraphNodeRef>::new();
540 let mut inserted = BTreeSet::<(String, i64, i64)>::new();
541 for query in dataset
542 .queries
543 .iter()
544 .filter(|query| query.slice_label() == "associative")
545 {
546 let hop = query
547 .hop_path
548 .as_ref()
549 .with_context(|| format!("associative query {} missing hop_path", query.id))?;
550 let project = query.project.as_deref().unwrap_or("");
551 let key = (
552 project.to_string(),
553 hop.entity_type.clone(),
554 hop.entity.clone(),
555 );
556 let bridge = if let Some(node) = bridges.get(&key) {
557 *node
558 } else {
559 let node = create_graph_bridge(conn, project, &hop.entity_type, &hop.entity)?;
560 bridges.insert(key, node);
561 node
562 };
563 let edge_type = if hop.entity_type == "file_path" {
564 GraphEdgeType::TouchesFile
565 } else {
566 GraphEdgeType::Mentions
567 };
568 for topic_key in [&hop.source, &hop.target] {
569 let memory_id = conn
570 .query_row(
571 "SELECT id FROM memories WHERE topic_key = ?1
572 AND (?2 IS NULL OR project = ?2)
573 AND (?3 IS NULL OR branch = ?3 OR branch IS NULL) LIMIT 1",
574 rusqlite::params![topic_key, query.project, query.branch],
575 |row| row.get(0),
576 )
577 .with_context(|| format!("resolve golden graph memory {topic_key}"))?;
578 if inserted.insert((edge_type.as_str().to_string(), memory_id, bridge.id)) {
579 insert_graph_edge(
580 conn,
581 &GraphEdgeInput {
582 edge_type,
583 from_node: GraphNodeRef::memory(memory_id)?,
584 to_node: bridge,
585 provenance,
586 valid_from_epoch: None,
587 valid_to_epoch: None,
588 },
589 )?;
590 }
591 }
592 }
593 Ok(())
594}
595
596fn seed_graph_provenance(conn: &Connection) -> Result<(i64, i64, i64)> {
597 let now = 1_700_000_000_i64;
598 let host_id: i64 =
599 conn.query_row("SELECT id FROM hosts WHERE name = 'codex-cli'", [], |row| {
600 row.get(0)
601 })?;
602 conn.execute(
603 "INSERT INTO workspaces(root_path, git_remote, git_branch, created_at_epoch, updated_at_epoch)
604 VALUES ('/tmp/remem-gh853-eval', 'origin', 'main', ?1, ?1)",
605 [now],
606 )?;
607 let workspace_id = conn.last_insert_rowid();
608 conn.execute(
609 "INSERT INTO projects(workspace_id, project_path, project_key, created_at_epoch, updated_at_epoch)
610 VALUES (?1, '/tmp/remem-gh853-eval', 'gh853-eval', ?2, ?2)",
611 rusqlite::params![workspace_id, now],
612 )?;
613 let project_id = conn.last_insert_rowid();
614 conn.execute(
615 "INSERT INTO sessions(host_id, workspace_id, project_id, session_id, started_at_epoch,
616 last_seen_at_epoch, status) VALUES (?1, ?2, ?3, 'gh853-eval', ?4, ?4, 'active')",
617 rusqlite::params![host_id, workspace_id, project_id, now],
618 )?;
619 let session_row_id = conn.last_insert_rowid();
620 conn.execute(
621 "INSERT INTO captured_events(host_id, workspace_id, project_id, session_row_id,
622 session_id, event_id, event_type, content_hash, retention_class, created_at_epoch,
623 inserted_at_epoch) VALUES (?1, ?2, ?3, ?4, 'gh853-eval', 'gh853-eval-event',
624 'message', 'gh853-eval-hash', 'default', ?5, ?5)",
625 rusqlite::params![host_id, workspace_id, project_id, session_row_id, now],
626 )?;
627 let event_id = conn.last_insert_rowid();
628 conn.execute(
629 "INSERT INTO memory_candidates(project_id, scope, memory_type, topic_key, text,
630 evidence_event_ids, confidence, risk_class, review_status, created_at_epoch,
631 updated_at_epoch) VALUES (?1, 'project', 'decision', 'gh853-eval',
632 'pre-registered graph fixture', ?2, 1.0, 'low', 'accepted', ?3, ?3)",
633 rusqlite::params![project_id, format!("[{event_id}]"), now],
634 )?;
635 let candidate_id = conn.last_insert_rowid();
636 conn.execute(
637 "INSERT INTO memory_operation_log(operation, planner_version, actor, source,
638 owner_scope, owner_key, memory_type, state_key, source_candidate_id, superseded_ids,
639 conflicting_ids, confidence, reason, created_at_epoch) VALUES ('add', 'gh853-eval',
640 'eval', 'memory_candidate', 'project', 'gh853-eval', 'decision', 'gh853-eval',
641 ?1, '[]', '[]', 1.0, 'pre-registered graph fixture', ?2)",
642 rusqlite::params![candidate_id, now],
643 )?;
644 Ok((event_id, candidate_id, conn.last_insert_rowid()))
645}
646
647fn create_graph_bridge(
648 conn: &Connection,
649 project: &str,
650 entity_type: &str,
651 entity: &str,
652) -> Result<crate::memory::graph_contract::GraphNodeRef> {
653 use crate::memory::graph_contract::GraphNodeRef;
654 let now = 1_700_000_000_i64;
655 if entity_type == "file_path" {
656 conn.execute(
657 "INSERT INTO graph_file_nodes(project_id, source_project, path,
658 created_at_epoch, updated_at_epoch) VALUES (NULL, ?1, ?2, ?3, ?3)",
659 rusqlite::params![project, entity, now],
660 )?;
661 return GraphNodeRef::file(conn.last_insert_rowid());
662 }
663 conn.execute(
664 "INSERT OR IGNORE INTO entities(canonical_name, entity_type, mention_count,
665 created_at_epoch) VALUES (?1, ?2, 1, ?3)",
666 rusqlite::params![entity, entity_type, now],
667 )?;
668 let id = conn.query_row(
669 "SELECT id FROM entities WHERE canonical_name = ?1 COLLATE NOCASE LIMIT 1",
670 [entity],
671 |row| row.get(0),
672 )?;
673 GraphNodeRef::entity(id)
674}
675
676impl Display for GraphDecisionReport {
677 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
678 writeln!(
679 f,
680 "remem graph decision eval — {:?}, k={}, threshold={:.2}",
681 self.decision, self.k, self.benefit_threshold
682 )?;
683 writeln!(f, "reason: {}", self.decision_reason)?;
684 writeln!(
685 f,
686 "associative evidence delta={:.3}, non-associative evidence delta={:.3}, literal-graph p95={:.2}ms",
687 self.deltas.associative_evidence_recall_at_k,
688 self.deltas.non_associative_evidence_recall_at_k,
689 self.literal_graph.overall.retrieval_latency_p95_ms
690 )?;
691 writeln!(
692 f,
693 "checks: associative_slice_present={} literal_two_hop_observed={} benefit_threshold_met={} non_associative_zero_regression={} zero_scope_leak={} p95_latency_within_budget={} safe_to_wire_literal_graph={} all_checks_passed={}",
694 self.checks.associative_slice_present,
695 self.checks.literal_two_hop_observed,
696 self.checks.benefit_threshold_met,
697 self.checks.non_associative_zero_regression,
698 self.checks.zero_scope_leak,
699 self.checks.p95_latency_within_budget,
700 self.checks.safe_to_wire_literal_graph,
701 self.checks.all_checks_passed
702 )
703 }
704}
705
706pub mod evidence_fingerprint;
707
708#[cfg(test)]
709mod tests;