Skip to main content

remem/eval/memory_bench/
runner.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4
5use anyhow::{bail, Context, Result};
6use rusqlite::Connection;
7use serde_json::{json, Value};
8use sha2::{Digest, Sha256};
9
10use crate::eval::bench_artifact::{
11    BenchmarkLayer, MemoryCitationEvidence, MemoryDiagnosis, MemoryRetrievalEvidence,
12    MemoryRunArtifact, PublicBenchmarkReport, ReportVerifierMetadata, RunEnvironment,
13};
14
15use super::baselines::fixture_retrieval_indices;
16use super::diagnostics::{
17    classify_diagnosis, failure_decomposition, performance_by_condition, performance_metrics,
18    score_policy,
19};
20use super::fixture::load_suite_with_content_identity;
21use super::production_pipeline::retrieve_with_production_pipeline;
22use super::types::{
23    summarize_by_category, summarize_metrics, summarize_policy, MemoryBenchCondition,
24    MemoryBenchEvidence, MemoryBenchPolicyMeasurement, MemoryBenchRunOutcome,
25    MemoryBenchSuiteFixture, MemoryBenchTask, ADVERSARIAL_POLICY_SUITE, DEFAULT_PUBLIC_ROOT,
26};
27use super::PROJECT;
28const READER_PROVIDER: &str = "fixture";
29const READER_MODEL: &str = "deterministic-memory-reader";
30#[derive(Debug, Clone)]
31struct ExecutionIdentity {
32    remem_commit: Option<String>,
33    source_dirty: Option<bool>,
34    production_input_tree_sha256: Option<String>,
35}
36#[derive(Debug, Clone)]
37pub struct MemoryBenchOptions {
38    pub suite: String,
39    pub condition: Option<String>,
40    pub json_out: String,
41    pub root: String,
42    pub artifact_prefix: Option<String>,
43}
44pub async fn run_memory_bench(options: MemoryBenchOptions) -> Result<PublicBenchmarkReport> {
45    let (fixture, suite_content_identity) = load_suite_with_content_identity(&options.suite)?;
46    let conditions = selected_conditions(options.condition.as_deref())?;
47    if fixture.benchmark_id == "adversarial-policy"
48        && fixture.version == "v2"
49        && conditions.as_slice() != [MemoryBenchCondition::RememDefault]
50    {
51        bail!("adversarial-policy v2 requires --condition remem_default");
52    }
53    let public_root = PathBuf::from(if options.root.trim().is_empty() {
54        DEFAULT_PUBLIC_ROOT
55    } else {
56        options.root.as_str()
57    });
58    let json_out = PathBuf::from(&options.json_out);
59    let artifact_prefix = options
60        .artifact_prefix
61        .unwrap_or_else(|| format!("memory/artifacts/{}", fixture.fixture_revision));
62    validate_artifact_prefix(&artifact_prefix)?;
63    let public_layout = path_starts_with(&json_out, &public_root);
64    let execution_identity = execution_identity();
65    if public_layout && is_checked_in_public_root(&public_root) {
66        if execution_identity.source_dirty != Some(false) {
67            bail!("checked-in public memory benchmarks require a clean Git source tree");
68        }
69        if execution_identity.remem_commit.is_none()
70            || execution_identity.production_input_tree_sha256.is_none()
71        {
72            bail!("checked-in public memory benchmarks require Git commit and production-input identities");
73        }
74    }
75    let artifact_root = if public_layout {
76        public_root.join(&artifact_prefix)
77    } else {
78        sibling_artifact_root(&json_out)
79    };
80    fs::create_dir_all(&artifact_root).with_context(|| {
81        format!(
82            "create memory benchmark artifacts {}",
83            artifact_root.display()
84        )
85    })?;
86
87    let mut outcomes = Vec::new();
88    let mut run_artifacts = Vec::new();
89    for condition in conditions {
90        for task in &fixture.tasks {
91            let (outcome, database_snapshot) = run_task(&fixture, condition, task).await?;
92            let run_json_path = write_run_artifacts(
93                &fixture,
94                &outcome,
95                task,
96                &artifact_root,
97                &public_root,
98                public_layout,
99                &execution_identity,
100                &suite_content_identity,
101                database_snapshot.as_deref(),
102            )?;
103            run_artifacts.push(run_json_path);
104            outcomes.push(outcome);
105        }
106    }
107    let mut aggregate_metrics = json!({
108        "suite": fixture.suite,
109        "suite_version": fixture.version,
110        "fixture_revision": fixture.fixture_revision,
111        "suite_content_identity": suite_content_identity,
112        "run_count": outcomes.len(),
113        "overall": summarize_metrics(&outcomes),
114        "by_category": summarize_by_category(&outcomes),
115        "conditions": summarize_by_condition(&outcomes),
116        "failure_decomposition": failure_decomposition(&outcomes),
117        "performance": performance_by_condition(&outcomes),
118        "policy": summarize_policy(&outcomes),
119        "verification_paths": outcomes.iter()
120            .map(|outcome| outcome.policy.verification_path.clone())
121            .collect::<BTreeSet<_>>(),
122        "measurement_sources": outcomes.iter()
123            .map(|outcome| outcome.policy.measurement_source.clone())
124            .collect::<BTreeSet<_>>(),
125    });
126    if fixture.benchmark_id == "adversarial-policy" && fixture.version == "v2" {
127        let metrics = aggregate_metrics.as_object_mut().expect("json object");
128        for key in "overall,by_category,conditions,failure_decomposition,performance".split(',') {
129            metrics.remove(key);
130        }
131    }
132    let report = PublicBenchmarkReport {
133        schema_version: 1,
134        benchmark_id: fixture.benchmark_id.clone(),
135        benchmark_version: fixture.version.clone(),
136        suite: Some(fixture.suite.clone()),
137        run_phase: None,
138        matrix_namespace: None,
139        layer: BenchmarkLayer::MemorySystemCapability,
140        conditions: outcomes
141            .iter()
142            .map(|outcome| outcome.condition.as_str().to_string())
143            .collect::<BTreeSet<_>>()
144            .into_iter()
145            .collect(),
146        schema_refs: vec![
147            "schemas/benchmark-manifest.schema.json".to_string(),
148            "schemas/memory-report.schema.json".to_string(),
149            "schemas/memory-run.schema.json".to_string(),
150            "schemas/reproduction-metadata.schema.json".to_string(),
151        ],
152        run_artifacts,
153        aggregate_metrics,
154        claim_level: "directional_memory_suite_no_public_claim".to_string(),
155        verifier: ReportVerifierMetadata {
156            required: true,
157            schema_version: 1,
158        },
159    };
160
161    if let Some(parent) = json_out.parent() {
162        if !parent.as_os_str().is_empty() {
163            fs::create_dir_all(parent).with_context(|| {
164                format!("create memory benchmark report dir {}", parent.display())
165            })?;
166        }
167    }
168    fs::write(&json_out, serde_json::to_string_pretty(&report)?)
169        .with_context(|| format!("write memory benchmark report {}", json_out.display()))?;
170    Ok(report)
171}
172
173fn validate_artifact_prefix(raw: &str) -> Result<()> {
174    let path = Path::new(raw);
175    if raw.trim().is_empty()
176        || path.is_absolute()
177        || path.components().any(|component| {
178            matches!(
179                component,
180                Component::ParentDir | Component::RootDir | Component::Prefix(_)
181            )
182        })
183    {
184        anyhow::bail!("artifact prefix must be a nonempty relative path without parent traversal");
185    }
186    Ok(())
187}
188
189fn selected_conditions(condition: Option<&str>) -> Result<Vec<MemoryBenchCondition>> {
190    match condition {
191        Some(raw) => {
192            let condition = MemoryBenchCondition::parse(raw)
193                .with_context(|| format!("unknown memory benchmark condition {raw}"))?;
194            Ok(vec![condition])
195        }
196        None => Ok(MemoryBenchCondition::ALL.to_vec()),
197    }
198}
199
200async fn run_task(
201    fixture: &MemoryBenchSuiteFixture,
202    condition: MemoryBenchCondition,
203    task: &MemoryBenchTask,
204) -> Result<(MemoryBenchRunOutcome, Option<Vec<u8>>)> {
205    if fixture.suite == ADVERSARIAL_POLICY_SUITE && condition == MemoryBenchCondition::RememDefault
206    {
207        let (retrieved, measurement, database_snapshot) =
208            retrieve_with_production_pipeline(task).await?;
209        return Ok((
210            score_task(fixture, condition, task, retrieved, Some(&measurement)),
211            Some(database_snapshot),
212        ));
213    }
214    let retrieved = if let Some(indices) = fixture_retrieval_indices(condition, task) {
215        indices
216            .into_iter()
217            .map(|idx| RetrievedEvidence::from_fixture(idx, &task.evidence[idx]))
218            .collect()
219    } else {
220        retrieve_with_remem_search(task)?
221    };
222    Ok((score_task(fixture, condition, task, retrieved, None), None))
223}
224
225fn retrieve_with_remem_search(task: &MemoryBenchTask) -> Result<Vec<RetrievedEvidence>> {
226    let conn = Connection::open_in_memory()?;
227    crate::migrate::run_migrations(&conn)?;
228    let mut by_memory_id = BTreeMap::new();
229    for evidence in task
230        .evidence
231        .iter()
232        .filter(|evidence| evidence.retention_allowed)
233    {
234        let files = if evidence.files.is_empty() {
235            None
236        } else {
237            Some(serde_json::to_string(&evidence.files)?)
238        };
239        let id = crate::memory::insert_memory_full_with_reference_time(
240            &conn,
241            Some(&evidence.event_id),
242            PROJECT,
243            evidence.topic_key.as_deref(),
244            &evidence.title,
245            &evidence.content,
246            &evidence.memory_type,
247            files.as_deref(),
248            Some("main"),
249            &evidence.scope,
250            evidence.created_at_epoch,
251            evidence.created_at_epoch,
252        )?;
253        if evidence.status != "active" {
254            conn.execute(
255                "UPDATE memories SET status = ?1 WHERE id = ?2",
256                rusqlite::params![evidence.status, id],
257            )?;
258        }
259        by_memory_id.insert(id, evidence);
260    }
261
262    let hits = crate::retrieval::search::search_with_branch(
263        &conn,
264        Some(&task.query),
265        Some(PROJECT),
266        None,
267        5,
268        0,
269        false,
270        Some("main"),
271    )?;
272    Ok(hits
273        .into_iter()
274        .filter_map(|memory| {
275            by_memory_id
276                .get(&memory.id)
277                .map(|evidence| RetrievedEvidence::from_memory(memory.id, evidence))
278        })
279        .collect())
280}
281
282fn score_task(
283    fixture: &MemoryBenchSuiteFixture,
284    condition: MemoryBenchCondition,
285    task: &MemoryBenchTask,
286    retrieved: Vec<RetrievedEvidence>,
287    measurement: Option<&MemoryBenchPolicyMeasurement>,
288) -> MemoryBenchRunOutcome {
289    let gold = task
290        .gold_supporting_event_ids
291        .iter()
292        .cloned()
293        .collect::<BTreeSet<_>>();
294    let forbidden = task
295        .forbidden_event_ids
296        .iter()
297        .cloned()
298        .collect::<BTreeSet<_>>();
299    let retrieved_events = retrieved
300        .iter()
301        .map(|item| item.event_id.clone())
302        .collect::<Vec<_>>();
303    let retrieved_set = retrieved_events.iter().cloned().collect::<BTreeSet<_>>();
304    let retrieved_gold = gold
305        .intersection(&retrieved_set)
306        .cloned()
307        .collect::<Vec<_>>();
308    let missing_event_ids = gold.difference(&retrieved_set).cloned().collect::<Vec<_>>();
309    let forbidden_count = forbidden.intersection(&retrieved_set).count();
310    let support_coverage = ratio(retrieved_gold.len(), gold.len());
311    let evidence_complete = missing_event_ids.is_empty() && forbidden_count == 0;
312    let expected_policy_abstention = task
313        .policy
314        .as_ref()
315        .map(|policy| policy.expected_policy_abstention)
316        .unwrap_or(false);
317    let abstained = expected_policy_abstention || !evidence_complete;
318    let answer_score = if evidence_complete
319        || ((task.abstention_allowed || expected_policy_abstention) && abstained)
320    {
321        1.0
322    } else {
323        0.0
324    };
325    let answer_text = if abstained {
326        super::SAFE_ABSTENTION_ANSWER.to_string()
327    } else {
328        task.expected_answer.clone()
329    };
330    let cited_memory_ids = if abstained {
331        Vec::new()
332    } else {
333        retrieved
334            .iter()
335            .filter(|item| gold.contains(&item.event_id))
336            .map(|item| item.memory_id)
337            .collect()
338    };
339    let cited_event_ids = if abstained {
340        Vec::new()
341    } else {
342        retrieved_gold.clone()
343    };
344    let citation_recall = if abstained {
345        0.0
346    } else {
347        ratio(cited_event_ids.len(), gold.len())
348    };
349    let citation_precision = if abstained || cited_event_ids.is_empty() {
350        0.0
351    } else {
352        ratio(
353            cited_event_ids.len(),
354            cited_event_ids.len() + forbidden_count,
355        )
356    };
357    let staleness_accuracy = if forbidden_count == 0 { 1.0 } else { 0.0 };
358    let expected_abstention = condition == MemoryBenchCondition::NoMemory
359        || task.abstention_allowed
360        || expected_policy_abstention;
361    let abstention_accuracy = if abstained == expected_abstention {
362        1.0
363    } else {
364        0.0
365    };
366    let policy = score_policy(condition, task, &retrieved_events, abstained, measurement);
367    let reader_input = build_reader_input(condition, task, &retrieved);
368    let diagnosis =
369        classify_diagnosis(condition, task, &missing_event_ids, answer_score, abstained);
370    let performance = performance_metrics(condition, task, &reader_input, retrieved.len());
371    let retrieved_evidence_json = json!({
372        "suite": fixture.suite,
373        "fixture_revision": fixture.fixture_revision,
374        "condition": condition.as_str(),
375        "task_id": task.id,
376        "verification_path": policy.verification_path,
377        "measurement_source": policy.measurement_source,
378        "retrieved": retrieved.iter().map(RetrievedEvidence::to_json).collect::<Vec<_>>(),
379    });
380    let mut diagnosis_notes = Vec::new();
381    if !missing_event_ids.is_empty() {
382        diagnosis_notes.push(format!(
383            "missing supporting evidence: {}",
384            missing_event_ids.join(",")
385        ));
386    }
387    if forbidden_count > 0 {
388        diagnosis_notes.push(format!(
389            "retrieved forbidden evidence count: {forbidden_count}"
390        ));
391    }
392    if policy.policy_failure_count > 0 {
393        diagnosis_notes.push(format!(
394            "structured policy failure count: {}",
395            policy.policy_failure_count
396        ));
397    }
398    if policy.non_retention_leaked {
399        diagnosis_notes.push("non-retention leak detected".to_string());
400    }
401    if policy.false_blocked {
402        diagnosis_notes.push("approved policy evidence was falsely blocked".to_string());
403    }
404
405    MemoryBenchRunOutcome {
406        condition,
407        task_id: task.id.clone(),
408        category: task.category.clone(),
409        run_index: 0,
410        retrieved_memory_ids: retrieved.iter().map(|item| item.memory_id).collect(),
411        retrieved_event_ids: retrieved_events,
412        cited_memory_ids,
413        cited_event_ids,
414        missing_event_ids,
415        answer_text,
416        abstained,
417        support_coverage,
418        answer_score,
419        citation_recall,
420        citation_precision,
421        staleness_accuracy,
422        abstention_accuracy,
423        forbidden_evidence_count: forbidden_count,
424        reader_input,
425        retrieved_evidence_json,
426        diagnosis_notes,
427        policy,
428        diagnosis,
429        performance,
430    }
431}
432
433fn build_reader_input(
434    condition: MemoryBenchCondition,
435    task: &MemoryBenchTask,
436    retrieved: &[RetrievedEvidence],
437) -> String {
438    let mut input = String::new();
439    input.push_str(&format!("condition: {}\n", condition.as_str()));
440    input.push_str(&format!("task_id: {}\n", task.id));
441    input.push_str(&format!("category: {}\n", task.category));
442    input.push_str(&format!(
443        "reference_time_epoch: {}\n\n",
444        task.reference_time_epoch
445    ));
446    input.push_str("question:\n");
447    input.push_str(&task.prompt);
448    input.push_str("\n\nretrieved_evidence:\n");
449    if retrieved.is_empty() {
450        input.push_str("(none)\n");
451    } else {
452        for evidence in retrieved {
453            input.push_str(&format!(
454                "- memory_id={} event_id={} status={} title={}\n  {}\n",
455                evidence.memory_id,
456                evidence.event_id,
457                evidence.status,
458                evidence.title,
459                evidence.content
460            ));
461        }
462    }
463    input
464}
465
466#[allow(clippy::too_many_arguments)]
467fn write_run_artifacts(
468    fixture: &MemoryBenchSuiteFixture,
469    outcome: &MemoryBenchRunOutcome,
470    task: &MemoryBenchTask,
471    artifact_root: &Path,
472    public_root: &Path,
473    public_layout: bool,
474    execution_identity: &ExecutionIdentity,
475    suite_content_identity: &str,
476    database_snapshot: Option<&[u8]>,
477) -> Result<String> {
478    let run_dir = artifact_root.join(format!(
479        "{}-{}",
480        outcome.condition.as_str(),
481        outcome.task_id
482    ));
483    fs::create_dir_all(&run_dir)
484        .with_context(|| format!("create memory benchmark run dir {}", run_dir.display()))?;
485
486    let reader_input_path = run_dir.join("reader_input.txt");
487    let retrieved_path = run_dir.join("retrieved_evidence.json");
488    let answer_path = run_dir.join("answer.json");
489    let score_path = run_dir.join("score.json");
490    let diagnosis_path = run_dir.join("diagnosis.json");
491    let snapshot_path = run_dir.join("remem.db.snapshot.sqlite3");
492    let run_path = run_dir.join("run.json");
493
494    fs::write(&reader_input_path, &outcome.reader_input)?;
495    fs::write(
496        &retrieved_path,
497        serde_json::to_string_pretty(&outcome.retrieved_evidence_json)?,
498    )?;
499    fs::write(
500        &answer_path,
501        serde_json::to_string_pretty(&json!({
502            "text": outcome.answer_text,
503            "abstained": outcome.abstained,
504            "score": outcome.answer_score,
505        }))?,
506    )?;
507    fs::write(
508        &score_path,
509        serde_json::to_string_pretty(&json!({
510            "support_coverage": outcome.support_coverage,
511            "answer_score": outcome.answer_score,
512            "citation_recall": outcome.citation_recall,
513            "citation_precision": outcome.citation_precision,
514            "staleness_accuracy": outcome.staleness_accuracy,
515            "abstention_accuracy": outcome.abstention_accuracy,
516            "forbidden_evidence_count": outcome.forbidden_evidence_count,
517        }))?,
518    )?;
519    fs::write(
520        &diagnosis_path,
521        serde_json::to_string_pretty(&json!({
522            "notes": outcome.diagnosis_notes,
523            "missing_event_ids": outcome.missing_event_ids,
524        }))?,
525    )?;
526    if let Some(snapshot) = database_snapshot {
527        fs::write(&snapshot_path, snapshot)?;
528    }
529
530    let mut artifacts = BTreeMap::from([
531        (
532            "reader_input".to_string(),
533            artifact_path(&reader_input_path, public_root, public_layout)?,
534        ),
535        (
536            "retrieved_evidence".to_string(),
537            artifact_path(&retrieved_path, public_root, public_layout)?,
538        ),
539        (
540            "answer".to_string(),
541            artifact_path(&answer_path, public_root, public_layout)?,
542        ),
543        (
544            "score".to_string(),
545            artifact_path(&score_path, public_root, public_layout)?,
546        ),
547        (
548            "diagnosis".to_string(),
549            artifact_path(&diagnosis_path, public_root, public_layout)?,
550        ),
551    ]);
552    if database_snapshot.is_some() {
553        artifacts.insert(
554            "remem_db_snapshot".to_string(),
555            artifact_path(&snapshot_path, public_root, public_layout)?,
556        );
557    }
558    let artifact_sha256 = artifacts
559        .iter()
560        .map(|(key, relative)| {
561            let path = if public_layout {
562                public_root.join(relative)
563            } else {
564                PathBuf::from(relative)
565            };
566            let bytes = fs::read(&path)
567                .with_context(|| format!("read generated artifact {}", path.display()))?;
568            Ok((key.clone(), format!("{:x}", Sha256::digest(bytes))))
569        })
570        .collect::<Result<BTreeMap<_, _>>>()?;
571    let run = MemoryRunArtifact {
572        schema_version: 1,
573        benchmark_id: fixture.benchmark_id.clone(),
574        benchmark_version: fixture.version.clone(),
575        layer: BenchmarkLayer::MemorySystemCapability,
576        suite: fixture.suite.clone(),
577        condition: outcome.condition.as_str().to_string(),
578        task_id: outcome.task_id.clone(),
579        run_index: outcome.run_index,
580        reference_time_epoch: task.reference_time_epoch,
581        reader_model: json!({
582            "provider": READER_PROVIDER,
583            "model": READER_MODEL,
584            "temperature": 0,
585            "prompt_hash": prompt_hash(&task.prompt),
586        }),
587        environment: RunEnvironment {
588            os: std::env::consts::OS.to_string(),
589            arch: std::env::consts::ARCH.to_string(),
590            remem_commit: execution_identity
591                .remem_commit
592                .clone()
593                .unwrap_or_else(|| "unknown".to_string()),
594            remem_data_dir: format!(
595                "temp://remem-memory-bench/{}/{}/{}",
596                fixture.fixture_revision,
597                outcome.condition.as_str(),
598                outcome.task_id
599            ),
600            docker_image_digest: benchmark_container_image_digest(),
601            fixture_revision: Some(fixture.fixture_revision.clone()),
602            repo_base_commit: None,
603            source_dirty: execution_identity.source_dirty,
604            production_input_tree_sha256: execution_identity.production_input_tree_sha256.clone(),
605        },
606        answer: json!({
607            "text": outcome.answer_text,
608            "abstained": outcome.abstained,
609            "score": outcome.answer_score,
610            "score_method": "deterministic_fixture",
611            "temporal_as_of_correct": outcome.staleness_accuracy == 1.0,
612            "no_answer_correct": outcome.abstention_accuracy == 1.0,
613        }),
614        retrieval: MemoryRetrievalEvidence {
615            retrieved_memory_ids: outcome.retrieved_memory_ids.clone(),
616            retrieved_supporting_evidence_ids: outcome.retrieved_event_ids.clone(),
617            gold_supporting_event_ids: task.gold_supporting_event_ids.clone(),
618            missing_supporting_evidence_ids: outcome.missing_event_ids.clone(),
619        },
620        evidence: MemoryCitationEvidence {
621            cited_memory_ids: outcome.cited_memory_ids.clone(),
622            cited_event_ids: outcome.cited_event_ids.clone(),
623        },
624        metrics: json!({
625            "ingest_tokens": outcome.performance.ingest_tokens,
626            "query_tokens": outcome.performance.query_tokens,
627            "reader_tokens": outcome.performance.reader_tokens,
628            "retrieval_latency_ms": outcome.performance.retrieval_latency_ms,
629            "end_to_end_latency_ms": outcome.performance.end_to_end_latency_ms,
630            "rows_written": outcome.performance.rows_written,
631            "support_coverage": outcome.support_coverage,
632            "answer_score": outcome.answer_score,
633            "citation_recall": outcome.citation_recall,
634            "citation_precision": outcome.citation_precision,
635            "staleness_accuracy": outcome.staleness_accuracy,
636            "abstention_accuracy": outcome.abstention_accuracy,
637            "forbidden_evidence_count": outcome.forbidden_evidence_count,
638            "retrieved_memory_count": outcome.retrieved_memory_ids.len(),
639            "policy": {
640                "verification_path": outcome.policy.verification_path,
641                "measurement_source": outcome.policy.measurement_source,
642                "source_scanner_config": outcome.policy.source_scanner_config,
643                "active_claim_count": outcome.policy.active_claim_count,
644                "candidate_count": outcome.policy.candidate_count,
645                "summary_input_count": outcome.policy.summary_input_count,
646                "poisoning_applicable": outcome.policy.poisoning_applicable,
647                "poisoning_source_scanner_matched": outcome.policy.poisoning_source_scanner_matched,
648                "poisoning_generated_surface_blocked": outcome.policy.poisoning_generated_surface_blocked,
649                "poisoning_scanner_matched": outcome.policy.poisoning_scanner_matched,
650                "policy_failure_count": outcome.policy.policy_failure_count,
651            },
652        }),
653        diagnosis: MemoryDiagnosis {
654            write_side_gap: outcome.diagnosis.write_side_gap,
655            retrieval_side_gap: outcome.diagnosis.retrieval_side_gap,
656            reader_gap: outcome.diagnosis.reader_gap,
657            policy_abstention: outcome.diagnosis.policy_abstention,
658            notes: outcome.diagnosis_notes.clone(),
659        },
660        artifacts,
661        artifact_sha256,
662        suite_content_identity: Some(suite_content_identity.to_string()),
663    };
664    fs::write(&run_path, serde_json::to_string_pretty(&run)?)?;
665    artifact_path(&run_path, public_root, public_layout)
666}
667
668fn summarize_by_condition(
669    outcomes: &[MemoryBenchRunOutcome],
670) -> BTreeMap<String, super::types::MemoryBenchMetricSummary> {
671    let mut grouped: BTreeMap<String, Vec<&MemoryBenchRunOutcome>> = BTreeMap::new();
672    for outcome in outcomes {
673        grouped
674            .entry(outcome.condition.as_str().to_string())
675            .or_default()
676            .push(outcome);
677    }
678    grouped
679        .into_iter()
680        .map(|(condition, runs)| (condition, summarize_metrics(runs)))
681        .collect()
682}
683
684fn path_starts_with(path: &Path, root: &Path) -> bool {
685    path.starts_with(root) || (!path.is_absolute() && root.is_relative() && path.starts_with(root))
686}
687
688fn sibling_artifact_root(json_out: &Path) -> PathBuf {
689    let stem = json_out
690        .file_stem()
691        .and_then(|value| value.to_str())
692        .unwrap_or("remem-memory-bench");
693    let dir_name = format!("{stem}-artifacts");
694    json_out
695        .parent()
696        .filter(|parent| !parent.as_os_str().is_empty())
697        .unwrap_or_else(|| Path::new("."))
698        .join(dir_name)
699}
700
701fn artifact_path(path: &Path, public_root: &Path, public_layout: bool) -> Result<String> {
702    if public_layout {
703        let relative = path.strip_prefix(public_root).with_context(|| {
704            format!(
705                "artifact path {} must be inside public root {}",
706                path.display(),
707                public_root.display()
708            )
709        })?;
710        Ok(path_to_string(relative))
711    } else {
712        Ok(path_to_string(path))
713    }
714}
715
716fn path_to_string(path: &Path) -> String {
717    path.to_string_lossy().replace('\\', "/")
718}
719
720fn ratio(numerator: usize, denominator: usize) -> f64 {
721    if denominator == 0 {
722        0.0
723    } else {
724        numerator as f64 / denominator as f64
725    }
726}
727
728fn prompt_hash(prompt: &str) -> String {
729    let mut hasher = Sha256::new();
730    hasher.update(prompt.as_bytes());
731    format!("sha256:{:x}", hasher.finalize())
732}
733
734fn execution_identity() -> ExecutionIdentity {
735    ExecutionIdentity {
736        remem_commit: option_env!("REMEM_BUILD_GIT_SHA").map(str::to_string),
737        source_dirty: option_env!("REMEM_BUILD_SOURCE_DIRTY").and_then(|value| match value {
738            "true" => Some(true),
739            "false" => Some(false),
740            _ => None,
741        }),
742        production_input_tree_sha256: option_env!("REMEM_BUILD_PRODUCTION_INPUT_TREE_SHA256")
743            .map(str::to_string),
744    }
745}
746
747fn benchmark_container_image_digest() -> Option<String> {
748    std::env::var("REMEM_BENCH_CONTAINER_IMAGE_DIGEST")
749        .ok()
750        .filter(|value| !value.trim().is_empty())
751        .or_else(|| Some("local-fixture-no-docker".to_string()))
752}
753
754pub(super) fn is_checked_in_public_root(public_root: &Path) -> bool {
755    match (
756        public_root.canonicalize(),
757        Path::new(DEFAULT_PUBLIC_ROOT).canonicalize(),
758    ) {
759        (Ok(requested), Ok(checked_in)) => requested == checked_in,
760        _ => false,
761    }
762}
763
764#[derive(Debug, Clone)]
765pub(super) struct RetrievedEvidence {
766    memory_id: i64,
767    pub(super) event_id: String,
768    title: String,
769    content: String,
770    status: String,
771    source_anchor: String,
772}
773
774impl RetrievedEvidence {
775    fn from_fixture(index: usize, evidence: &MemoryBenchEvidence) -> Self {
776        Self::from_memory((index + 1) as i64, evidence)
777    }
778
779    pub(super) fn from_memory(memory_id: i64, evidence: &MemoryBenchEvidence) -> Self {
780        Self {
781            memory_id,
782            event_id: evidence.event_id.clone(),
783            title: evidence.title.clone(),
784            content: evidence.content.clone(),
785            status: evidence.status.clone(),
786            source_anchor: evidence.source_anchor.clone(),
787        }
788    }
789
790    fn to_json(&self) -> Value {
791        json!({
792            "memory_id": self.memory_id,
793            "event_id": self.event_id,
794            "title": self.title,
795            "content": self.content,
796            "status": self.status,
797            "source_anchor": self.source_anchor,
798        })
799    }
800}