storage/checkpoint/
provenance.rs1use std::path::Path;
24
25use kernel::event::record::CommitAttribution;
26use kernel::{Address, B3Hash, Effort, RunId};
27
28use crate::error::StorageError;
29use crate::jsonl::{first_line, ledger_segments_at};
30use crate::real_fs::RealFs;
31
32#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
38pub struct ModelChoice {
39 pub id: String,
42 pub effort: Option<Effort>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Provenance {
53 run: RunId,
54 actor: Address,
55 model: String,
56 effort: Option<Effort>,
57 city: B3Hash,
58 predecessor: Option<RunId>,
59}
60
61impl Provenance {
62 pub fn new(run: RunId, actor: Address, city: B3Hash, chosen: ModelChoice) -> Provenance {
64 Provenance {
65 run,
66 actor,
67 model: chosen.id,
68 effort: chosen.effort,
69 city,
70 predecessor: None,
71 }
72 }
73
74 #[must_use]
77 pub fn succeeding(mut self, predecessor: RunId) -> Provenance {
78 self.predecessor = Some(predecessor);
79 self
80 }
81
82 pub fn predecessor(&self) -> Option<RunId> {
84 self.predecessor
85 }
86
87 pub fn city_of(ledger_dir: &Path) -> Result<B3Hash, StorageError> {
99 let vfs = RealFs::new();
100 for segment in ledger_segments_at(ledger_dir)? {
101 let first = first_line(&vfs, &segment).map_err(|source| StorageError::Io {
102 op: "read the genesis line",
103 path: segment.clone(),
104 source,
105 })?;
106 match first {
107 Some(line) if !line.is_empty() => return Ok(kernel::ledger::chain_hash(&line)),
108 _ => continue,
109 }
110 }
111 Err(StorageError::Checkpoint {
112 op: "read the city's genesis line",
113 detail: format!("{} holds no ledger line", ledger_dir.display()),
114 })
115 }
116
117 pub fn actor(&self) -> &Address {
119 &self.actor
120 }
121
122 pub fn run(&self) -> RunId {
125 self.run
126 }
127
128 pub(crate) fn email(&self) -> String {
132 let city = self.city.to_string();
133 let short = city.get(..CITY_PREFIX_HEX).unwrap_or(city.as_str());
134 format!("{}@{short}.sprawling", self.actor.as_str())
135 }
136
137 pub fn attribution(&self) -> CommitAttribution {
142 CommitAttribution {
143 model: self.model.clone(),
144 effort: Some(recorded_effort(self.effort)),
145 predecessor: self.predecessor,
146 }
147 }
148
149 pub fn trailers(&self) -> String {
153 let mut out = format!(
154 "Sprawling-Run: {}\nSprawling-Actor: {}\nSprawling-Model: {}\nSprawling-Effort: {}\nSprawling-City: {}\n",
155 self.run,
156 self.actor.as_str(),
157 self.model,
158 recorded_effort(self.effort).as_str(),
159 self.city,
160 );
161 if let Some(predecessor) = self.predecessor {
162 out.push_str(&format!("Sprawling-Predecessor: {predecessor}\n"));
163 }
164 out
165 }
166}
167
168const CITY_PREFIX_HEX: usize = 12;
172
173#[must_use]
182pub fn recorded_effort(effort: Option<Effort>) -> Effort {
183 effort.unwrap_or(Effort::None)
184}
185
186#[cfg(test)]
187#[allow(
188 clippy::unwrap_used,
189 clippy::expect_used,
190 clippy::panic,
191 clippy::indexing_slicing,
192 reason = "test code"
193)]
194mod tests {
195 use super::*;
196
197 pub(crate) fn sample() -> Provenance {
198 Provenance::new(
199 RunId::CITY,
200 Address::parse("lab/parser").unwrap(),
201 B3Hash::digest(b"a city"),
202 ModelChoice {
203 id: "claude-sonnet-4-6".to_owned(),
204 effort: Some(Effort::High),
205 },
206 )
207 }
208
209 #[test]
210 fn the_five_trailers_come_in_the_order_a_reader_was_promised() {
211 let rendered = sample().trailers();
212 let lines: Vec<String> = rendered.lines().map(str::to_owned).collect();
213 let city = B3Hash::digest(b"a city");
214 assert_eq!(
215 lines,
216 vec![
217 format!("Sprawling-Run: {}", RunId::CITY),
218 "Sprawling-Actor: lab/parser".to_owned(),
219 "Sprawling-Model: claude-sonnet-4-6".to_owned(),
220 "Sprawling-Effort: high".to_owned(),
221 format!("Sprawling-City: {city}"),
222 ]
223 );
224 }
225
226 #[test]
227 fn a_successor_signs_a_sixth_trailer_and_a_first_run_signs_five() {
228 assert_eq!(sample().trailers().lines().count(), 5);
229 let predecessor = RunId::from_bytes([3u8; 16]);
230 let successor = sample().succeeding(predecessor);
231 let rendered = successor.trailers();
232 assert_eq!(rendered.lines().count(), 6);
233 assert!(
234 rendered.ends_with(&format!("Sprawling-Predecessor: {predecessor}\n")),
235 "{rendered}"
236 );
237 assert_eq!(successor.predecessor(), Some(predecessor));
238 assert_eq!(sample().predecessor(), None);
239 }
240
241 #[test]
242 fn an_unknown_model_and_an_unasked_effort_are_written_as_what_they_are() {
243 let bare = Provenance::new(
244 RunId::CITY,
245 Address::parse("lab/parser").unwrap(),
246 B3Hash::digest(b"a city"),
247 ModelChoice {
248 id: String::new(),
249 effort: None,
250 },
251 );
252 let rendered = bare.trailers();
253 assert!(rendered.contains("Sprawling-Model: \n"), "{rendered}");
254 assert!(rendered.contains("Sprawling-Effort: none\n"), "{rendered}");
255 }
256
257 #[test]
258 fn an_effort_nobody_asked_for_is_recorded_as_the_level_none() {
259 assert_eq!(recorded_effort(None), Effort::None);
262 assert_eq!(recorded_effort(Some(Effort::High)), Effort::High);
263 assert_eq!(recorded_effort(None).as_str(), "none");
264 }
265
266 #[test]
267 fn the_author_mailbox_names_the_city_rather_than_a_host() {
268 let city = B3Hash::digest(b"a city").to_string();
269 assert_eq!(
270 sample().email(),
271 format!("lab/parser@{}.sprawling", city.get(..12).unwrap())
272 );
273 }
274
275 #[test]
276 fn a_directory_with_no_ledger_line_has_no_identity_to_sign_with() {
277 let tmp = tempfile::tempdir().unwrap();
278 let err = Provenance::city_of(tmp.path()).unwrap_err();
279 assert!(err.to_string().contains("holds no ledger line"));
280 }
281}