Skip to main content

harn_vm/llm/
cache_conformance.rs

1//! Prompt-cache conformance probe + classifier for Harn providers.
2//!
3//! The classifier is the stable contract Burin dogfood (#3532) and Harn Cloud
4//! receipts (#1106) consume; a live repeat-run HTTP probe is a convenience
5//! around it. Given a provider/model and one-or-more repeat runs of a
6//! stable-prefix request, this module:
7//!
8//! - resolves prompt-cache SUPPORT + cache-control requirements from the single
9//!   provider capability path ([`crate::llm::capabilities::lookup`]), projecting
10//!   a self-describing [`CacheControlProfile`] (breakpoint style, minimum useful
11//!   prefix, TTL notes, and the provider usage-field mapping);
12//! - normalizes each run's usage keeping fresh-input / cache-read / cache-write /
13//!   output / unknown-missing SEPARATE ([`NormalizedCacheUsage`]);
14//! - classifies each run into one stable bucket
15//!   ([`CacheConformanceClassification`]); and
16//! - aggregates a report verdict a repeat run can act on.
17//!
18//! The taxonomy here is the Harn-owned home for what Burin's
19//! `lib/runtime/model-selection.harn` bootstrapped: support classification plus
20//! the observation buckets. Product/runtime layers read this one verdict rather
21//! than re-deriving provider behavior.
22//!
23//! A missing provider usage field is recorded as an OBSERVATION
24//! ([`NormalizedCacheUsage::missing_fields`]); it never re-classifies a route to
25//! "unsupported". Only the capability matrix decides support.
26
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29
30use crate::llm::capabilities::{self, Capabilities, WireDialect};
31
32/// Wire-format version of [`CacheConformanceReport`]. Bump on a breaking shape
33/// change so Burin/Cloud consumers can gate on the contract they parse.
34pub const CACHE_CONFORMANCE_SCHEMA_VERSION: u32 = 1;
35
36/// Cache-control requirements for a `(provider, model)` route, derived from the
37/// single provider capability path. This is the self-describing capability the
38/// issue asks Harn to expose: cache-control strategy, minimum useful prefix,
39/// TTL notes, and the usage-field mapping — one source, no per-call-site
40/// provider branching.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct CacheControlProfile {
43    /// Whether the route reports prompt-cache accounting at all
44    /// ([`Capabilities::prompt_caching`]).
45    pub prompt_caching: bool,
46    /// Request-side cache breakpoint strategy: `none`, `top_level`, or
47    /// `last_block` ([`Capabilities::cache_breakpoint_style`]).
48    pub cache_breakpoint_style: String,
49    /// Minimum prompt-prefix tokens below which a provider will not create or
50    /// serve a cache entry, so a zero cache-read on a short prefix is expected
51    /// rather than a miss. `None` when the route reports no cache accounting.
52    pub min_useful_prefix_tokens: Option<u32>,
53    /// Human-readable cache time-to-live / eviction notes for the route. `None`
54    /// when the route reports no cache accounting.
55    pub ttl_notes: Option<String>,
56    /// Explicit prompt-cache TTL values Harn knows how to request for this
57    /// route. Empty means the route may cache, but Harn has no explicit TTL
58    /// knob for it.
59    pub supported_ttls: Vec<String>,
60    /// Provider response usage field that carries cache-read (served-from-cache)
61    /// prompt tokens, in dotted path form. Empty when the route reports none.
62    pub cache_read_usage_field: String,
63    /// Provider response usage field that carries cache-write (cache-creation)
64    /// prompt tokens, in dotted path form. Empty when the route neither reports
65    /// nor bills a separate cache-write field (OpenAI-style automatic caching).
66    pub cache_write_usage_field: String,
67}
68
69impl CacheControlProfile {
70    /// Derive the cache-control profile from resolved [`Capabilities`]. Minimum
71    /// prefix, TTL notes, and the usage-field mapping are wire-dialect facts, so
72    /// they live here keyed off the one capability path rather than duplicated
73    /// per model row or per call site.
74    pub fn from_capabilities(caps: &Capabilities) -> Self {
75        if !caps.prompt_caching {
76            return Self {
77                prompt_caching: false,
78                cache_breakpoint_style: caps.cache_breakpoint_style.clone(),
79                min_useful_prefix_tokens: None,
80                ttl_notes: None,
81                supported_ttls: Vec::new(),
82                cache_read_usage_field: String::new(),
83                cache_write_usage_field: String::new(),
84            };
85        }
86        let (min_prefix, ttl, read_field, write_field) = match caps.message_wire_format {
87            WireDialect::Anthropic => (
88                1024,
89                "5m default breakpoint TTL; 1h with the extended-cache-ttl beta",
90                "usage.cache_read_input_tokens",
91                "usage.cache_creation_input_tokens",
92            ),
93            WireDialect::Gemini => (
94                1024,
95                "Implicit caching with provider-managed eviction; explicit cachedContent honors a caller TTL",
96                "usageMetadata.cachedContentTokenCount",
97                "",
98            ),
99            // OpenAI-compatible routes (including OpenRouter's OpenAI passthrough)
100            // cache automatically with no separate cache-write field billed.
101            WireDialect::OpenAiCompat => (
102                1024,
103                "Automatic prefix caching; entries idle-evict after ~5-10 minutes",
104                "usage.prompt_tokens_details.cached_tokens",
105                "",
106            ),
107            // Native Ollama reports no cache accounting; a prompt_caching=true
108            // rule on this dialect is unexpected, so surface the normalized
109            // fields and let the miss classify on capability support.
110            WireDialect::Ollama => (0, "No provider-reported cache accounting", "", ""),
111        };
112        Self {
113            prompt_caching: true,
114            cache_breakpoint_style: caps.cache_breakpoint_style.clone(),
115            min_useful_prefix_tokens: if min_prefix > 0 {
116                Some(min_prefix)
117            } else {
118                None
119            },
120            ttl_notes: if ttl.is_empty() {
121                None
122            } else {
123                Some(ttl.to_string())
124            },
125            supported_ttls: caps.prompt_cache_ttls.clone(),
126            cache_read_usage_field: read_field.to_string(),
127            cache_write_usage_field: write_field.to_string(),
128        }
129    }
130}
131
132/// Capability-derived prompt-cache support verdict. `Unknown` is distinct from
133/// `Unsupported`: an unresolved provider/model (empty or `auto`) is not proof of
134/// no support, matching the missing-field rule.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum PromptCacheSupportStatus {
138    CacheSupported,
139    CacheUnsupported,
140    CacheSupportUnknown,
141}
142
143impl PromptCacheSupportStatus {
144    pub fn as_str(self) -> &'static str {
145        match self {
146            Self::CacheSupported => "cache_supported",
147            Self::CacheUnsupported => "cache_unsupported",
148            Self::CacheSupportUnknown => "cache_support_unknown",
149        }
150    }
151}
152
153/// Prompt-cache support resolved from the provider capability path, plus the
154/// cache-control profile consumers need to explain a zero cache-read.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct PromptCacheSupport {
157    pub status: PromptCacheSupportStatus,
158    /// `Some(true)` / `Some(false)` from the capability matrix; `None` when the
159    /// provider/model didn't resolve to a concrete route.
160    pub supported: Option<bool>,
161    /// `provider-prompt-cache` when supported, `none` when explicitly
162    /// unsupported, absent when unknown.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub cache_tier: Option<String>,
165    pub resolved_provider: String,
166    pub resolved_model: String,
167    pub source: String,
168    pub profile: CacheControlProfile,
169}
170
171/// Resolve prompt-cache support for a `(provider, model)` pair from the single
172/// provider capability path. An empty or `auto` provider (or empty model)
173/// resolves to `Unknown` rather than fabricating an unsupported verdict.
174pub fn prompt_cache_support(provider: &str, model: &str) -> PromptCacheSupport {
175    let provider_key = provider.trim();
176    let model_key = model.trim();
177    let unresolved = provider_key.is_empty()
178        || provider_key.eq_ignore_ascii_case("auto")
179        || model_key.is_empty();
180    if unresolved {
181        return PromptCacheSupport {
182            status: PromptCacheSupportStatus::CacheSupportUnknown,
183            supported: None,
184            cache_tier: None,
185            resolved_provider: provider_key.to_string(),
186            resolved_model: model_key.to_string(),
187            source: "unresolved".to_string(),
188            profile: CacheControlProfile {
189                prompt_caching: false,
190                cache_breakpoint_style: "none".to_string(),
191                min_useful_prefix_tokens: None,
192                ttl_notes: None,
193                supported_ttls: Vec::new(),
194                cache_read_usage_field: String::new(),
195                cache_write_usage_field: String::new(),
196            },
197        };
198    }
199    let caps = capabilities::lookup(provider_key, model_key);
200    let profile = CacheControlProfile::from_capabilities(&caps);
201    let (status, cache_tier) = if caps.prompt_caching {
202        (
203            PromptCacheSupportStatus::CacheSupported,
204            Some("provider-prompt-cache".to_string()),
205        )
206    } else {
207        (
208            PromptCacheSupportStatus::CacheUnsupported,
209            Some("none".to_string()),
210        )
211    };
212    PromptCacheSupport {
213        status,
214        supported: Some(caps.prompt_caching),
215        cache_tier,
216        resolved_provider: provider_key.to_string(),
217        resolved_model: model_key.to_string(),
218        source: "provider-capabilities".to_string(),
219        profile,
220    }
221}
222
223/// Normalized cache usage for one run. Fresh-input, cache-read, cache-write, and
224/// output token counts stay SEPARATE; fields the provider omitted are recorded
225/// in `missing_fields` as an observation, never folded into a zero that would
226/// read as "no support".
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct NormalizedCacheUsage {
229    /// Total prompt tokens as the provider reported them (cache-read tokens are
230    /// included here on providers that count them toward the prompt total).
231    pub input_tokens: i64,
232    /// Prompt tokens billed as fresh (non-cached) input: `input - read - write`,
233    /// clamped at 0.
234    pub fresh_input_tokens: i64,
235    /// Prompt tokens served from the provider cache.
236    pub cache_read_tokens: i64,
237    /// Prompt tokens written to the provider cache on this request.
238    pub cache_write_tokens: i64,
239    pub output_tokens: i64,
240    /// Whether the provider reported any cache accounting field for this run.
241    /// `false` means "unknown", not "0% hit".
242    pub cache_supported: bool,
243    /// Usage fields the provider response did not carry (e.g. `cache_read_tokens`
244    /// on a native-Ollama done frame). Diagnostic only.
245    #[serde(default, skip_serializing_if = "Vec::is_empty")]
246    pub missing_fields: Vec<String>,
247}
248
249fn usage_i64(usage: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<i64> {
250    for key in keys {
251        if let Some(found) = usage.get(*key).and_then(Value::as_i64) {
252            return Some(found);
253        }
254    }
255    None
256}
257
258impl NormalizedCacheUsage {
259    /// Normalize a usage object that may be Harn's own usage dict shape or a raw
260    /// provider usage object. Accepts the provider aliases Harn already reads in
261    /// [`crate::llm::jsonl`] and [`crate::llm::api::result`]
262    /// (`cache_creation_input_tokens`, `cache_read_input_tokens`,
263    /// `prompt_tokens_details.cached_tokens`), so a fixture can be a saved
264    /// provider response or a normalized transcript usage entry.
265    pub fn from_usage_value(usage: &Value) -> Self {
266        let Some(object) = usage.as_object() else {
267            return Self {
268                input_tokens: 0,
269                fresh_input_tokens: 0,
270                cache_read_tokens: 0,
271                cache_write_tokens: 0,
272                output_tokens: 0,
273                cache_supported: false,
274                missing_fields: vec!["usage".to_string()],
275            };
276        };
277        let mut missing_fields = Vec::new();
278
279        let input_tokens =
280            usage_i64(object, &["input_tokens", "prompt_tokens"]).unwrap_or_else(|| {
281                missing_fields.push("input_tokens".to_string());
282                0
283            });
284        let output_tokens = usage_i64(object, &["output_tokens", "completion_tokens"])
285            .unwrap_or_else(|| {
286                missing_fields.push("output_tokens".to_string());
287                0
288            });
289
290        // A provider "reports cache accounting" when it carries an explicit
291        // read/write field OR an explicit cache_supported flag. Native local
292        // runtimes carry neither, so a 0 there is unknown, not a real miss.
293        let explicit_supported = object.get("cache_supported").and_then(Value::as_bool);
294        let cache_read = usage_i64(
295            object,
296            &[
297                "cache_read_tokens",
298                "cache_read_input_tokens",
299                "cached_tokens",
300            ],
301        )
302        .or_else(|| nested_cached_tokens(object));
303        let cache_write = usage_i64(
304            object,
305            &["cache_write_tokens", "cache_creation_input_tokens"],
306        );
307        if cache_read.is_none() {
308            missing_fields.push("cache_read_tokens".to_string());
309        }
310        if cache_write.is_none() {
311            missing_fields.push("cache_write_tokens".to_string());
312        }
313        let cache_read_tokens = cache_read.unwrap_or(0);
314        let cache_write_tokens = cache_write.unwrap_or(0);
315        let cache_supported = match explicit_supported {
316            Some(flag) => flag,
317            None => cache_read.is_some() || cache_write.is_some(),
318        };
319        let fresh_input_tokens = (input_tokens - cache_read_tokens - cache_write_tokens).max(0);
320        Self {
321            input_tokens,
322            fresh_input_tokens,
323            cache_read_tokens,
324            cache_write_tokens,
325            output_tokens,
326            cache_supported,
327            missing_fields,
328        }
329    }
330}
331
332fn nested_cached_tokens(object: &serde_json::Map<String, Value>) -> Option<i64> {
333    object
334        .get("prompt_tokens_details")
335        .and_then(Value::as_object)
336        .and_then(|details| details.get("cached_tokens"))
337        .and_then(Value::as_i64)
338}
339
340/// The stable observation bucket for one repeat run. `ProviderFieldInconsistent`
341/// flags a response whose own usage fields contradict each other so a consumer
342/// never trusts a cache verdict built on bad numbers.
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
344#[serde(rename_all = "snake_case")]
345pub enum CacheConformanceClassification {
346    /// Cache-read tokens > 0: the cache served part of the prefix.
347    CacheEffective,
348    /// Capability says the route caches, but this run read 0 from cache.
349    CacheSupportedMiss,
350    /// Capability says the route does NOT cache; a 0 read is expected.
351    UnsupportedZero,
352    /// Capability could not resolve support; a 0 read is inconclusive.
353    SupportUnknownZero,
354    /// No prompt tokens on the request, so cache behavior is undefined.
355    NoPromptTokens,
356    /// The run's own usage fields contradict each other (e.g. cache tokens
357    /// exceed the prompt total, or a read on a route that flagged no support).
358    ProviderFieldInconsistent,
359}
360
361impl CacheConformanceClassification {
362    pub fn as_str(self) -> &'static str {
363        match self {
364            Self::CacheEffective => "cache_effective",
365            Self::CacheSupportedMiss => "cache_supported_miss",
366            Self::UnsupportedZero => "unsupported_zero",
367            Self::SupportUnknownZero => "support_unknown_zero",
368            Self::NoPromptTokens => "no_prompt_tokens",
369            Self::ProviderFieldInconsistent => "provider_field_inconsistent",
370        }
371    }
372}
373
374/// Detect a self-contradictory usage report. Returns a human reason when the
375/// numbers can't be trusted, else `None`.
376fn field_inconsistency(usage: &NormalizedCacheUsage) -> Option<String> {
377    if usage.input_tokens < 0
378        || usage.output_tokens < 0
379        || usage.cache_read_tokens < 0
380        || usage.cache_write_tokens < 0
381    {
382        return Some("negative token count".to_string());
383    }
384    // A read with no prompt at all can't have come from this prompt's cache.
385    if usage.input_tokens <= 0 && (usage.cache_read_tokens > 0 || usage.cache_write_tokens > 0) {
386        return Some("cache tokens reported with zero prompt tokens".to_string());
387    }
388    if usage.input_tokens > 0
389        && usage.cache_read_tokens + usage.cache_write_tokens > usage.input_tokens
390    {
391        return Some("cache-read + cache-write exceed prompt tokens".to_string());
392    }
393    // Provider both flagged "no cache accounting" AND reported cache tokens.
394    if !usage.cache_supported && (usage.cache_read_tokens > 0 || usage.cache_write_tokens > 0) {
395        return Some("cache tokens reported while cache_supported=false".to_string());
396    }
397    None
398}
399
400/// Classify one run from its normalized usage and the capability support
401/// verdict. Support status — never the presence/absence of a usage field —
402/// decides the zero-read bucket, so a missing field can't masquerade as
403/// "unsupported".
404pub fn classify_cache_run(
405    usage: &NormalizedCacheUsage,
406    support: &PromptCacheSupport,
407) -> CacheConformanceClassification {
408    if field_inconsistency(usage).is_some() {
409        return CacheConformanceClassification::ProviderFieldInconsistent;
410    }
411    if usage.input_tokens <= 0 {
412        return CacheConformanceClassification::NoPromptTokens;
413    }
414    if usage.cache_read_tokens > 0 {
415        return CacheConformanceClassification::CacheEffective;
416    }
417    match support.status {
418        PromptCacheSupportStatus::CacheSupported => {
419            CacheConformanceClassification::CacheSupportedMiss
420        }
421        PromptCacheSupportStatus::CacheUnsupported => {
422            CacheConformanceClassification::UnsupportedZero
423        }
424        PromptCacheSupportStatus::CacheSupportUnknown => {
425            CacheConformanceClassification::SupportUnknownZero
426        }
427    }
428}
429
430/// The stable identity of the request whose prefix must stay fixed across repeat
431/// runs for a cache-read to mean anything. Captured (not the raw bytes, which
432/// may carry secrets) so a consumer can confirm the runs were actually
433/// comparable.
434#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
435pub struct CacheRequestIdentity {
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub task: Option<String>,
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub prefix_sha256: Option<String>,
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub prefix_tokens_estimate: Option<u32>,
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub tool_schema_sha256: Option<String>,
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub settings_sha256: Option<String>,
446}
447
448/// One repeat run: request identity, normalized usage, classification, timing.
449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450pub struct CacheConformanceRun {
451    pub run_index: usize,
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub request: Option<CacheRequestIdentity>,
454    pub usage: NormalizedCacheUsage,
455    pub classification: CacheConformanceClassification,
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub inconsistency_reason: Option<String>,
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub elapsed_ms: Option<u64>,
460    /// Raw provider usage object as captured, for downstream audit. Preserved
461    /// verbatim so a consumer can re-derive without re-running the provider.
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub raw_usage: Option<Value>,
464}
465
466/// Report-level cache verdict aggregated across repeat runs — the one signal
467/// Burin dogfood and Cloud receipts key on.
468#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
469#[serde(rename_all = "snake_case")]
470pub enum CacheVerdict {
471    /// A run after the first read from cache: repeat caching works.
472    CacheEffective,
473    /// Route caches per capability, but no repeat run read from cache.
474    CacheSupportedMiss,
475    /// Route does not cache per capability; zero reads are expected.
476    UnsupportedZero,
477    /// Support unknown and no reads observed.
478    SupportUnknownZero,
479    /// At least one run's usage fields were self-contradictory.
480    ProviderFieldInconsistent,
481    /// No run carried prompt tokens.
482    NoPromptTokens,
483    /// Fewer than two runs, so repeat-cache behavior can't be judged.
484    InsufficientRuns,
485}
486
487impl CacheVerdict {
488    pub fn as_str(self) -> &'static str {
489        match self {
490            Self::CacheEffective => "cache_effective",
491            Self::CacheSupportedMiss => "cache_supported_miss",
492            Self::UnsupportedZero => "unsupported_zero",
493            Self::SupportUnknownZero => "support_unknown_zero",
494            Self::ProviderFieldInconsistent => "provider_field_inconsistent",
495            Self::NoPromptTokens => "no_prompt_tokens",
496            Self::InsufficientRuns => "insufficient_runs",
497        }
498    }
499
500    /// Whether this verdict should fail product dogfood. A non-cache provider
501    /// classifying as `unsupported_zero` is NOT a failure; only a supported
502    /// route that never caches, or a provider reporting contradictory fields,
503    /// is a real conformance failure.
504    pub fn is_dogfood_failure(self) -> bool {
505        matches!(
506            self,
507            Self::CacheSupportedMiss | Self::ProviderFieldInconsistent
508        )
509    }
510}
511
512/// Per-bucket run counts for report rollups. Mirrors Burin's
513/// `prompt_cache_observation_bucket_counts`, now Harn-owned.
514#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
515pub struct CacheConformanceBucketCounts {
516    pub cache_effective: usize,
517    pub cache_supported_miss: usize,
518    pub unsupported_zero: usize,
519    pub support_unknown_zero: usize,
520    pub no_prompt_tokens: usize,
521    pub provider_field_inconsistent: usize,
522}
523
524impl CacheConformanceBucketCounts {
525    fn tally(runs: &[CacheConformanceRun]) -> Self {
526        let mut counts = Self::default();
527        for run in runs {
528            match run.classification {
529                CacheConformanceClassification::CacheEffective => counts.cache_effective += 1,
530                CacheConformanceClassification::CacheSupportedMiss => {
531                    counts.cache_supported_miss += 1;
532                }
533                CacheConformanceClassification::UnsupportedZero => counts.unsupported_zero += 1,
534                CacheConformanceClassification::SupportUnknownZero => {
535                    counts.support_unknown_zero += 1;
536                }
537                CacheConformanceClassification::NoPromptTokens => counts.no_prompt_tokens += 1,
538                CacheConformanceClassification::ProviderFieldInconsistent => {
539                    counts.provider_field_inconsistent += 1;
540                }
541            }
542        }
543        counts
544    }
545}
546
547/// The full conformance report: capability support + per-run observations + one
548/// aggregate verdict, consumable by Burin #3532 and Harn Cloud #1106 without
549/// reclassifying provider behavior.
550#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
551pub struct CacheConformanceReport {
552    pub schema_version: u32,
553    pub provider: String,
554    pub model: String,
555    pub support: PromptCacheSupport,
556    pub runs: Vec<CacheConformanceRun>,
557    pub bucket_counts: CacheConformanceBucketCounts,
558    pub verdict: CacheVerdict,
559    /// Whether `verdict` should fail product dogfood (mirror of
560    /// [`CacheVerdict::is_dogfood_failure`], serialized for consumers that read
561    /// JSON without the enum semantics).
562    pub dogfood_failure: bool,
563}
564
565fn aggregate_verdict(runs: &[CacheConformanceRun], support: &PromptCacheSupport) -> CacheVerdict {
566    if runs
567        .iter()
568        .any(|run| run.classification == CacheConformanceClassification::ProviderFieldInconsistent)
569    {
570        return CacheVerdict::ProviderFieldInconsistent;
571    }
572    // A repeat run (index > 0) reading from cache is the positive signal; a
573    // first-run read alone can't prove repeat caching.
574    let repeat_cache_read = runs.iter().any(|run| {
575        run.run_index > 0 && run.classification == CacheConformanceClassification::CacheEffective
576    });
577    if repeat_cache_read {
578        return CacheVerdict::CacheEffective;
579    }
580    // A single run that read from cache (e.g. a warm fixture) still confirms the
581    // cache served this prefix.
582    let any_cache_read = runs
583        .iter()
584        .any(|run| run.classification == CacheConformanceClassification::CacheEffective);
585    let all_no_prompt = !runs.is_empty()
586        && runs
587            .iter()
588            .all(|run| run.classification == CacheConformanceClassification::NoPromptTokens);
589    if all_no_prompt {
590        return CacheVerdict::NoPromptTokens;
591    }
592    match support.status {
593        PromptCacheSupportStatus::CacheUnsupported => CacheVerdict::UnsupportedZero,
594        PromptCacheSupportStatus::CacheSupportUnknown => CacheVerdict::SupportUnknownZero,
595        PromptCacheSupportStatus::CacheSupported => {
596            if any_cache_read {
597                // Only a first-run read observed; need a repeat to confirm.
598                if runs.len() < 2 {
599                    CacheVerdict::InsufficientRuns
600                } else {
601                    CacheVerdict::CacheSupportedMiss
602                }
603            } else if runs.len() < 2 {
604                CacheVerdict::InsufficientRuns
605            } else {
606                CacheVerdict::CacheSupportedMiss
607            }
608        }
609    }
610}
611
612/// Assemble a report from already-classified runs.
613pub fn report_from_runs(
614    provider: String,
615    model: String,
616    support: PromptCacheSupport,
617    runs: Vec<CacheConformanceRun>,
618) -> CacheConformanceReport {
619    let bucket_counts = CacheConformanceBucketCounts::tally(&runs);
620    let verdict = aggregate_verdict(&runs, &support);
621    CacheConformanceReport {
622        schema_version: CACHE_CONFORMANCE_SCHEMA_VERSION,
623        provider,
624        model,
625        support,
626        runs,
627        bucket_counts,
628        verdict,
629        dogfood_failure: verdict.is_dogfood_failure(),
630    }
631}
632
633/// Parse one fixture run entry. Accepts either a bare usage object or an entry
634/// wrapping `usage` plus optional `request`, `elapsed_ms`, and a `raw_usage`
635/// passthrough.
636fn run_from_fixture_entry(
637    index: usize,
638    entry: &Value,
639    support: &PromptCacheSupport,
640) -> CacheConformanceRun {
641    let (usage_value, request, elapsed_ms) = match entry.as_object() {
642        Some(object) if object.contains_key("usage") => {
643            let usage_value = object.get("usage").cloned().unwrap_or(Value::Null);
644            let request = object.get("request").and_then(|value| {
645                serde_json::from_value::<CacheRequestIdentity>(value.clone()).ok()
646            });
647            let elapsed_ms = object.get("elapsed_ms").and_then(Value::as_u64);
648            (usage_value, request, elapsed_ms)
649        }
650        // A bare usage object is the whole entry.
651        _ => (entry.clone(), None, None),
652    };
653    let usage = NormalizedCacheUsage::from_usage_value(&usage_value);
654    let classification = classify_cache_run(&usage, support);
655    let inconsistency_reason = field_inconsistency(&usage);
656    CacheConformanceRun {
657        run_index: index,
658        request,
659        usage,
660        classification,
661        inconsistency_reason,
662        elapsed_ms,
663        raw_usage: Some(usage_value),
664    }
665}
666
667/// Classify a saved repeat-run fixture into a conformance report. `raw` is a
668/// JSON document shaped as either a top-level array of run entries or an object
669/// with a `runs` array (and optional `provider`/`model` overrides). This is the
670/// committed-conformance path: no keys, no live provider, deterministic verdict.
671pub fn classify_cache_conformance_fixture(
672    provider: impl Into<String>,
673    model: impl Into<String>,
674    raw: &str,
675) -> Result<CacheConformanceReport, String> {
676    let document: Value = serde_json::from_str(raw)
677        .map_err(|error| format!("failed to parse cache conformance fixture: {error}"))?;
678    let mut provider = provider.into();
679    let mut model = model.into();
680    let runs_value = match &document {
681        Value::Array(items) => items.clone(),
682        Value::Object(object) => {
683            if let Some(fixture_provider) = object.get("provider").and_then(Value::as_str) {
684                if provider.trim().is_empty() {
685                    provider = fixture_provider.to_string();
686                }
687            }
688            if let Some(fixture_model) = object.get("model").and_then(Value::as_str) {
689                if model.trim().is_empty() {
690                    model = fixture_model.to_string();
691                }
692            }
693            match object.get("runs") {
694                Some(Value::Array(items)) => items.clone(),
695                _ => {
696                    return Err(
697                        "cache conformance fixture object must carry a `runs` array".to_string()
698                    )
699                }
700            }
701        }
702        _ => {
703            return Err(
704                "cache conformance fixture must be a runs array or an object with `runs`"
705                    .to_string(),
706            )
707        }
708    };
709    let support = prompt_cache_support(&provider, &model);
710    let runs = runs_value
711        .iter()
712        .enumerate()
713        .map(|(index, entry)| run_from_fixture_entry(index, entry, &support))
714        .collect::<Vec<_>>();
715    Ok(report_from_runs(provider, model, support, runs))
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721    use serde_json::json;
722
723    fn supported() -> PromptCacheSupport {
724        PromptCacheSupport {
725            status: PromptCacheSupportStatus::CacheSupported,
726            supported: Some(true),
727            cache_tier: Some("provider-prompt-cache".to_string()),
728            resolved_provider: "anthropic".to_string(),
729            resolved_model: "claude-sonnet-4-6".to_string(),
730            source: "provider-capabilities".to_string(),
731            profile: CacheControlProfile {
732                prompt_caching: true,
733                cache_breakpoint_style: "last_block".to_string(),
734                min_useful_prefix_tokens: Some(1024),
735                ttl_notes: Some("5m".to_string()),
736                supported_ttls: Vec::new(),
737                cache_read_usage_field: "usage.cache_read_input_tokens".to_string(),
738                cache_write_usage_field: "usage.cache_creation_input_tokens".to_string(),
739            },
740        }
741    }
742
743    fn unsupported() -> PromptCacheSupport {
744        PromptCacheSupport {
745            status: PromptCacheSupportStatus::CacheUnsupported,
746            supported: Some(false),
747            cache_tier: Some("none".to_string()),
748            resolved_provider: "ollama".to_string(),
749            resolved_model: "qwen3".to_string(),
750            source: "provider-capabilities".to_string(),
751            profile: CacheControlProfile {
752                prompt_caching: false,
753                cache_breakpoint_style: "none".to_string(),
754                min_useful_prefix_tokens: None,
755                ttl_notes: None,
756                supported_ttls: Vec::new(),
757                cache_read_usage_field: String::new(),
758                cache_write_usage_field: String::new(),
759            },
760        }
761    }
762
763    fn unknown() -> PromptCacheSupport {
764        prompt_cache_support("auto", "")
765    }
766
767    fn usage(input: i64, read: i64, write: i64, output: i64) -> NormalizedCacheUsage {
768        NormalizedCacheUsage {
769            input_tokens: input,
770            fresh_input_tokens: (input - read - write).max(0),
771            cache_read_tokens: read,
772            cache_write_tokens: write,
773            output_tokens: output,
774            cache_supported: true,
775            missing_fields: Vec::new(),
776        }
777    }
778
779    #[test]
780    fn cache_read_is_effective_regardless_of_support() {
781        let run = usage(2000, 1800, 0, 50);
782        assert_eq!(
783            classify_cache_run(&run, &supported()),
784            CacheConformanceClassification::CacheEffective
785        );
786    }
787
788    #[test]
789    fn supported_zero_read_is_a_miss_not_unsupported() {
790        let run = usage(2000, 0, 2000, 50);
791        assert_eq!(
792            classify_cache_run(&run, &supported()),
793            CacheConformanceClassification::CacheSupportedMiss
794        );
795    }
796
797    #[test]
798    fn unsupported_zero_read_classifies_unsupported() {
799        let run = usage(2000, 0, 0, 50);
800        assert_eq!(
801            classify_cache_run(&run, &unsupported()),
802            CacheConformanceClassification::UnsupportedZero
803        );
804    }
805
806    #[test]
807    fn missing_field_with_unknown_support_stays_unknown_not_unsupported() {
808        // Native-local run: no cache fields at all. cache_supported=false is an
809        // observation, not proof of no support — the capability path is unknown.
810        let raw = json!({ "input_tokens": 2000, "output_tokens": 40 });
811        let normalized = NormalizedCacheUsage::from_usage_value(&raw);
812        assert!(!normalized.cache_supported);
813        assert!(normalized
814            .missing_fields
815            .contains(&"cache_read_tokens".to_string()));
816        assert_eq!(
817            classify_cache_run(&normalized, &unknown()),
818            CacheConformanceClassification::SupportUnknownZero
819        );
820    }
821
822    #[test]
823    fn no_prompt_tokens_bucket() {
824        let run = usage(0, 0, 0, 10);
825        assert_eq!(
826            classify_cache_run(&run, &supported()),
827            CacheConformanceClassification::NoPromptTokens
828        );
829    }
830
831    #[test]
832    fn cache_exceeding_prompt_is_inconsistent() {
833        let run = usage(1000, 900, 500, 10);
834        assert_eq!(
835            classify_cache_run(&run, &supported()),
836            CacheConformanceClassification::ProviderFieldInconsistent
837        );
838    }
839
840    #[test]
841    fn read_with_support_false_is_inconsistent() {
842        let mut run = usage(2000, 500, 0, 10);
843        run.cache_supported = false;
844        assert_eq!(
845            classify_cache_run(&run, &supported()),
846            CacheConformanceClassification::ProviderFieldInconsistent
847        );
848    }
849
850    #[test]
851    fn normalize_reads_anthropic_aliases() {
852        let raw = json!({
853            "input_tokens": 4000,
854            "output_tokens": 120,
855            "cache_read_input_tokens": 3500,
856            "cache_creation_input_tokens": 500,
857        });
858        let normalized = NormalizedCacheUsage::from_usage_value(&raw);
859        assert_eq!(normalized.cache_read_tokens, 3500);
860        assert_eq!(normalized.cache_write_tokens, 500);
861        assert_eq!(normalized.fresh_input_tokens, 0);
862        assert!(normalized.cache_supported);
863        assert!(normalized.missing_fields.is_empty());
864    }
865
866    #[test]
867    fn normalize_reads_openai_nested_cached_tokens() {
868        let raw = json!({
869            "prompt_tokens": 3000,
870            "completion_tokens": 90,
871            "prompt_tokens_details": { "cached_tokens": 2048 },
872        });
873        let normalized = NormalizedCacheUsage::from_usage_value(&raw);
874        assert_eq!(normalized.input_tokens, 3000);
875        assert_eq!(normalized.cache_read_tokens, 2048);
876        assert_eq!(normalized.fresh_input_tokens, 952);
877    }
878
879    #[test]
880    fn repeat_run_cache_read_yields_cache_effective_verdict() {
881        let raw = json!({
882            "provider": "anthropic",
883            "model": "claude-sonnet-4-6",
884            "runs": [
885                { "usage": { "input_tokens": 4000, "output_tokens": 80, "cache_read_tokens": 0, "cache_creation_input_tokens": 3800 } },
886                { "usage": { "input_tokens": 4000, "output_tokens": 80, "cache_read_tokens": 3800, "cache_creation_input_tokens": 0 } }
887            ]
888        });
889        let report =
890            classify_cache_conformance_fixture("", "", &raw.to_string()).expect("classify");
891        assert_eq!(report.verdict, CacheVerdict::CacheEffective);
892        assert!(!report.dogfood_failure);
893        assert_eq!(report.bucket_counts.cache_effective, 1);
894        assert_eq!(report.bucket_counts.cache_supported_miss, 1);
895    }
896
897    #[test]
898    fn non_cache_provider_does_not_fail_dogfood() {
899        let raw = json!({
900            "provider": "ollama",
901            "model": "qwen3",
902            "runs": [
903                { "usage": { "input_tokens": 4000, "output_tokens": 80 } },
904                { "usage": { "input_tokens": 4000, "output_tokens": 80 } }
905            ]
906        });
907        let report =
908            classify_cache_conformance_fixture("", "", &raw.to_string()).expect("classify");
909        assert_eq!(report.verdict, CacheVerdict::UnsupportedZero);
910        assert!(!report.dogfood_failure);
911    }
912
913    #[test]
914    fn supported_route_that_never_caches_fails_dogfood() {
915        let raw = json!({
916            "provider": "anthropic",
917            "model": "claude-sonnet-4-6",
918            "runs": [
919                { "usage": { "input_tokens": 4000, "output_tokens": 80, "cache_creation_input_tokens": 3800 } },
920                { "usage": { "input_tokens": 4000, "output_tokens": 80, "cache_creation_input_tokens": 3800 } }
921            ]
922        });
923        let report =
924            classify_cache_conformance_fixture("", "", &raw.to_string()).expect("classify");
925        assert_eq!(report.verdict, CacheVerdict::CacheSupportedMiss);
926        assert!(report.dogfood_failure);
927    }
928}