use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::conscience::Challenge;
pub const FILE: &str = "absorbed.jsonl";
pub const ABSORB: &str = "absorb";
pub const RELEASE: &str = "release";
pub const NOTE_CHARS: usize = 1000;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Record {
pub id: String,
pub kind: String,
pub ms: i64,
#[serde(default)]
pub by: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
}
impl Record {
pub fn well_formed(&self) -> bool {
!self.id.trim().is_empty() && (self.kind == ABSORB || self.kind == RELEASE) && self.note.as_ref().is_none_or(|n| n.chars().count() <= NOTE_CHARS)
}
pub fn absorbed(&self) -> bool {
self.kind == ABSORB
}
}
pub fn path(store: &Path) -> PathBuf {
store.join(FILE)
}
pub fn beside(lessons: &Path) -> PathBuf {
lessons.with_file_name(FILE)
}
pub fn written(line: &str, now: i64) -> Option<Record> {
let r = serde_json::from_str::<Record>(line).ok().filter(|r| r.well_formed() && r.ms <= now.saturating_add(crate::msg::SKEW_MS))?;
let Ok(serde_json::Value::Object(o)) = serde_json::from_str::<serde_json::Value>(line) else { return None };
let known = ["id", "kind", "ms", "by", "note", "version"];
(o.contains_key("by") && o.keys().all(|k| known.contains(&k.as_str()))).then_some(r)
}
pub fn latest(file: &Path, before_ms: Option<i64>) -> HashMap<String, Record> {
let text = std::fs::read_to_string(file).unwrap_or_default();
let ahead = crate::learn::now_ms().saturating_add(crate::msg::SKEW_MS);
let mut by: HashMap<String, (i64, u8, &str, Record)> = HashMap::new();
for line in text.lines() {
let Ok(r) = serde_json::from_str::<Record>(line) else { continue };
if !r.well_formed() || r.ms > ahead || before_ms.is_some_and(|b| r.ms >= b) {
continue;
}
let key = (r.ms, u8::from(r.kind == RELEASE), line);
if by.get(&r.id).is_none_or(|(ms, k, l, _)| (*ms, *k, *l) < key) {
by.insert(r.id.clone(), (key.0, key.1, key.2, r));
}
}
by.into_iter().map(|(id, (.., r))| (id, r)).collect()
}
pub fn state(file: &Path, before_ms: Option<i64>) -> HashMap<String, Record> {
let mut all = latest(file, before_ms);
all.retain(|_, r| r.absorbed());
all
}
pub fn withhold(lessons: &mut Vec<Challenge>, absorbed: &HashMap<String, Record>) -> Vec<Challenge> {
let held = |c: &Challenge| !c.standing() && absorbed.contains_key(&c.id);
if !lessons.iter().any(held) {
return Vec::new();
}
let hidden = crate::trust::successors(lessons);
let ends: std::collections::HashSet<String> = lessons.iter().filter(|c| held(c) && !hidden.contains_key(&c.id)).map(|c| c.id.clone()).collect();
let gone = |c: &Challenge| held(c) && hidden.get(&c.id).is_none_or(|end| ends.contains(end));
let mut dropped: Vec<Challenge> = Vec::new();
let mut keep = Vec::with_capacity(lessons.len());
for c in lessons.drain(..) {
if gone(&c) { dropped.push(c) } else { keep.push(c) }
}
*lessons = keep;
loop {
let still = crate::trust::successors(lessons);
let (back, stay): (Vec<Challenge>, Vec<Challenge>) = lessons.drain(..).partition(|c| hidden.contains_key(&c.id) && !still.contains_key(&c.id));
*lessons = stay;
if back.is_empty() {
return dropped;
}
dropped.extend(back);
}
}
pub fn withhold_beside(lessons_path: &Path, lessons: &mut Vec<Challenge>, before_ms: Option<i64>) -> Vec<Challenge> {
withhold(lessons, &state(&beside(lessons_path), before_ms))
}
pub fn stamp(latest: &HashMap<String, Record>, ids: &[&str], now: i64) -> i64 {
ids.iter().filter_map(|i| latest.get(i.trim())).map(|r| r.ms.saturating_add(1)).fold(now, i64::max)
}
pub fn note(text: Option<&str>) -> Result<Option<String>, String> {
let Some(t) = text.map(str::trim).filter(|t| !t.is_empty()) else { return Ok(None) };
let redacted = crate::notes::redact(t);
let n = t.chars().count().max(redacted.chars().count());
if n > NOTE_CHARS {
return Err(format!("the note has {n} characters, at most {NOTE_CHARS}: name where the lesson is held, do not copy it"));
}
Ok(Some(redacted))
}
pub fn write(
store: &Path,
ids: &[&str],
kind: &str,
note: Option<&str>,
by: &str,
now: i64,
version: &dyn Fn(&str) -> Option<u32>,
) -> std::io::Result<Vec<Record>> {
let mut out: Vec<Record> = Vec::new();
for id in ids.iter().map(|i| i.trim()).filter(|i| !i.is_empty()) {
if out.iter().any(|r| r.id == id) {
continue;
}
let absorb = kind == ABSORB;
let r = Record {
id: id.into(),
kind: kind.into(),
ms: now,
by: by.into(),
note: note.filter(|_| absorb).map(str::to_string),
version: if absorb { version(id) } else { None },
};
crate::learn::append_jsonl(&path(store), &r)?;
out.push(r);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conscience;
fn temp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("borg-absorb-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn line(id: &str, kind: &str, ms: i64, note: Option<&str>) -> String {
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()
}
#[test]
fn the_state_is_the_last_record_by_time_then_kind_then_text_in_any_line_order() {
let lines = [
line("A", ABSORB, 10, Some("skills/review@1")),
line("A", RELEASE, 20, None),
line("B", ABSORB, 10, None),
line("C", ABSORB, 30, None),
line("C", RELEASE, 30, None),
line("D", ABSORB, 40, Some("b")),
line("D", ABSORB, 40, Some("a")),
];
let mut orders = vec![lines.to_vec()];
let mut rev = lines.to_vec();
rev.reverse();
orders.push(rev);
let dir = temp("order");
for order in orders {
std::fs::write(path(&dir), order.join("\n") + "\n").unwrap();
let s = state(&path(&dir), None);
let mut ids: Vec<&str> = s.keys().map(String::as_str).collect();
ids.sort_unstable();
assert_eq!(ids, ["B", "D"]);
assert_eq!(s["D"].note.as_deref(), Some("b"));
}
assert!(state(&path(&dir), Some(15)).contains_key("A"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_missing_file_or_malformed_lines_are_no_absorb() {
let dir = temp("malformed");
assert!(state(&path(&dir), None).is_empty());
let long = "x".repeat(NOTE_CHARS + 1);
let text = [
"not json".to_string(),
r#"{"id": "", "kind": "absorb", "ms": 1}"#.into(),
r#"{"id": "E", "kind": "forget", "ms": 1}"#.into(),
r#"{"id": "F", "kind": "absorb"}"#.into(),
line("G", ABSORB, 1, Some(&long)),
line("H", ABSORB, 1, None),
];
std::fs::write(path(&dir), text.join("\n")).unwrap();
assert_eq!(state(&path(&dir), None).keys().collect::<Vec<_>>(), ["H"]);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_note_is_redacted_and_bounded() {
assert_eq!(note(None), Ok(None));
assert_eq!(note(Some(" ")), Ok(None));
assert_eq!(note(Some(" skills/review@2026-10-30 ")), Ok(Some("skills/review@2026-10-30".into())));
let secret = note(Some("held in skills/review, token ghp_0123456789abcdefghijABCDEFGHIJ012345")).unwrap().unwrap();
assert!(!secret.contains("ghp_0123456789"), "{secret}");
assert!(note(Some(&"x".repeat(NOTE_CHARS))).is_ok());
assert!(note(Some(&"x".repeat(NOTE_CHARS + 1))).unwrap_err().contains("at most 1000"));
}
#[test]
fn write_appends_one_line_per_id_and_a_release_carries_no_note_or_version() {
let dir = temp("write");
let version = |id: &str| (id == "A").then_some(3);
let w = write(&dir, &["A", "Z", "A", " "], ABSORB, Some("skills/review@1"), "a@x", 5, &version).unwrap();
assert_eq!(w.iter().map(|r| (r.id.as_str(), r.version)).collect::<Vec<_>>(), [("A", Some(3)), ("Z", None)]);
let r = write(&dir, &["A"], RELEASE, Some("ignored"), "b@x", 6, &version).unwrap();
assert_eq!((r[0].note.clone(), r[0].version), (None, None));
let text = std::fs::read_to_string(path(&dir)).unwrap();
assert_eq!(text.lines().count(), 3);
assert_eq!(text.lines().next().unwrap(), r#"{"id":"A","kind":"absorb","ms":5,"by":"a@x","note":"skills/review@1","version":3}"#);
assert_eq!(text.lines().nth(2).unwrap(), r#"{"id":"A","kind":"release","ms":6,"by":"b@x"}"#);
let s = state(&path(&dir), None);
assert!(!s.contains_key("A") && s.contains_key("Z"), "released; Z applies when it arrives");
let _ = std::fs::remove_dir_all(dir);
}
fn lesson(id: &str, ms: i64, supersedes: &[&str]) -> Challenge {
Challenge {
id: id.into(),
origin_ms: ms,
question: format!("q {id}"),
supersedes: supersedes.iter().map(|s| s.to_string()).collect(),
..Default::default()
}
}
fn absorbed(ids: &[&str]) -> HashMap<String, Record> {
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()
}
fn ids(cs: &[Challenge]) -> Vec<&str> {
cs.iter().map(|c| c.id.as_str()).collect()
}
#[test]
fn withhold_drops_the_absorbed_and_what_they_hid_never_a_constraint() {
let mut new = lesson("New", 2, &["Old"]);
new.source = "queen".into();
let constraint = Challenge { id: "cX".into(), source: crate::conscience::CONSTRAINT.into(), ..lesson("cX", 3, &[]) };
let mut all = vec![lesson("Old", 1, &[]), new, lesson("Free", 4, &[]), constraint];
withhold(&mut all, &absorbed(&["New", "cX"]));
assert_eq!(ids(&all), ["Free", "cX"]);
let mut all = vec![lesson("Old", 1, &[]), lesson("Free", 4, &[])];
withhold(&mut all, &absorbed(&["Gone"]));
assert_eq!(ids(&all), ["Old", "Free"]);
let mut mid = lesson("Mid", 2, &["Old"]);
mid.source = "queen".into();
let mut top = lesson("Top", 3, &["Mid"]);
top.source = "queen".into();
let mut all = vec![lesson("Old", 1, &[]), mid.clone(), top.clone()];
let dropped = withhold(&mut all, &absorbed(&["Mid"]));
assert_eq!(ids(&all), ["Old", "Mid", "Top"], "Mid stays, hidden by Top, as Old does through it");
assert!(dropped.is_empty());
assert_eq!(crate::trust::successors(&all).len(), 2, "both still hidden: neither is scored or delivered");
let mut all = vec![lesson("Old", 1, &[]), mid, top];
let dropped = withhold(&mut all, &absorbed(&["Mid", "Top"]));
assert!(all.is_empty() && dropped.len() == 3, "{:?}", ids(&all));
}
#[test]
fn what_an_absorbed_end_hid_never_comes_back_through_a_lesson_that_goes() {
let mut old = lesson("Old", 2, &["Older"]);
old.source = "queen".into();
let mut new = lesson("New", 3, &["Old"]);
new.source = "queen".into();
let mut all = vec![lesson("Older", 1, &[]), old, new, lesson("Free", 4, &[])];
let dropped = withhold(&mut all, &absorbed(&["New"]));
assert_eq!(ids(&all), ["Free"]);
assert_eq!(dropped.len(), 3);
}
#[test]
fn absorbing_a_hidden_lesson_keeps_its_live_successors_rank() {
let mut old = Challenge { question: "Is the socket buffer drained before the reconnect?".into(), ..lesson("Old", 1, &[]) };
old.consequence = "a reconnect replays stale frames".into();
let mut new = Challenge { question: "Is the retry budget shared across tenants?".into(), ..lesson("New", 2, &["Old"]) };
new.source = "queen".into();
let context = "drain the socket buffer before the reconnect, or stale frames replay";
let score = |all: &[Challenge]| conscience::rank_scoped(all, context, context).iter().find(|(_, c)| c.id == "New").map(|(s, _)| *s);
let mut all = vec![old, new, lesson("Free", 3, &[])];
let before = score(&all);
assert!(before.is_some_and(|s| s > 0.0), "New ranks at Old's match");
withhold(&mut all, &absorbed(&["Old"]));
assert_eq!(score(&all), before);
assert!(conscience::rank_scoped(&all, context, context).iter().all(|(_, c)| c.id != "Old"), "Old itself is never ranked");
}
#[test]
fn written_is_what_the_read_folds_and_bounds_the_clock() {
let now = 1_000_000;
let ok = line("L1", ABSORB, now, Some("skills/review@1"));
assert!(written(&ok, now).is_some());
assert!(written(&line("L1", RELEASE, now + crate::msg::SKEW_MS, None), now).is_some(), "within the skew");
for (what, bad) in [
("ahead of the clock", line("L1", ABSORB, now + crate::msg::SKEW_MS + 1, None)),
("a blank id", line(" ", ABSORB, now, None)),
("a version past u32", r#"{"id":"L1","kind":"absorb","ms":1,"by":"a@x","version":5000000000}"#.into()),
("a duplicate key", r#"{"id":"A","id":"B","kind":"absorb","ms":1,"by":"a@x"}"#.into()),
("no by", r#"{"id":"L1","kind":"release","ms":1}"#.into()),
("an unknown field", r#"{"id":"L1","kind":"absorb","ms":1,"by":"a@x","reach":"team"}"#.into()),
("an oversize note", line("L1", ABSORB, 1, Some(&"n".repeat(NOTE_CHARS + 1)))),
] {
assert!(written(&bad, now).is_none(), "{what} is refused");
}
}
#[test]
fn a_future_absorb_never_outlives_a_release() {
let dir = temp("future");
let far = crate::learn::now_ms() + 365 * 24 * 3_600_000;
std::fs::write(path(&dir), line("L1", ABSORB, far, None) + "\n").unwrap();
assert!(state(&path(&dir), None).is_empty(), "stamped a year ahead: ignored");
let near = crate::learn::now_ms() + crate::msg::SKEW_MS / 2;
std::fs::write(path(&dir), line("L1", ABSORB, near, None) + "\n").unwrap();
let seen = latest(&path(&dir), None);
let now = crate::learn::now_ms();
let at = stamp(&seen, &["L1"], now);
assert!(at > near);
write(&dir, &["L1"], RELEASE, None, "b@x", at, &|_| None).unwrap();
assert!(state(&path(&dir), None).is_empty(), "the release wins over the absorb it saw");
assert_eq!(stamp(&seen, &["Other"], now), now);
let _ = std::fs::remove_dir_all(dir);
}
}