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