1use std::collections::HashMap;
39use std::path::{Path, PathBuf};
40
41use serde::{Deserialize, Serialize};
42
43use crate::conscience::Challenge;
44
45pub const FILE: &str = "absorbed.jsonl";
47
48pub const ABSORB: &str = "absorb";
50pub const RELEASE: &str = "release";
51
52pub const NOTE_CHARS: usize = 1000;
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Record {
59 pub id: String,
60 pub kind: String,
61 pub ms: i64,
62 #[serde(default)]
63 pub by: String,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub note: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub version: Option<u32>,
68}
69
70impl Record {
71 pub fn well_formed(&self) -> bool {
74 !self.id.trim().is_empty() && (self.kind == ABSORB || self.kind == RELEASE) && self.note.as_ref().is_none_or(|n| n.chars().count() <= NOTE_CHARS)
75 }
76
77 pub fn absorbed(&self) -> bool {
78 self.kind == ABSORB
79 }
80}
81
82pub fn path(store: &Path) -> PathBuf {
84 store.join(FILE)
85}
86
87pub fn beside(lessons: &Path) -> PathBuf {
89 lessons.with_file_name(FILE)
90}
91
92pub fn written(line: &str, now: i64) -> Option<Record> {
97 let r = serde_json::from_str::<Record>(line).ok().filter(|r| r.well_formed() && r.ms <= now.saturating_add(crate::msg::SKEW_MS))?;
98 let Ok(serde_json::Value::Object(o)) = serde_json::from_str::<serde_json::Value>(line) else { return None };
99 let known = ["id", "kind", "ms", "by", "note", "version"];
100 (o.contains_key("by") && o.keys().all(|k| known.contains(&k.as_str()))).then_some(r)
101}
102
103pub fn latest(file: &Path, before_ms: Option<i64>) -> HashMap<String, Record> {
108 let text = std::fs::read_to_string(file).unwrap_or_default();
109 let ahead = crate::learn::now_ms().saturating_add(crate::msg::SKEW_MS);
110 let mut by: HashMap<String, (i64, u8, &str, Record)> = HashMap::new();
111 for line in text.lines() {
112 let Ok(r) = serde_json::from_str::<Record>(line) else { continue };
113 if !r.well_formed() || r.ms > ahead || before_ms.is_some_and(|b| r.ms >= b) {
114 continue;
115 }
116 let key = (r.ms, u8::from(r.kind == RELEASE), line);
117 if by.get(&r.id).is_none_or(|(ms, k, l, _)| (*ms, *k, *l) < key) {
118 by.insert(r.id.clone(), (key.0, key.1, key.2, r));
119 }
120 }
121 by.into_iter().map(|(id, (.., r))| (id, r)).collect()
122}
123
124pub fn state(file: &Path, before_ms: Option<i64>) -> HashMap<String, Record> {
126 let mut all = latest(file, before_ms);
127 all.retain(|_, r| r.absorbed());
128 all
129}
130
131pub fn withhold(lessons: &mut Vec<Challenge>, absorbed: &HashMap<String, Record>) -> Vec<Challenge> {
139 let held = |c: &Challenge| !c.standing() && absorbed.contains_key(&c.id);
140 if !lessons.iter().any(held) {
141 return Vec::new();
142 }
143 let hidden = crate::trust::successors(lessons);
144 let ends: std::collections::HashSet<String> = lessons.iter().filter(|c| held(c) && !hidden.contains_key(&c.id)).map(|c| c.id.clone()).collect();
146 let gone = |c: &Challenge| held(c) && hidden.get(&c.id).is_none_or(|end| ends.contains(end));
147 let mut dropped: Vec<Challenge> = Vec::new();
148 let mut keep = Vec::with_capacity(lessons.len());
149 for c in lessons.drain(..) {
150 if gone(&c) { dropped.push(c) } else { keep.push(c) }
151 }
152 *lessons = keep;
153 loop {
156 let still = crate::trust::successors(lessons);
157 let (back, stay): (Vec<Challenge>, Vec<Challenge>) = lessons.drain(..).partition(|c| hidden.contains_key(&c.id) && !still.contains_key(&c.id));
158 *lessons = stay;
159 if back.is_empty() {
160 return dropped;
161 }
162 dropped.extend(back);
163 }
164}
165
166pub fn withhold_beside(lessons_path: &Path, lessons: &mut Vec<Challenge>, before_ms: Option<i64>) -> Vec<Challenge> {
168 withhold(lessons, &state(&beside(lessons_path), before_ms))
169}
170
171pub fn stamp(latest: &HashMap<String, Record>, ids: &[&str], now: i64) -> i64 {
175 ids.iter().filter_map(|i| latest.get(i.trim())).map(|r| r.ms.saturating_add(1)).fold(now, i64::max)
176}
177
178pub fn note(text: Option<&str>) -> Result<Option<String>, String> {
184 let Some(t) = text.map(str::trim).filter(|t| !t.is_empty()) else { return Ok(None) };
185 let redacted = crate::notes::redact(t);
186 let n = t.chars().count().max(redacted.chars().count());
187 if n > NOTE_CHARS {
188 return Err(format!("the note has {n} characters, at most {NOTE_CHARS}: name where the lesson is held, do not copy it"));
189 }
190 Ok(Some(redacted))
191}
192
193pub fn write(
200 store: &Path,
201 ids: &[&str],
202 kind: &str,
203 note: Option<&str>,
204 by: &str,
205 now: i64,
206 version: &dyn Fn(&str) -> Option<u32>,
207) -> std::io::Result<Vec<Record>> {
208 let mut out: Vec<Record> = Vec::new();
209 for id in ids.iter().map(|i| i.trim()).filter(|i| !i.is_empty()) {
210 if out.iter().any(|r| r.id == id) {
211 continue;
212 }
213 let absorb = kind == ABSORB;
214 let r = Record {
215 id: id.into(),
216 kind: kind.into(),
217 ms: now,
218 by: by.into(),
219 note: note.filter(|_| absorb).map(str::to_string),
220 version: if absorb { version(id) } else { None },
221 };
222 crate::learn::append_jsonl(&path(store), &r)?;
223 out.push(r);
224 }
225 Ok(out)
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use crate::conscience;
232
233 fn temp(name: &str) -> PathBuf {
234 let d = std::env::temp_dir().join(format!("borg-absorb-{name}-{}", std::process::id()));
235 let _ = std::fs::remove_dir_all(&d);
236 std::fs::create_dir_all(&d).unwrap();
237 d
238 }
239
240 fn line(id: &str, kind: &str, ms: i64, note: Option<&str>) -> String {
241 serde_json::to_string(&Record { id: id.into(), kind: kind.into(), ms, by: "a@x".into(), note: note.map(str::to_string), version: None }).unwrap()
242 }
243
244 #[test]
245 fn the_state_is_the_last_record_by_time_then_kind_then_text_in_any_line_order() {
246 let lines = [
247 line("A", ABSORB, 10, Some("skills/review@1")),
248 line("A", RELEASE, 20, None),
249 line("B", ABSORB, 10, None),
250 line("C", ABSORB, 30, None),
252 line("C", RELEASE, 30, None),
253 line("D", ABSORB, 40, Some("b")),
255 line("D", ABSORB, 40, Some("a")),
256 ];
257 let mut orders = vec![lines.to_vec()];
258 let mut rev = lines.to_vec();
259 rev.reverse();
260 orders.push(rev);
261 let dir = temp("order");
262 for order in orders {
263 std::fs::write(path(&dir), order.join("\n") + "\n").unwrap();
264 let s = state(&path(&dir), None);
265 let mut ids: Vec<&str> = s.keys().map(String::as_str).collect();
266 ids.sort_unstable();
267 assert_eq!(ids, ["B", "D"]);
268 assert_eq!(s["D"].note.as_deref(), Some("b"));
269 }
270 assert!(state(&path(&dir), Some(15)).contains_key("A"));
272 let _ = std::fs::remove_dir_all(dir);
273 }
274
275 #[test]
276 fn a_missing_file_or_malformed_lines_are_no_absorb() {
277 let dir = temp("malformed");
278 assert!(state(&path(&dir), None).is_empty());
279 let long = "x".repeat(NOTE_CHARS + 1);
280 let text = [
281 "not json".to_string(),
282 r#"{"id": "", "kind": "absorb", "ms": 1}"#.into(),
283 r#"{"id": "E", "kind": "forget", "ms": 1}"#.into(),
284 r#"{"id": "F", "kind": "absorb"}"#.into(),
285 line("G", ABSORB, 1, Some(&long)),
286 line("H", ABSORB, 1, None),
287 ];
288 std::fs::write(path(&dir), text.join("\n")).unwrap();
289 assert_eq!(state(&path(&dir), None).keys().collect::<Vec<_>>(), ["H"]);
290 let _ = std::fs::remove_dir_all(dir);
291 }
292
293 #[test]
294 fn a_note_is_redacted_and_bounded() {
295 assert_eq!(note(None), Ok(None));
296 assert_eq!(note(Some(" ")), Ok(None));
297 assert_eq!(note(Some(" skills/review@2026-10-30 ")), Ok(Some("skills/review@2026-10-30".into())));
298 let secret = note(Some("held in skills/review, token ghp_0123456789abcdefghijABCDEFGHIJ012345")).unwrap().unwrap();
299 assert!(!secret.contains("ghp_0123456789"), "{secret}");
300 assert!(note(Some(&"x".repeat(NOTE_CHARS))).is_ok());
301 assert!(note(Some(&"x".repeat(NOTE_CHARS + 1))).unwrap_err().contains("at most 1000"));
302 }
303
304 #[test]
305 fn write_appends_one_line_per_id_and_a_release_carries_no_note_or_version() {
306 let dir = temp("write");
307 let version = |id: &str| (id == "A").then_some(3);
308 let w = write(&dir, &["A", "Z", "A", " "], ABSORB, Some("skills/review@1"), "a@x", 5, &version).unwrap();
309 assert_eq!(w.iter().map(|r| (r.id.as_str(), r.version)).collect::<Vec<_>>(), [("A", Some(3)), ("Z", None)]);
310 let r = write(&dir, &["A"], RELEASE, Some("ignored"), "b@x", 6, &version).unwrap();
311 assert_eq!((r[0].note.clone(), r[0].version), (None, None));
312 let text = std::fs::read_to_string(path(&dir)).unwrap();
313 assert_eq!(text.lines().count(), 3);
314 assert_eq!(text.lines().next().unwrap(), r#"{"id":"A","kind":"absorb","ms":5,"by":"a@x","note":"skills/review@1","version":3}"#);
315 assert_eq!(text.lines().nth(2).unwrap(), r#"{"id":"A","kind":"release","ms":6,"by":"b@x"}"#);
316 let s = state(&path(&dir), None);
317 assert!(!s.contains_key("A") && s.contains_key("Z"), "released; Z applies when it arrives");
318 let _ = std::fs::remove_dir_all(dir);
319 }
320
321 fn lesson(id: &str, ms: i64, supersedes: &[&str]) -> Challenge {
322 Challenge {
323 id: id.into(),
324 origin_ms: ms,
325 question: format!("q {id}"),
326 supersedes: supersedes.iter().map(|s| s.to_string()).collect(),
327 ..Default::default()
328 }
329 }
330
331 fn absorbed(ids: &[&str]) -> HashMap<String, Record> {
332 ids.iter().map(|i| (i.to_string(), Record { id: i.to_string(), kind: ABSORB.into(), ms: 1, by: String::new(), note: None, version: None })).collect()
333 }
334
335 fn ids(cs: &[Challenge]) -> Vec<&str> {
336 cs.iter().map(|c| c.id.as_str()).collect()
337 }
338
339 #[test]
340 fn withhold_drops_the_absorbed_and_what_they_hid_never_a_constraint() {
341 let mut new = lesson("New", 2, &["Old"]);
343 new.source = "queen".into();
344 let constraint = Challenge { id: "cX".into(), source: crate::conscience::CONSTRAINT.into(), ..lesson("cX", 3, &[]) };
345 let mut all = vec![lesson("Old", 1, &[]), new, lesson("Free", 4, &[]), constraint];
346 withhold(&mut all, &absorbed(&["New", "cX"]));
347 assert_eq!(ids(&all), ["Free", "cX"]);
348 let mut all = vec![lesson("Old", 1, &[]), lesson("Free", 4, &[])];
350 withhold(&mut all, &absorbed(&["Gone"]));
351 assert_eq!(ids(&all), ["Old", "Free"]);
352 let mut mid = lesson("Mid", 2, &["Old"]);
354 mid.source = "queen".into();
355 let mut top = lesson("Top", 3, &["Mid"]);
356 top.source = "queen".into();
357 let mut all = vec![lesson("Old", 1, &[]), mid.clone(), top.clone()];
358 let dropped = withhold(&mut all, &absorbed(&["Mid"]));
359 assert_eq!(ids(&all), ["Old", "Mid", "Top"], "Mid stays, hidden by Top, as Old does through it");
360 assert!(dropped.is_empty());
361 assert_eq!(crate::trust::successors(&all).len(), 2, "both still hidden: neither is scored or delivered");
362 let mut all = vec![lesson("Old", 1, &[]), mid, top];
364 let dropped = withhold(&mut all, &absorbed(&["Mid", "Top"]));
365 assert!(all.is_empty() && dropped.len() == 3, "{:?}", ids(&all));
366 }
367
368 #[test]
369 fn what_an_absorbed_end_hid_never_comes_back_through_a_lesson_that_goes() {
370 let mut old = lesson("Old", 2, &["Older"]);
373 old.source = "queen".into();
374 let mut new = lesson("New", 3, &["Old"]);
375 new.source = "queen".into();
376 let mut all = vec![lesson("Older", 1, &[]), old, new, lesson("Free", 4, &[])];
377 let dropped = withhold(&mut all, &absorbed(&["New"]));
378 assert_eq!(ids(&all), ["Free"]);
379 assert_eq!(dropped.len(), 3);
380 }
381
382 #[test]
385 fn absorbing_a_hidden_lesson_keeps_its_live_successors_rank() {
386 let mut old = Challenge { question: "Is the socket buffer drained before the reconnect?".into(), ..lesson("Old", 1, &[]) };
387 old.consequence = "a reconnect replays stale frames".into();
388 let mut new = Challenge { question: "Is the retry budget shared across tenants?".into(), ..lesson("New", 2, &["Old"]) };
389 new.source = "queen".into();
390 let context = "drain the socket buffer before the reconnect, or stale frames replay";
391 let score = |all: &[Challenge]| conscience::rank_scoped(all, context, context).iter().find(|(_, c)| c.id == "New").map(|(s, _)| *s);
392 let mut all = vec![old, new, lesson("Free", 3, &[])];
393 let before = score(&all);
394 assert!(before.is_some_and(|s| s > 0.0), "New ranks at Old's match");
395 withhold(&mut all, &absorbed(&["Old"]));
396 assert_eq!(score(&all), before);
397 assert!(conscience::rank_scoped(&all, context, context).iter().all(|(_, c)| c.id != "Old"), "Old itself is never ranked");
398 }
399
400 #[test]
401 fn written_is_what_the_read_folds_and_bounds_the_clock() {
402 let now = 1_000_000;
403 let ok = line("L1", ABSORB, now, Some("skills/review@1"));
404 assert!(written(&ok, now).is_some());
405 assert!(written(&line("L1", RELEASE, now + crate::msg::SKEW_MS, None), now).is_some(), "within the skew");
406 for (what, bad) in [
407 ("ahead of the clock", line("L1", ABSORB, now + crate::msg::SKEW_MS + 1, None)),
408 ("a blank id", line(" ", ABSORB, now, None)),
409 ("a version past u32", r#"{"id":"L1","kind":"absorb","ms":1,"by":"a@x","version":5000000000}"#.into()),
410 ("a duplicate key", r#"{"id":"A","id":"B","kind":"absorb","ms":1,"by":"a@x"}"#.into()),
411 ("no by", r#"{"id":"L1","kind":"release","ms":1}"#.into()),
412 ("an unknown field", r#"{"id":"L1","kind":"absorb","ms":1,"by":"a@x","reach":"team"}"#.into()),
413 ("an oversize note", line("L1", ABSORB, 1, Some(&"n".repeat(NOTE_CHARS + 1)))),
414 ] {
415 assert!(written(&bad, now).is_none(), "{what} is refused");
416 }
417 }
418
419 #[test]
422 fn a_future_absorb_never_outlives_a_release() {
423 let dir = temp("future");
424 let far = crate::learn::now_ms() + 365 * 24 * 3_600_000;
425 std::fs::write(path(&dir), line("L1", ABSORB, far, None) + "\n").unwrap();
426 assert!(state(&path(&dir), None).is_empty(), "stamped a year ahead: ignored");
427 let near = crate::learn::now_ms() + crate::msg::SKEW_MS / 2;
428 std::fs::write(path(&dir), line("L1", ABSORB, near, None) + "\n").unwrap();
429 let seen = latest(&path(&dir), None);
430 let now = crate::learn::now_ms();
431 let at = stamp(&seen, &["L1"], now);
432 assert!(at > near);
433 write(&dir, &["L1"], RELEASE, None, "b@x", at, &|_| None).unwrap();
434 assert!(state(&path(&dir), None).is_empty(), "the release wins over the absorb it saw");
435 assert_eq!(stamp(&seen, &["Other"], now), now);
436 let _ = std::fs::remove_dir_all(dir);
437 }
438}