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}