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