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