Skip to main content

supercode_harness/
model_change.rs

1//! P4c (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4" core NEW-significant item,
2//! §1.10/§3.1 `core.model_switch.allow_switch`, D9 row): a persisted,
3//! TYPED record of a mid-session model switch — pi's `model_change`
4//! precedent (design §1.10: "persisted change records … pi's `model_change`
5//! is the cleanest precedent"). Deliberately flat/typed (not a formatted
6//! string), mirroring [`crate::usage_log::UsageRecord`]'s exact rationale:
7//! a translatable, lossless session-data channel (§1.13), not a lossy
8//! notice — so it survives a save/load round trip byte-for-byte in the
9//! fields that matter, and a future reader (a translator emitting this same
10//! session under another harness's format, a `doctor`/`inspect stats`
11//! command) can consume it without re-parsing prose.
12
13use serde::{Deserialize, Serialize};
14
15/// One mid-session model switch, as `Agent::switch_model` records it when
16/// `Config::model_switch_allow_switch` is on (see that field's doc comment
17/// for the exact gate — with the knob off, `switch_model` never creates one
18/// of these at all, matching `Agent::set_model`'s pre-existing,
19/// record-free mechanics byte-for-byte).
20#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
21pub struct ModelChangeRecord {
22    /// 0-based index of the model round-trip THIS switch takes effect
23    /// before (i.e. `Agent::turn_index` at the moment of the switch) — the
24    /// same per-turn addressing [`crate::usage_log::UsageRecord::turn`]
25    /// uses, so the two logs can be correlated.
26    pub turn: usize,
27    /// The model id in effect immediately before this switch.
28    pub from_model: String,
29    /// The model id in effect immediately after this switch.
30    pub to_model: String,
31    /// Whether `reduce::rehydrate::filter_reasoning_artifacts` ran over the
32    /// live history as part of this switch (dep 8) — always `true` when
33    /// this record exists at all (a record is only ever created under
34    /// `allow_switch = true`, which is the same gate that runs the filter),
35    /// but recorded explicitly rather than implied, so a reader of the
36    /// persisted log alone (without also knowing the config that produced
37    /// it) can see the safety guarantee held for this specific switch.
38    #[serde(default)]
39    pub reasoning_filtered: bool,
40    /// Count of `ChatMessage`s the reasoning-artifact filter actually
41    /// touched (stripped a metadata key from, or removed a `content_parts`
42    /// reasoning block from) during this switch. `0` is a legitimate,
43    /// common value (nothing to filter yet), not an error signal.
44    #[serde(default)]
45    pub reasoning_artifacts_filtered: usize,
46    /// Unix-ms wall-clock time the switch happened.
47    #[serde(default)]
48    pub timestamp_ms: i64,
49}
50
51impl ModelChangeRecord {
52    /// Build a record — the constructor `Agent::switch_model` calls.
53    pub fn new(
54        turn: usize,
55        from_model: impl Into<String>,
56        to_model: impl Into<String>,
57        reasoning_filtered: bool,
58        reasoning_artifacts_filtered: usize,
59        timestamp_ms: i64,
60    ) -> ModelChangeRecord {
61        ModelChangeRecord {
62            turn,
63            from_model: from_model.into(),
64            to_model: to_model.into(),
65            reasoning_filtered,
66            reasoning_artifacts_filtered,
67            timestamp_ms,
68        }
69    }
70}
71
72/// Serialize `records` as JSONL (one [`ModelChangeRecord`] per line) — the
73/// same shape [`crate::usage_log::to_jsonl`] and every other append-log in
74/// this crate uses. Never fails on an empty slice (produces an empty
75/// string).
76pub fn to_jsonl(records: &[ModelChangeRecord]) -> crate::Result<String> {
77    let mut out = String::new();
78    for r in records {
79        out.push_str(&serde_json::to_string(r).map_err(crate::Error::Decode)?);
80        out.push('\n');
81    }
82    Ok(out)
83}
84
85/// Parse a JSONL model-change log back into records — the exact inverse of
86/// [`to_jsonl`]. Blank lines are skipped; a malformed line is a hard error
87/// (like [`crate::usage_log::from_jsonl`] — this is accounting/provenance
88/// data, corruption should be visible, not silently dropped).
89pub fn from_jsonl(text: &str) -> crate::Result<Vec<ModelChangeRecord>> {
90    let mut out = Vec::new();
91    for line in text.lines() {
92        let line = line.trim();
93        if line.is_empty() {
94            continue;
95        }
96        out.push(serde_json::from_str(line).map_err(crate::Error::Decode)?);
97    }
98    Ok(out)
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn new_carries_every_field() {
107        let r = ModelChangeRecord::new(
108            2,
109            "anthropic/claude-opus-4-8",
110            "anthropic/claude-haiku-4-5",
111            true,
112            3,
113            1_700_000_000_000,
114        );
115        assert_eq!(r.turn, 2);
116        assert_eq!(r.from_model, "anthropic/claude-opus-4-8");
117        assert_eq!(r.to_model, "anthropic/claude-haiku-4-5");
118        assert!(r.reasoning_filtered);
119        assert_eq!(r.reasoning_artifacts_filtered, 3);
120        assert_eq!(r.timestamp_ms, 1_700_000_000_000);
121    }
122
123    /// §1.13 "translatable, lossless — not a lossy channel": every field
124    /// round-trips through JSONL byte-for-byte, not just "close enough".
125    #[test]
126    fn jsonl_round_trip_is_lossless() {
127        let records = vec![
128            ModelChangeRecord::new(
129                0,
130                "vendor/model-a",
131                "vendor/model-b",
132                true,
133                5,
134                1_700_000_000_000,
135            ),
136            ModelChangeRecord::new(
137                3,
138                "vendor/model-b",
139                "vendor/model-c",
140                true,
141                0,
142                1_700_000_010_000,
143            ),
144        ];
145        let jsonl = to_jsonl(&records).unwrap();
146        let round_tripped = from_jsonl(&jsonl).unwrap();
147        assert_eq!(records, round_tripped);
148    }
149
150    #[test]
151    fn empty_records_round_trip_to_empty() {
152        assert_eq!(to_jsonl(&[]).unwrap(), "");
153        assert_eq!(from_jsonl("").unwrap(), Vec::<ModelChangeRecord>::new());
154    }
155
156    #[test]
157    fn from_jsonl_skips_blank_lines() {
158        assert_eq!(from_jsonl("\n\n").unwrap(), Vec::<ModelChangeRecord>::new());
159    }
160
161    #[test]
162    fn from_jsonl_rejects_malformed_lines_rather_than_silently_dropping_them() {
163        assert!(from_jsonl("{not json}").is_err());
164    }
165
166    /// Old records written before `reasoning_filtered`/
167    /// `reasoning_artifacts_filtered`/`timestamp_ms` existed (hypothetically
168    /// — this crate is pre-1.0, but the `#[serde(default)]` discipline
169    /// matches every other log in this crate, e.g.
170    /// `crate::store::SessionInfo::reduced`) still parse.
171    #[test]
172    fn tolerates_a_record_missing_the_optional_fields() {
173        let minimal = r#"{"turn":0,"from_model":"a","to_model":"b"}"#;
174        let parsed = from_jsonl(minimal).unwrap();
175        assert_eq!(parsed.len(), 1);
176        assert!(!parsed[0].reasoning_filtered);
177        assert_eq!(parsed[0].reasoning_artifacts_filtered, 0);
178        assert_eq!(parsed[0].timestamp_ms, 0);
179    }
180}