Skip to main content

remem/eval/
graph_decision.rs

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;