Skip to main content

tsift_agent_doc/
session_cost.rs

1use anyhow::{Result, bail};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::{BTreeMap, BTreeSet};
5
6use tsift_quality::runtime_churn::{RestartChurnState, RestartChurnSummary};
7
8const MAX_LARGEST_TURNS: usize = 5;
9const MAX_RUNTIME_EVENTS: usize = 8;
10const MAX_GUARDRAILS: usize = 8;
11const MAX_LOOP_CLUSTERS: usize = 8;
12const MAX_FILE_READ_DIAGNOSTICS: usize = 8;
13const MAX_PROMPT_CACHE_TIMELINE: usize = 8;
14const MAX_PROMPT_CACHE_DIAGNOSTICS: usize = 6;
15const MAX_PROMPT_CACHE_PREFIX_DRIFT: usize = 6;
16const MAX_PROMPT_CACHE_SCORECARD: usize = 6;
17const MAX_PROMPT_CACHE_BREAKPOINTS: usize = 8;
18const MAX_COMMANDS_PER_BUNDLE: usize = 6;
19const PROMPT_CACHE_SCORECARD_DEFAULT_NEXT_COMMAND: &str =
20    "tsift session-cost --input <session.jsonl> --json";
21const PROMPT_BUDGET_WARN_TOKENS: u64 = 100_000;
22const CACHED_RATIO_WARN_PERCENT: f64 = 90.0;
23const CACHED_RATIO_WARN_PROMPT_TOKENS: u64 = 50_000;
24const PROMPT_CACHE_CANDIDATE_TOKENS: u64 = 16_000;
25const PROMPT_CACHE_GOOD_HIT_PERCENT: f64 = 75.0;
26const PROMPT_CACHE_TREND_DELTA_PERCENT: f64 = 5.0;
27const PROMPT_CACHE_RATIO_DROP_WARN_PERCENT: f64 = 20.0;
28const PROMPT_CACHE_CREATION_SPIKE_WARN_PERCENT: f64 = 20.0;
29pub(crate) const PROMPT_CACHE_READ_CREATE_REGRESSION_RATIO: f64 = 2.0;
30const RESTART_LOOP_WARN_OCCURRENCES: usize = 3;
31const NOOP_CLOSEOUT_WARN_OCCURRENCES: usize = 3;
32const DEFAULT_FULL_FILE_READ_TOKENS: u64 = 4_000;
33const ESTIMATED_TOKENS_PER_SOURCE_LINE: u64 = 18;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "snake_case")]
37pub enum SessionCostSource {
38    ClaudeJsonl,
39    CodexJsonl,
40    AgentDocLog,
41}
42
43impl SessionCostSource {
44    pub fn parse(raw: &str) -> Result<Self> {
45        match raw.trim().to_ascii_lowercase().as_str() {
46            "claude" | "claude-jsonl" => Ok(Self::ClaudeJsonl),
47            "codex" | "codex-jsonl" => Ok(Self::CodexJsonl),
48            "agent-doc-log" | "agent_doc_log" | "log" => Ok(Self::AgentDocLog),
49            other => bail!(
50                "unsupported session-cost source `{other}`; expected claude-jsonl, codex-jsonl, or agent-doc-log"
51            ),
52        }
53    }
54
55    pub fn as_str(self) -> &'static str {
56        match self {
57            Self::ClaudeJsonl => "claude_jsonl",
58            Self::CodexJsonl => "codex_jsonl",
59            Self::AgentDocLog => "agent_doc_log",
60        }
61    }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct SessionCostPromptCacheMetadata {
66    pub provider: String,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub cache_key: Option<String>,
69    pub stable_prefix_fingerprint: String,
70    // True when the provider supplied `stable_prefix_fingerprint` explicitly
71    // rather than us deriving it from provider/cache_key/stable_prefix/
72    // breakpoints. A derived fingerprint is a pure function of those tracked
73    // sub-fields, so a derived change is always a redundant echo of one of them
74    // and is suppressed from drift attribution; an explicit fingerprint is
75    // independent signal and is always reported (#tsreviewcleanup). This is an
76    // internal attribution detail, not part of the serialized report.
77    #[serde(skip)]
78    pub stable_prefix_fingerprint_explicit: bool,
79    // Raw stable-prefix content, tracked independently of the fingerprint so
80    // prefix-content drift is attributed even when a provider supplies an
81    // explicit `stable_prefix_fingerprint` that bypasses the derived material
82    // (#pcacheexplattr).
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub stable_prefix: Option<String>,
85    #[serde(skip_serializing_if = "Vec::is_empty", default)]
86    pub breakpoints: Vec<String>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub routing_affinity: Option<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92pub struct SessionCostTurn {
93    pub label: String,
94    pub prompt_tokens: u64,
95    pub cached_input_tokens: u64,
96    pub cache_creation_input_tokens: u64,
97    pub output_tokens: u64,
98    pub reasoning_output_tokens: u64,
99    pub total_tokens: u64,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub prompt_cache_metadata: Option<SessionCostPromptCacheMetadata>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
105pub struct SessionCostRuntimeEvent {
106    pub event: String,
107    pub occurrences: usize,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
111pub struct SessionCostGuardrail {
112    pub kind: String,
113    pub severity: String,
114    pub message: String,
115    pub guidance: String,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
119pub struct SessionCostPromptCachePlan {
120    pub status: String,
121    pub feasible: bool,
122    pub observed_cached_input_tokens: u64,
123    pub observed_cache_creation_tokens: u64,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub observed_cached_input_ratio: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub analytics: Option<SessionCostPromptCacheAnalytics>,
128    #[serde(skip_serializing_if = "Vec::is_empty", default)]
129    pub scorecard: Vec<SessionCostPromptCacheRoiScorecard>,
130    pub invariants: Vec<String>,
131    pub provider_adapters: Vec<SessionCostPromptCacheProvider>,
132    pub actions: Vec<SessionCostPromptCacheAction>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
136pub struct SessionCostPromptCacheProvider {
137    pub provider: String,
138    pub status: String,
139    pub requirements: Vec<String>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
143pub struct SessionCostPromptCacheAction {
144    pub kind: String,
145    pub severity: String,
146    pub message: String,
147    pub guidance: String,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
151pub struct SessionCostPromptCacheRoiScorecard {
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub session_source: Option<String>,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub session_path: Option<String>,
156    pub provider: String,
157    pub sample_count: usize,
158    pub net_cached_read_tokens: i64,
159    pub read_create_ratio: String,
160    pub trend: String,
161    pub suspected_invalidation_cause: String,
162    pub next_command: String,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
166pub struct SessionCostPromptCacheAnalytics {
167    pub sample_count: usize,
168    pub effective: bool,
169    pub trend: String,
170    pub total_prompt_tokens: u64,
171    pub total_cached_input_tokens: u64,
172    pub total_cache_creation_tokens: u64,
173    pub net_cached_input_tokens: i64,
174    pub timeline_truncated: bool,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub average_cached_input_ratio: Option<String>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub first_cached_input_ratio: Option<String>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub last_cached_input_ratio: Option<String>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub cached_input_ratio_delta: Option<String>,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub cache_read_to_creation_ratio: Option<String>,
185    #[serde(skip_serializing_if = "Vec::is_empty", default)]
186    pub diagnostics: Vec<SessionCostPromptCacheDiagnostic>,
187    pub prefix_drift_truncated: bool,
188    #[serde(skip_serializing_if = "Vec::is_empty", default)]
189    pub prefix_drift: Vec<SessionCostPromptCachePrefixDrift>,
190    pub timeline: Vec<SessionCostPromptCacheTimelineEntry>,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
194pub struct SessionCostPromptCacheDiagnostic {
195    pub kind: String,
196    pub severity: String,
197    pub label: String,
198    pub message: String,
199    pub likely_causes: Vec<String>,
200    pub guidance: String,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
204pub struct SessionCostPromptCachePrefixDrift {
205    pub previous_label: String,
206    pub current_label: String,
207    pub trigger: String,
208    pub severity: String,
209    pub first_changed_field: String,
210    /// Concrete, field-specific fix for the attributed drift cause: stabilize the
211    /// changed input or move the volatile field below the cache breakpoint. Always
212    /// populated so the report tells the agent what to *do*, not just what drifted
213    /// (#pcacheremediation).
214    pub remediation: String,
215    pub field_changes: Vec<SessionCostPromptCacheFieldChange>,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub cached_input_ratio_before: Option<String>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub cached_input_ratio_after: Option<String>,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub cache_creation_ratio: Option<String>,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
225pub struct SessionCostPromptCacheFieldChange {
226    pub field: String,
227    pub previous: String,
228    pub current: String,
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
232pub struct SessionCostPromptCacheTimelineEntry {
233    pub label: String,
234    pub prompt_tokens: u64,
235    pub cached_input_tokens: u64,
236    pub cache_creation_input_tokens: u64,
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub cached_input_ratio: Option<String>,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub cache_creation_ratio: Option<String>,
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub prompt_cache_metadata: Option<SessionCostPromptCacheMetadata>,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
246pub struct SessionCostLoopCluster {
247    pub kind: String,
248    pub label: String,
249    pub occurrences: usize,
250    pub max_consecutive: usize,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
254pub struct SessionCostFileReadDiagnostic {
255    pub path: String,
256    pub range: String,
257    pub occurrences: usize,
258    pub estimated_tokens: u64,
259    pub duplicate_estimated_tokens: u64,
260    pub follow_up_commands: Vec<String>,
261}
262
263#[derive(Debug, Clone, Default)]
264pub struct SessionCostGuardrailInput {
265    pub largest_prompt_turn_tokens: u64,
266    pub largest_prompt_turn_label: Option<String>,
267    pub prompt_tokens: u64,
268    pub cached_input_ratio: Option<f64>,
269    pub fresh_restart_occurrences: usize,
270    pub auto_trigger_timeout_occurrences: usize,
271    pub ctrl_d_restart_loop_occurrences: usize,
272    pub noop_closeout_occurrences: usize,
273    pub max_restart_count: Option<usize>,
274}
275
276#[derive(Debug, Clone, PartialEq, Serialize)]
277pub struct SessionCostReport {
278    pub source: String,
279    pub record_count: usize,
280    pub usage_samples: usize,
281    pub prompt_tokens: u64,
282    pub cached_input_tokens: u64,
283    pub cache_creation_input_tokens: u64,
284    pub output_tokens: u64,
285    pub reasoning_output_tokens: u64,
286    pub total_tokens: u64,
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub cached_input_ratio: Option<f64>,
289    pub largest_turn_total_tokens: u64,
290    pub runtime_event_groups: usize,
291    pub total_runtime_events: usize,
292    pub restart_churn_groups: usize,
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub max_restart_count: Option<usize>,
295    pub largest_turns: Vec<SessionCostTurn>,
296    pub runtime_events: Vec<SessionCostRuntimeEvent>,
297    #[serde(skip_serializing_if = "Vec::is_empty", default)]
298    pub loop_clusters: Vec<SessionCostLoopCluster>,
299    #[serde(skip_serializing_if = "Vec::is_empty", default)]
300    pub file_read_diagnostics: Vec<SessionCostFileReadDiagnostic>,
301    #[serde(skip_serializing_if = "Vec::is_empty", default)]
302    pub restart_churn: Vec<RestartChurnSummary>,
303    #[serde(skip_serializing_if = "Vec::is_empty", default)]
304    pub guardrails: Vec<SessionCostGuardrail>,
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub prompt_cache_plan: Option<SessionCostPromptCachePlan>,
307    #[serde(skip_serializing_if = "Vec::is_empty", default)]
308    pub warnings: Vec<String>,
309}
310
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312pub struct SessionCostPromptCacheEffectivenessFixture {
313    pub schema_version: u64,
314    #[serde(default)]
315    pub description: String,
316    #[serde(skip_serializing_if = "Vec::is_empty", default)]
317    pub required_regression_scenarios: Vec<String>,
318    pub cases: Vec<SessionCostPromptCacheEffectivenessCase>,
319}
320
321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
322pub struct SessionCostPromptCacheEffectivenessCase {
323    pub name: String,
324    pub source: String,
325    pub input_lines: Vec<String>,
326    pub minimum_cached_input_ratio: f64,
327    pub minimum_net_cached_input_tokens: i64,
328    pub maximum_read_create_regressions: usize,
329    #[serde(skip_serializing_if = "Vec::is_empty", default)]
330    pub regression_scenarios: Vec<String>,
331    #[serde(skip_serializing_if = "Vec::is_empty", default)]
332    pub required_prefix_drift_fields: Vec<String>,
333    #[serde(skip_serializing_if = "Vec::is_empty", default)]
334    pub required_diagnostics: Vec<String>,
335}
336
337#[derive(Debug, Clone, PartialEq, Serialize)]
338pub struct SessionCostPromptCacheEffectivenessReport {
339    pub schema_version: u64,
340    pub pass: bool,
341    pub totals: SessionCostPromptCacheEffectivenessTotals,
342    pub required_regression_scenarios: Vec<String>,
343    pub covered_regression_scenarios: Vec<String>,
344    pub missing_regression_scenarios: Vec<String>,
345    pub cases: Vec<SessionCostPromptCacheEffectivenessCaseReport>,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
349pub struct SessionCostPromptCacheEffectivenessTotals {
350    pub cases: usize,
351    pub passed: usize,
352    pub failed: usize,
353    pub prompt_tokens: u64,
354    pub cached_input_tokens: u64,
355    pub cache_creation_input_tokens: u64,
356    pub net_cached_input_tokens: i64,
357    pub read_create_regressions: usize,
358}
359
360#[derive(Debug, Clone, PartialEq, Serialize)]
361pub struct SessionCostPromptCacheEffectivenessCaseReport {
362    pub name: String,
363    pub source: String,
364    pub status: String,
365    pub prompt_tokens: u64,
366    pub cached_input_tokens: u64,
367    pub cache_creation_input_tokens: u64,
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub cached_input_ratio: Option<f64>,
370    pub minimum_cached_input_ratio: f64,
371    pub net_cached_input_tokens: i64,
372    pub minimum_net_cached_input_tokens: i64,
373    pub read_create_regressions: usize,
374    pub maximum_read_create_regressions: usize,
375    #[serde(skip_serializing_if = "Vec::is_empty", default)]
376    pub regression_scenarios: Vec<String>,
377    #[serde(skip_serializing_if = "Vec::is_empty", default)]
378    pub required_prefix_drift_fields: Vec<String>,
379    #[serde(skip_serializing_if = "Vec::is_empty", default)]
380    pub required_diagnostics: Vec<String>,
381    #[serde(skip_serializing_if = "Vec::is_empty", default)]
382    pub failures: Vec<String>,
383}
384
385#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
386struct UsageTotals {
387    prompt_tokens: u64,
388    cached_input_tokens: u64,
389    cache_creation_input_tokens: u64,
390    output_tokens: u64,
391    reasoning_output_tokens: u64,
392    total_tokens: u64,
393}
394
395impl UsageTotals {
396    fn delta_from(self, previous: Self) -> Self {
397        Self {
398            prompt_tokens: self.prompt_tokens.saturating_sub(previous.prompt_tokens),
399            cached_input_tokens: self
400                .cached_input_tokens
401                .saturating_sub(previous.cached_input_tokens),
402            cache_creation_input_tokens: self
403                .cache_creation_input_tokens
404                .saturating_sub(previous.cache_creation_input_tokens),
405            output_tokens: self.output_tokens.saturating_sub(previous.output_tokens),
406            reasoning_output_tokens: self
407                .reasoning_output_tokens
408                .saturating_sub(previous.reasoning_output_tokens),
409            total_tokens: self.total_tokens.saturating_sub(previous.total_tokens),
410        }
411    }
412
413    fn is_zero(self) -> bool {
414        self.prompt_tokens == 0
415            && self.cached_input_tokens == 0
416            && self.cache_creation_input_tokens == 0
417            && self.output_tokens == 0
418            && self.reasoning_output_tokens == 0
419            && self.total_tokens == 0
420    }
421}
422
423#[derive(Debug, Default)]
424struct CostState {
425    warnings: Vec<String>,
426    usage_turns: Vec<SessionCostTurn>,
427    runtime_events: BTreeMap<String, usize>,
428    seen_document_cycle_events: BTreeSet<(String, String)>,
429    total_runtime_events: usize,
430    max_restart_count: Option<usize>,
431    restart_churn: RestartChurnState,
432    pending_commands: Vec<String>,
433    loop_signals: Vec<LoopSignal>,
434    file_read_signals: Vec<FileReadSignal>,
435}
436
437#[derive(Debug, Default)]
438struct PromptCacheAdapterEvidence {
439    anthropic_samples: usize,
440    anthropic_cache_control_samples: usize,
441    openai_samples: usize,
442    openai_prompt_cache_key_samples: usize,
443    openai_prompt_cache_keys: BTreeSet<String>,
444    routed_provider_samples: usize,
445    routing_affinity_samples: usize,
446    routing_affinity_values: BTreeSet<String>,
447}
448
449#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
450struct LoopSignal {
451    kind: LoopClusterKind,
452    label: String,
453}
454
455#[derive(Debug, Clone, PartialEq, Eq)]
456struct FileReadSignal {
457    path: String,
458    range: String,
459    start: Option<usize>,
460    lines: Option<usize>,
461    estimated_tokens: u64,
462}
463
464#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
465enum LoopClusterKind {
466    PromptRepeat,
467    CommandBundle,
468    CloseoutChurn,
469}
470
471impl LoopClusterKind {
472    fn as_str(self) -> &'static str {
473        match self {
474            Self::PromptRepeat => "prompt_repeat",
475            Self::CommandBundle => "command_bundle",
476            Self::CloseoutChurn => "closeout_churn",
477        }
478    }
479}
480
481#[derive(Debug, Clone)]
482enum TranscriptBlock {
483    Text { role: Option<String>, text: String },
484    ToolUse { name: String, input: Value },
485}
486
487pub fn compute(input: &str, source_hint: Option<&str>) -> Result<SessionCostReport> {
488    if input.trim().is_empty() {
489        bail!(
490            "no session-cost input provided; pass --input <file> or pipe transcript/log data on stdin"
491        );
492    }
493
494    let source = resolve_source(input, source_hint)?;
495    let mut state = CostState::default();
496    let record_count = input.lines().filter(|line| !line.trim().is_empty()).count();
497
498    match source {
499        SessionCostSource::ClaudeJsonl => ingest_claude_jsonl(input, &mut state)?,
500        SessionCostSource::CodexJsonl => ingest_codex_jsonl(input, &mut state)?,
501        SessionCostSource::AgentDocLog => ingest_agent_doc_log(input, &mut state),
502    }
503
504    let usage_samples = state.usage_turns.len();
505    let mut prompt_tokens = 0_u64;
506    let mut cached_input_tokens = 0_u64;
507    let mut cache_creation_input_tokens = 0_u64;
508    let mut output_tokens = 0_u64;
509    let mut reasoning_output_tokens = 0_u64;
510    let mut total_tokens = 0_u64;
511    let mut largest_turn_total_tokens = 0_u64;
512    for turn in &state.usage_turns {
513        prompt_tokens += turn.prompt_tokens;
514        cached_input_tokens += turn.cached_input_tokens;
515        cache_creation_input_tokens += turn.cache_creation_input_tokens;
516        output_tokens += turn.output_tokens;
517        reasoning_output_tokens += turn.reasoning_output_tokens;
518        total_tokens += turn.total_tokens;
519        largest_turn_total_tokens = largest_turn_total_tokens.max(turn.total_tokens);
520    }
521
522    let cached_input_ratio = (prompt_tokens > 0).then_some(
523        ((cached_input_tokens as f64) / (prompt_tokens as f64) * 10_000.0).round() / 100.0,
524    );
525    let largest_prompt_turn = state
526        .usage_turns
527        .iter()
528        .max_by(|left, right| {
529            left.prompt_tokens
530                .cmp(&right.prompt_tokens)
531                .then(left.label.cmp(&right.label))
532        })
533        .map(|turn| (turn.prompt_tokens, turn.label.clone()));
534    let noop_closeout_occurrences = state
535        .runtime_events
536        .get("commit_already_current")
537        .copied()
538        .unwrap_or(0);
539    flush_pending_commands(&mut state);
540    let loop_clusters = collect_loop_clusters(&state.loop_signals);
541    let file_read_diagnostics = collect_file_read_diagnostics(&state.file_read_signals);
542    let prompt_cache_plan = derive_prompt_cache_plan(
543        source,
544        prompt_tokens,
545        cached_input_tokens,
546        cache_creation_input_tokens,
547        cached_input_ratio,
548        &state.usage_turns,
549    );
550
551    let mut largest_turns = state.usage_turns;
552    largest_turns.sort_by(|left, right| {
553        right
554            .total_tokens
555            .cmp(&left.total_tokens)
556            .then(right.prompt_tokens.cmp(&left.prompt_tokens))
557            .then(left.label.cmp(&right.label))
558    });
559    largest_turns.truncate(MAX_LARGEST_TURNS);
560
561    let mut runtime_events = state
562        .runtime_events
563        .into_iter()
564        .map(|(event, occurrences)| SessionCostRuntimeEvent { event, occurrences })
565        .collect::<Vec<_>>();
566    runtime_events.sort_by(|left, right| {
567        right
568            .occurrences
569            .cmp(&left.occurrences)
570            .then(left.event.cmp(&right.event))
571    });
572    let runtime_event_groups = runtime_events.len();
573    runtime_events.truncate(MAX_RUNTIME_EVENTS);
574    let restart_churn_groups = state.restart_churn.groups();
575    let restart_churn = state.restart_churn.summaries();
576    let guardrails = derive_guardrails(&SessionCostGuardrailInput {
577        largest_prompt_turn_tokens: largest_prompt_turn.as_ref().map_or(0, |turn| turn.0),
578        largest_prompt_turn_label: largest_prompt_turn.as_ref().map(|turn| turn.1.clone()),
579        prompt_tokens,
580        cached_input_ratio,
581        fresh_restart_occurrences: count_restart_family(&restart_churn, "fresh_restart"),
582        auto_trigger_timeout_occurrences: count_restart_family(
583            &restart_churn,
584            "auto_trigger_timeout",
585        ),
586        ctrl_d_restart_loop_occurrences: count_restart_family(
587            &restart_churn,
588            "ctrl_d_restart_loop",
589        ),
590        noop_closeout_occurrences,
591        max_restart_count: state.max_restart_count,
592    });
593
594    if usage_samples == 0 && runtime_event_groups == 0 {
595        state
596            .warnings
597            .push("no cost or runtime signals were detected in the provided input".to_string());
598    }
599
600    Ok(SessionCostReport {
601        source: source.as_str().to_string(),
602        record_count,
603        usage_samples,
604        prompt_tokens,
605        cached_input_tokens,
606        cache_creation_input_tokens,
607        output_tokens,
608        reasoning_output_tokens,
609        total_tokens,
610        cached_input_ratio,
611        largest_turn_total_tokens,
612        runtime_event_groups,
613        total_runtime_events: state.total_runtime_events,
614        restart_churn_groups,
615        max_restart_count: state.max_restart_count,
616        largest_turns,
617        runtime_events,
618        loop_clusters,
619        file_read_diagnostics,
620        restart_churn,
621        guardrails,
622        prompt_cache_plan,
623        warnings: state.warnings,
624    })
625}
626
627pub fn set_prompt_cache_scorecard_next_command(report: &mut SessionCostReport, next_command: &str) {
628    if let Some(plan) = &mut report.prompt_cache_plan {
629        for row in &mut plan.scorecard {
630            row.next_command = next_command.to_string();
631        }
632    }
633}
634
635pub fn prompt_cache_scorecard_for_session(
636    report: &SessionCostReport,
637    session_source: &str,
638    session_path: &str,
639    next_command: &str,
640) -> Vec<SessionCostPromptCacheRoiScorecard> {
641    report
642        .prompt_cache_plan
643        .as_ref()
644        .map(|plan| {
645            plan.scorecard
646                .iter()
647                .cloned()
648                .map(|mut row| {
649                    row.session_source = Some(session_source.to_string());
650                    row.session_path = Some(session_path.to_string());
651                    row.next_command = next_command.to_string();
652                    row
653                })
654                .collect()
655        })
656        .unwrap_or_default()
657}
658
659pub fn build_prompt_cache_effectiveness_report(
660    fixture: &SessionCostPromptCacheEffectivenessFixture,
661) -> Result<SessionCostPromptCacheEffectivenessReport> {
662    if fixture.cases.is_empty() {
663        bail!("prompt-cache effectiveness fixture has no cases");
664    }
665
666    let mut cases = Vec::new();
667    let required_regression_scenarios =
668        normalized_prompt_cache_scenarios(&fixture.required_regression_scenarios);
669    let mut covered_regression_scenarios = BTreeSet::new();
670    let mut totals = SessionCostPromptCacheEffectivenessTotals {
671        cases: 0,
672        passed: 0,
673        failed: 0,
674        prompt_tokens: 0,
675        cached_input_tokens: 0,
676        cache_creation_input_tokens: 0,
677        net_cached_input_tokens: 0,
678        read_create_regressions: 0,
679    };
680
681    for case in &fixture.cases {
682        if case.input_lines.is_empty() {
683            bail!(
684                "prompt-cache fixture case `{}` has no input_lines",
685                case.name
686            );
687        }
688        let input = format!("{}\n", case.input_lines.join("\n"));
689        let report = compute(&input, Some(&case.source))
690            .map_err(|err| err.context(format!("evaluating prompt-cache fixture {}", case.name)))?;
691        let analytics = report
692            .prompt_cache_plan
693            .as_ref()
694            .and_then(|plan| plan.analytics.as_ref());
695        let net_cached_input_tokens = analytics.map_or(
696            signed_token_delta(
697                report.cached_input_tokens,
698                report.cache_creation_input_tokens,
699            ),
700            |analytics| analytics.net_cached_input_tokens,
701        );
702        // Count the regression from the raw token signal, not the display-
703        // truncated diagnostics vec, so a long degraded session whose per-turn
704        // diagnostics would truncate the session-level regression cannot pass
705        // the read/create gate (#pcacheregtrunc).
706        let read_create_regressions = usize::from(
707            prompt_cache_read_create_regression(
708                report.cached_input_tokens,
709                report.cache_creation_input_tokens,
710            )
711            .is_some(),
712        );
713        let regression_scenarios = normalized_prompt_cache_scenarios(&case.regression_scenarios);
714        covered_regression_scenarios.extend(regression_scenarios.iter().cloned());
715
716        let mut failures = Vec::new();
717        if report.prompt_cache_plan.is_none() {
718            failures.push("missing prompt_cache_plan".to_string());
719        }
720        if analytics.is_none() {
721            failures.push("missing prompt_cache_plan.analytics".to_string());
722        }
723        match report.cached_input_ratio {
724            Some(ratio) if ratio >= case.minimum_cached_input_ratio => {}
725            Some(ratio) => failures.push(format!(
726                "cached_input_ratio {:.2}% below required {:.2}%",
727                ratio, case.minimum_cached_input_ratio
728            )),
729            None => failures.push(format!(
730                "cached_input_ratio missing; required {:.2}%",
731                case.minimum_cached_input_ratio
732            )),
733        }
734        if net_cached_input_tokens < case.minimum_net_cached_input_tokens {
735            failures.push(format!(
736                "net_cached_input_tokens {} below required {}",
737                net_cached_input_tokens, case.minimum_net_cached_input_tokens
738            ));
739        }
740        if read_create_regressions > case.maximum_read_create_regressions {
741            failures.push(format!(
742                "read_create_regressions {} exceeded allowed {}",
743                read_create_regressions, case.maximum_read_create_regressions
744            ));
745        }
746        failures.extend(prompt_cache_provider_adapter_failures(
747            case,
748            report.prompt_cache_plan.as_ref(),
749        ));
750        failures.extend(prompt_cache_required_prefix_drift_failures(
751            analytics,
752            &case.required_prefix_drift_fields,
753        ));
754        failures.extend(prompt_cache_required_diagnostic_failures(
755            analytics,
756            &case.required_diagnostics,
757        ));
758
759        let status = if failures.is_empty() {
760            "pass".to_string()
761        } else {
762            "fail".to_string()
763        };
764        totals.cases += 1;
765        if status == "pass" {
766            totals.passed += 1;
767        } else {
768            totals.failed += 1;
769        }
770        totals.prompt_tokens += report.prompt_tokens;
771        totals.cached_input_tokens += report.cached_input_tokens;
772        totals.cache_creation_input_tokens += report.cache_creation_input_tokens;
773        totals.net_cached_input_tokens += net_cached_input_tokens;
774        totals.read_create_regressions += read_create_regressions;
775
776        cases.push(SessionCostPromptCacheEffectivenessCaseReport {
777            name: case.name.clone(),
778            source: report.source,
779            status,
780            prompt_tokens: report.prompt_tokens,
781            cached_input_tokens: report.cached_input_tokens,
782            cache_creation_input_tokens: report.cache_creation_input_tokens,
783            cached_input_ratio: report.cached_input_ratio,
784            minimum_cached_input_ratio: case.minimum_cached_input_ratio,
785            net_cached_input_tokens,
786            minimum_net_cached_input_tokens: case.minimum_net_cached_input_tokens,
787            read_create_regressions,
788            maximum_read_create_regressions: case.maximum_read_create_regressions,
789            regression_scenarios,
790            required_prefix_drift_fields: normalized_prompt_cache_scenarios(
791                &case.required_prefix_drift_fields,
792            ),
793            required_diagnostics: normalized_prompt_cache_scenarios(&case.required_diagnostics),
794            failures,
795        });
796    }
797
798    let covered_regression_scenarios = covered_regression_scenarios.into_iter().collect::<Vec<_>>();
799    let covered_set = covered_regression_scenarios
800        .iter()
801        .cloned()
802        .collect::<BTreeSet<_>>();
803    let missing_regression_scenarios = required_regression_scenarios
804        .iter()
805        .filter(|scenario| !covered_set.contains(*scenario))
806        .cloned()
807        .collect::<Vec<_>>();
808
809    Ok(SessionCostPromptCacheEffectivenessReport {
810        schema_version: fixture.schema_version,
811        pass: totals.failed == 0 && missing_regression_scenarios.is_empty(),
812        totals,
813        required_regression_scenarios,
814        covered_regression_scenarios,
815        missing_regression_scenarios,
816        cases,
817    })
818}
819
820fn normalized_prompt_cache_scenarios(values: &[String]) -> Vec<String> {
821    values
822        .iter()
823        .map(|value| value.trim())
824        .filter(|value| !value.is_empty())
825        .map(str::to_string)
826        .collect::<BTreeSet<_>>()
827        .into_iter()
828        .collect()
829}
830
831fn prompt_cache_required_prefix_drift_failures(
832    analytics: Option<&SessionCostPromptCacheAnalytics>,
833    required_fields: &[String],
834) -> Vec<String> {
835    let required_fields = normalized_prompt_cache_scenarios(required_fields);
836    if required_fields.is_empty() {
837        return Vec::new();
838    }
839    let observed_fields = analytics
840        .map(|analytics| {
841            analytics
842                .prefix_drift
843                .iter()
844                .flat_map(|drift| {
845                    drift
846                        .field_changes
847                        .iter()
848                        .map(|change| change.field.clone())
849                })
850                .collect::<BTreeSet<_>>()
851        })
852        .unwrap_or_default();
853    required_fields
854        .into_iter()
855        .filter(|field| !observed_fields.contains(field))
856        .map(|field| format!("missing required prompt-cache prefix drift field `{field}`"))
857        .collect()
858}
859
860fn prompt_cache_required_diagnostic_failures(
861    analytics: Option<&SessionCostPromptCacheAnalytics>,
862    required_kinds: &[String],
863) -> Vec<String> {
864    let required_kinds = normalized_prompt_cache_scenarios(required_kinds);
865    if required_kinds.is_empty() {
866        return Vec::new();
867    }
868    let observed_kinds = analytics
869        .map(|analytics| {
870            analytics
871                .diagnostics
872                .iter()
873                .map(|diagnostic| diagnostic.kind.clone())
874                .collect::<BTreeSet<_>>()
875        })
876        .unwrap_or_default();
877    required_kinds
878        .into_iter()
879        .filter(|kind| !observed_kinds.contains(kind))
880        .map(|kind| format!("missing required prompt-cache diagnostic `{kind}`"))
881        .collect()
882}
883
884fn prompt_cache_provider_adapter_failures(
885    case: &SessionCostPromptCacheEffectivenessCase,
886    plan: Option<&SessionCostPromptCachePlan>,
887) -> Vec<String> {
888    let mut failures = Vec::new();
889    let Some(plan) = plan else {
890        return failures;
891    };
892    let Ok(source) = SessionCostSource::parse(&case.source) else {
893        return failures;
894    };
895
896    match source {
897        SessionCostSource::ClaudeJsonl => require_prompt_cache_provider_adapter(
898            plan,
899            "anthropic",
900            &["cache_control"],
901            "Anthropic cache_control",
902            &mut failures,
903        ),
904        SessionCostSource::CodexJsonl => require_prompt_cache_provider_adapter(
905            plan,
906            "openai",
907            if case_has_regression_scenario(case, "openai_prompt_cache_key_churn") {
908                &["prompt_cache_key", "prompt_cache_key_churn"]
909            } else {
910                &["prompt_cache_key"]
911            },
912            "OpenAI prompt_cache_key",
913            &mut failures,
914        ),
915        SessionCostSource::AgentDocLog => {}
916    }
917    if matches!(
918        source,
919        SessionCostSource::ClaudeJsonl | SessionCostSource::CodexJsonl
920    ) {
921        require_prompt_cache_provider_adapter(
922            plan,
923            "replica_local",
924            if case_has_regression_scenario(case, "replica_routing_churn") {
925                &["routing_affinity", "routing_affinity_churn"]
926            } else {
927                &["routing_affinity"]
928            },
929            "replica-local routing_affinity",
930            &mut failures,
931        );
932    }
933
934    failures
935}
936
937fn require_prompt_cache_provider_adapter(
938    plan: &SessionCostPromptCachePlan,
939    provider: &str,
940    expected_statuses: &[&str],
941    label: &str,
942    failures: &mut Vec<String>,
943) {
944    match plan
945        .provider_adapters
946        .iter()
947        .find(|adapter| adapter.provider == provider)
948    {
949        Some(adapter) if expected_statuses.contains(&adapter.status.as_str()) => {}
950        Some(adapter) => failures.push(format!(
951            "{label} adapter status `{}`; expected one of {}",
952            adapter.status,
953            expected_statuses.join(", ")
954        )),
955        None => failures.push(format!("missing {label} adapter")),
956    }
957}
958
959fn case_has_regression_scenario(
960    case: &SessionCostPromptCacheEffectivenessCase,
961    scenario: &str,
962) -> bool {
963    case.regression_scenarios
964        .iter()
965        .any(|value| value.trim() == scenario)
966}
967
968pub fn derive_guardrails(input: &SessionCostGuardrailInput) -> Vec<SessionCostGuardrail> {
969    let mut guardrails = Vec::new();
970
971    if input.largest_prompt_turn_tokens >= PROMPT_BUDGET_WARN_TOKENS {
972        let label = input
973            .largest_prompt_turn_label
974            .as_deref()
975            .map(|label| format!(" at {label}"))
976            .unwrap_or_default();
977        guardrails.push(SessionCostGuardrail {
978            kind: "prompt_budget".to_string(),
979            severity: "warn".to_string(),
980            message: format!(
981                "largest prompt turn reached {} tokens{label}",
982                input.largest_prompt_turn_tokens
983            ),
984            guidance:
985                "compact the session or split the task before another large turn resends the same context"
986                    .to_string(),
987        });
988    }
989
990    if input.prompt_tokens >= CACHED_RATIO_WARN_PROMPT_TOKENS
991        && input
992            .cached_input_ratio
993            .is_some_and(|ratio| ratio >= CACHED_RATIO_WARN_PERCENT)
994    {
995        guardrails.push(SessionCostGuardrail {
996            kind: "cache_resend".to_string(),
997            severity: "warn".to_string(),
998            message: format!(
999                "cached input ratio was {:.2}% across {} prompt tokens",
1000                input.cached_input_ratio.unwrap_or_default(),
1001                input.prompt_tokens
1002            ),
1003            guidance:
1004                "compact or restart the session when most prompt spend is cached context instead of new work"
1005                    .to_string(),
1006        });
1007    }
1008
1009    let restart_signal_count = input.fresh_restart_occurrences
1010        + input.auto_trigger_timeout_occurrences
1011        + input.ctrl_d_restart_loop_occurrences;
1012    if restart_signal_count >= RESTART_LOOP_WARN_OCCURRENCES
1013        || input.ctrl_d_restart_loop_occurrences > 0
1014        || input.auto_trigger_timeout_occurrences > 0
1015    {
1016        let max_restart = input
1017            .max_restart_count
1018            .map(|count| format!(" max_restart={count}."))
1019            .unwrap_or_default();
1020        guardrails.push(SessionCostGuardrail {
1021            kind: "restart_loop".to_string(),
1022            severity: "warn".to_string(),
1023            message: format!(
1024                "restart churn detected: fresh_restart={} auto_trigger_timeout={} ctrl_d_restart_loop={}.{}",
1025                input.fresh_restart_occurrences,
1026                input.auto_trigger_timeout_occurrences,
1027                input.ctrl_d_restart_loop_occurrences,
1028                max_restart
1029            )
1030            .trim()
1031            .to_string(),
1032            guidance:
1033                "fix the startup/retry issue before another restart, or compact and reopen cleanly instead of looping"
1034                    .to_string(),
1035        });
1036    }
1037
1038    if input.noop_closeout_occurrences >= NOOP_CLOSEOUT_WARN_OCCURRENCES {
1039        guardrails.push(SessionCostGuardrail {
1040            kind: "noop_closeout".to_string(),
1041            severity: "warn".to_string(),
1042            message: format!(
1043                "commit_already_current appeared {} times",
1044                input.noop_closeout_occurrences
1045            ),
1046            guidance:
1047                "compact the document or avoid reopening it without new edits when closeouts are mostly no-ops"
1048                    .to_string(),
1049        });
1050    }
1051
1052    guardrails.truncate(MAX_GUARDRAILS);
1053    guardrails
1054}
1055
1056fn derive_prompt_cache_plan(
1057    source: SessionCostSource,
1058    prompt_tokens: u64,
1059    cached_input_tokens: u64,
1060    cache_creation_input_tokens: u64,
1061    cached_input_ratio: Option<f64>,
1062    usage_turns: &[SessionCostTurn],
1063) -> Option<SessionCostPromptCachePlan> {
1064    let usage_samples = usage_turns.len();
1065    if usage_samples == 0 {
1066        return None;
1067    }
1068
1069    let observed = cached_input_tokens > 0 || cache_creation_input_tokens > 0;
1070    let candidate = prompt_tokens >= PROMPT_CACHE_CANDIDATE_TOKENS;
1071    if !observed && !candidate {
1072        return None;
1073    }
1074
1075    let adapter_evidence = prompt_cache_adapter_evidence(usage_turns);
1076    let mut actions = Vec::new();
1077    if !observed {
1078        actions.push(SessionCostPromptCacheAction {
1079            kind: "enable_provider_cache".to_string(),
1080            severity: "recommend".to_string(),
1081            message: format!(
1082                "prompt volume reached {prompt_tokens} tokens without observed cache reads"
1083            ),
1084            guidance: "add a provider adapter that keeps stable context byte-identical and passes the provider cache hint on each turn"
1085                .to_string(),
1086        });
1087    } else if cached_input_ratio.is_some_and(|ratio| ratio < PROMPT_CACHE_GOOD_HIT_PERCENT) {
1088        actions.push(SessionCostPromptCacheAction {
1089            kind: "improve_cache_hit_rate".to_string(),
1090            severity: "recommend".to_string(),
1091            message: format!(
1092                "cached input ratio was {:.2}% across {prompt_tokens} prompt tokens",
1093                cached_input_ratio.unwrap_or_default()
1094            ),
1095            guidance:
1096                "move volatile timestamps, generated headers, and one-off compaction prompts after the cached prefix"
1097                    .to_string(),
1098        });
1099    } else {
1100        actions.push(SessionCostPromptCacheAction {
1101            kind: "preserve_cache_shape".to_string(),
1102            severity: "info".to_string(),
1103            message: format!(
1104                "cache reads were observed across {cached_input_tokens} input tokens"
1105            ),
1106            guidance:
1107                "keep the stable prefix and append-only transcript shape intact while adding new tools or context"
1108                    .to_string(),
1109        });
1110    }
1111
1112    if cache_creation_input_tokens > cached_input_tokens && cached_input_tokens > 0 {
1113        actions.push(SessionCostPromptCacheAction {
1114            kind: "reduce_cache_rewrites".to_string(),
1115            severity: "recommend".to_string(),
1116            message: format!(
1117                "cache creation tokens ({cache_creation_input_tokens}) exceeded cache read tokens ({cached_input_tokens})"
1118            ),
1119            guidance:
1120                "check for prefix churn before each model call; repeated writes can erase the economics of prompt caching"
1121            .to_string(),
1122        });
1123    }
1124    push_prompt_cache_adapter_actions(&adapter_evidence, &mut actions);
1125
1126    Some(SessionCostPromptCachePlan {
1127        status: if observed { "observed" } else { "candidate" }.to_string(),
1128        feasible: true,
1129        observed_cached_input_tokens: cached_input_tokens,
1130        observed_cache_creation_tokens: cache_creation_input_tokens,
1131        observed_cached_input_ratio: cached_input_ratio.map(|ratio| format!("{ratio:.2}%")),
1132        analytics: derive_prompt_cache_analytics(
1133            usage_turns,
1134            prompt_tokens,
1135            cached_input_tokens,
1136            cache_creation_input_tokens,
1137            cached_input_ratio,
1138        ),
1139        scorecard: derive_prompt_cache_roi_scorecard(
1140            usage_turns,
1141            default_prompt_cache_provider(source),
1142            PROMPT_CACHE_SCORECARD_DEFAULT_NEXT_COMMAND,
1143        ),
1144        invariants: vec![
1145            "place stable system/developer context before per-turn content".to_string(),
1146            "treat conversation history as append-only until an intentional compaction boundary"
1147                .to_string(),
1148            "exclude volatile timestamps, random ids, and transient instructions from the cached prefix"
1149                .to_string(),
1150            "run compaction against the same live prefix whenever the provider cache is still warm"
1151                .to_string(),
1152        ],
1153        provider_adapters: derive_prompt_cache_provider_adapters(&adapter_evidence),
1154        actions,
1155    })
1156}
1157
1158fn derive_prompt_cache_provider_adapters(
1159    evidence: &PromptCacheAdapterEvidence,
1160) -> Vec<SessionCostPromptCacheProvider> {
1161    vec![
1162        SessionCostPromptCacheProvider {
1163            provider: "anthropic".to_string(),
1164            status: anthropic_cache_control_status(evidence).to_string(),
1165            requirements: vec![
1166                "attach cache_control to the stable system block".to_string(),
1167                "attach cache_control to the final tool definition when tools are sent".to_string(),
1168                "attach cache_control to the last two user-role messages; skip one-off compaction instructions"
1169                    .to_string(),
1170            ],
1171        },
1172        SessionCostPromptCacheProvider {
1173            provider: "openai".to_string(),
1174            status: openai_prompt_cache_key_status(evidence).to_string(),
1175            requirements: vec![
1176                "derive prompt_cache_key from the stable thread/session id".to_string(),
1177                "keep prefixes byte-identical across consecutive calls for the same key".to_string(),
1178            ],
1179        },
1180        SessionCostPromptCacheProvider {
1181            provider: "replica_local".to_string(),
1182            status: replica_local_routing_affinity_status(evidence).to_string(),
1183            requirements: vec![
1184                "route consecutive calls for the same cache key to the same replica when the provider cache is replica-local"
1185                    .to_string(),
1186            ],
1187        },
1188    ]
1189}
1190
1191fn prompt_cache_adapter_evidence(usage_turns: &[SessionCostTurn]) -> PromptCacheAdapterEvidence {
1192    let mut evidence = PromptCacheAdapterEvidence::default();
1193    for metadata in usage_turns
1194        .iter()
1195        .filter_map(|turn| turn.prompt_cache_metadata.as_ref())
1196    {
1197        let anthropic = is_anthropic_provider(&metadata.provider);
1198        let openai = is_openai_provider(&metadata.provider);
1199        if anthropic {
1200            evidence.anthropic_samples += 1;
1201            if metadata_has_cache_control_breakpoint(metadata) {
1202                evidence.anthropic_cache_control_samples += 1;
1203            }
1204        }
1205        if openai {
1206            evidence.openai_samples += 1;
1207            if let Some(cache_key) = metadata.cache_key.as_ref() {
1208                evidence.openai_prompt_cache_key_samples += 1;
1209                evidence.openai_prompt_cache_keys.insert(cache_key.clone());
1210            }
1211        }
1212        if anthropic || openai {
1213            evidence.routed_provider_samples += 1;
1214            if let Some(routing_affinity) = metadata.routing_affinity.as_ref() {
1215                evidence.routing_affinity_samples += 1;
1216                evidence
1217                    .routing_affinity_values
1218                    .insert(routing_affinity.clone());
1219            }
1220        }
1221    }
1222    evidence
1223}
1224
1225fn anthropic_cache_control_status(evidence: &PromptCacheAdapterEvidence) -> &'static str {
1226    if evidence.anthropic_samples == 0 {
1227        "not_observed"
1228    } else if evidence.anthropic_cache_control_samples == evidence.anthropic_samples {
1229        "cache_control"
1230    } else if evidence.anthropic_cache_control_samples > 0 {
1231        "partial_cache_control"
1232    } else {
1233        "missing_cache_control"
1234    }
1235}
1236
1237fn openai_prompt_cache_key_status(evidence: &PromptCacheAdapterEvidence) -> &'static str {
1238    if evidence.openai_samples == 0 {
1239        "not_observed"
1240    } else if evidence.openai_prompt_cache_key_samples < evidence.openai_samples {
1241        if evidence.openai_prompt_cache_key_samples == 0 {
1242            "missing_prompt_cache_key"
1243        } else {
1244            "partial_prompt_cache_key"
1245        }
1246    } else if evidence.openai_prompt_cache_keys.len() > 1 {
1247        "prompt_cache_key_churn"
1248    } else {
1249        "prompt_cache_key"
1250    }
1251}
1252
1253fn replica_local_routing_affinity_status(evidence: &PromptCacheAdapterEvidence) -> &'static str {
1254    if evidence.routed_provider_samples == 0 {
1255        "not_observed"
1256    } else if evidence.routing_affinity_samples < evidence.routed_provider_samples {
1257        if evidence.routing_affinity_samples == 0 {
1258            "missing_routing_affinity"
1259        } else {
1260            "partial_routing_affinity"
1261        }
1262    } else if evidence.routing_affinity_values.len() > 1 {
1263        "routing_affinity_churn"
1264    } else {
1265        "routing_affinity"
1266    }
1267}
1268
1269fn push_prompt_cache_adapter_actions(
1270    evidence: &PromptCacheAdapterEvidence,
1271    actions: &mut Vec<SessionCostPromptCacheAction>,
1272) {
1273    match anthropic_cache_control_status(evidence) {
1274        "missing_cache_control" | "partial_cache_control" => {
1275            actions.push(SessionCostPromptCacheAction {
1276                kind: "fix_anthropic_cache_control".to_string(),
1277                severity: "recommend".to_string(),
1278                message: "Anthropic prompt-cache calls are missing cache_control breakpoints"
1279                    .to_string(),
1280                guidance: "attach cache_control to the stable Anthropic system/tool/user blocks that should be cached"
1281                    .to_string(),
1282            });
1283        }
1284        _ => {}
1285    }
1286    match openai_prompt_cache_key_status(evidence) {
1287        "missing_prompt_cache_key" | "partial_prompt_cache_key" | "prompt_cache_key_churn" => {
1288            actions.push(SessionCostPromptCacheAction {
1289                kind: "fix_openai_prompt_cache_key".to_string(),
1290                severity: "recommend".to_string(),
1291                message: "OpenAI prompt-cache calls need a stable prompt_cache_key".to_string(),
1292                guidance: "derive prompt_cache_key from the stable session/thread id and keep it unchanged across warm-prefix calls"
1293                    .to_string(),
1294            });
1295        }
1296        _ => {}
1297    }
1298    match replica_local_routing_affinity_status(evidence) {
1299        "missing_routing_affinity" | "partial_routing_affinity" | "routing_affinity_churn" => {
1300            actions.push(SessionCostPromptCacheAction {
1301                kind: "fix_replica_routing_affinity".to_string(),
1302                severity: "recommend".to_string(),
1303                message: "prompt-cache calls need stable replica-local routing affinity"
1304                    .to_string(),
1305                guidance: "route consecutive calls for the same cache key to the same provider replica or deployment"
1306                    .to_string(),
1307            });
1308        }
1309        _ => {}
1310    }
1311}
1312
1313fn derive_prompt_cache_analytics(
1314    usage_turns: &[SessionCostTurn],
1315    prompt_tokens: u64,
1316    cached_input_tokens: u64,
1317    cache_creation_input_tokens: u64,
1318    cached_input_ratio: Option<f64>,
1319) -> Option<SessionCostPromptCacheAnalytics> {
1320    if usage_turns.is_empty() {
1321        return None;
1322    }
1323
1324    let first_ratio = usage_turns
1325        .first()
1326        .and_then(|turn| percent_ratio(turn.cached_input_tokens, turn.prompt_tokens));
1327    let last_ratio = usage_turns
1328        .last()
1329        .and_then(|turn| percent_ratio(turn.cached_input_tokens, turn.prompt_tokens));
1330    let ratio_delta = first_ratio
1331        .zip(last_ratio)
1332        .map(|(first, last)| last - first);
1333    let trend = prompt_cache_trend(usage_turns.len(), ratio_delta).to_string();
1334    let effective = cached_input_ratio.is_some_and(|ratio| ratio >= PROMPT_CACHE_GOOD_HIT_PERCENT)
1335        && cached_input_tokens >= cache_creation_input_tokens;
1336    let cache_read_to_creation_ratio = (cache_creation_input_tokens > 0).then(|| {
1337        format!(
1338            "{:.2}x",
1339            (cached_input_tokens as f64) / (cache_creation_input_tokens as f64)
1340        )
1341    });
1342    let timeline = prompt_cache_timeline(usage_turns);
1343    let (prefix_drift, prefix_drift_truncated) = derive_prompt_cache_prefix_drift(usage_turns);
1344    let diagnostics = derive_prompt_cache_diagnostics(
1345        usage_turns,
1346        cached_input_tokens,
1347        cache_creation_input_tokens,
1348    );
1349
1350    Some(SessionCostPromptCacheAnalytics {
1351        sample_count: usage_turns.len(),
1352        effective,
1353        trend,
1354        total_prompt_tokens: prompt_tokens,
1355        total_cached_input_tokens: cached_input_tokens,
1356        total_cache_creation_tokens: cache_creation_input_tokens,
1357        net_cached_input_tokens: signed_token_delta(
1358            cached_input_tokens,
1359            cache_creation_input_tokens,
1360        ),
1361        timeline_truncated: usage_turns.len() > MAX_PROMPT_CACHE_TIMELINE,
1362        average_cached_input_ratio: cached_input_ratio.map(format_percent),
1363        first_cached_input_ratio: first_ratio.map(format_percent),
1364        last_cached_input_ratio: last_ratio.map(format_percent),
1365        cached_input_ratio_delta: ratio_delta.map(format_signed_percent),
1366        cache_read_to_creation_ratio,
1367        diagnostics,
1368        prefix_drift_truncated,
1369        prefix_drift,
1370        timeline,
1371    })
1372}
1373
1374fn derive_prompt_cache_roi_scorecard(
1375    usage_turns: &[SessionCostTurn],
1376    fallback_provider: &str,
1377    next_command: &str,
1378) -> Vec<SessionCostPromptCacheRoiScorecard> {
1379    let mut by_provider = BTreeMap::<String, Vec<SessionCostTurn>>::new();
1380    for turn in usage_turns {
1381        let provider = turn
1382            .prompt_cache_metadata
1383            .as_ref()
1384            .map(|metadata| metadata.provider.trim())
1385            .filter(|provider| !provider.is_empty())
1386            .unwrap_or(fallback_provider)
1387            .to_ascii_lowercase();
1388        by_provider.entry(provider).or_default().push(turn.clone());
1389    }
1390
1391    let mut rows = by_provider
1392        .into_iter()
1393        .map(|(provider, turns)| prompt_cache_roi_scorecard_row(provider, &turns, next_command))
1394        .collect::<Vec<_>>();
1395    rows.sort_by(|left, right| {
1396        right
1397            .net_cached_read_tokens
1398            .cmp(&left.net_cached_read_tokens)
1399            .then(left.provider.cmp(&right.provider))
1400    });
1401    rows.truncate(MAX_PROMPT_CACHE_SCORECARD);
1402    rows
1403}
1404
1405fn prompt_cache_roi_scorecard_row(
1406    provider: String,
1407    turns: &[SessionCostTurn],
1408    next_command: &str,
1409) -> SessionCostPromptCacheRoiScorecard {
1410    let prompt_tokens = turns.iter().map(|turn| turn.prompt_tokens).sum::<u64>();
1411    let cached_input_tokens = turns
1412        .iter()
1413        .map(|turn| turn.cached_input_tokens)
1414        .sum::<u64>();
1415    let cache_creation_input_tokens = turns
1416        .iter()
1417        .map(|turn| turn.cache_creation_input_tokens)
1418        .sum::<u64>();
1419    let first_ratio = turns
1420        .first()
1421        .and_then(|turn| percent_ratio(turn.cached_input_tokens, turn.prompt_tokens));
1422    let last_ratio = turns
1423        .last()
1424        .and_then(|turn| percent_ratio(turn.cached_input_tokens, turn.prompt_tokens));
1425    let ratio_delta = first_ratio
1426        .zip(last_ratio)
1427        .map(|(first, last)| last - first);
1428    let diagnostics =
1429        derive_prompt_cache_diagnostics(turns, cached_input_tokens, cache_creation_input_tokens);
1430    let (prefix_drift, _) = derive_prompt_cache_prefix_drift(turns);
1431    let adapter_evidence = prompt_cache_adapter_evidence(turns);
1432
1433    SessionCostPromptCacheRoiScorecard {
1434        session_source: None,
1435        session_path: None,
1436        provider: provider.clone(),
1437        sample_count: turns.len(),
1438        net_cached_read_tokens: signed_token_delta(
1439            cached_input_tokens,
1440            cache_creation_input_tokens,
1441        ),
1442        read_create_ratio: prompt_cache_read_create_ratio(
1443            cached_input_tokens,
1444            cache_creation_input_tokens,
1445        ),
1446        trend: prompt_cache_trend(turns.len(), ratio_delta).to_string(),
1447        suspected_invalidation_cause: prompt_cache_scorecard_cause(
1448            &provider,
1449            &diagnostics,
1450            &prefix_drift,
1451            &adapter_evidence,
1452            prompt_tokens,
1453            cached_input_tokens,
1454            cache_creation_input_tokens,
1455        ),
1456        next_command: next_command.to_string(),
1457    }
1458}
1459
1460fn prompt_cache_read_create_ratio(
1461    cached_input_tokens: u64,
1462    cache_creation_input_tokens: u64,
1463) -> String {
1464    if cache_creation_input_tokens > 0 {
1465        format!(
1466            "{:.2}x",
1467            (cached_input_tokens as f64) / (cache_creation_input_tokens as f64)
1468        )
1469    } else if cached_input_tokens > 0 {
1470        "read_only".to_string()
1471    } else {
1472        "-".to_string()
1473    }
1474}
1475
1476fn prompt_cache_scorecard_cause(
1477    provider: &str,
1478    diagnostics: &[SessionCostPromptCacheDiagnostic],
1479    prefix_drift: &[SessionCostPromptCachePrefixDrift],
1480    adapter_evidence: &PromptCacheAdapterEvidence,
1481    prompt_tokens: u64,
1482    cached_input_tokens: u64,
1483    cache_creation_input_tokens: u64,
1484) -> String {
1485    if let Some(diagnostic) = diagnostics.first() {
1486        return diagnostic
1487            .likely_causes
1488            .first()
1489            .cloned()
1490            .unwrap_or_else(|| diagnostic.kind.clone());
1491    }
1492    if let Some(drift) = prefix_drift
1493        .iter()
1494        .find(|drift| drift.severity == "warn")
1495        .or_else(|| prefix_drift.first())
1496    {
1497        return format!("{} changed ({})", drift.first_changed_field, drift.trigger);
1498    }
1499    if let Some(adapter_cause) = prompt_cache_adapter_scorecard_cause(provider, adapter_evidence) {
1500        return adapter_cause;
1501    }
1502    if cached_input_tokens == 0 && prompt_tokens >= PROMPT_CACHE_CANDIDATE_TOKENS {
1503        return "no provider cache reads observed".to_string();
1504    }
1505    if cache_creation_input_tokens > cached_input_tokens {
1506        return "cache creation exceeded cache reads".to_string();
1507    }
1508    "none observed".to_string()
1509}
1510
1511fn prompt_cache_adapter_scorecard_cause(
1512    provider: &str,
1513    evidence: &PromptCacheAdapterEvidence,
1514) -> Option<String> {
1515    if is_anthropic_provider(provider) {
1516        match anthropic_cache_control_status(evidence) {
1517            "missing_cache_control" => {
1518                return Some("missing Anthropic cache_control breakpoints".to_string());
1519            }
1520            "partial_cache_control" => {
1521                return Some("partial Anthropic cache_control breakpoint coverage".to_string());
1522            }
1523            _ => {}
1524        }
1525    }
1526    if is_openai_provider(provider) {
1527        match openai_prompt_cache_key_status(evidence) {
1528            "missing_prompt_cache_key" => {
1529                return Some("missing OpenAI prompt_cache_key".to_string());
1530            }
1531            "partial_prompt_cache_key" => {
1532                return Some("partial OpenAI prompt_cache_key coverage".to_string());
1533            }
1534            "prompt_cache_key_churn" => {
1535                return Some("OpenAI prompt_cache_key changed between calls".to_string());
1536            }
1537            _ => {}
1538        }
1539    }
1540    if is_anthropic_provider(provider) || is_openai_provider(provider) {
1541        match replica_local_routing_affinity_status(evidence) {
1542            "missing_routing_affinity" => {
1543                return Some("missing replica-local routing affinity".to_string());
1544            }
1545            "partial_routing_affinity" => {
1546                return Some("partial replica-local routing affinity coverage".to_string());
1547            }
1548            "routing_affinity_churn" => {
1549                return Some("replica-local routing affinity changed between calls".to_string());
1550            }
1551            _ => {}
1552        }
1553    }
1554    None
1555}
1556
1557fn derive_prompt_cache_diagnostics(
1558    usage_turns: &[SessionCostTurn],
1559    cached_input_tokens: u64,
1560    cache_creation_input_tokens: u64,
1561) -> Vec<SessionCostPromptCacheDiagnostic> {
1562    let mut diagnostics = Vec::new();
1563
1564    for pair in usage_turns.windows(2) {
1565        let previous = &pair[0];
1566        let current = &pair[1];
1567        let Some(previous_ratio) =
1568            percent_ratio(previous.cached_input_tokens, previous.prompt_tokens)
1569        else {
1570            continue;
1571        };
1572        let Some(current_ratio) = percent_ratio(current.cached_input_tokens, current.prompt_tokens)
1573        else {
1574            continue;
1575        };
1576        let drop = previous_ratio - current_ratio;
1577        if drop >= PROMPT_CACHE_RATIO_DROP_WARN_PERCENT {
1578            let first_changed_field = prompt_cache_first_changed_field(previous, current);
1579            let drift_suffix = first_changed_field
1580                .as_ref()
1581                .map(|change| format!("; first changed prompt-cache field: {}", change.field))
1582                .unwrap_or_else(|| {
1583                    "; no prompt-cache metadata field changed between adjacent turns".to_string()
1584                });
1585            let mut likely_causes = vec![
1586                "stable prefix bytes changed before the cache boundary".to_string(),
1587                "prompt_cache_key or thread/session id changed".to_string(),
1588                "replica-local cache affinity was lost".to_string(),
1589            ];
1590            if let Some(change) = first_changed_field {
1591                likely_causes.insert(
1592                    0,
1593                    format!(
1594                        "first changed prompt-cache field: {} ({} -> {})",
1595                        change.field, change.previous, change.current
1596                    ),
1597                );
1598            }
1599            diagnostics.push(SessionCostPromptCacheDiagnostic {
1600                kind: "cached_ratio_drop".to_string(),
1601                severity: "warn".to_string(),
1602                label: current.label.clone(),
1603                message: format!(
1604                    "cached input ratio dropped from {} to {} at {}{}",
1605                    format_percent(previous_ratio),
1606                    format_percent(current_ratio),
1607                    current.label,
1608                    drift_suffix
1609                ),
1610                likely_causes,
1611                guidance:
1612                    "compare the prefix, tool set, cache key, compaction boundary, and routing between the previous turn and this turn"
1613                        .to_string(),
1614            });
1615        }
1616    }
1617
1618    for (index, turn) in usage_turns.iter().enumerate() {
1619        let Some(creation_ratio) =
1620            percent_ratio(turn.cache_creation_input_tokens, turn.prompt_tokens)
1621        else {
1622            continue;
1623        };
1624        if turn.cache_creation_input_tokens > 0
1625            && creation_ratio >= PROMPT_CACHE_CREATION_SPIKE_WARN_PERCENT
1626        {
1627            let first_changed_field = index
1628                .checked_sub(1)
1629                .and_then(|previous_index| usage_turns.get(previous_index))
1630                .and_then(|previous| prompt_cache_first_changed_field(previous, turn));
1631            let drift_suffix = first_changed_field
1632                .as_ref()
1633                .map(|change| format!("; first changed prompt-cache field: {}", change.field))
1634                .unwrap_or_else(|| {
1635                    "; no adjacent prompt-cache metadata drift was detected".to_string()
1636                });
1637            let mut likely_causes = vec![
1638                "provider created a fresh cached prefix instead of reusing the warm prefix"
1639                    .to_string(),
1640                "system, developer, or tool block changed before the cache boundary".to_string(),
1641                "compaction or transient instructions entered the cached prefix".to_string(),
1642            ];
1643            if let Some(change) = first_changed_field {
1644                likely_causes.insert(
1645                    0,
1646                    format!(
1647                        "first changed prompt-cache field: {} ({} -> {})",
1648                        change.field, change.previous, change.current
1649                    ),
1650                );
1651            }
1652            diagnostics.push(SessionCostPromptCacheDiagnostic {
1653                kind: "cache_creation_spike".to_string(),
1654                severity: "warn".to_string(),
1655                label: turn.label.clone(),
1656                message: format!(
1657                    "cache creation was {} of prompt tokens at {}{}",
1658                    format_percent(creation_ratio),
1659                    turn.label,
1660                    drift_suffix
1661                ),
1662                likely_causes,
1663                guidance:
1664                    "inspect the cached prefix and provider breakpoint placement for this turn before treating the cache as effective"
1665                        .to_string(),
1666            });
1667        }
1668    }
1669
1670    // The session-level read/create regression is computed once per session.
1671    let read_create_regression = prompt_cache_read_create_regression(
1672        cached_input_tokens,
1673        cache_creation_input_tokens,
1674    )
1675    .map(|read_to_creation| SessionCostPromptCacheDiagnostic {
1676        kind: "read_create_regression".to_string(),
1677        severity: "recommend".to_string(),
1678        label: "session".to_string(),
1679        message: format!(
1680            "cache read/create ratio was {read_to_creation:.2}x ({cached_input_tokens} read tokens, {cache_creation_input_tokens} creation tokens)"
1681        ),
1682        likely_causes: vec![
1683            "cached prefix is being rewritten too often for warm reuse".to_string(),
1684            "volatile values are inside the cached prefix".to_string(),
1685            "cache key or replica routing is changing between turns".to_string(),
1686        ],
1687        guidance:
1688            "stabilize the prefix/key/routing path until cache reads clearly exceed creation work"
1689                .to_string(),
1690    });
1691
1692    // Reserve a slot for the session-level regression before truncating the
1693    // per-turn diagnostics, so a long degraded session with many per-turn
1694    // ratio-drop/creation-spike diagnostics cannot truncate the regression away
1695    // and silently pass the read/create gate (#pcacheregtrunc).
1696    let per_turn_cap = if read_create_regression.is_some() {
1697        MAX_PROMPT_CACHE_DIAGNOSTICS.saturating_sub(1)
1698    } else {
1699        MAX_PROMPT_CACHE_DIAGNOSTICS
1700    };
1701    diagnostics.truncate(per_turn_cap);
1702    if let Some(regression) = read_create_regression {
1703        diagnostics.push(regression);
1704    }
1705    diagnostics
1706}
1707
1708/// The session-level cache read/create regression signal: returns the
1709/// read-to-creation ratio when creation tokens exist and the ratio is below the
1710/// regression threshold. Computed from raw token totals so it is independent of
1711/// the display-truncated diagnostics vec.
1712pub(crate) fn prompt_cache_read_create_regression(
1713    cached_input_tokens: u64,
1714    cache_creation_input_tokens: u64,
1715) -> Option<f64> {
1716    if cache_creation_input_tokens == 0 {
1717        return None;
1718    }
1719    let read_to_creation = (cached_input_tokens as f64) / (cache_creation_input_tokens as f64);
1720    (read_to_creation < PROMPT_CACHE_READ_CREATE_REGRESSION_RATIO).then_some(read_to_creation)
1721}
1722
1723fn derive_prompt_cache_prefix_drift(
1724    usage_turns: &[SessionCostTurn],
1725) -> (Vec<SessionCostPromptCachePrefixDrift>, bool) {
1726    let mut drift = Vec::new();
1727
1728    for pair in usage_turns.windows(2) {
1729        let previous = &pair[0];
1730        let current = &pair[1];
1731        let field_changes = prompt_cache_field_changes(previous, current);
1732        let Some(first_changed_field) = field_changes.first().map(|change| change.field.clone())
1733        else {
1734            continue;
1735        };
1736
1737        let ratio_drop = prompt_cache_ratio_drop_triggered(previous, current);
1738        let creation_spike = prompt_cache_creation_spike_triggered(current);
1739        let trigger = match (ratio_drop, creation_spike) {
1740            (true, true) => "cached_ratio_drop_and_cache_creation_spike",
1741            (true, false) => "cached_ratio_drop",
1742            (false, true) => "cache_creation_spike",
1743            (false, false) => "metadata_drift",
1744        };
1745
1746        drift.push(SessionCostPromptCachePrefixDrift {
1747            previous_label: previous.label.clone(),
1748            current_label: current.label.clone(),
1749            trigger: trigger.to_string(),
1750            severity: if ratio_drop || creation_spike {
1751                "warn".to_string()
1752            } else {
1753                "info".to_string()
1754            },
1755            remediation: prompt_cache_prefix_drift_remediation(&first_changed_field),
1756            first_changed_field,
1757            field_changes,
1758            cached_input_ratio_before: percent_ratio(
1759                previous.cached_input_tokens,
1760                previous.prompt_tokens,
1761            )
1762            .map(format_percent),
1763            cached_input_ratio_after: percent_ratio(
1764                current.cached_input_tokens,
1765                current.prompt_tokens,
1766            )
1767            .map(format_percent),
1768            cache_creation_ratio: percent_ratio(
1769                current.cache_creation_input_tokens,
1770                current.prompt_tokens,
1771            )
1772            .map(format_percent),
1773        });
1774    }
1775
1776    let truncated = drift.len() > MAX_PROMPT_CACHE_PREFIX_DRIFT;
1777    drift.truncate(MAX_PROMPT_CACHE_PREFIX_DRIFT);
1778    (drift, truncated)
1779}
1780
1781/// Map an attributed prefix-drift cause (`first_changed_field`) to a concrete,
1782/// actionable fix. The diagnostics list already explains *what* invalidated the
1783/// cache; this turns the named field into a specific remediation the agent can
1784/// apply — stabilize the changed input, or move the volatile field below the
1785/// cache breakpoint so the warm prefix is reused (#pcacheremediation, #0g7c).
1786fn prompt_cache_prefix_drift_remediation(first_changed_field: &str) -> String {
1787    match first_changed_field {
1788        "cache_key" => "stabilize the prompt_cache_key: reuse the same thread/session id across turns so the provider serves the warm cached prefix instead of creating a cold one",
1789        "breakpoints" => "keep cache breakpoint placement constant: move the volatile block below the cache breakpoint so the stable prefix above it stays byte-identical and cached",
1790        "routing_affinity" => "pin routing/replica affinity: route every turn of the session to the same cache replica so the warm prefix is not lost to a cold replica",
1791        "provider" => "avoid switching providers mid-session: a provider change discards the warm cached prefix, so keep the session on one provider",
1792        "stable_prefix" => "stabilize the prefix bytes: move the volatile content that changed out of the stable prefix and below the cache breakpoint so the cached prefix stays byte-identical",
1793        "stable_prefix_fingerprint" => "the provider rotated the stable-prefix fingerprint: audit the cached prefix material and move any volatile content below the cache breakpoint so the fingerprint stays stable",
1794        _ => "stabilize the changed input or move the volatile field below the cache breakpoint so the cached prefix is reused",
1795    }
1796    .to_string()
1797}
1798
1799fn prompt_cache_ratio_drop_triggered(
1800    previous: &SessionCostTurn,
1801    current: &SessionCostTurn,
1802) -> bool {
1803    let Some(previous_ratio) = percent_ratio(previous.cached_input_tokens, previous.prompt_tokens)
1804    else {
1805        return false;
1806    };
1807    let Some(current_ratio) = percent_ratio(current.cached_input_tokens, current.prompt_tokens)
1808    else {
1809        return false;
1810    };
1811    previous_ratio - current_ratio >= PROMPT_CACHE_RATIO_DROP_WARN_PERCENT
1812}
1813
1814fn prompt_cache_creation_spike_triggered(turn: &SessionCostTurn) -> bool {
1815    turn.cache_creation_input_tokens > 0
1816        && percent_ratio(turn.cache_creation_input_tokens, turn.prompt_tokens)
1817            .is_some_and(|ratio| ratio >= PROMPT_CACHE_CREATION_SPIKE_WARN_PERCENT)
1818}
1819
1820fn prompt_cache_first_changed_field(
1821    previous: &SessionCostTurn,
1822    current: &SessionCostTurn,
1823) -> Option<SessionCostPromptCacheFieldChange> {
1824    prompt_cache_field_changes(previous, current)
1825        .into_iter()
1826        .next()
1827}
1828
1829fn prompt_cache_field_changes(
1830    previous: &SessionCostTurn,
1831    current: &SessionCostTurn,
1832) -> Vec<SessionCostPromptCacheFieldChange> {
1833    let Some(previous) = previous.prompt_cache_metadata.as_ref() else {
1834        return Vec::new();
1835    };
1836    let Some(current) = current.prompt_cache_metadata.as_ref() else {
1837        return Vec::new();
1838    };
1839
1840    // Attribute the most specific concrete cause first. The
1841    // `stable_prefix_fingerprint` is usually a *derived* composite of
1842    // provider + cache_key + stable_prefix + breakpoints, so any sub-field
1843    // change also flips the fingerprint; reporting the fingerprint first made
1844    // `first_changed_field` always read `stable_prefix_fingerprint` and never
1845    // named the real cause (#pcacheattr). With concrete fields ordered first,
1846    // the fingerprint only becomes the first change when no tracked sub-field
1847    // moved — i.e. the stable prefix content itself drifted — which is the
1848    // correct residual attribution.
1849    let mut changes = Vec::new();
1850    push_prompt_cache_field_change(
1851        &mut changes,
1852        "cache_key",
1853        &prompt_cache_optional_value(previous.cache_key.as_deref()),
1854        &prompt_cache_optional_value(current.cache_key.as_deref()),
1855    );
1856    push_prompt_cache_field_change(
1857        &mut changes,
1858        "breakpoints",
1859        &prompt_cache_breakpoint_value(&previous.breakpoints),
1860        &prompt_cache_breakpoint_value(&current.breakpoints),
1861    );
1862    push_prompt_cache_field_change(
1863        &mut changes,
1864        "routing_affinity",
1865        &prompt_cache_optional_value(previous.routing_affinity.as_deref()),
1866        &prompt_cache_optional_value(current.routing_affinity.as_deref()),
1867    );
1868    push_prompt_cache_field_change(
1869        &mut changes,
1870        "provider",
1871        &previous.provider,
1872        &current.provider,
1873    );
1874    // Raw stable-prefix content is ordered *before* the fingerprint. When a
1875    // provider supplies an explicit `stable_prefix_fingerprint`, the derived
1876    // material (which folds in `stable_prefix`) is bypassed, so a real prefix
1877    // CONTENT drift would otherwise change nothing tracked and go unattributed.
1878    // Tracking the raw prefix as its own field attributes that drift regardless
1879    // of explicit-vs-derived fingerprint; and because it precedes the
1880    // fingerprint, a derived-path prefix change is named `stable_prefix` (the
1881    // concrete cause) rather than the composite fingerprint (#pcacheexplattr).
1882    push_prompt_cache_field_change(
1883        &mut changes,
1884        "stable_prefix",
1885        &prompt_cache_optional_value(previous.stable_prefix.as_deref()),
1886        &prompt_cache_optional_value(current.stable_prefix.as_deref()),
1887    );
1888    // A *derived* fingerprint is a pure function of already-tracked sub-fields
1889    // (provider, cache_key, stable_prefix, breakpoints), so whenever it changes
1890    // one of those entries changed too and is reported above — the fingerprint
1891    // entry is a redundant echo. Suppress it on the derived path and report the
1892    // fingerprint only when the provider supplied it *explicitly* (independent
1893    // signal that is not captured by any tracked sub-field) (#tsreviewcleanup).
1894    if current.stable_prefix_fingerprint_explicit {
1895        push_prompt_cache_field_change(
1896            &mut changes,
1897            "stable_prefix_fingerprint",
1898            &previous.stable_prefix_fingerprint,
1899            &current.stable_prefix_fingerprint,
1900        );
1901    }
1902    changes
1903}
1904
1905fn push_prompt_cache_field_change(
1906    changes: &mut Vec<SessionCostPromptCacheFieldChange>,
1907    field: &str,
1908    previous: &str,
1909    current: &str,
1910) {
1911    if previous != current {
1912        changes.push(SessionCostPromptCacheFieldChange {
1913            field: field.to_string(),
1914            previous: previous.to_string(),
1915            current: current.to_string(),
1916        });
1917    }
1918}
1919
1920fn prompt_cache_optional_value(value: Option<&str>) -> String {
1921    value
1922        .filter(|value| !value.trim().is_empty())
1923        .unwrap_or("-")
1924        .to_string()
1925}
1926
1927fn prompt_cache_breakpoint_value(breakpoints: &[String]) -> String {
1928    if breakpoints.is_empty() {
1929        "-".to_string()
1930    } else {
1931        breakpoints.join("; ")
1932    }
1933}
1934
1935fn prompt_cache_timeline(
1936    usage_turns: &[SessionCostTurn],
1937) -> Vec<SessionCostPromptCacheTimelineEntry> {
1938    let selected = if usage_turns.len() <= MAX_PROMPT_CACHE_TIMELINE {
1939        usage_turns.iter().collect::<Vec<_>>()
1940    } else {
1941        let tail_count = MAX_PROMPT_CACHE_TIMELINE.saturating_sub(1);
1942        let mut selected = Vec::with_capacity(MAX_PROMPT_CACHE_TIMELINE);
1943        if let Some(first) = usage_turns.first() {
1944            selected.push(first);
1945        }
1946        selected.extend(usage_turns.iter().skip(usage_turns.len() - tail_count));
1947        selected
1948    };
1949
1950    selected
1951        .into_iter()
1952        .map(|turn| SessionCostPromptCacheTimelineEntry {
1953            label: turn.label.clone(),
1954            prompt_tokens: turn.prompt_tokens,
1955            cached_input_tokens: turn.cached_input_tokens,
1956            cache_creation_input_tokens: turn.cache_creation_input_tokens,
1957            cached_input_ratio: percent_ratio(turn.cached_input_tokens, turn.prompt_tokens)
1958                .map(format_percent),
1959            cache_creation_ratio: percent_ratio(
1960                turn.cache_creation_input_tokens,
1961                turn.prompt_tokens,
1962            )
1963            .map(format_percent),
1964            prompt_cache_metadata: turn.prompt_cache_metadata.clone(),
1965        })
1966        .collect()
1967}
1968
1969fn prompt_cache_trend(sample_count: usize, ratio_delta: Option<f64>) -> &'static str {
1970    if sample_count < 2 {
1971        return "single_sample";
1972    }
1973    let Some(delta) = ratio_delta else {
1974        return "insufficient_data";
1975    };
1976    if delta >= PROMPT_CACHE_TREND_DELTA_PERCENT {
1977        "improving"
1978    } else if delta <= -PROMPT_CACHE_TREND_DELTA_PERCENT {
1979        "declining"
1980    } else {
1981        "stable"
1982    }
1983}
1984
1985fn percent_ratio(numerator: u64, denominator: u64) -> Option<f64> {
1986    (denominator > 0)
1987        .then_some(((numerator as f64) / (denominator as f64) * 10_000.0).round() / 100.0)
1988}
1989
1990fn format_percent(value: f64) -> String {
1991    format!("{value:.2}%")
1992}
1993
1994fn format_signed_percent(value: f64) -> String {
1995    format!("{value:+.2}%")
1996}
1997
1998pub(crate) fn signed_token_delta(read_tokens: u64, creation_tokens: u64) -> i64 {
1999    if read_tokens >= creation_tokens {
2000        i64::try_from(read_tokens - creation_tokens).unwrap_or(i64::MAX)
2001    } else {
2002        -i64::try_from(creation_tokens - read_tokens).unwrap_or(i64::MAX)
2003    }
2004}
2005
2006fn resolve_source(input: &str, source_hint: Option<&str>) -> Result<SessionCostSource> {
2007    if let Some(raw) = source_hint {
2008        return SessionCostSource::parse(raw);
2009    }
2010
2011    let non_empty = input
2012        .lines()
2013        .map(str::trim)
2014        .filter(|line| !line.is_empty())
2015        .collect::<Vec<_>>();
2016    if non_empty.is_empty() {
2017        bail!(
2018            "no session-cost input provided; pass --input <file> or pipe transcript/log data on stdin"
2019        );
2020    }
2021
2022    if non_empty
2023        .iter()
2024        .all(|line| line.starts_with('{') && serde_json::from_str::<Value>(line).is_ok())
2025    {
2026        for line in &non_empty {
2027            let value = serde_json::from_str::<Value>(line).unwrap_or(Value::Null);
2028            if value
2029                .get("message")
2030                .and_then(|message| message.get("usage"))
2031                .is_some()
2032            {
2033                return Ok(SessionCostSource::ClaudeJsonl);
2034            }
2035            if value.get("type").and_then(Value::as_str) == Some("event_msg")
2036                && value
2037                    .get("payload")
2038                    .and_then(|payload| payload.get("type"))
2039                    .and_then(Value::as_str)
2040                    == Some("token_count")
2041            {
2042                return Ok(SessionCostSource::CodexJsonl);
2043            }
2044        }
2045        if non_empty.iter().any(|line| line.contains("\"parentUuid\"")) {
2046            return Ok(SessionCostSource::ClaudeJsonl);
2047        }
2048        if non_empty
2049            .iter()
2050            .any(|line| line.contains("\"response_item\"") || line.contains("\"turn_context\""))
2051        {
2052            return Ok(SessionCostSource::CodexJsonl);
2053        }
2054    }
2055
2056    if non_empty
2057        .iter()
2058        .all(|line| line.starts_with('[') && line.contains(']'))
2059    {
2060        return Ok(SessionCostSource::AgentDocLog);
2061    }
2062
2063    bail!(
2064        "could not auto-detect session-cost input; pass --source claude-jsonl, codex-jsonl, or agent-doc-log"
2065    )
2066}
2067
2068fn ingest_claude_jsonl(input: &str, state: &mut CostState) -> Result<()> {
2069    let mut seen_keys = BTreeSet::new();
2070    for (index, raw_line) in input.lines().enumerate() {
2071        let trimmed = raw_line.trim();
2072        if trimmed.is_empty() {
2073            continue;
2074        }
2075        let value = match serde_json::from_str::<Value>(trimmed) {
2076            Ok(value) => value,
2077            Err(_) => {
2078                state.warnings.push(format!(
2079                    "skipping malformed Claude transcript jsonl line {}",
2080                    index + 1
2081                ));
2082                continue;
2083            }
2084        };
2085        let Some(message) = value.get("message") else {
2086            collect_claude_loop_signals(&value, state);
2087            continue;
2088        };
2089        collect_claude_loop_signals(&value, state);
2090        if message.get("role").and_then(Value::as_str) != Some("assistant") {
2091            continue;
2092        }
2093        let Some(usage) = message.get("usage") else {
2094            continue;
2095        };
2096
2097        let key = message
2098            .get("id")
2099            .and_then(Value::as_str)
2100            .or_else(|| value.get("requestId").and_then(Value::as_str))
2101            .or_else(|| value.get("uuid").and_then(Value::as_str))
2102            .map(|value| value.to_string())
2103            .unwrap_or_else(|| format!("line-{}", index + 1));
2104        if !seen_keys.insert(key.clone()) {
2105            continue;
2106        }
2107
2108        let prompt_tokens = usage_u64(usage, "input_tokens")
2109            + usage_u64(usage, "cache_creation_input_tokens")
2110            + usage_u64(usage, "cache_read_input_tokens");
2111        let cached_input_tokens = usage_u64(usage, "cache_read_input_tokens");
2112        let cache_creation_input_tokens = usage_u64(usage, "cache_creation_input_tokens");
2113        let output_tokens = usage_u64(usage, "output_tokens");
2114        let total_tokens = prompt_tokens + output_tokens;
2115        if prompt_tokens == 0 && output_tokens == 0 {
2116            continue;
2117        }
2118
2119        state.usage_turns.push(SessionCostTurn {
2120            label: value
2121                .get("timestamp")
2122                .and_then(Value::as_str)
2123                .map(|value| value.to_string())
2124                .unwrap_or(key),
2125            prompt_tokens,
2126            cached_input_tokens,
2127            cache_creation_input_tokens,
2128            output_tokens,
2129            reasoning_output_tokens: 0,
2130            total_tokens,
2131            prompt_cache_metadata: Some(prompt_cache_metadata(
2132                &value,
2133                SessionCostSource::ClaudeJsonl,
2134            )),
2135        });
2136    }
2137    Ok(())
2138}
2139
2140fn ingest_codex_jsonl(input: &str, state: &mut CostState) -> Result<()> {
2141    let mut previous = UsageTotals::default();
2142    let mut seen_cumulative_snapshots = BTreeSet::<UsageTotals>::new();
2143    let mut saw_token_count = false;
2144    for (index, raw_line) in input.lines().enumerate() {
2145        let trimmed = raw_line.trim();
2146        if trimmed.is_empty() {
2147            continue;
2148        }
2149        let value = match serde_json::from_str::<Value>(trimmed) {
2150            Ok(value) => value,
2151            Err(_) => {
2152                state.warnings.push(format!(
2153                    "skipping malformed Codex transcript jsonl line {}",
2154                    index + 1
2155                ));
2156                continue;
2157            }
2158        };
2159        match value.get("type").and_then(Value::as_str) {
2160            Some("response_item") => {
2161                collect_codex_response_item_loop_signals(&value, index + 1, state)
2162            }
2163            Some("event_msg") => collect_codex_event_msg_loop_signals(&value, index + 1, state),
2164            _ => {}
2165        }
2166        if value.get("type").and_then(Value::as_str) != Some("event_msg") {
2167            continue;
2168        }
2169        let Some(payload) = value.get("payload") else {
2170            continue;
2171        };
2172        if payload.get("type").and_then(Value::as_str) != Some("token_count") {
2173            continue;
2174        }
2175        saw_token_count = true;
2176
2177        let Some(total) = payload
2178            .get("info")
2179            .and_then(|info| info.get("total_token_usage"))
2180        else {
2181            state.warnings.push(format!(
2182                "codex token_count event on line {} did not include info.total_token_usage",
2183                index + 1
2184            ));
2185            continue;
2186        };
2187        let cumulative = codex_usage_totals(total);
2188        let duplicate_snapshot = !seen_cumulative_snapshots.insert(cumulative);
2189        let delta = if duplicate_snapshot {
2190            UsageTotals::default()
2191        } else if let Some(last) = payload
2192            .get("info")
2193            .and_then(|info| info.get("last_token_usage"))
2194            .map(codex_usage_totals)
2195            .filter(|last| !last.is_zero())
2196        {
2197            last
2198        } else if previous.is_zero() {
2199            cumulative
2200        } else {
2201            cumulative.delta_from(previous)
2202        };
2203        previous = cumulative;
2204        if delta.is_zero() {
2205            continue;
2206        }
2207
2208        state.usage_turns.push(SessionCostTurn {
2209            label: value
2210                .get("timestamp")
2211                .and_then(Value::as_str)
2212                .map(|value| value.to_string())
2213                .unwrap_or_else(|| format!("line-{}", index + 1)),
2214            prompt_tokens: delta.prompt_tokens,
2215            cached_input_tokens: delta.cached_input_tokens,
2216            cache_creation_input_tokens: 0,
2217            output_tokens: delta.output_tokens,
2218            reasoning_output_tokens: delta.reasoning_output_tokens,
2219            total_tokens: delta
2220                .total_tokens
2221                .max(delta.prompt_tokens + delta.output_tokens),
2222            prompt_cache_metadata: Some(prompt_cache_metadata(
2223                &value,
2224                SessionCostSource::CodexJsonl,
2225            )),
2226        });
2227    }
2228
2229    if !saw_token_count {
2230        state.warnings.push(
2231            "codex transcript did not contain any token_count events; no token cost summary could be derived"
2232                .to_string(),
2233        );
2234    }
2235    Ok(())
2236}
2237
2238fn ingest_agent_doc_log(input: &str, state: &mut CostState) {
2239    for raw_line in input.lines() {
2240        let trimmed = raw_line.trim();
2241        if trimmed.is_empty() {
2242            continue;
2243        }
2244        let Some((_, after_bracket)) = trimmed.split_once("] ") else {
2245            continue;
2246        };
2247        let detail = after_bracket.trim();
2248        let Some(event_name) = detail.split_whitespace().next() else {
2249            continue;
2250        };
2251        let normalized = normalize_runtime_event(event_name, detail);
2252        let closeout_event = is_closeout_runtime_event(event_name, &normalized);
2253        if should_count_runtime_event(event_name, detail, &normalized, state) {
2254            *state.runtime_events.entry(normalized.clone()).or_default() += 1;
2255            state.total_runtime_events += 1;
2256            if closeout_event {
2257                push_closeout_signal(&normalized, state);
2258            }
2259        }
2260        state.restart_churn.observe(event_name, detail);
2261        if let Some(restart_count) =
2262            extract_field(detail, "restart_count").and_then(|value| value.parse::<usize>().ok())
2263        {
2264            state.max_restart_count = Some(
2265                state
2266                    .max_restart_count
2267                    .map_or(restart_count, |current| current.max(restart_count)),
2268            );
2269        }
2270    }
2271}
2272
2273fn collect_claude_loop_signals(value: &Value, state: &mut CostState) {
2274    let mut blocks = Vec::new();
2275    collect_transcript_blocks(value, &mut blocks);
2276    if blocks.is_empty() && is_ignorable_claude_record(value) {
2277        return;
2278    }
2279    for block in blocks {
2280        match block {
2281            TranscriptBlock::Text { role, text } => {
2282                let user_bias = role
2283                    .as_deref()
2284                    .is_some_and(|value| value.eq_ignore_ascii_case("user"));
2285                collect_text_loop_signals(&text, user_bias, state);
2286            }
2287            TranscriptBlock::ToolUse { name, input } => {
2288                collect_tool_use_loop_signals(&name, &input, state);
2289            }
2290        }
2291    }
2292}
2293
2294fn collect_codex_response_item_loop_signals(
2295    value: &Value,
2296    line_number: usize,
2297    state: &mut CostState,
2298) {
2299    let Some(payload) = value.get("payload") else {
2300        return;
2301    };
2302    match payload.get("type").and_then(Value::as_str) {
2303        Some("message") => {
2304            let Some(content) = payload.get("content").and_then(Value::as_array) else {
2305                return;
2306            };
2307            for item in content {
2308                let Some(text) = item
2309                    .get("text")
2310                    .and_then(Value::as_str)
2311                    .or_else(|| item.get("content").and_then(Value::as_str))
2312                else {
2313                    continue;
2314                };
2315                collect_text_loop_signals(text, false, state);
2316            }
2317        }
2318        Some("function_call") => {
2319            let name = payload
2320                .get("name")
2321                .and_then(Value::as_str)
2322                .unwrap_or("function_call");
2323            let Some(arguments) = payload.get("arguments").and_then(Value::as_str) else {
2324                return;
2325            };
2326            let input = serde_json::from_str::<Value>(arguments).unwrap_or_else(|_| {
2327                state.warnings.push(format!(
2328                    "codex function_call arguments on line {} were not valid JSON; loop extraction may be incomplete",
2329                    line_number
2330                ));
2331                Value::String(arguments.to_string())
2332            });
2333            collect_tool_use_loop_signals(name, &input, state);
2334        }
2335        _ => {}
2336    }
2337}
2338
2339fn collect_codex_event_msg_loop_signals(value: &Value, _line_number: usize, state: &mut CostState) {
2340    let Some(payload) = value.get("payload") else {
2341        return;
2342    };
2343    match payload.get("type").and_then(Value::as_str) {
2344        Some("user_message") => {
2345            if let Some(message) = payload.get("message").and_then(Value::as_str) {
2346                collect_text_loop_signals(message, true, state);
2347            }
2348        }
2349        Some("agent_message") => {
2350            if let Some(message) = payload.get("message").and_then(Value::as_str) {
2351                collect_text_loop_signals(message, false, state);
2352            }
2353        }
2354        Some("exec_command_end") => {
2355            if let Some(command) = extract_raw_codex_exec_command(payload) {
2356                collect_file_read_command_signals(&command, state);
2357            }
2358            if let Some(command) = extract_codex_exec_command(payload) {
2359                push_command(command, state);
2360            }
2361            if let Some(output) = payload
2362                .get("aggregated_output")
2363                .and_then(Value::as_str)
2364                .or_else(|| payload.get("stdout").and_then(Value::as_str))
2365            {
2366                collect_text_loop_signals(output, false, state);
2367            }
2368        }
2369        _ => {}
2370    }
2371}
2372
2373fn collect_tool_use_loop_signals(name: &str, input: &Value, state: &mut CostState) {
2374    collect_file_read_tool_signals(name, input, state);
2375    if let Some(command) = extract_raw_tool_command(name, input) {
2376        collect_file_read_command_signals(&command, state);
2377    }
2378    if let Some(command) = extract_tool_command(name, input) {
2379        push_command(command, state);
2380    }
2381    if let Some(text) = extract_tool_text(input) {
2382        collect_text_loop_signals(&text, false, state);
2383    }
2384}
2385
2386fn collect_file_read_tool_signals(name: &str, input: &Value, state: &mut CostState) {
2387    let lower = name.to_ascii_lowercase();
2388    if !matches!(lower.as_str(), "read" | "file_read" | "read_file") {
2389        return;
2390    }
2391    let Value::Object(map) = input else {
2392        return;
2393    };
2394    let Some(path) = ["file_path", "path"]
2395        .iter()
2396        .find_map(|key| map.get(*key).and_then(Value::as_str))
2397        .map(normalize_file_read_path)
2398        .filter(|path| !path.is_empty())
2399    else {
2400        return;
2401    };
2402    let start = ["offset", "start", "line"]
2403        .iter()
2404        .find_map(|key| map.get(*key).and_then(Value::as_u64))
2405        .and_then(|value| usize::try_from(value).ok())
2406        .filter(|value| *value > 0);
2407    let lines = ["limit", "lines", "line_count"]
2408        .iter()
2409        .find_map(|key| map.get(*key).and_then(Value::as_u64))
2410        .and_then(|value| usize::try_from(value).ok())
2411        .filter(|value| *value > 0);
2412    push_file_read_signal(path, start, lines, state);
2413}
2414
2415fn collect_file_read_command_signals(command: &str, state: &mut CostState) {
2416    if let Some(signal) = parse_file_read_command(command) {
2417        state.file_read_signals.push(signal);
2418    }
2419}
2420
2421fn parse_file_read_command(command: &str) -> Option<FileReadSignal> {
2422    let tokens = shell_words(command);
2423    let head = tokens.first()?.as_str();
2424    match head {
2425        "cat" | "bat" | "batcat" | "nl" => {
2426            let path = first_non_option_arg(&tokens[1..])?;
2427            Some(file_read_signal(
2428                normalize_file_read_path(path),
2429                "full".to_string(),
2430                None,
2431                None,
2432            ))
2433        }
2434        "sed" => parse_sed_file_read(&tokens),
2435        "head" => parse_head_file_read(&tokens),
2436        "tail" => parse_tail_file_read(&tokens),
2437        _ => None,
2438    }
2439}
2440
2441fn parse_sed_file_read(tokens: &[String]) -> Option<FileReadSignal> {
2442    let mut expr = None::<String>;
2443    let mut path = None::<String>;
2444    let mut skip_next = false;
2445    for token in tokens.iter().skip(1) {
2446        if skip_next {
2447            skip_next = false;
2448            continue;
2449        }
2450        if token == "-n" {
2451            continue;
2452        }
2453        if token == "-e" {
2454            skip_next = true;
2455            continue;
2456        }
2457        if expr.is_none() && parse_sed_range(token).is_some() {
2458            expr = Some(token.clone());
2459            continue;
2460        }
2461        if !token.starts_with('-') {
2462            path = Some(token.clone());
2463        }
2464    }
2465    let expr = expr?;
2466    let path = path?;
2467    let (start, lines) = parse_sed_range(&expr)?;
2468    Some(file_read_signal(
2469        normalize_file_read_path(&path),
2470        format!("{}-{}", start, start + lines - 1),
2471        Some(start),
2472        Some(lines),
2473    ))
2474}
2475
2476fn parse_sed_range(expr: &str) -> Option<(usize, usize)> {
2477    let trimmed = expr.trim_matches(['\'', '"']).trim();
2478    let body = trimmed.strip_suffix('p')?;
2479    let (start_raw, end_raw) = body.split_once(',')?;
2480    let start = start_raw.trim().parse::<usize>().ok()?;
2481    let lines = if let Some(relative) = end_raw.trim().strip_prefix('+') {
2482        relative.trim().parse::<usize>().ok()?.saturating_add(1)
2483    } else {
2484        let end = end_raw.trim().parse::<usize>().ok()?;
2485        end.checked_sub(start)?.saturating_add(1)
2486    };
2487    (lines > 0).then_some((start, lines))
2488}
2489
2490fn parse_head_file_read(tokens: &[String]) -> Option<FileReadSignal> {
2491    let mut lines = 10_usize;
2492    let mut path = None::<String>;
2493    let mut index = 1_usize;
2494    while index < tokens.len() {
2495        let token = &tokens[index];
2496        if token == "-n" || token == "--lines" {
2497            index += 1;
2498            lines = tokens.get(index)?.parse::<usize>().ok()?;
2499        } else if let Some(value) = token.strip_prefix("-n") {
2500            lines = value.parse::<usize>().ok()?;
2501        } else if token.starts_with('-') && token[1..].chars().all(|ch| ch.is_ascii_digit()) {
2502            lines = token[1..].parse::<usize>().ok()?;
2503        } else if !token.starts_with('-') {
2504            path = Some(token.clone());
2505        }
2506        index += 1;
2507    }
2508    let path = path?;
2509    Some(file_read_signal(
2510        normalize_file_read_path(&path),
2511        format!("head:{lines}"),
2512        Some(1),
2513        Some(lines),
2514    ))
2515}
2516
2517fn parse_tail_file_read(tokens: &[String]) -> Option<FileReadSignal> {
2518    let mut lines = 10_usize;
2519    let mut path = None::<String>;
2520    let mut index = 1_usize;
2521    while index < tokens.len() {
2522        let token = &tokens[index];
2523        if token == "-n" || token == "--lines" {
2524            index += 1;
2525            lines = tokens.get(index)?.parse::<usize>().ok()?;
2526        } else if let Some(value) = token.strip_prefix("-n") {
2527            lines = value.trim_start_matches('+').parse::<usize>().ok()?;
2528        } else if token.starts_with('-') && token[1..].chars().all(|ch| ch.is_ascii_digit()) {
2529            lines = token[1..].parse::<usize>().ok()?;
2530        } else if !token.starts_with('-') {
2531            path = Some(token.clone());
2532        }
2533        index += 1;
2534    }
2535    let path = path?;
2536    Some(file_read_signal(
2537        normalize_file_read_path(&path),
2538        format!("tail:{lines}"),
2539        None,
2540        Some(lines),
2541    ))
2542}
2543
2544fn first_non_option_arg(tokens: &[String]) -> Option<&str> {
2545    tokens
2546        .iter()
2547        .find(|token| !token.starts_with('-'))
2548        .map(String::as_str)
2549}
2550
2551fn push_file_read_signal(
2552    path: String,
2553    start: Option<usize>,
2554    lines: Option<usize>,
2555    state: &mut CostState,
2556) {
2557    let range = match (start, lines) {
2558        (Some(start), Some(lines)) => format!("{}-{}", start, start + lines - 1),
2559        (Some(start), None) => format!("{start}-end"),
2560        (None, Some(lines)) => format!("window:{lines}"),
2561        (None, None) => "full".to_string(),
2562    };
2563    state
2564        .file_read_signals
2565        .push(file_read_signal(path, range, start, lines));
2566}
2567
2568fn file_read_signal(
2569    path: String,
2570    range: String,
2571    start: Option<usize>,
2572    lines: Option<usize>,
2573) -> FileReadSignal {
2574    FileReadSignal {
2575        path,
2576        range,
2577        start,
2578        lines,
2579        estimated_tokens: estimate_file_read_tokens(lines),
2580    }
2581}
2582
2583fn estimate_file_read_tokens(lines: Option<usize>) -> u64 {
2584    lines
2585        .map(|lines| (lines as u64).saturating_mul(ESTIMATED_TOKENS_PER_SOURCE_LINE))
2586        .unwrap_or(DEFAULT_FULL_FILE_READ_TOKENS)
2587        .max(80)
2588}
2589
2590fn collect_file_read_diagnostics(signals: &[FileReadSignal]) -> Vec<SessionCostFileReadDiagnostic> {
2591    let mut grouped = BTreeMap::<(String, String), FileReadDiagnosticBuilder>::new();
2592    for signal in signals {
2593        let entry = grouped
2594            .entry((signal.path.clone(), signal.range.clone()))
2595            .or_insert_with(|| FileReadDiagnosticBuilder {
2596                path: signal.path.clone(),
2597                range: signal.range.clone(),
2598                start: signal.start,
2599                lines: signal.lines,
2600                occurrences: 0,
2601                estimated_tokens: 0,
2602                max_single_read_tokens: 0,
2603            });
2604        entry.occurrences += 1;
2605        entry.estimated_tokens = entry
2606            .estimated_tokens
2607            .saturating_add(signal.estimated_tokens);
2608        entry.max_single_read_tokens = entry.max_single_read_tokens.max(signal.estimated_tokens);
2609        entry.start = entry.start.or(signal.start);
2610        entry.lines = entry.lines.or(signal.lines);
2611    }
2612
2613    let mut diagnostics = grouped
2614        .into_values()
2615        .filter(|entry| entry.occurrences >= 2)
2616        .map(|entry| {
2617            let duplicate_estimated_tokens = entry
2618                .estimated_tokens
2619                .saturating_sub(entry.max_single_read_tokens);
2620            SessionCostFileReadDiagnostic {
2621                path: entry.path.clone(),
2622                range: entry.range.clone(),
2623                occurrences: entry.occurrences,
2624                estimated_tokens: entry.estimated_tokens,
2625                duplicate_estimated_tokens,
2626                follow_up_commands: file_read_follow_up_commands(
2627                    &entry.path,
2628                    entry.start,
2629                    entry.lines,
2630                ),
2631            }
2632        })
2633        .collect::<Vec<_>>();
2634    diagnostics.sort_by(|left, right| {
2635        right
2636            .duplicate_estimated_tokens
2637            .cmp(&left.duplicate_estimated_tokens)
2638            .then(right.occurrences.cmp(&left.occurrences))
2639            .then(left.path.cmp(&right.path))
2640            .then(left.range.cmp(&right.range))
2641    });
2642    diagnostics.truncate(MAX_FILE_READ_DIAGNOSTICS);
2643    diagnostics
2644}
2645
2646#[derive(Debug)]
2647struct FileReadDiagnosticBuilder {
2648    path: String,
2649    range: String,
2650    start: Option<usize>,
2651    lines: Option<usize>,
2652    occurrences: usize,
2653    estimated_tokens: u64,
2654    max_single_read_tokens: u64,
2655}
2656
2657fn file_read_follow_up_commands(
2658    path: &str,
2659    start: Option<usize>,
2660    lines: Option<usize>,
2661) -> Vec<String> {
2662    let start = start.unwrap_or(1);
2663    let lines = lines.unwrap_or(120).max(1);
2664    vec![
2665        format!(
2666            "tsift source-read {} --start {} --lines {} --budget normal",
2667            shell_quote(path),
2668            start,
2669            lines
2670        ),
2671        format!("tsift summarize --file {}", shell_quote(path)),
2672    ]
2673}
2674
2675fn normalize_file_read_path(raw: &str) -> String {
2676    raw.trim()
2677        .trim_matches(['\'', '"'])
2678        .trim_start_matches("./")
2679        .to_string()
2680}
2681
2682fn shell_words(command: &str) -> Vec<String> {
2683    let mut words = Vec::new();
2684    let mut current = String::new();
2685    let mut quote = None::<char>;
2686    let mut escaped = false;
2687
2688    for ch in command.chars() {
2689        if escaped {
2690            current.push(ch);
2691            escaped = false;
2692            continue;
2693        }
2694        if ch == '\\' {
2695            escaped = true;
2696            continue;
2697        }
2698        if let Some(quote_ch) = quote {
2699            if ch == quote_ch {
2700                quote = None;
2701            } else {
2702                current.push(ch);
2703            }
2704            continue;
2705        }
2706        if ch == '\'' || ch == '"' {
2707            quote = Some(ch);
2708            continue;
2709        }
2710        if ch.is_whitespace() {
2711            if !current.is_empty() {
2712                words.push(std::mem::take(&mut current));
2713            }
2714            continue;
2715        }
2716        current.push(ch);
2717    }
2718    if !current.is_empty() {
2719        words.push(current);
2720    }
2721    words
2722}
2723
2724fn shell_quote(value: &str) -> String {
2725    if value
2726        .chars()
2727        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':'))
2728    {
2729        return value.to_string();
2730    }
2731    format!("'{}'", value.replace('\'', "'\\''"))
2732}
2733
2734fn collect_text_loop_signals(text: &str, user_bias: bool, state: &mut CostState) {
2735    for raw_line in text.lines() {
2736        let trimmed = raw_line.trim();
2737        if trimmed.is_empty() || looks_like_instruction_ballast(trimmed) {
2738            continue;
2739        }
2740        let prompt_candidate = trimmed
2741            .strip_prefix("❯ ")
2742            .or_else(|| trimmed.strip_prefix("> "))
2743            .unwrap_or(trimmed)
2744            .trim();
2745        if looks_like_prompt_target(prompt_candidate, user_bias || prompt_candidate != trimmed) {
2746            push_prompt_signal(prompt_candidate, state);
2747            continue;
2748        }
2749        for (kind, detail) in detect_closeout(trimmed) {
2750            push_closeout_signal(&format!("{kind}: {detail}"), state);
2751        }
2752    }
2753}
2754
2755fn push_prompt_signal(text: &str, state: &mut CostState) {
2756    flush_pending_commands(state);
2757    push_loop_signal(LoopClusterKind::PromptRepeat, text, state);
2758}
2759
2760fn push_closeout_signal(text: &str, state: &mut CostState) {
2761    flush_pending_commands(state);
2762    push_loop_signal(LoopClusterKind::CloseoutChurn, text, state);
2763}
2764
2765fn push_command(command: String, state: &mut CostState) {
2766    let normalized = normalize_whitespace(&command);
2767    if normalized.is_empty() {
2768        return;
2769    }
2770    if state
2771        .pending_commands
2772        .last()
2773        .is_some_and(|existing| existing == &normalized)
2774    {
2775        return;
2776    }
2777    state.pending_commands.push(normalized);
2778}
2779
2780fn flush_pending_commands(state: &mut CostState) {
2781    if state.pending_commands.is_empty() {
2782        return;
2783    }
2784    let label = truncate_detail(
2785        &state
2786            .pending_commands
2787            .iter()
2788            .take(MAX_COMMANDS_PER_BUNDLE)
2789            .cloned()
2790            .collect::<Vec<_>>()
2791            .join(" -> "),
2792        220,
2793    );
2794    state.pending_commands.clear();
2795    push_loop_signal(LoopClusterKind::CommandBundle, &label, state);
2796}
2797
2798fn push_loop_signal(kind: LoopClusterKind, label: &str, state: &mut CostState) {
2799    let normalized = truncate_detail(&normalize_whitespace(label), 220);
2800    if normalized.is_empty() {
2801        return;
2802    }
2803    state.loop_signals.push(LoopSignal {
2804        kind,
2805        label: normalized,
2806    });
2807}
2808
2809fn collect_loop_clusters(signals: &[LoopSignal]) -> Vec<SessionCostLoopCluster> {
2810    let mut summary = BTreeMap::<(LoopClusterKind, String), (usize, usize)>::new();
2811    let mut previous = None::<(LoopClusterKind, String)>;
2812    let mut streak = 0_usize;
2813
2814    for signal in signals {
2815        let key = (signal.kind, signal.label.clone());
2816        let entry = summary.entry(key.clone()).or_insert((0, 0));
2817        entry.0 += 1;
2818        if previous.as_ref() == Some(&key) {
2819            streak += 1;
2820        } else {
2821            previous = Some(key.clone());
2822            streak = 1;
2823        }
2824        entry.1 = entry.1.max(streak);
2825    }
2826
2827    let mut clusters = summary
2828        .into_iter()
2829        .filter_map(|((kind, label), (occurrences, max_consecutive))| {
2830            (occurrences >= 2).then_some(SessionCostLoopCluster {
2831                kind: kind.as_str().to_string(),
2832                label,
2833                occurrences,
2834                max_consecutive,
2835            })
2836        })
2837        .collect::<Vec<_>>();
2838    clusters.sort_by(|left, right| {
2839        right
2840            .occurrences
2841            .cmp(&left.occurrences)
2842            .then(right.max_consecutive.cmp(&left.max_consecutive))
2843            .then(left.kind.cmp(&right.kind))
2844            .then(left.label.cmp(&right.label))
2845    });
2846    clusters.truncate(MAX_LOOP_CLUSTERS);
2847    clusters
2848}
2849
2850fn is_ignorable_claude_record(value: &Value) -> bool {
2851    value.get("attachment").is_some()
2852        || value.get("toolUseResult").is_some()
2853        || (value.get("message").is_none()
2854            && value.get("content").is_none()
2855            && value.get("text").is_none())
2856}
2857
2858fn collect_transcript_blocks(value: &Value, out: &mut Vec<TranscriptBlock>) {
2859    if let Some(message) = value.get("message") {
2860        collect_message_blocks(message, out);
2861        return;
2862    }
2863    collect_message_blocks(value, out);
2864}
2865
2866fn collect_message_blocks(value: &Value, out: &mut Vec<TranscriptBlock>) {
2867    let role = value
2868        .get("role")
2869        .and_then(Value::as_str)
2870        .map(|value| value.to_string());
2871    if let Some(content) = value.get("content") {
2872        match content {
2873            Value::String(text) => out.push(TranscriptBlock::Text {
2874                role,
2875                text: text.to_string(),
2876            }),
2877            Value::Array(items) => {
2878                for item in items {
2879                    collect_content_block(role.clone(), item, out);
2880                }
2881            }
2882            _ => {}
2883        }
2884    } else if let Some(text) = value.get("text").and_then(Value::as_str) {
2885        out.push(TranscriptBlock::Text {
2886            role,
2887            text: text.to_string(),
2888        });
2889    }
2890}
2891
2892fn collect_content_block(role: Option<String>, value: &Value, out: &mut Vec<TranscriptBlock>) {
2893    match value.get("type").and_then(Value::as_str) {
2894        Some("text") => {
2895            if let Some(text) = value.get("text").and_then(Value::as_str) {
2896                out.push(TranscriptBlock::Text {
2897                    role,
2898                    text: text.to_string(),
2899                });
2900            }
2901        }
2902        Some("tool_use") => {
2903            let name = value
2904                .get("name")
2905                .and_then(Value::as_str)
2906                .unwrap_or("tool_use")
2907                .to_string();
2908            let input = value.get("input").cloned().unwrap_or(Value::Null);
2909            out.push(TranscriptBlock::ToolUse { name, input });
2910        }
2911        Some("tool_result") => match value.get("content") {
2912            Some(Value::String(text)) => out.push(TranscriptBlock::Text {
2913                role,
2914                text: text.to_string(),
2915            }),
2916            Some(Value::Array(items)) => {
2917                for item in items {
2918                    collect_content_block(role.clone(), item, out);
2919                }
2920            }
2921            _ => {}
2922        },
2923        _ => {
2924            if let Some(text) = value.get("text").and_then(Value::as_str) {
2925                out.push(TranscriptBlock::Text {
2926                    role,
2927                    text: text.to_string(),
2928                });
2929            }
2930        }
2931    }
2932}
2933
2934fn extract_tool_command(name: &str, input: &Value) -> Option<String> {
2935    let normalized = extract_raw_tool_command(name, input)?;
2936    looks_like_command(&normalized).then_some(normalized)
2937}
2938
2939fn extract_raw_tool_command(name: &str, input: &Value) -> Option<String> {
2940    if !matches!(
2941        name.to_ascii_lowercase().as_str(),
2942        "bash" | "exec_command" | "shell" | "terminal" | "sh"
2943    ) {
2944        return None;
2945    }
2946
2947    match input {
2948        Value::Object(map) => {
2949            for key in ["command", "cmd", "shell_command"] {
2950                if let Some(raw) = map.get(key).and_then(Value::as_str) {
2951                    let normalized = normalize_whitespace(raw);
2952                    if !normalized.is_empty() {
2953                        return Some(normalized);
2954                    }
2955                }
2956            }
2957            None
2958        }
2959        Value::String(raw) => {
2960            let normalized = normalize_whitespace(raw);
2961            (!normalized.is_empty()).then_some(normalized)
2962        }
2963        _ => None,
2964    }
2965}
2966
2967fn extract_tool_text(input: &Value) -> Option<String> {
2968    match input {
2969        Value::Object(map) => {
2970            for key in ["text", "output", "stderr", "stdout", "content", "message"] {
2971                if let Some(raw) = map.get(key).and_then(Value::as_str) {
2972                    return Some(raw.to_string());
2973                }
2974            }
2975            None
2976        }
2977        Value::String(raw) => Some(raw.to_string()),
2978        _ => None,
2979    }
2980}
2981
2982fn extract_codex_exec_command(payload: &Value) -> Option<String> {
2983    let normalized = extract_raw_codex_exec_command(payload)?;
2984    looks_like_command(&normalized).then_some(normalized)
2985}
2986
2987fn extract_raw_codex_exec_command(payload: &Value) -> Option<String> {
2988    if let Some(parsed) = payload.get("parsed_cmd").and_then(Value::as_array) {
2989        for item in parsed {
2990            if let Some(command) = item.get("cmd").and_then(Value::as_str) {
2991                let normalized = normalize_whitespace(command);
2992                if !normalized.is_empty() {
2993                    return Some(normalized);
2994                }
2995            }
2996        }
2997    }
2998
2999    if let Some(command) = payload.get("command").and_then(Value::as_array)
3000        && let Some(last) = command.last().and_then(Value::as_str)
3001    {
3002        let normalized = normalize_whitespace(last);
3003        if !normalized.is_empty() {
3004            return Some(normalized);
3005        }
3006    }
3007    None
3008}
3009
3010fn looks_like_prompt_target(text: &str, user_bias: bool) -> bool {
3011    let trimmed = text.trim();
3012    if trimmed.is_empty()
3013        || looks_like_markdown_heading(trimmed)
3014        || looks_like_slash_command_example(trimmed)
3015        || trimmed == "#"
3016        || trimmed.starts_with("#!")
3017        || trimmed.starts_with("#[")
3018        || trimmed.starts_with("/**")
3019        || trimmed.starts_with("*/")
3020        || trimmed.starts_with("//")
3021        || trimmed.starts_with("###")
3022        || trimmed.starts_with("<!--")
3023        || trimmed.starts_with("- [")
3024        || trimmed == "###"
3025    {
3026        return false;
3027    }
3028
3029    if trimmed.starts_with("do ")
3030        || trimmed.starts_with('#')
3031        || looks_like_slash_prompt_target(trimmed)
3032        || trimmed.ends_with('?')
3033    {
3034        return true;
3035    }
3036
3037    if user_bias
3038        && (trimmed.contains("commit + push")
3039            || trimmed.contains("run tests")
3040            || trimmed.contains("build + install")
3041            || trimmed.contains("#spec-test"))
3042    {
3043        return true;
3044    }
3045
3046    false
3047}
3048
3049fn looks_like_instruction_ballast(text: &str) -> bool {
3050    let trimmed = strip_common_prefixes(text.trim());
3051    if trimmed.is_empty() {
3052        return false;
3053    }
3054
3055    looks_like_markdown_heading(trimmed)
3056        || looks_like_slash_command_example(trimmed)
3057        || looks_like_frontmatter_prompt_preset(trimmed)
3058        || looks_like_completed_backlog_archive(trimmed)
3059        || trimmed.starts_with("<!-- tsift:")
3060        || trimmed.starts_with("<!-- /tsift:")
3061        || looks_like_instruction_label(trimmed)
3062}
3063
3064fn looks_like_markdown_heading(text: &str) -> bool {
3065    let trimmed = text.trim_start();
3066    let heading_level = trimmed.chars().take_while(|ch| *ch == '#').count();
3067    heading_level > 0
3068        && heading_level <= 6
3069        && trimmed
3070            .chars()
3071            .nth(heading_level)
3072            .is_some_and(|ch| ch.is_whitespace())
3073}
3074
3075fn looks_like_slash_command_example(text: &str) -> bool {
3076    let trimmed = text.trim();
3077    trimmed.starts_with('/')
3078        && trimmed.contains('<')
3079        && trimmed.contains('>')
3080        && !trimmed.contains('`')
3081}
3082
3083fn looks_like_slash_prompt_target(text: &str) -> bool {
3084    let Some(first_token) = text.split_whitespace().next() else {
3085        return false;
3086    };
3087    first_token.starts_with('/') && !first_token[1..].contains('/')
3088}
3089
3090fn looks_like_instruction_label(text: &str) -> bool {
3091    let trimmed = text.trim();
3092    if !trimmed.starts_with("**") {
3093        return false;
3094    }
3095    let Some(label_end) = trimmed[2..].find("**") else {
3096        return false;
3097    };
3098    let label = &trimmed[..label_end + 4];
3099    if label.len() <= 4 {
3100        return false;
3101    }
3102    let remainder = trimmed[label_end + 4..]
3103        .trim_start_matches([' ', ':', '-', '—'])
3104        .trim_start();
3105    if remainder.is_empty() {
3106        return false;
3107    }
3108    let lower = remainder.to_ascii_lowercase();
3109    matches!(
3110        lower.split_whitespace().next(),
3111        Some("run")
3112            | Some("use")
3113            | Some("treat")
3114            | Some("respond")
3115            | Some("print")
3116            | Some("prefer")
3117            | Some("preserve")
3118            | Some("show")
3119            | Some("complete")
3120            | Some("append")
3121            | Some("when")
3122            | Some("if")
3123    )
3124}
3125
3126fn strip_common_prefixes(text: &str) -> &str {
3127    text.strip_prefix("❯ ")
3128        .or_else(|| text.strip_prefix("- "))
3129        .or_else(|| text.strip_prefix("* "))
3130        .or_else(|| text.strip_prefix("> "))
3131        .unwrap_or(text)
3132        .trim()
3133}
3134
3135fn looks_like_frontmatter_prompt_preset(text: &str) -> bool {
3136    let trimmed = strip_common_prefixes(text.trim());
3137    if trimmed == "prompt_presets:" || trimmed.starts_with("prompt_presets:") {
3138        return true;
3139    }
3140    let Some((key, _)) = trimmed.split_once(':') else {
3141        return false;
3142    };
3143    let key = key.trim().trim_matches(['"', '\'']);
3144    key.starts_with('#') && key.len() > 1 && key[1..].chars().all(is_prompt_preset_char)
3145}
3146
3147fn is_prompt_preset_char(ch: char) -> bool {
3148    ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')
3149}
3150
3151fn looks_like_completed_backlog_archive(text: &str) -> bool {
3152    let stripped = strip_common_prefixes(text.trim());
3153    let Some(date) = stripped.get(..10) else {
3154        return false;
3155    };
3156    date.chars().enumerate().all(|(index, ch)| match index {
3157        4 | 7 => ch == '-',
3158        _ => ch.is_ascii_digit(),
3159    }) && stripped[10..].contains("[#")
3160}
3161
3162fn looks_like_command(text: &str) -> bool {
3163    if text.is_empty()
3164        || text.contains('\n')
3165        || text.contains("://")
3166        || text.starts_with('/')
3167        || text.starts_with("###")
3168    {
3169        return false;
3170    }
3171
3172    let head = text.split_whitespace().next().unwrap_or_default();
3173    matches!(
3174        head,
3175        "agent-doc"
3176            | "cargo"
3177            | "git"
3178            | "make"
3179            | "pytest"
3180            | "python"
3181            | "uv"
3182            | "tsift"
3183            | "npm"
3184            | "pnpm"
3185            | "yarn"
3186            | "bash"
3187            | "zsh"
3188            | "rg"
3189            | "grep"
3190            | "./scripts/run_benchmark.sh"
3191    ) || head.starts_with("./")
3192}
3193
3194fn detect_closeout(text: &str) -> Vec<(String, String)> {
3195    let mut out = Vec::new();
3196    let normalized = normalize_whitespace(strip_common_prefixes(text));
3197    let lower = normalized.to_ascii_lowercase();
3198
3199    if normalized.starts_with("document_cycle ") {
3200        let phase = extract_field(&normalized, "phase");
3201        let event = extract_field(&normalized, "event");
3202        if phase == Some("committed")
3203            && let Some(event) = event
3204        {
3205            out.push((
3206                "commit".to_string(),
3207                format!("document_cycle phase=committed event={event}"),
3208            ));
3209        }
3210        return dedupe_pairs(out);
3211    }
3212
3213    if lower.contains("verification passed") || lower.starts_with("verification in ") {
3214        out.push((
3215            "verification".to_string(),
3216            truncate_detail(&normalized, 220),
3217        ));
3218    }
3219    if lower.contains("cargo build")
3220        || lower.contains("make check")
3221        || lower.contains("cargo test")
3222        || lower.contains("pytest")
3223    {
3224        out.push((
3225            "verification".to_string(),
3226            truncate_detail(&normalized, 220),
3227        ));
3228    }
3229    if lower.contains("cargo install") || lower.contains("installed") {
3230        out.push(("install".to_string(), truncate_detail(&normalized, 220)));
3231    }
3232    if lower.contains("committed and pushed") {
3233        out.push(("push".to_string(), truncate_detail(&normalized, 220)));
3234    } else if lower.contains("committed") {
3235        out.push(("commit".to_string(), truncate_detail(&normalized, 220)));
3236    }
3237    if lower.contains("tsift --version") || lower.contains("tsift v0.") {
3238        out.push(("version".to_string(), truncate_detail(&normalized, 220)));
3239    }
3240    if lower.contains("agent-doc finalize") || lower.contains("session-check") {
3241        out.push(("closeout".to_string(), truncate_detail(&normalized, 220)));
3242    }
3243
3244    dedupe_pairs(out)
3245}
3246
3247fn is_closeout_runtime_event(event_name: &str, normalized: &str) -> bool {
3248    event_name == "document_cycle"
3249        || matches!(
3250            normalized,
3251            "preflight_started"
3252                | "response_captured"
3253                | "commit_staging"
3254                | "commit_success"
3255                | "commit_already_current"
3256                | "snapshot_save"
3257                | "write_origin"
3258                | "ipc_write_attempt"
3259                | "ipc_write_consumed"
3260                | "out_of_band_write"
3261        )
3262}
3263
3264fn dedupe_pairs(items: Vec<(String, String)>) -> Vec<(String, String)> {
3265    let mut seen = BTreeSet::new();
3266    let mut deduped = Vec::new();
3267    for item in items {
3268        if seen.insert(item.clone()) {
3269            deduped.push(item);
3270        }
3271    }
3272    deduped
3273}
3274
3275fn normalize_whitespace(raw: &str) -> String {
3276    raw.split_whitespace().collect::<Vec<_>>().join(" ")
3277}
3278
3279fn truncate_detail(text: &str, max_chars: usize) -> String {
3280    if text.chars().count() <= max_chars {
3281        return text.to_string();
3282    }
3283    let mut truncated = String::new();
3284    for ch in text.chars().take(max_chars.saturating_sub(1)) {
3285        truncated.push(ch);
3286    }
3287    truncated.push('…');
3288    truncated
3289}
3290
3291fn normalize_runtime_event(event_name: &str, detail: &str) -> String {
3292    if event_name == "document_cycle"
3293        && let Some(document_event) = extract_field(detail, "event")
3294    {
3295        return document_event.to_string();
3296    }
3297    if matches!(
3298        event_name,
3299        "claude_start" | "codex_start" | "claude_restart" | "codex_restart"
3300    ) && let Some(mode) = extract_field(detail, "mode")
3301    {
3302        return format!("{event_name}:{mode}");
3303    }
3304    event_name.to_string()
3305}
3306
3307fn should_count_runtime_event(
3308    event_name: &str,
3309    detail: &str,
3310    normalized: &str,
3311    state: &mut CostState,
3312) -> bool {
3313    if event_name == "document_cycle"
3314        && let Some(cycle) = extract_field(detail, "cycle")
3315    {
3316        return state
3317            .seen_document_cycle_events
3318            .insert((cycle.to_string(), normalized.to_string()));
3319    }
3320    true
3321}
3322
3323fn prompt_cache_metadata(
3324    value: &Value,
3325    source: SessionCostSource,
3326) -> SessionCostPromptCacheMetadata {
3327    let provider = find_first_string_field(
3328        value,
3329        &[
3330            "provider",
3331            "model_provider",
3332            "provider_id",
3333            "model_provider_id",
3334        ],
3335    )
3336    .unwrap_or_else(|| default_prompt_cache_provider(source).to_string());
3337    let cache_key = find_first_string_field(
3338        value,
3339        &[
3340            "prompt_cache_key",
3341            "promptCacheKey",
3342            "cache_key",
3343            "cacheKey",
3344        ],
3345    );
3346    let routing_affinity = find_first_string_field(
3347        value,
3348        &[
3349            "routing_affinity",
3350            "routingAffinity",
3351            "replica",
3352            "replica_id",
3353            "replicaId",
3354            "deployment_id",
3355            "deploymentId",
3356        ],
3357    );
3358    let explicit_fingerprint = find_first_string_field(
3359        value,
3360        &[
3361            "stable_prefix_fingerprint",
3362            "stablePrefixFingerprint",
3363            "prefix_fingerprint",
3364            "prefixFingerprint",
3365        ],
3366    );
3367    let stable_prefix = find_first_string_field(
3368        value,
3369        &[
3370            "stable_prefix",
3371            "stablePrefix",
3372            "cached_prefix",
3373            "cachedPrefix",
3374            "prompt_prefix",
3375            "promptPrefix",
3376        ],
3377    );
3378    let mut breakpoints = Vec::new();
3379    collect_prompt_cache_breakpoints(value, "$", &mut breakpoints);
3380    // Breakpoint identity is position-independent (array indices stripped in
3381    // collection); collapse identical entries into a counted entry so multiple
3382    // ephemeral breakpoints keep their cardinality without the per-position
3383    // churn that read as false drift when a block was inserted (#pcachebp).
3384    let mut breakpoints = aggregate_prompt_cache_breakpoint_counts(breakpoints);
3385    breakpoints.truncate(MAX_PROMPT_CACHE_BREAKPOINTS);
3386
3387    let stable_prefix_fingerprint_explicit = explicit_fingerprint.is_some();
3388    let stable_prefix_fingerprint = explicit_fingerprint.unwrap_or_else(|| {
3389        let mut material = vec![format!("provider={provider}")];
3390        if let Some(cache_key) = &cache_key {
3391            material.push(format!("cache_key={cache_key}"));
3392        }
3393        if let Some(stable_prefix) = &stable_prefix {
3394            material.push(format!("stable_prefix={stable_prefix}"));
3395        }
3396        for breakpoint in &breakpoints {
3397            material.push(format!("breakpoint={breakpoint}"));
3398        }
3399        stable_prompt_cache_fingerprint(&material.join("\n"))
3400    });
3401
3402    SessionCostPromptCacheMetadata {
3403        provider,
3404        cache_key,
3405        stable_prefix_fingerprint,
3406        stable_prefix_fingerprint_explicit,
3407        stable_prefix,
3408        breakpoints,
3409        routing_affinity,
3410    }
3411}
3412
3413fn default_prompt_cache_provider(source: SessionCostSource) -> &'static str {
3414    match source {
3415        SessionCostSource::ClaudeJsonl => "anthropic",
3416        SessionCostSource::CodexJsonl => "openai",
3417        SessionCostSource::AgentDocLog => "agent_doc_log",
3418    }
3419}
3420
3421fn find_first_string_field(value: &Value, keys: &[&str]) -> Option<String> {
3422    let mut matches = Vec::new();
3423    collect_string_field_matches(value, "$", keys, &mut matches);
3424    matches.sort_by(|left, right| left.0.cmp(&right.0));
3425    matches
3426        .into_iter()
3427        .map(|(_, value)| value)
3428        .find(|value| !value.trim().is_empty())
3429}
3430
3431fn collect_string_field_matches(
3432    value: &Value,
3433    path: &str,
3434    keys: &[&str],
3435    matches: &mut Vec<(String, String)>,
3436) {
3437    match value {
3438        Value::Object(object) => {
3439            for (key, child) in object {
3440                let child_path = json_child_path(path, key);
3441                if metadata_key_matches(key, keys)
3442                    && let Some(text) = child.as_str()
3443                {
3444                    matches.push((child_path.clone(), text.to_string()));
3445                }
3446                collect_string_field_matches(child, &child_path, keys, matches);
3447            }
3448        }
3449        Value::Array(items) => {
3450            for (index, child) in items.iter().enumerate() {
3451                let child_path = format!("{path}[{index}]");
3452                collect_string_field_matches(child, &child_path, keys, matches);
3453            }
3454        }
3455        _ => {}
3456    }
3457}
3458
3459fn collect_prompt_cache_breakpoints(value: &Value, path: &str, breakpoints: &mut Vec<String>) {
3460    match value {
3461        Value::Object(object) => {
3462            for (key, child) in object {
3463                let child_path = json_child_path(path, key);
3464                if metadata_key_matches(
3465                    key,
3466                    &[
3467                        "cache_control",
3468                        "cacheControl",
3469                        "cache_breakpoint",
3470                        "cacheBreakpoint",
3471                        "prompt_cache_breakpoint",
3472                        "promptCacheBreakpoint",
3473                    ],
3474                ) {
3475                    breakpoints.push(format!(
3476                        "{}={}",
3477                        strip_json_array_indices(child_path.trim_start_matches("$.")),
3478                        describe_prompt_cache_breakpoint(child)
3479                    ));
3480                }
3481                collect_prompt_cache_breakpoints(child, &child_path, breakpoints);
3482            }
3483        }
3484        Value::Array(items) => {
3485            for (index, child) in items.iter().enumerate() {
3486                let child_path = format!("{path}[{index}]");
3487                collect_prompt_cache_breakpoints(child, &child_path, breakpoints);
3488            }
3489        }
3490        _ => {}
3491    }
3492}
3493
3494/// Drop `[N]` array-index segments from a JSON path so a cache breakpoint's
3495/// identity does not change when a non-cached block is inserted ahead of the
3496/// cached one (e.g. `message.content[0].cache_control` and
3497/// `message.content[1].cache_control` both become `message.content.cache_control`).
3498fn strip_json_array_indices(path: &str) -> String {
3499    let mut out = String::with_capacity(path.len());
3500    let mut in_index = false;
3501    for ch in path.chars() {
3502        match ch {
3503            '[' => in_index = true,
3504            ']' => in_index = false,
3505            _ if !in_index => out.push(ch),
3506            _ => {}
3507        }
3508    }
3509    out
3510}
3511
3512/// Collapse identical position-independent breakpoints into a sorted list where
3513/// repeated entries carry an `(xN)` count, so multiple breakpoints of the same
3514/// shape keep their cardinality without re-introducing positional churn.
3515fn aggregate_prompt_cache_breakpoint_counts(mut breakpoints: Vec<String>) -> Vec<String> {
3516    // Escape any provider-supplied breakpoint text that already ends in a
3517    // `(xN)`-shaped suffix, so a literal cannot masquerade as the count suffix
3518    // this function appends. Without it, one literal `foo (x2)` would serialize
3519    // identically to two plain `foo` breakpoints (which aggregate to
3520    // `foo (x2)`), hiding real cache-boundary drift behind a false match
3521    // (#tsreviewcleanup).
3522    for breakpoint in &mut breakpoints {
3523        if let Some(open) = breakpoint_count_suffix_start(breakpoint) {
3524            *breakpoint = format!("{} (\\x{}", &breakpoint[..open], &breakpoint[open + 3..]);
3525        }
3526    }
3527    breakpoints.sort();
3528    let mut aggregated: Vec<String> = Vec::new();
3529    let mut index = 0;
3530    while index < breakpoints.len() {
3531        let breakpoint = &breakpoints[index];
3532        let mut count = 1;
3533        while index + count < breakpoints.len() && breakpoints[index + count] == *breakpoint {
3534            count += 1;
3535        }
3536        if count > 1 {
3537            aggregated.push(format!("{breakpoint} (x{count})"));
3538        } else {
3539            aggregated.push(breakpoint.clone());
3540        }
3541        index += count;
3542    }
3543    aggregated
3544}
3545
3546/// Byte offset of a trailing ` (x<digits>)` count-shaped suffix, if present.
3547/// The real aggregation suffix never contains a backslash, so an escaped
3548/// literal (` (\xN)`) is not matched and cannot be double-escaped.
3549fn breakpoint_count_suffix_start(breakpoint: &str) -> Option<usize> {
3550    let inner = breakpoint.strip_suffix(')')?;
3551    let open = inner.rfind(" (x")?;
3552    let digits = &inner[open + 3..];
3553    if !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) {
3554        Some(open)
3555    } else {
3556        None
3557    }
3558}
3559
3560fn describe_prompt_cache_breakpoint(value: &Value) -> String {
3561    if let Some(text) = value.as_str() {
3562        return text.to_string();
3563    }
3564    if let Some(enabled) = value.as_bool() {
3565        return enabled.to_string();
3566    }
3567    if let Some(object) = value.as_object()
3568        && let Some(kind) = object.get("type").and_then(Value::as_str)
3569    {
3570        return format!("type:{kind}");
3571    }
3572    value.to_string()
3573}
3574
3575fn metadata_has_cache_control_breakpoint(metadata: &SessionCostPromptCacheMetadata) -> bool {
3576    metadata.breakpoints.iter().any(|breakpoint| {
3577        let key = breakpoint
3578            .split_once('=')
3579            .map_or(breakpoint.as_str(), |(key, _)| key);
3580        normalize_metadata_key(key).contains("cachecontrol")
3581    })
3582}
3583
3584fn is_anthropic_provider(provider: &str) -> bool {
3585    let provider = normalize_metadata_key(provider);
3586    provider.contains("anthropic") || provider.contains("claude")
3587}
3588
3589fn is_openai_provider(provider: &str) -> bool {
3590    let provider = normalize_metadata_key(provider);
3591    provider.contains("openai") || provider.contains("azureopenai") || provider.contains("codex")
3592}
3593
3594fn metadata_key_matches(key: &str, candidates: &[&str]) -> bool {
3595    let key = normalize_metadata_key(key);
3596    candidates
3597        .iter()
3598        .any(|candidate| key == normalize_metadata_key(candidate))
3599}
3600
3601fn normalize_metadata_key(key: &str) -> String {
3602    key.chars()
3603        .filter(|value| *value != '_' && *value != '-')
3604        .flat_map(char::to_lowercase)
3605        .collect()
3606}
3607
3608fn json_child_path(parent: &str, key: &str) -> String {
3609    if parent == "$" {
3610        format!("$.{key}")
3611    } else {
3612        format!("{parent}.{key}")
3613    }
3614}
3615
3616fn stable_prompt_cache_fingerprint(material: &str) -> String {
3617    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
3618    for byte in material.as_bytes() {
3619        hash ^= u64::from(*byte);
3620        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
3621    }
3622    format!("spfx-{hash:016x}")
3623}
3624
3625fn usage_u64(value: &Value, key: &str) -> u64 {
3626    value.get(key).and_then(Value::as_u64).unwrap_or(0)
3627}
3628
3629fn codex_usage_totals(value: &Value) -> UsageTotals {
3630    UsageTotals {
3631        prompt_tokens: usage_u64(value, "input_tokens"),
3632        cached_input_tokens: usage_u64(value, "cached_input_tokens"),
3633        cache_creation_input_tokens: 0,
3634        output_tokens: usage_u64(value, "output_tokens"),
3635        reasoning_output_tokens: usage_u64(value, "reasoning_output_tokens"),
3636        total_tokens: usage_u64(value, "total_tokens"),
3637    }
3638}
3639
3640fn count_restart_family(restart_churn: &[RestartChurnSummary], family: &str) -> usize {
3641    restart_churn
3642        .iter()
3643        .find(|entry| entry.family == family)
3644        .map_or(0, |entry| entry.occurrences)
3645}
3646
3647fn extract_field<'a>(detail: &'a str, key: &str) -> Option<&'a str> {
3648    let needle = format!("{key}=");
3649    let start = detail.find(&needle)? + needle.len();
3650    let remainder = &detail[start..];
3651    let end = remainder
3652        .find(char::is_whitespace)
3653        .unwrap_or(remainder.len());
3654    Some(remainder[..end].trim_matches('"'))
3655}
3656
3657#[cfg(test)]
3658mod tests {
3659    use super::*;
3660
3661    fn prompt_cache_adapter_status<'a>(
3662        plan: &'a SessionCostPromptCachePlan,
3663        provider: &str,
3664    ) -> Option<&'a str> {
3665        plan.provider_adapters
3666            .iter()
3667            .find(|adapter| adapter.provider == provider)
3668            .map(|adapter| adapter.status.as_str())
3669    }
3670
3671    #[test]
3672    fn auto_detects_claude_jsonl_and_dedupes_usage_by_message_id() {
3673        let input = concat!(
3674            r#"{"timestamp":"2026-05-05T00:00:01Z","requestId":"req-1","message":{"id":"msg-1","role":"assistant","usage":{"input_tokens":9,"cache_creation_input_tokens":300,"cache_read_input_tokens":1200,"output_tokens":10}}}"#,
3675            "\n",
3676            r#"{"timestamp":"2026-05-05T00:00:02Z","requestId":"req-1","message":{"id":"msg-1","role":"assistant","usage":{"input_tokens":9,"cache_creation_input_tokens":300,"cache_read_input_tokens":1200,"output_tokens":10}}}"#,
3677            "\n",
3678            r#"{"timestamp":"2026-05-05T00:00:03Z","requestId":"req-2","message":{"id":"msg-2","role":"assistant","usage":{"input_tokens":12,"cache_creation_input_tokens":0,"cache_read_input_tokens":800,"output_tokens":8}}}"#,
3679            "\n"
3680        );
3681
3682        let report = compute(input, None).unwrap();
3683        assert_eq!(report.source, "claude_jsonl");
3684        assert_eq!(report.usage_samples, 2);
3685        assert_eq!(report.prompt_tokens, 2321);
3686        assert_eq!(report.cached_input_tokens, 2000);
3687        assert_eq!(report.cache_creation_input_tokens, 300);
3688        assert_eq!(report.output_tokens, 18);
3689        assert_eq!(report.total_tokens, 2339);
3690        assert_eq!(report.cached_input_ratio, Some(86.17));
3691    }
3692
3693    #[test]
3694    fn codex_jsonl_uses_cumulative_deltas_and_skips_duplicate_snapshots() {
3695        let input = concat!(
3696            r#"{"timestamp":"2026-05-05T00:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":10,"total_tokens":1050}}}}"#,
3697            "\n",
3698            r#"{"timestamp":"2026-05-05T00:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1600,"cached_input_tokens":1400,"output_tokens":90,"reasoning_output_tokens":20,"total_tokens":1690}}}}"#,
3699            "\n",
3700            r#"{"timestamp":"2026-05-05T00:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1600,"cached_input_tokens":1400,"output_tokens":90,"reasoning_output_tokens":20,"total_tokens":1690}}}}"#,
3701            "\n"
3702        );
3703
3704        let report = compute(input, Some("codex-jsonl")).unwrap();
3705        assert_eq!(report.usage_samples, 2);
3706        assert_eq!(report.prompt_tokens, 1600);
3707        assert_eq!(report.cached_input_tokens, 1400);
3708        assert_eq!(report.output_tokens, 90);
3709        assert_eq!(report.reasoning_output_tokens, 20);
3710        assert_eq!(report.total_tokens, 1690);
3711        assert_eq!(report.largest_turn_total_tokens, 1050);
3712        assert_eq!(report.largest_turns[0].total_tokens, 1050);
3713        assert_eq!(report.largest_turns[1].total_tokens, 640);
3714    }
3715
3716    #[test]
3717    fn codex_jsonl_prefers_last_usage_for_interleaved_cumulative_streams() {
3718        let input = concat!(
3719            r#"{"timestamp":"2026-05-05T00:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":10,"total_tokens":1050},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":10,"total_tokens":1050}}}}"#,
3720            "\n",
3721            r#"{"timestamp":"2026-05-05T00:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":500,"cached_input_tokens":450,"output_tokens":20,"reasoning_output_tokens":5,"total_tokens":520},"last_token_usage":{"input_tokens":500,"cached_input_tokens":450,"output_tokens":20,"reasoning_output_tokens":5,"total_tokens":520}}}}"#,
3722            "\n",
3723            r#"{"timestamp":"2026-05-05T00:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1600,"cached_input_tokens":1400,"output_tokens":90,"reasoning_output_tokens":20,"total_tokens":1690},"last_token_usage":{"input_tokens":600,"cached_input_tokens":500,"output_tokens":40,"reasoning_output_tokens":10,"total_tokens":640}}}}"#,
3724            "\n",
3725            r#"{"timestamp":"2026-05-05T00:00:04Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":900,"cached_input_tokens":800,"output_tokens":45,"reasoning_output_tokens":10,"total_tokens":945},"last_token_usage":{"input_tokens":400,"cached_input_tokens":350,"output_tokens":25,"reasoning_output_tokens":5,"total_tokens":425}}}}"#,
3726            "\n",
3727            r#"{"timestamp":"2026-05-05T00:00:05Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":900,"cached_input_tokens":800,"output_tokens":45,"reasoning_output_tokens":10,"total_tokens":945},"last_token_usage":{"input_tokens":400,"cached_input_tokens":350,"output_tokens":25,"reasoning_output_tokens":5,"total_tokens":425}}}}"#,
3728            "\n"
3729        );
3730
3731        let report = compute(input, Some("codex-jsonl")).unwrap();
3732        assert_eq!(report.usage_samples, 4);
3733        assert_eq!(report.prompt_tokens, 2500);
3734        assert_eq!(report.cached_input_tokens, 2200);
3735        assert_eq!(report.output_tokens, 135);
3736        assert_eq!(report.reasoning_output_tokens, 30);
3737        assert_eq!(report.total_tokens, 2635);
3738        assert_eq!(report.largest_turn_total_tokens, 1050);
3739    }
3740
3741    #[test]
3742    fn prompt_cache_plan_summarizes_effectiveness_over_time() {
3743        let input = concat!(
3744            r#"{"timestamp":"2026-05-05T00:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":100,"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":1050},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":100,"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":1050}}}}"#,
3745            "\n",
3746            r#"{"timestamp":"2026-05-05T00:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":2000,"cached_input_tokens":600,"output_tokens":100,"reasoning_output_tokens":0,"total_tokens":2100},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":500,"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":1050}}}}"#,
3747            "\n",
3748            r#"{"timestamp":"2026-05-05T00:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":3000,"cached_input_tokens":1500,"output_tokens":150,"reasoning_output_tokens":0,"total_tokens":3150},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":1050}}}}"#,
3749            "\n",
3750        );
3751
3752        let report = compute(input, Some("codex-jsonl")).unwrap();
3753        let analytics = report
3754            .prompt_cache_plan
3755            .as_ref()
3756            .and_then(|plan| plan.analytics.as_ref())
3757            .expect("prompt cache analytics should be present");
3758
3759        assert_eq!(analytics.sample_count, 3);
3760        assert!(!analytics.effective);
3761        assert_eq!(analytics.trend, "improving");
3762        assert_eq!(
3763            analytics.average_cached_input_ratio.as_deref(),
3764            Some("50.00%")
3765        );
3766        assert_eq!(
3767            analytics.first_cached_input_ratio.as_deref(),
3768            Some("10.00%")
3769        );
3770        assert_eq!(analytics.last_cached_input_ratio.as_deref(), Some("90.00%"));
3771        assert_eq!(
3772            analytics.cached_input_ratio_delta.as_deref(),
3773            Some("+80.00%")
3774        );
3775        assert_eq!(analytics.net_cached_input_tokens, 1500);
3776        assert_eq!(analytics.timeline.len(), 3);
3777        assert_eq!(
3778            analytics.timeline[2].cached_input_ratio.as_deref(),
3779            Some("90.00%")
3780        );
3781    }
3782
3783    #[test]
3784    fn prompt_cache_timeline_emits_attribution_metadata() {
3785        let input = concat!(
3786            r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-1","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":1000,"cache_creation_input_tokens":100,"cache_read_input_tokens":900,"output_tokens":10}}}"#,
3787            "\n",
3788            r#"{"timestamp":"2026-05-05T00:00:02Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-2","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":1100,"cache_creation_input_tokens":0,"cache_read_input_tokens":1000,"output_tokens":12}}}"#,
3789            "\n",
3790        );
3791
3792        let report = compute(input, Some("claude-jsonl")).unwrap();
3793        let plan = report
3794            .prompt_cache_plan
3795            .as_ref()
3796            .expect("prompt cache plan should be present");
3797        let analytics = report
3798            .prompt_cache_plan
3799            .as_ref()
3800            .and_then(|plan| plan.analytics.as_ref())
3801            .expect("prompt cache analytics should be present");
3802        let first = analytics.timeline[0]
3803            .prompt_cache_metadata
3804            .as_ref()
3805            .expect("timeline should include prompt cache metadata");
3806        let second = analytics.timeline[1]
3807            .prompt_cache_metadata
3808            .as_ref()
3809            .expect("timeline should include prompt cache metadata");
3810
3811        assert_eq!(first.provider, "anthropic");
3812        assert_eq!(first.cache_key.as_deref(), Some("agent-doc:tsift"));
3813        assert_eq!(first.routing_affinity.as_deref(), Some("replica-a"));
3814        // Breakpoint identity is position-independent: array indices are stripped
3815        // so an inserted block ahead of the cached one is not read as drift (#pcachebp).
3816        assert!(
3817            first
3818                .breakpoints
3819                .iter()
3820                .any(|breakpoint| { breakpoint == "message.content.cache_control=type:ephemeral" })
3821        );
3822        assert!(first.stable_prefix_fingerprint.starts_with("spfx-"));
3823        assert_eq!(
3824            first.stable_prefix_fingerprint,
3825            second.stable_prefix_fingerprint
3826        );
3827        assert_eq!(
3828            prompt_cache_adapter_status(plan, "anthropic"),
3829            Some("cache_control")
3830        );
3831        assert_eq!(
3832            prompt_cache_adapter_status(plan, "replica_local"),
3833            Some("routing_affinity")
3834        );
3835    }
3836
3837    #[test]
3838    fn prompt_cache_plan_marks_missing_provider_adapter_evidence() {
3839        let input = concat!(
3840            r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"openai","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":24000,"cached_input_tokens":23000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":24300}}}}"#,
3841            "\n",
3842        );
3843
3844        let report = compute(input, Some("codex-jsonl")).unwrap();
3845        let plan = report
3846            .prompt_cache_plan
3847            .as_ref()
3848            .expect("prompt cache plan should be present");
3849
3850        assert_eq!(
3851            prompt_cache_adapter_status(plan, "openai"),
3852            Some("missing_prompt_cache_key")
3853        );
3854        assert_eq!(
3855            prompt_cache_adapter_status(plan, "replica_local"),
3856            Some("missing_routing_affinity")
3857        );
3858        assert!(plan.actions.iter().any(|action| {
3859            action.kind == "fix_openai_prompt_cache_key"
3860                && action.guidance.contains("prompt_cache_key")
3861        }));
3862        assert!(plan.actions.iter().any(|action| {
3863            action.kind == "fix_replica_routing_affinity"
3864                && action.guidance.contains("same provider replica")
3865        }));
3866
3867        let anthropic = concat!(
3868            r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"anthropic","routing_affinity":"replica-a","message":{"id":"msg-1","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1000,"cache_creation_input_tokens":100,"cache_read_input_tokens":900,"output_tokens":10}}}"#,
3869            "\n",
3870        );
3871        let report = compute(anthropic, Some("claude-jsonl")).unwrap();
3872        let plan = report
3873            .prompt_cache_plan
3874            .as_ref()
3875            .expect("prompt cache plan should be present");
3876        assert_eq!(
3877            prompt_cache_adapter_status(plan, "anthropic"),
3878            Some("missing_cache_control")
3879        );
3880        assert!(plan.actions.iter().any(|action| {
3881            action.kind == "fix_anthropic_cache_control"
3882                && action.guidance.contains("cache_control")
3883        }));
3884    }
3885
3886    #[test]
3887    fn prompt_cache_plan_marks_routing_affinity_churn() {
3888        let input = concat!(
3889            r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"openai","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":24000,"cached_input_tokens":23000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":24300}}}}"#,
3890            "\n",
3891            r#"{"timestamp":"2026-05-05T00:00:02Z","provider":"openai","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-b","stable_prefix":"agent-doc stable prefix v1","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":50000,"cached_input_tokens":48000,"output_tokens":650,"reasoning_output_tokens":180,"total_tokens":50650}}}}"#,
3892            "\n",
3893        );
3894
3895        let report = compute(input, Some("codex-jsonl")).unwrap();
3896        let plan = report
3897            .prompt_cache_plan
3898            .as_ref()
3899            .expect("prompt cache plan should be present");
3900
3901        assert_eq!(
3902            prompt_cache_adapter_status(plan, "openai"),
3903            Some("prompt_cache_key")
3904        );
3905        assert_eq!(
3906            prompt_cache_adapter_status(plan, "replica_local"),
3907            Some("routing_affinity_churn")
3908        );
3909        assert!(
3910            plan.actions
3911                .iter()
3912                .any(|action| action.kind == "fix_replica_routing_affinity")
3913        );
3914    }
3915
3916    #[test]
3917    fn prompt_cache_plan_classifies_likely_invalidation_diagnostics() {
3918        let input = concat!(
3919            r#"{"timestamp":"2026-05-05T00:00:01Z","message":{"id":"msg-1","role":"assistant","usage":{"input_tokens":1000,"cache_creation_input_tokens":0,"cache_read_input_tokens":9000,"output_tokens":50}}}"#,
3920            "\n",
3921            r#"{"timestamp":"2026-05-05T00:00:02Z","message":{"id":"msg-2","role":"assistant","usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#,
3922            "\n",
3923            r#"{"timestamp":"2026-05-05T00:00:03Z","message":{"id":"msg-3","role":"assistant","usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#,
3924            "\n",
3925        );
3926
3927        let report = compute(input, Some("claude-jsonl")).unwrap();
3928        let diagnostics = &report
3929            .prompt_cache_plan
3930            .as_ref()
3931            .and_then(|plan| plan.analytics.as_ref())
3932            .expect("prompt cache analytics should be present")
3933            .diagnostics;
3934
3935        assert!(diagnostics.iter().any(|diagnostic| {
3936            diagnostic.kind == "cached_ratio_drop"
3937                && diagnostic.label == "2026-05-05T00:00:02Z"
3938                && diagnostic
3939                    .likely_causes
3940                    .iter()
3941                    .any(|cause| cause.contains("prompt_cache_key"))
3942        }));
3943        assert!(diagnostics.iter().any(|diagnostic| {
3944            diagnostic.kind == "cache_creation_spike" && diagnostic.message.contains("60.00%")
3945        }));
3946        assert!(diagnostics.iter().any(|diagnostic| {
3947            diagnostic.kind == "read_create_regression" && diagnostic.message.contains("0.92x")
3948        }));
3949    }
3950
3951    #[test]
3952    fn prompt_cache_prefix_drift_points_regressions_at_first_changed_field() {
3953        // Only the cache_key changes between turns; provider, routing, prefix,
3954        // and breakpoints are identical. The derived fingerprint flips too (it
3955        // hashes the cache_key), but attribution must name the concrete cause —
3956        // `cache_key` — not the composite fingerprint (#pcacheattr).
3957        let input = concat!(
3958            r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-1","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":1000,"cache_creation_input_tokens":0,"cache_read_input_tokens":9000,"output_tokens":50}}}"#,
3959            "\n",
3960            r#"{"timestamp":"2026-05-05T00:00:02Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift-cold","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-2","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#,
3961            "\n",
3962        );
3963
3964        let report = compute(input, Some("claude-jsonl")).unwrap();
3965        let analytics = report
3966            .prompt_cache_plan
3967            .as_ref()
3968            .and_then(|plan| plan.analytics.as_ref())
3969            .expect("prompt cache analytics should be present");
3970
3971        assert_eq!(analytics.prefix_drift.len(), 1);
3972        let drift = &analytics.prefix_drift[0];
3973        assert_eq!(drift.trigger, "cached_ratio_drop_and_cache_creation_spike");
3974        assert_eq!(drift.severity, "warn");
3975        // Attribution names the real cause, not the derived composite.
3976        assert_eq!(drift.first_changed_field, "cache_key");
3977        // Attribution carries a concrete, field-specific fix, not just the cause
3978        // (#pcacheremediation / #0g7c).
3979        assert!(
3980            drift.remediation.contains("prompt_cache_key"),
3981            "cache_key drift remediation should name the prompt_cache_key fix, got: {}",
3982            drift.remediation
3983        );
3984        assert_eq!(drift.cached_input_ratio_before.as_deref(), Some("90.00%"));
3985        assert_eq!(drift.cached_input_ratio_after.as_deref(), Some("10.00%"));
3986        assert_eq!(drift.cache_creation_ratio.as_deref(), Some("60.00%"));
3987        // Only the changed concrete field is reported. The derived fingerprint
3988        // flips too (it hashes the cache_key), but that is a redundant echo of
3989        // the cache_key change and is suppressed (#tsreviewcleanup).
3990        assert!(
3991            drift
3992                .field_changes
3993                .iter()
3994                .any(|change| change.field == "cache_key"),
3995            "expected drift field cache_key"
3996        );
3997        for field in [
3998            "provider",
3999            "routing_affinity",
4000            "breakpoints",
4001            "stable_prefix",
4002            "stable_prefix_fingerprint",
4003        ] {
4004            assert!(
4005                !drift
4006                    .field_changes
4007                    .iter()
4008                    .any(|change| change.field == field),
4009                "unchanged field {field} must not be reported as drift"
4010            );
4011        }
4012        assert!(analytics.diagnostics.iter().any(|diagnostic| {
4013            diagnostic.kind == "cached_ratio_drop"
4014                && diagnostic
4015                    .message
4016                    .contains("first changed prompt-cache field: cache_key")
4017        }));
4018        assert!(analytics.diagnostics.iter().any(|diagnostic| {
4019            diagnostic.kind == "cache_creation_spike"
4020                && diagnostic
4021                    .likely_causes
4022                    .iter()
4023                    .any(|cause| cause.contains("first changed prompt-cache field: cache_key"))
4024        }));
4025    }
4026
4027    #[test]
4028    fn prompt_cache_drift_attributes_pure_prefix_content_change_to_stable_prefix() {
4029        // Provider, cache_key, routing, and breakpoints are identical across
4030        // turns; only the stable prefix *content* drifts. The raw `stable_prefix`
4031        // is now tracked as its own field ordered before the fingerprint, so the
4032        // drift is attributed to the concrete `stable_prefix` cause rather than
4033        // the derived composite fingerprint (#pcacheexplattr).
4034        let input = concat!(
4035            r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-1","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":1000,"cache_creation_input_tokens":0,"cache_read_input_tokens":9000,"output_tokens":50}}}"#,
4036            "\n",
4037            r#"{"timestamp":"2026-05-05T00:00:02Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v2 with edits","message":{"id":"msg-2","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#,
4038            "\n",
4039        );
4040
4041        let report = compute(input, Some("claude-jsonl")).unwrap();
4042        let analytics = report
4043            .prompt_cache_plan
4044            .as_ref()
4045            .and_then(|plan| plan.analytics.as_ref())
4046            .expect("prompt cache analytics should be present");
4047
4048        assert_eq!(analytics.prefix_drift.len(), 1);
4049        let drift = &analytics.prefix_drift[0];
4050        // The concrete `stable_prefix` content change is the attributed cause.
4051        assert_eq!(drift.first_changed_field, "stable_prefix");
4052        // On the derived path the fingerprint folds in the prefix, so it would
4053        // also flip — but a derived fingerprint is a redundant echo of the
4054        // tracked sub-field that fed it and is suppressed (#tsreviewcleanup),
4055        // leaving `stable_prefix` as the sole reported change.
4056        assert_eq!(drift.field_changes.len(), 1);
4057        assert_eq!(drift.field_changes[0].field, "stable_prefix");
4058        // The remediation names the concrete prefix-bytes fix (move volatile
4059        // content below the cache breakpoint), not a generic restatement.
4060        assert!(
4061            drift.remediation.contains("below the cache breakpoint"),
4062            "stable_prefix drift remediation should suggest moving volatile content below the cache breakpoint, got: {}",
4063            drift.remediation
4064        );
4065        assert!(
4066            !drift
4067                .field_changes
4068                .iter()
4069                .any(|change| change.field == "stable_prefix_fingerprint"),
4070            "derived fingerprint echo must be suppressed when a sub-field changed"
4071        );
4072    }
4073
4074    #[test]
4075    fn prompt_cache_prefix_drift_remediation_is_field_specific() {
4076        // Every attributed cause maps to a concrete fix the agent can act on,
4077        // and the fallback still gives actionable guidance for unknown fields
4078        // (#pcacheremediation / #0g7c).
4079        assert!(prompt_cache_prefix_drift_remediation("cache_key").contains("prompt_cache_key"));
4080        assert!(
4081            prompt_cache_prefix_drift_remediation("breakpoints").contains("breakpoint placement")
4082        );
4083        assert!(
4084            prompt_cache_prefix_drift_remediation("routing_affinity").contains("replica affinity")
4085        );
4086        assert!(prompt_cache_prefix_drift_remediation("provider").contains("provider"));
4087        assert!(prompt_cache_prefix_drift_remediation("stable_prefix").contains("prefix bytes"));
4088        assert!(
4089            prompt_cache_prefix_drift_remediation("stable_prefix_fingerprint")
4090                .contains("fingerprint")
4091        );
4092        // Unknown / future field falls back to actionable guidance, never empty.
4093        let fallback = prompt_cache_prefix_drift_remediation("some_new_field");
4094        assert!(fallback.contains("cache breakpoint"));
4095        assert!(!fallback.is_empty());
4096    }
4097
4098    #[test]
4099    fn prompt_cache_drift_attributes_prefix_change_under_explicit_fingerprint() {
4100        // A provider supplies an explicit `stable_prefix_fingerprint` that stays
4101        // CONSTANT across turns while the raw `stable_prefix` content drifts.
4102        // Before #pcacheexplattr the explicit fingerprint bypassed the derived
4103        // material, so nothing tracked changed and the prefix drift went
4104        // unattributed. The raw `stable_prefix` field now captures it.
4105        let input = concat!(
4106            r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix_fingerprint":"provider-fpr-constant","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-1","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":1000,"cache_creation_input_tokens":0,"cache_read_input_tokens":9000,"output_tokens":50}}}"#,
4107            "\n",
4108            r#"{"timestamp":"2026-05-05T00:00:02Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix_fingerprint":"provider-fpr-constant","stable_prefix":"agent-doc stable prefix v2 with edits","message":{"id":"msg-2","role":"assistant","content":[{"type":"text","text":"ok","cache_control":{"type":"ephemeral"}}],"usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#,
4109            "\n",
4110        );
4111
4112        let report = compute(input, Some("claude-jsonl")).unwrap();
4113        let analytics = report
4114            .prompt_cache_plan
4115            .as_ref()
4116            .and_then(|plan| plan.analytics.as_ref())
4117            .expect("prompt cache analytics should be present");
4118
4119        assert_eq!(analytics.prefix_drift.len(), 1);
4120        let drift = &analytics.prefix_drift[0];
4121        // The explicit fingerprint is unchanged, so the ONLY attributed cause is
4122        // the raw prefix content — which would have been invisible before.
4123        assert_eq!(drift.first_changed_field, "stable_prefix");
4124        assert_eq!(drift.field_changes.len(), 1);
4125        assert_eq!(drift.field_changes[0].field, "stable_prefix");
4126        assert!(
4127            !drift
4128                .field_changes
4129                .iter()
4130                .any(|change| change.field == "stable_prefix_fingerprint"),
4131            "constant explicit fingerprint must not be reported as drift"
4132        );
4133    }
4134
4135    #[test]
4136    fn prompt_cache_breakpoint_identity_is_position_independent() {
4137        let turn_a = serde_json::json!({
4138            "message": { "content": [
4139                { "type": "text", "text": "sys", "cache_control": { "type": "ephemeral" } }
4140            ]}
4141        });
4142        // A non-cached block inserted ahead shifts content[0] -> content[1].
4143        let turn_b = serde_json::json!({
4144            "message": { "content": [
4145                { "type": "thinking", "text": "..." },
4146                { "type": "text", "text": "sys", "cache_control": { "type": "ephemeral" } }
4147            ]}
4148        });
4149
4150        let mut bp_a = Vec::new();
4151        collect_prompt_cache_breakpoints(&turn_a, "$", &mut bp_a);
4152        let bp_a = aggregate_prompt_cache_breakpoint_counts(bp_a);
4153
4154        let mut bp_b = Vec::new();
4155        collect_prompt_cache_breakpoints(&turn_b, "$", &mut bp_b);
4156        let bp_b = aggregate_prompt_cache_breakpoint_counts(bp_b);
4157
4158        assert_eq!(
4159            bp_a,
4160            vec!["message.content.cache_control=type:ephemeral".to_string()]
4161        );
4162        assert_eq!(
4163            bp_a, bp_b,
4164            "inserting a non-cached block must not change breakpoint identity (#pcachebp)"
4165        );
4166    }
4167
4168    #[test]
4169    fn prompt_cache_breakpoints_keep_count_of_repeated_shapes() {
4170        let turn = serde_json::json!({
4171            "message": { "content": [
4172                { "type": "text", "cache_control": { "type": "ephemeral" } },
4173                { "type": "text", "cache_control": { "type": "ephemeral" } }
4174            ]}
4175        });
4176        let mut bp = Vec::new();
4177        collect_prompt_cache_breakpoints(&turn, "$", &mut bp);
4178        let bp = aggregate_prompt_cache_breakpoint_counts(bp);
4179        assert_eq!(
4180            bp,
4181            vec!["message.content.cache_control=type:ephemeral (x2)".to_string()]
4182        );
4183    }
4184
4185    #[test]
4186    fn prompt_cache_breakpoint_literal_count_suffix_does_not_collide_with_aggregation() {
4187        // A provider breakpoint whose text literally ends in `(x2)` must not
4188        // serialize identically to two plain breakpoints that aggregate to
4189        // `... (x2)`, or real cache-boundary drift between the two states would
4190        // be hidden behind a false match (#tsreviewcleanup).
4191        let literal = aggregate_prompt_cache_breakpoint_counts(vec!["foo (x2)".to_string()]);
4192        let aggregated =
4193            aggregate_prompt_cache_breakpoint_counts(vec!["foo".to_string(), "foo".to_string()]);
4194        assert_ne!(literal, aggregated);
4195        // The literal is escaped (`(\x2)`); the genuine count suffix is `(x2)`.
4196        assert_eq!(literal, vec!["foo (\\x2)".to_string()]);
4197        assert_eq!(aggregated, vec!["foo (x2)".to_string()]);
4198        // Two copies of the literal aggregate on the escaped form, still
4199        // distinct from a single literal.
4200        let two_literals = aggregate_prompt_cache_breakpoint_counts(vec![
4201            "foo (x2)".to_string(),
4202            "foo (x2)".to_string(),
4203        ]);
4204        assert_eq!(two_literals, vec!["foo (\\x2) (x2)".to_string()]);
4205        assert_ne!(two_literals, literal);
4206    }
4207
4208    #[test]
4209    fn read_create_regression_survives_diagnostics_truncation() {
4210        // Eight turns each trigger a cache-creation spike (50% creation ratio),
4211        // producing more per-turn diagnostics than MAX_PROMPT_CACHE_DIAGNOSTICS,
4212        // plus an overall read/create regression (800 read / 4000 creation =
4213        // 0.2x, far below the 2.0 threshold).
4214        let turns: Vec<SessionCostTurn> = (0..8)
4215            .map(|idx| SessionCostTurn {
4216                label: format!("t{idx}"),
4217                prompt_tokens: 1000,
4218                cached_input_tokens: 100,
4219                cache_creation_input_tokens: 500,
4220                output_tokens: 0,
4221                reasoning_output_tokens: 0,
4222                total_tokens: 1100,
4223                prompt_cache_metadata: None,
4224            })
4225            .collect();
4226
4227        let diagnostics = derive_prompt_cache_diagnostics(&turns, 800, 4000);
4228
4229        assert_eq!(diagnostics.len(), MAX_PROMPT_CACHE_DIAGNOSTICS);
4230        // Before the fix the session-level regression was pushed last and
4231        // truncated away, so the read/create gate read 0 and passed (#pcacheregtrunc).
4232        assert!(
4233            diagnostics
4234                .iter()
4235                .any(|diagnostic| diagnostic.kind == "read_create_regression"),
4236            "session-level read/create regression must survive diagnostics truncation: {:?}",
4237            diagnostics
4238                .iter()
4239                .map(|diagnostic| diagnostic.kind.as_str())
4240                .collect::<Vec<_>>()
4241        );
4242    }
4243
4244    #[test]
4245    fn prompt_cache_effectiveness_fixture_passes_thresholds() {
4246        let fixture = SessionCostPromptCacheEffectivenessFixture {
4247            schema_version: 1,
4248            description: "fixture".to_string(),
4249            required_regression_scenarios: Vec::new(),
4250            cases: vec![SessionCostPromptCacheEffectivenessCase {
4251                name: "warm-codex-prefix".to_string(),
4252                source: "codex-jsonl".to_string(),
4253                input_lines: vec![
4254                    r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"openai","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":24000,"cached_input_tokens":23000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":24300}}}}"#.to_string(),
4255                    r#"{"timestamp":"2026-05-05T00:00:04Z","provider":"openai","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":50000,"cached_input_tokens":48000,"output_tokens":650,"reasoning_output_tokens":180,"total_tokens":50650}}}}"#.to_string(),
4256                ],
4257                minimum_cached_input_ratio: 90.0,
4258                minimum_net_cached_input_tokens: 40_000,
4259                maximum_read_create_regressions: 0,
4260                regression_scenarios: Vec::new(),
4261                required_prefix_drift_fields: Vec::new(),
4262                required_diagnostics: Vec::new(),
4263            }],
4264        };
4265
4266        let report = build_prompt_cache_effectiveness_report(&fixture).unwrap();
4267
4268        assert!(report.pass);
4269        assert_eq!(report.totals.passed, 1);
4270        assert_eq!(report.totals.failed, 0);
4271        assert_eq!(report.cases[0].status, "pass");
4272        assert_eq!(report.cases[0].cached_input_ratio, Some(96.0));
4273        assert_eq!(report.cases[0].net_cached_input_tokens, 48_000);
4274        assert_eq!(report.cases[0].read_create_regressions, 0);
4275    }
4276
4277    #[test]
4278    fn prompt_cache_effectiveness_fixture_fails_missing_adapter_evidence() {
4279        let fixture = SessionCostPromptCacheEffectivenessFixture {
4280            schema_version: 1,
4281            description: "fixture".to_string(),
4282            required_regression_scenarios: Vec::new(),
4283            cases: vec![
4284                SessionCostPromptCacheEffectivenessCase {
4285                    name: "missing-openai-key".to_string(),
4286                    source: "codex-jsonl".to_string(),
4287                    input_lines: vec![
4288                        r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"openai","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":24000,"cached_input_tokens":23000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":24300}}}}"#.to_string(),
4289                        r#"{"timestamp":"2026-05-05T00:00:04Z","provider":"openai","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":50000,"cached_input_tokens":48000,"output_tokens":650,"reasoning_output_tokens":180,"total_tokens":50650}}}}"#.to_string(),
4290                    ],
4291                    minimum_cached_input_ratio: 90.0,
4292                    minimum_net_cached_input_tokens: 40_000,
4293                    maximum_read_create_regressions: 0,
4294                    regression_scenarios: Vec::new(),
4295                    required_prefix_drift_fields: Vec::new(),
4296                    required_diagnostics: Vec::new(),
4297                },
4298                SessionCostPromptCacheEffectivenessCase {
4299                    name: "missing-anthropic-cache-control".to_string(),
4300                    source: "claude-jsonl".to_string(),
4301                    input_lines: vec![
4302                        r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-1","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1000,"cache_creation_input_tokens":100,"cache_read_input_tokens":9000,"output_tokens":10}}}"#.to_string(),
4303                        r#"{"timestamp":"2026-05-05T00:00:02Z","provider":"anthropic","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix v1","message":{"id":"msg-2","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1100,"cache_creation_input_tokens":0,"cache_read_input_tokens":10000,"output_tokens":12}}}"#.to_string(),
4304                    ],
4305                    minimum_cached_input_ratio: 70.0,
4306                    minimum_net_cached_input_tokens: 1,
4307                    maximum_read_create_regressions: 0,
4308                    regression_scenarios: Vec::new(),
4309                    required_prefix_drift_fields: Vec::new(),
4310                    required_diagnostics: Vec::new(),
4311                },
4312            ],
4313        };
4314
4315        let report = build_prompt_cache_effectiveness_report(&fixture).unwrap();
4316
4317        assert!(!report.pass);
4318        assert_eq!(report.totals.failed, 2);
4319        assert!(report.cases[0].failures.iter().any(|failure| {
4320            failure.contains("OpenAI prompt_cache_key")
4321                && failure.contains("missing_prompt_cache_key")
4322        }));
4323        assert!(report.cases[0].failures.iter().any(|failure| {
4324            failure.contains("replica-local routing_affinity")
4325                && failure.contains("missing_routing_affinity")
4326        }));
4327        assert!(report.cases[1].failures.iter().any(|failure| {
4328            failure.contains("Anthropic cache_control") && failure.contains("missing_cache_control")
4329        }));
4330    }
4331
4332    #[test]
4333    fn prompt_cache_effectiveness_fixture_fails_read_create_regression() {
4334        let fixture = SessionCostPromptCacheEffectivenessFixture {
4335            schema_version: 1,
4336            description: "fixture".to_string(),
4337            required_regression_scenarios: Vec::new(),
4338            cases: vec![SessionCostPromptCacheEffectivenessCase {
4339                name: "cold-rewrite".to_string(),
4340                source: "claude-jsonl".to_string(),
4341                input_lines: vec![
4342                    r#"{"timestamp":"2026-05-05T00:00:01Z","message":{"id":"msg-1","role":"assistant","usage":{"input_tokens":1000,"cache_creation_input_tokens":0,"cache_read_input_tokens":9000,"output_tokens":50}}}"#.to_string(),
4343                    r#"{"timestamp":"2026-05-05T00:00:02Z","message":{"id":"msg-2","role":"assistant","usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#.to_string(),
4344                    r#"{"timestamp":"2026-05-05T00:00:03Z","message":{"id":"msg-3","role":"assistant","usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#.to_string(),
4345                ],
4346                minimum_cached_input_ratio: 70.0,
4347                minimum_net_cached_input_tokens: 1,
4348                maximum_read_create_regressions: 0,
4349                regression_scenarios: Vec::new(),
4350                required_prefix_drift_fields: Vec::new(),
4351                required_diagnostics: Vec::new(),
4352            }],
4353        };
4354
4355        let report = build_prompt_cache_effectiveness_report(&fixture).unwrap();
4356
4357        assert!(!report.pass);
4358        assert_eq!(report.totals.failed, 1);
4359        assert_eq!(report.cases[0].status, "fail");
4360        assert_eq!(report.cases[0].read_create_regressions, 1);
4361        assert!(
4362            report.cases[0]
4363                .failures
4364                .iter()
4365                .any(|failure| failure.contains("read_create_regressions"))
4366        );
4367    }
4368
4369    #[test]
4370    fn prompt_cache_effectiveness_fixture_requires_regression_coverage_and_drift_fields() {
4371        let fixture = SessionCostPromptCacheEffectivenessFixture {
4372            schema_version: 1,
4373            description: "fixture".to_string(),
4374            required_regression_scenarios: vec![
4375                "volatile_prefix_generated_header".to_string(),
4376                "openai_prompt_cache_key_churn".to_string(),
4377            ],
4378            cases: vec![SessionCostPromptCacheEffectivenessCase {
4379                name: "volatile-prefix".to_string(),
4380                source: "codex-jsonl".to_string(),
4381                input_lines: vec![
4382                    r#"{"timestamp":"2026-05-05T00:00:01Z","provider":"openai","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix\nGenerated: 2026-05-05T00:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":24000,"cached_input_tokens":23000,"output_tokens":300,"reasoning_output_tokens":100,"total_tokens":24300}}}}"#.to_string(),
4383                    r#"{"timestamp":"2026-05-05T00:00:04Z","provider":"openai","prompt_cache_key":"agent-doc:tsift","routing_affinity":"replica-a","stable_prefix":"agent-doc stable prefix\nGenerated: 2026-05-05T00:00:04Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":50000,"cached_input_tokens":48000,"output_tokens":650,"reasoning_output_tokens":180,"total_tokens":50650}}}}"#.to_string(),
4384                ],
4385                minimum_cached_input_ratio: 90.0,
4386                minimum_net_cached_input_tokens: 40_000,
4387                maximum_read_create_regressions: 0,
4388                regression_scenarios: vec!["volatile_prefix_generated_header".to_string()],
4389                // Prefix-content drift (the `Generated:` timestamp) is now
4390                // attributed to the concrete `stable_prefix` field; the derived
4391                // fingerprint echo is suppressed (#tsreviewcleanup).
4392                required_prefix_drift_fields: vec!["stable_prefix".to_string()],
4393                required_diagnostics: Vec::new(),
4394            }],
4395        };
4396
4397        let report = build_prompt_cache_effectiveness_report(&fixture).unwrap();
4398
4399        assert!(!report.pass);
4400        assert_eq!(
4401            report.missing_regression_scenarios,
4402            vec!["openai_prompt_cache_key_churn".to_string()]
4403        );
4404        assert_eq!(
4405            report.covered_regression_scenarios,
4406            vec!["volatile_prefix_generated_header".to_string()]
4407        );
4408        assert!(report.cases[0].failures.is_empty());
4409    }
4410
4411    #[test]
4412    fn agent_doc_log_summarizes_runtime_churn() {
4413        let input = "\
4414[1776452736] claude_start mode=fresh restart_count=0
4415[1776528398] claude_start mode=fresh_restart restart_count=1
4416[1776528446] auto_trigger_timeout (no prompt after 30s)
4417[1776528450] ctrl_d_restart_fresh restart_count=2
4418[1776528582] claude_start mode=fresh_restart restart_count=2
4419[1776528599] codex_start mode=continue restart_count=3
4420[1776528601] user_quit_after_ctrl_d
4421[1776528602] commit_already_current file=tasks/software/tsift.md basis=head
4422[1776528603] commit_already_current file=tasks/software/tsift.md basis=head
4423[1776528604] commit_already_current file=tasks/software/tsift.md basis=head
4424";
4425
4426        let report = compute(input, Some("agent-doc-log")).unwrap();
4427        assert_eq!(report.source, "agent_doc_log");
4428        assert_eq!(report.usage_samples, 0);
4429        assert_eq!(report.runtime_event_groups, 7);
4430        assert_eq!(report.total_runtime_events, 10);
4431        assert_eq!(report.restart_churn_groups, 4);
4432        assert_eq!(report.max_restart_count, Some(3));
4433        assert!(
4434            report
4435                .runtime_events
4436                .iter()
4437                .any(|event| event.event == "claude_start:fresh_restart" && event.occurrences == 2)
4438        );
4439        assert!(
4440            report
4441                .runtime_events
4442                .iter()
4443                .any(|event| event.event == "auto_trigger_timeout" && event.occurrences == 1)
4444        );
4445        assert!(
4446            report
4447                .restart_churn
4448                .iter()
4449                .any(|entry| entry.family == "fresh_restart" && entry.occurrences == 3)
4450        );
4451        assert!(
4452            report
4453                .restart_churn
4454                .iter()
4455                .any(|entry| entry.family == "ctrl_d_restart_loop" && entry.occurrences == 1)
4456        );
4457        assert!(
4458            report
4459                .restart_churn
4460                .iter()
4461                .any(|entry| entry.family == "quit_after_eof" && entry.occurrences == 1)
4462        );
4463        assert!(
4464            report
4465                .guardrails
4466                .iter()
4467                .any(|guardrail| guardrail.kind == "restart_loop")
4468        );
4469        assert!(
4470            report
4471                .guardrails
4472                .iter()
4473                .any(|guardrail| guardrail.kind == "noop_closeout")
4474        );
4475        assert!(
4476            report
4477                .loop_clusters
4478                .iter()
4479                .any(|cluster| cluster.kind == "closeout_churn"
4480                    && cluster.label == "commit_already_current"
4481                    && cluster.occurrences == 3)
4482        );
4483    }
4484
4485    #[test]
4486    fn agent_doc_log_dedupes_document_cycle_runtime_events_by_cycle() {
4487        let input = "\
4488[1777603275] document_cycle phase=response_captured cycle=cycle-1 event=response_captured capture_id=cycle-1
4489[1777603276] document_cycle phase=committed cycle=cycle-1 event=commit_success capture_id=cycle-1
4490[1777603403] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
4491[1777603404] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
4492[1777603405] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
4493[1777603500] document_cycle phase=preflight_started cycle=cycle-2 event=preflight_started
4494[1777603600] document_cycle phase=committed cycle=cycle-2 event=commit_already_current
4495[1777603601] document_cycle phase=committed cycle=cycle-2 event=commit_already_current
4496[1777603700] document_cycle phase=committed cycle=cycle-3 event=commit_already_current
4497";
4498
4499        let report = compute(input, Some("agent-doc-log")).unwrap();
4500
4501        assert_eq!(report.total_runtime_events, 6);
4502        assert!(
4503            report
4504                .runtime_events
4505                .iter()
4506                .any(|event| event.event == "commit_already_current" && event.occurrences == 3)
4507        );
4508        assert!(
4509            report
4510                .runtime_events
4511                .iter()
4512                .any(|event| event.event == "commit_success" && event.occurrences == 1)
4513        );
4514        assert!(
4515            report
4516                .runtime_events
4517                .iter()
4518                .any(|event| event.event == "response_captured" && event.occurrences == 1)
4519        );
4520        assert!(
4521            report
4522                .guardrails
4523                .iter()
4524                .any(|guardrail| guardrail.kind == "noop_closeout")
4525        );
4526        assert!(
4527            report
4528                .loop_clusters
4529                .iter()
4530                .any(|cluster| cluster.kind == "closeout_churn"
4531                    && cluster.label == "commit_already_current"
4532                    && cluster.occurrences == 3)
4533        );
4534    }
4535
4536    #[test]
4537    fn codex_jsonl_surfaces_prompt_and_command_loop_clusters() {
4538        let input = concat!(
4539            r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#looprank]. spec-test-build-install-commit-push"}}"#,
4540            "\n",
4541            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
4542            "\n",
4543            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
4544            "\n",
4545            r#"{"type":"event_msg","payload":{"type":"agent_message","message":"Committed and pushed in `src/tsift` as `abc123`."}}"#,
4546            "\n",
4547            r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#looprank]. spec-test-build-install-commit-push"}}"#,
4548            "\n",
4549            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
4550            "\n",
4551            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
4552            "\n",
4553            r#"{"type":"event_msg","payload":{"type":"agent_message","message":"Committed and pushed in `src/tsift` as `abc123`."}}"#,
4554            "\n"
4555        );
4556
4557        let report = compute(input, Some("codex-jsonl")).unwrap();
4558
4559        assert!(
4560            report
4561                .loop_clusters
4562                .iter()
4563                .any(|cluster| cluster.kind == "prompt_repeat"
4564                    && cluster.label == "do [#looprank]. spec-test-build-install-commit-push"
4565                    && cluster.occurrences == 2)
4566        );
4567        assert!(
4568            report
4569                .loop_clusters
4570                .iter()
4571                .any(|cluster| cluster.kind == "command_bundle"
4572                    && cluster.label == "cargo test -> cargo build --release"
4573                    && cluster.occurrences == 2)
4574        );
4575        assert!(report.loop_clusters.iter().any(|cluster| {
4576            cluster.kind == "closeout_churn"
4577                && cluster
4578                    .label
4579                    .contains("Committed and pushed in `src/tsift`")
4580                && cluster.occurrences == 2
4581        }));
4582    }
4583
4584    #[test]
4585    fn codex_jsonl_surfaces_repeated_file_read_diagnostics() {
4586        let input = concat!(
4587            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"sed -n '1,220p' src/session_cost.rs\"}"}}"#,
4588            "\n",
4589            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"sed -n '1,220p' src/session_cost.rs\"}"}}"#,
4590            "\n",
4591            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cat src/main.rs\"}"}}"#,
4592            "\n",
4593            r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cat src/main.rs\"}"}}"#,
4594            "\n"
4595        );
4596
4597        let report = compute(input, Some("codex-jsonl")).unwrap();
4598
4599        assert!(report.file_read_diagnostics.iter().any(|diagnostic| {
4600            diagnostic.path == "src/session_cost.rs"
4601                && diagnostic.range == "1-220"
4602                && diagnostic.occurrences == 2
4603                && diagnostic.duplicate_estimated_tokens == 3_960
4604                && diagnostic.follow_up_commands.iter().any(|command| {
4605                    command == "tsift source-read src/session_cost.rs --start 1 --lines 220 --budget normal"
4606                })
4607        }));
4608        assert!(report.file_read_diagnostics.iter().any(|diagnostic| {
4609            diagnostic.path == "src/main.rs"
4610                && diagnostic.range == "full"
4611                && diagnostic.duplicate_estimated_tokens == 4_000
4612                && diagnostic
4613                    .follow_up_commands
4614                    .iter()
4615                    .any(|command| command == "tsift summarize --file src/main.rs")
4616        }));
4617    }
4618
4619    #[test]
4620    fn claude_jsonl_surfaces_repeated_native_read_tool_diagnostics() {
4621        let input = concat!(
4622            r#"{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","offset":40,"limit":80}}]}}"#,
4623            "\n",
4624            r#"{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","offset":40,"limit":80}}]}}"#,
4625            "\n"
4626        );
4627
4628        let report = compute(input, Some("claude-jsonl")).unwrap();
4629
4630        assert_eq!(report.file_read_diagnostics.len(), 1);
4631        let diagnostic = &report.file_read_diagnostics[0];
4632        assert_eq!(diagnostic.path, "src/lib.rs");
4633        assert_eq!(diagnostic.range, "40-119");
4634        assert_eq!(diagnostic.occurrences, 2);
4635        assert_eq!(diagnostic.duplicate_estimated_tokens, 1_440);
4636        assert!(diagnostic.follow_up_commands.iter().any(|command| {
4637            command == "tsift source-read src/lib.rs --start 40 --lines 80 --budget normal"
4638        }));
4639    }
4640
4641    #[test]
4642    fn derive_guardrails_flags_large_prompt_turns() {
4643        let guardrails = derive_guardrails(&SessionCostGuardrailInput {
4644            largest_prompt_turn_tokens: 140_000,
4645            largest_prompt_turn_label: Some("2026-05-05T00:00:01Z".to_string()),
4646            ..SessionCostGuardrailInput::default()
4647        });
4648
4649        assert!(
4650            guardrails
4651                .iter()
4652                .any(|guardrail| guardrail.kind == "prompt_budget")
4653        );
4654    }
4655
4656    #[test]
4657    fn derive_guardrails_flags_cached_resend_ratio() {
4658        let guardrails = derive_guardrails(&SessionCostGuardrailInput {
4659            prompt_tokens: 80_000,
4660            cached_input_ratio: Some(96.0),
4661            ..SessionCostGuardrailInput::default()
4662        });
4663
4664        assert!(
4665            guardrails
4666                .iter()
4667                .any(|guardrail| guardrail.kind == "cache_resend")
4668        );
4669    }
4670
4671    #[test]
4672    fn derive_guardrails_ignores_restart_count_without_churn() {
4673        let guardrails = derive_guardrails(&SessionCostGuardrailInput {
4674            max_restart_count: Some(3),
4675            ..SessionCostGuardrailInput::default()
4676        });
4677
4678        assert!(
4679            guardrails
4680                .iter()
4681                .all(|guardrail| guardrail.kind != "restart_loop")
4682        );
4683    }
4684}