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 /// BP-13: why this change happened, when it was NOT user-initiated —
50 /// the provider failure that made `Agent::run_loop` walk to the next
51 /// entry of `Config::model_fallback`. `None` for a deliberate switch
52 /// (`/model`), which needs no reason beyond the user having asked.
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub reason: Option<String>,
55}
56
57impl ModelChangeRecord {
58 /// Build a record — the constructor `Agent::switch_model` calls.
59 pub fn new(
60 turn: usize,
61 from_model: impl Into<String>,
62 to_model: impl Into<String>,
63 reasoning_filtered: bool,
64 reasoning_artifacts_filtered: usize,
65 timestamp_ms: i64,
66 ) -> ModelChangeRecord {
67 ModelChangeRecord {
68 turn,
69 from_model: from_model.into(),
70 to_model: to_model.into(),
71 reasoning_filtered,
72 reasoning_artifacts_filtered,
73 timestamp_ms,
74 reason: None,
75 }
76 }
77
78 /// BP-13: attach the non-user cause of this change (a fallback hop's
79 /// provider failure). A record with no reason describes a switch the
80 /// user asked for.
81 pub fn with_reason(mut self, reason: Option<String>) -> ModelChangeRecord {
82 self.reason = reason;
83 self
84 }
85
86 /// BP-13: whether this change was performed BY the loop rather than
87 /// asked for by the user.
88 pub fn automatic(&self) -> bool {
89 self.reason.is_some()
90 }
91}
92
93/// Serialize `records` as JSONL (one [`ModelChangeRecord`] per line) — the
94/// same shape [`crate::usage_log::to_jsonl`] and every other append-log in
95/// this crate uses. Never fails on an empty slice (produces an empty
96/// string).
97pub fn to_jsonl(records: &[ModelChangeRecord]) -> crate::Result<String> {
98 let mut out = String::new();
99 for r in records {
100 out.push_str(&serde_json::to_string(r).map_err(crate::Error::Decode)?);
101 out.push('\n');
102 }
103 Ok(out)
104}
105
106/// Parse a JSONL model-change log back into records — the exact inverse of
107/// [`to_jsonl`]. Blank lines are skipped; a malformed line is a hard error
108/// (like [`crate::usage_log::from_jsonl`] — this is accounting/provenance
109/// data, corruption should be visible, not silently dropped).
110pub fn from_jsonl(text: &str) -> crate::Result<Vec<ModelChangeRecord>> {
111 let mut out = Vec::new();
112 for line in text.lines() {
113 let line = line.trim();
114 if line.is_empty() {
115 continue;
116 }
117 out.push(serde_json::from_str(line).map_err(crate::Error::Decode)?);
118 }
119 Ok(out)
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn new_carries_every_field() {
128 let r = ModelChangeRecord::new(
129 2,
130 "anthropic/claude-opus-4-8",
131 "anthropic/claude-haiku-4-5",
132 true,
133 3,
134 1_700_000_000_000,
135 );
136 assert_eq!(r.turn, 2);
137 assert_eq!(r.from_model, "anthropic/claude-opus-4-8");
138 assert_eq!(r.to_model, "anthropic/claude-haiku-4-5");
139 assert!(r.reasoning_filtered);
140 assert_eq!(r.reasoning_artifacts_filtered, 3);
141 assert_eq!(r.timestamp_ms, 1_700_000_000_000);
142 }
143
144 /// §1.13 "translatable, lossless — not a lossy channel": every field
145 /// round-trips through JSONL byte-for-byte, not just "close enough".
146 #[test]
147 fn jsonl_round_trip_is_lossless() {
148 let records = vec![
149 ModelChangeRecord::new(
150 0,
151 "vendor/model-a",
152 "vendor/model-b",
153 true,
154 5,
155 1_700_000_000_000,
156 ),
157 ModelChangeRecord::new(
158 3,
159 "vendor/model-b",
160 "vendor/model-c",
161 true,
162 0,
163 1_700_000_010_000,
164 ),
165 ];
166 let jsonl = to_jsonl(&records).unwrap();
167 let round_tripped = from_jsonl(&jsonl).unwrap();
168 assert_eq!(records, round_tripped);
169 }
170
171 #[test]
172 fn empty_records_round_trip_to_empty() {
173 assert_eq!(to_jsonl(&[]).unwrap(), "");
174 assert_eq!(from_jsonl("").unwrap(), Vec::<ModelChangeRecord>::new());
175 }
176
177 #[test]
178 fn from_jsonl_skips_blank_lines() {
179 assert_eq!(from_jsonl("\n\n").unwrap(), Vec::<ModelChangeRecord>::new());
180 }
181
182 #[test]
183 fn from_jsonl_rejects_malformed_lines_rather_than_silently_dropping_them() {
184 assert!(from_jsonl("{not json}").is_err());
185 }
186
187 /// Old records written before `reasoning_filtered`/
188 /// `reasoning_artifacts_filtered`/`timestamp_ms` existed (hypothetically
189 /// — this crate is pre-1.0, but the `#[serde(default)]` discipline
190 /// matches every other log in this crate, e.g.
191 /// `crate::store::SessionInfo::reduced`) still parse.
192 #[test]
193 fn tolerates_a_record_missing_the_optional_fields() {
194 let minimal = r#"{"turn":0,"from_model":"a","to_model":"b"}"#;
195 let parsed = from_jsonl(minimal).unwrap();
196 assert_eq!(parsed.len(), 1);
197 assert!(!parsed[0].reasoning_filtered);
198 assert_eq!(parsed[0].reasoning_artifacts_filtered, 0);
199 assert_eq!(parsed[0].timestamp_ms, 0);
200 }
201}