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 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;