use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::adapter::{blake3_hex, TokenUsage};
pub type SourceClaim = String;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SummaryRequest {
pub model_name: String,
pub prompt_template_blake3: String,
pub source_claims: Vec<SourceClaim>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_output_bytes: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decay_job_id: Option<String>,
}
impl SummaryRequest {
#[must_use]
pub fn request_hash(&self) -> String {
let canonical = CanonicalSummaryRequest {
domain: "cortex.llm.summary.request.v1",
model_name: &self.model_name,
prompt_template_blake3: &self.prompt_template_blake3,
source_claims: &self.source_claims,
max_output_bytes: self.max_output_bytes,
decay_job_id: self.decay_job_id.as_deref(),
};
let bytes =
serde_json::to_vec(&canonical).expect("CanonicalSummaryRequest is always serializable");
blake3_hex(&bytes)
}
}
#[derive(Serialize)]
struct CanonicalSummaryRequest<'a> {
domain: &'static str,
model_name: &'a str,
prompt_template_blake3: &'a str,
source_claims: &'a [SourceClaim],
#[serde(skip_serializing_if = "Option::is_none")]
max_output_bytes: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
decay_job_id: Option<&'a str>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SummaryResponse {
pub claim: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_usage: Option<TokenUsage>,
pub model_name_echoed: String,
}
#[derive(Debug, Error, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum SummaryError {
#[error("summary_backend_not_configured")]
BackendNotConfigured,
#[error("summary_model_not_in_allowlist: {0}")]
ModelNotInAllowlist(String),
#[error("summary_prompt_template_mismatch: {0}")]
PromptTemplateMismatch(String),
#[error("summary_call_failed: {0}")]
CallFailed(String),
#[error("summary_output_validation_failed: {0}")]
OutputValidationFailed(String),
}
pub trait SummaryBackend: fmt::Debug + Send + Sync {
fn summarize(&self, request: &SummaryRequest) -> Result<SummaryResponse, SummaryError>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopSummaryBackend;
impl SummaryBackend for NoopSummaryBackend {
fn summarize(&self, _request: &SummaryRequest) -> Result<SummaryResponse, SummaryError> {
Err(SummaryError::BackendNotConfigured)
}
}
#[derive(Debug, Clone)]
pub struct ReplaySummaryBackend {
by_hash: HashMap<String, SummaryResponse>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplaySummaryFixture {
pub entries: Vec<ReplaySummaryFixtureEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplaySummaryFixtureEntry {
pub request: SummaryRequest,
pub response: SummaryResponse,
}
impl ReplaySummaryBackend {
pub fn from_entries(entries: Vec<ReplaySummaryFixtureEntry>) -> Result<Self, SummaryError> {
let mut by_hash: HashMap<String, SummaryResponse> = HashMap::new();
for entry in entries {
let key = entry.request.request_hash();
if by_hash.insert(key.clone(), entry.response).is_some() {
return Err(SummaryError::CallFailed(format!(
"duplicate replay summary fixture for request_hash={key}"
)));
}
}
Ok(Self { by_hash })
}
pub fn from_fixture_file(path: &Path) -> Result<Self, SummaryError> {
let raw = fs::read_to_string(path).map_err(|err| {
SummaryError::CallFailed(format!("fixture `{}` not readable: {err}", path.display()))
})?;
let fixture: ReplaySummaryFixture = serde_json::from_str(&raw).map_err(|err| {
SummaryError::CallFailed(format!("fixture `{}` did not parse: {err}", path.display()))
})?;
Self::from_entries(fixture.entries)
}
#[must_use]
pub fn fixture_count(&self) -> usize {
self.by_hash.len()
}
}
impl SummaryBackend for ReplaySummaryBackend {
fn summarize(&self, request: &SummaryRequest) -> Result<SummaryResponse, SummaryError> {
let key = request.request_hash();
match self.by_hash.get(&key) {
Some(response) => Ok(response.clone()),
None => Err(SummaryError::BackendNotConfigured),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn sample_request(model: &str, prompt: &str, claims: &[&str]) -> SummaryRequest {
SummaryRequest {
model_name: model.into(),
prompt_template_blake3: prompt.into(),
source_claims: claims.iter().map(|s| (*s).to_string()).collect(),
max_output_bytes: Some(2048),
decay_job_id: Some("dcy_01ARZ3NDEKTSV4RRFFQ69G5FAV".into()),
}
}
fn sample_response(claim: &str, model: &str) -> SummaryResponse {
SummaryResponse {
claim: claim.into(),
token_usage: Some(TokenUsage {
prompt_tokens: 10,
completion_tokens: 20,
}),
model_name_echoed: model.into(),
}
}
#[test]
fn request_hash_is_stable_across_calls() {
let req = sample_request(
"claude-sonnet-4-7@1",
"blake3:00000000000000000000000000000000",
&["alpha", "beta"],
);
assert_eq!(req.request_hash(), req.request_hash());
}
#[test]
fn request_hash_changes_with_any_field() {
let base = sample_request("claude-sonnet-4-7@1", "blake3:0000", &["alpha", "beta"]);
let mut model_changed = base.clone();
model_changed.model_name = "other".into();
assert_ne!(base.request_hash(), model_changed.request_hash());
let mut claims_reordered = base.clone();
claims_reordered.source_claims = vec!["beta".into(), "alpha".into()];
assert_ne!(base.request_hash(), claims_reordered.request_hash());
let mut prompt_changed = base.clone();
prompt_changed.prompt_template_blake3 = "blake3:1111".into();
assert_ne!(base.request_hash(), prompt_changed.request_hash());
let mut job_changed = base.clone();
job_changed.decay_job_id = Some("dcy_other".into());
assert_ne!(base.request_hash(), job_changed.request_hash());
}
#[test]
fn noop_backend_fails_closed() {
let backend = NoopSummaryBackend;
let req = sample_request("any-model", "blake3:00", &["one"]);
let err = backend.summarize(&req).unwrap_err();
assert_eq!(err, SummaryError::BackendNotConfigured);
assert!(err.to_string().contains("summary_backend_not_configured"));
}
#[test]
fn replay_backend_round_trips_a_pre_baked_response() {
let req = sample_request("claude-sonnet-4-7@1", "blake3:abcd", &["alpha", "beta"]);
let resp = sample_response("alpha and beta", "claude-sonnet-4-7@1");
let backend = ReplaySummaryBackend::from_entries(vec![ReplaySummaryFixtureEntry {
request: req.clone(),
response: resp.clone(),
}])
.expect("build replay backend");
assert_eq!(backend.fixture_count(), 1);
let got = backend.summarize(&req).expect("hit");
assert_eq!(got.claim, resp.claim);
assert_eq!(got.model_name_echoed, resp.model_name_echoed);
}
#[test]
fn replay_backend_miss_returns_backend_not_configured() {
let req = sample_request("m1", "blake3:aaaa", &["alpha"]);
let resp = sample_response("summary", "m1");
let backend = ReplaySummaryBackend::from_entries(vec![ReplaySummaryFixtureEntry {
request: req.clone(),
response: resp,
}])
.expect("build replay backend");
let other = sample_request("m1", "blake3:aaaa", &["never seen"]);
let err = backend.summarize(&other).unwrap_err();
assert_eq!(err, SummaryError::BackendNotConfigured);
}
#[test]
fn replay_backend_refuses_duplicate_fixture_keys() {
let req = sample_request("m1", "blake3:aaaa", &["alpha"]);
let resp = sample_response("s1", "m1");
let err = ReplaySummaryBackend::from_entries(vec![
ReplaySummaryFixtureEntry {
request: req.clone(),
response: resp.clone(),
},
ReplaySummaryFixtureEntry {
request: req,
response: resp,
},
])
.unwrap_err();
match err {
SummaryError::CallFailed(msg) => {
assert!(msg.contains("duplicate"), "got {msg}");
}
other => panic!("expected CallFailed, got {other:?}"),
}
}
#[test]
fn replay_backend_loads_from_disk_fixture_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("fixture.json");
let fixture = ReplaySummaryFixture {
entries: vec![ReplaySummaryFixtureEntry {
request: sample_request("m1", "blake3:abcd", &["alpha", "beta"]),
response: sample_response("summary text", "m1"),
}],
};
let mut f = fs::File::create(&path).unwrap();
f.write_all(&serde_json::to_vec_pretty(&fixture).unwrap())
.unwrap();
drop(f);
let backend = ReplaySummaryBackend::from_fixture_file(&path).expect("load fixture");
assert_eq!(backend.fixture_count(), 1);
let got = backend
.summarize(&sample_request("m1", "blake3:abcd", &["alpha", "beta"]))
.expect("hit");
assert_eq!(got.claim, "summary text");
}
#[test]
fn replay_backend_load_missing_file_returns_call_failed() {
let err = ReplaySummaryBackend::from_fixture_file(Path::new("this/does/not/exist.json"))
.unwrap_err();
match err {
SummaryError::CallFailed(msg) => {
assert!(msg.contains("not readable"), "got {msg}");
}
other => panic!("expected CallFailed, got {other:?}"),
}
}
#[test]
fn summary_error_display_carries_discriminator_tokens() {
assert!(SummaryError::BackendNotConfigured
.to_string()
.contains("summary_backend_not_configured"));
assert!(SummaryError::ModelNotInAllowlist("m".into())
.to_string()
.contains("summary_model_not_in_allowlist"));
assert!(SummaryError::PromptTemplateMismatch("p".into())
.to_string()
.contains("summary_prompt_template_mismatch"));
assert!(SummaryError::CallFailed("c".into())
.to_string()
.contains("summary_call_failed"));
assert!(SummaryError::OutputValidationFailed("o".into())
.to_string()
.contains("summary_output_validation_failed"));
}
}