Skip to main content

packset_core/
record.rs

1//! One atom as it sits in the store: a JSON object. Fields this module does
2//! not model round-trip untouched.
3
4use std::collections::{BTreeMap, BTreeSet};
5
6use serde_json::{Map, Value};
7
8use crate::atom::{check_entity, EntityRefusal};
9use crate::clock;
10use crate::prose;
11
12/// Schema name for a pack atom.
13pub const SCHEMA: &str = crate::atom::SCHEMA;
14/// Characters allowed in `USER.md`.
15pub const USER_CAP: usize = 1375;
16/// Characters allowed in a workspace `MEMORY.md`.
17pub const MEMORY_CAP: usize = 2200;
18/// Characters allowed in one atom's text.
19pub const TEXT_SOFT_CAP: usize = 500;
20/// Jaccard at or above which two atoms link.
21pub const LINK_THRESHOLD: f64 = 0.3;
22/// The most peers one atom names; without a cap every shared entity is a clique.
23pub const LINK_MAX: usize = 8;
24/// Review interval when nothing has been graded yet.
25pub const DEFAULT_REVIEW_INTERVAL_S: i64 = 86_400;
26/// SM-2 style ease, kept for readers of the review block.
27pub const REVIEW_EASE: f64 = 2.5;
28/// Starting stability, in days.
29pub const DEFAULT_STABILITY: f64 = 1.0;
30/// Starting difficulty, on a one to ten scale.
31pub const DEFAULT_DIFFICULTY: f64 = 5.0;
32
33/// What an atom may claim to be.
34pub const KINDS: &[&str] = &[
35    "voice",
36    "habit",
37    "cache-pointer",
38    "preference",
39    "lesson",
40    "goal",
41    "conclusion",
42    "card_line",
43    "summary",
44    "correction",
45    "belief",
46    "trust",
47    "persona",
48    "prediction",
49    "rule",
50];
51
52/// Whether the claim was stated or inferred.
53pub const LEVELS: &[&str] = &["explicit", "derived"];
54
55/// Prefixes a deed accession can open with: `deed-<kind>-<slug>` or a `sha256:`.
56const DEED_PREFIXES: &[&str] = &["deed-", "sha256:"];
57
58/// Whether an entity has the shape of a deed accession. The store is not asked.
59#[must_use]
60pub fn is_accession(value: &str) -> bool {
61    let value = value.trim();
62    if value.contains(|c: char| c.is_whitespace() || c == ',') {
63        return false;
64    }
65    DEED_PREFIXES.iter().any(|prefix| {
66        value
67            .strip_prefix(*prefix)
68            .is_some_and(|rest| !rest.is_empty())
69    })
70}
71
72/// Quote as the error messages do: single quotes; double when the value has a
73/// single quote and no double; backslash escapes when it has both. Clients
74/// parse these, so the rule is part of the API.
75#[must_use]
76pub fn quoted(value: &str) -> String {
77    let has_single = value.contains('\'');
78    let has_double = value.contains('"');
79    let quote = if has_single && !has_double { '"' } else { '\'' };
80    let mut out = String::with_capacity(value.len() + 2);
81    out.push(quote);
82    for ch in value.chars() {
83        match ch {
84            '\\' => out.push_str("\\\\"),
85            '\n' => out.push_str("\\n"),
86            '\r' => out.push_str("\\r"),
87            '\t' => out.push_str("\\t"),
88            c if c == quote => {
89                out.push('\\');
90                out.push(c);
91            }
92            c => out.push(c),
93        }
94    }
95    out.push(quote);
96    out
97}
98
99/// Why a record cannot be stored.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct AtomError(pub String);
102
103impl std::fmt::Display for AtomError {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.write_str(&self.0)
106    }
107}
108
109impl std::error::Error for AtomError {}
110
111impl From<prose::ProseError> for AtomError {
112    fn from(err: prose::ProseError) -> Self {
113        Self(err.0)
114    }
115}
116
117/// Zero-width and bidirectional controls, which hide text from a reader.
118fn has_invisible(text: &str) -> bool {
119    text.chars().any(|c| {
120        matches!(c as u32,
121            0x200b..=0x200f | 0x202a..=0x202e | 0x2060..=0x206f | 0xfeff)
122    })
123}
124
125const SECRET_KEYS: &[&str] = &[
126    "api_key", "api-key", "apikey", "secret", "password", "token",
127];
128
129/// Whether the text carries something credential-shaped: a key assigned to a
130/// name, a bearer token, or an `sk-` prefix.
131#[must_use]
132pub fn looks_like_a_secret(text: &str) -> bool {
133    let lower = text.to_ascii_lowercase();
134    for key in SECRET_KEYS {
135        let mut from = 0usize;
136        while let Some(at) = lower[from..].find(key) {
137            let idx = from + at;
138            let before_is_word = idx
139                .checked_sub(1)
140                .and_then(|i| lower.as_bytes().get(i))
141                .is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'-');
142            let after = lower[idx + key.len()..].trim_start();
143            if !before_is_word && (after.starts_with('=') || after.starts_with(':')) {
144                return true;
145            }
146            from = idx + key.len();
147        }
148    }
149    if let Some(at) = lower.find("bearer ") {
150        let rest = lower[at + 7..].trim_start();
151        let run = rest
152            .chars()
153            .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
154            .count();
155        if run >= 8 {
156            return true;
157        }
158    }
159    let mut from = 0usize;
160    while let Some(at) = lower[from..].find("sk-") {
161        let idx = from + at;
162        let run = lower[idx + 3..]
163            .chars()
164            .take_while(char::is_ascii_alphanumeric)
165            .count();
166        if run >= 8 {
167            return true;
168        }
169        from = idx + 3;
170    }
171    false
172}
173
174/// Refuse text a reader cannot see or should never have been handed.
175///
176/// # Errors
177///
178/// Returns [`AtomError`] for invisible unicode or credential-shaped text.
179pub fn reject_unsafe(text: &str) -> Result<(), AtomError> {
180    if has_invisible(text) {
181        return Err(AtomError("invisible unicode is rejected".into()));
182    }
183    if looks_like_a_secret(text) {
184        return Err(AtomError("credential-shaped text is rejected".into()));
185    }
186    Ok(())
187}
188
189fn refusal_message(value: &str, why: EntityRefusal) -> String {
190    match why {
191        EntityRefusal::Empty => "an entity cannot be empty".to_string(),
192        EntityRefusal::BarePrefix => {
193            let prefix = crate::atom::ACCESSION_PREFIXES
194                .iter()
195                .find(|p| value.trim().starts_with(**p))
196                .copied()
197                .unwrap_or("");
198            format!(
199                "{} is a bare {} and names no deed",
200                quoted(value),
201                quoted(prefix)
202            )
203        }
204        EntityRefusal::Separator(bad) => {
205            format!(
206                "{} carries {}, which would split the entity into two",
207                quoted(value),
208                quoted(&bad.to_string())
209            )
210        }
211    }
212}
213
214/// Check a record and normalise the fields that have one legal form.
215///
216/// # Errors
217///
218/// Returns [`AtomError`] for an unknown kind or level, missing text or
219/// workspace, text past the soft cap, a bad set name, an entity that opens like
220/// an accession and is not one, or prose too complex for one claim.
221pub fn validate(atom: &mut Map<String, Value>) -> Result<(), AtomError> {
222    let kind = atom.get("kind").and_then(Value::as_str).unwrap_or("");
223    if !KINDS.contains(&kind) {
224        let shown = atom.get("kind").map_or("None".into(), value_repr);
225        return Err(AtomError(format!("unknown atom kind: {shown}")));
226    }
227    let trust = kind == "trust";
228    let persona = kind == "persona";
229    let prediction = kind == "prediction";
230    let rule = kind == "rule";
231    let level = atom
232        .get("level")
233        .and_then(Value::as_str)
234        .unwrap_or("explicit");
235    if !LEVELS.contains(&level) {
236        let shown = atom.get("level").map_or("None".into(), value_repr);
237        return Err(AtomError(format!("unknown atom level: {shown}")));
238    }
239    let text = match atom.get("text").and_then(Value::as_str) {
240        Some(t) if !t.trim().is_empty() => t.to_string(),
241        _ => return Err(AtomError("atom text is required".into())),
242    };
243    reject_unsafe(&text)?;
244    if text.chars().count() > TEXT_SOFT_CAP {
245        return Err(AtomError(format!(
246            "atom text exceeds soft cap {TEXT_SOFT_CAP}"
247        )));
248    }
249    if atom
250        .get("workspace")
251        .and_then(Value::as_str)
252        .unwrap_or("")
253        .is_empty()
254    {
255        return Err(AtomError("atom workspace is required".into()));
256    }
257
258    match atom.get("set").and_then(Value::as_str) {
259        Some(raw) if !raw.is_empty() => {
260            let named = crate::set_name::check(raw).map_err(AtomError)?;
261            atom.insert("set".into(), Value::String(named));
262        }
263        _ => {
264            atom.remove("set");
265        }
266    }
267
268    if let Some(raw) = atom.get("entities").cloned() {
269        if let Some(items) = raw.as_array() {
270            let mut checked = Vec::with_capacity(items.len());
271            for item in items {
272                let text = item
273                    .as_str()
274                    .map_or_else(|| value_text(item), str::to_string);
275                match check_entity(&text) {
276                    Ok(kept) => checked.push(Value::String(kept.to_string())),
277                    Err(why) => return Err(AtomError(refusal_message(&text, why))),
278                }
279            }
280            atom.insert("entities".into(), Value::Array(checked));
281        }
282    }
283
284    if trust {
285        check_trust(atom)?;
286    }
287    if persona {
288        check_persona(atom)?;
289    }
290    if prediction {
291        check_prediction(atom)?;
292    }
293    if rule {
294        check_rule(atom)?;
295    }
296
297    let report = prose::refuse(&text, prose::Role::Atom)?;
298    atom.insert("prose".into(), prose_value(&report));
299    Ok(())
300}
301
302/// A `prediction` atom is one voter's forecast on one issue: `issue`,
303/// `agent`, and `expect`, an option name or an object of option to share.
304/// The surprisingly popular rule reads these beside the ballots.
305fn check_prediction(atom: &Map<String, Value>) -> Result<(), AtomError> {
306    for key in ["issue", "agent"] {
307        match atom.get(key).and_then(Value::as_str).map(str::trim) {
308            Some(v) if !v.is_empty() => {}
309            _ => return Err(AtomError(format!("prediction atom needs {key}"))),
310        }
311    }
312    match atom.get("expect") {
313        Some(Value::String(s)) if !s.trim().is_empty() => Ok(()),
314        Some(Value::Object(map))
315            if !map.is_empty() && map.values().all(|v| v.as_f64().is_some_and(|f| f >= 0.0)) =>
316        {
317            Ok(())
318        }
319        _ => Err(AtomError(
320            "prediction atom: expect is an option or an object of option to share".into(),
321        )),
322    }
323}
324
325/// A `rule` atom is argv law in the pack: `pattern`, a glob over the command
326/// line, and `verdict`, `deny` or `ask`. The text is the reason a reader
327/// sees when the rule fires. Rules are memory too: dated, supersedable,
328/// exported with the rest.
329fn check_rule(atom: &Map<String, Value>) -> Result<(), AtomError> {
330    match atom.get("pattern").and_then(Value::as_str).map(str::trim) {
331        Some(p) if !p.is_empty() => {}
332        _ => return Err(AtomError("rule atom needs a pattern".into())),
333    }
334    match atom.get("verdict").and_then(Value::as_str) {
335        Some("deny" | "ask") => Ok(()),
336        _ => Err(AtomError("rule atom: verdict is deny or ask".into())),
337    }
338}
339
340/// A `persona` atom names a voter and its anchor: `name`, and `anchor` in
341/// `[0, 1]`, how far the persona moves off its own ballot in a settle. The
342/// text is its view, the entities the domains it speaks to.
343fn check_persona(atom: &Map<String, Value>) -> Result<(), AtomError> {
344    match atom.get("name").and_then(Value::as_str).map(str::trim) {
345        Some(n) if !n.is_empty() => {}
346        _ => return Err(AtomError("persona atom needs a name".into())),
347    }
348    match atom.get("anchor").and_then(Value::as_f64) {
349        Some(a) if (0.0..=1.0).contains(&a) => Ok(()),
350        _ => Err(AtomError(
351            "persona atom: anchor must be a number in [0, 1]".into(),
352        )),
353    }
354}
355
356/// A `trust` atom names `from`, `to` and a `weight` in `(0, 1]`; it is one
357/// row of the influence graph a consensus settles over.
358fn check_trust(atom: &Map<String, Value>) -> Result<(), AtomError> {
359    let name = |key: &str| -> Result<String, AtomError> {
360        match atom.get(key).and_then(Value::as_str).map(str::trim) {
361            Some(v) if !v.is_empty() => Ok(v.to_string()),
362            _ => Err(AtomError(format!("trust atom needs {key}"))),
363        }
364    };
365    let (from, to) = (name("from")?, name("to")?);
366    if from == to {
367        return Err(AtomError(
368            "trust atom: from and to are the same agent".into(),
369        ));
370    }
371    match atom.get("weight").and_then(Value::as_f64) {
372        Some(w) if w > 0.0 && w <= 1.0 => Ok(()),
373        _ => Err(AtomError(
374            "trust atom: weight must be a number in (0, 1]".into(),
375        )),
376    }
377}
378
379fn value_repr(v: &Value) -> String {
380    match v {
381        Value::String(s) => s.clone(),
382        Value::Null => "None".into(),
383        other => other.to_string(),
384    }
385}
386
387fn value_text(v: &Value) -> String {
388    match v {
389        Value::String(s) => s.clone(),
390        other => other.to_string(),
391    }
392}
393
394fn prose_value(report: &prose::Report) -> Value {
395    let mut out = Map::new();
396    out.insert("words".into(), report.words.into());
397    out.insert("sentences".into(), report.sentences.into());
398    out.insert("grade".into(), number_or_null(report.grade));
399    out.insert("ease".into(), number_or_null(report.ease));
400    out.insert("adverbs".into(), report.adverbs.into());
401    out.insert(
402        "adverb_ratio".into(),
403        number_or_null(Some(report.adverb_ratio)),
404    );
405    out.insert("passives".into(), report.passives.into());
406    out.insert("hard_sentences".into(), report.hard_sentences.into());
407    out.insert(
408        "very_hard_sentences".into(),
409        report.very_hard_sentences.into(),
410    );
411    Value::Object(out)
412}
413
414fn number_or_null(v: Option<f64>) -> Value {
415    v.and_then(serde_json::Number::from_f64)
416        .map_or(Value::Null, Value::Number)
417}
418
419/// A stored timestamp field, or none when it is missing, null, or empty.
420fn field_stamp<'a>(atom: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
421    match atom.get(key) {
422        Some(Value::String(s)) if !s.is_empty() => Some(s.as_str()),
423        _ => None,
424    }
425}
426
427/// Live set: not tombstoned, and `valid_to` missing or still open.
428#[must_use]
429pub fn is_live(atom: &Map<String, Value>, now: &str) -> bool {
430    crate::atom::is_live(
431        atom.get("tombstone")
432            .and_then(Value::as_bool)
433            .unwrap_or(false),
434        field_stamp(atom, "valid_to"),
435        now,
436    )
437}
438
439/// Live at `at`: start is `valid_from`, else `ts`, else open; `valid_to` is
440/// the exclusive end as in [`is_live`].
441#[must_use]
442pub fn is_live_at(atom: &Map<String, Value>, at: &str) -> bool {
443    crate::atom::is_live_at(
444        atom.get("tombstone")
445            .and_then(Value::as_bool)
446            .unwrap_or(false),
447        field_stamp(atom, "valid_from").or_else(|| field_stamp(atom, "ts")),
448        field_stamp(atom, "valid_to"),
449        at,
450    )
451}
452
453/// Review clock. A missing `due_at` is not due, and `valid_to` is not consulted.
454#[must_use]
455pub fn is_due(atom: &Map<String, Value>, now: &str) -> bool {
456    match atom.get("due_at") {
457        None | Some(Value::Null) => false,
458        Some(Value::String(s)) if s.is_empty() => false,
459        Some(other) => value_text(other).as_str() <= now,
460    }
461}
462
463/// The names an atom is about: the declared `entities`, else capitalised runs
464/// and backtick names.
465#[must_use]
466pub fn entities_of(atom: &Map<String, Value>) -> BTreeSet<String> {
467    if let Some(Value::Array(items)) = atom.get("entities") {
468        return items
469            .iter()
470            .map(|item| value_text(item).trim().to_string())
471            .filter(|s| !s.is_empty())
472            .collect();
473    }
474    let text = atom.get("text").and_then(Value::as_str).unwrap_or("");
475    let mut names: BTreeSet<String> = capitalized_runs(text);
476    names.extend(backtick_names(text));
477    names
478}
479
480/// `\b[A-Z][A-Za-z0-9]{1,}\b`
481fn capitalized_runs(text: &str) -> BTreeSet<String> {
482    let bytes = text.as_bytes();
483    let mut out = BTreeSet::new();
484    let mut i = 0usize;
485    while i < bytes.len() {
486        let boundary = i == 0 || !is_word_byte(bytes[i - 1]);
487        if boundary && bytes[i].is_ascii_uppercase() {
488            let start = i;
489            i += 1;
490            while i < bytes.len() && bytes[i].is_ascii_alphanumeric() {
491                i += 1;
492            }
493            // {1,} after the first character means at least two in total, and
494            // the match must end on a word boundary.
495            if i - start >= 2 && (i == bytes.len() || !is_word_byte(bytes[i])) {
496                out.insert(text[start..i].to_string());
497            }
498        } else {
499            i += 1;
500        }
501    }
502    out
503}
504
505fn is_word_byte(b: u8) -> bool {
506    b.is_ascii_alphanumeric() || b == b'_'
507}
508
509/// Text between backticks, trimmed, empties dropped.
510fn backtick_names(text: &str) -> BTreeSet<String> {
511    let mut out = BTreeSet::new();
512    let mut rest = text;
513    while let Some(open) = rest.find('`') {
514        let after = &rest[open + 1..];
515        let Some(close) = after.find('`') else { break };
516        let inner = after[..close].trim();
517        if !inner.is_empty() {
518            out.insert(inner.to_string());
519        }
520        rest = &after[close + 1..];
521    }
522    out
523}
524
525/// One candidate neighbour: how alike it is, its id, and what it is about.
526struct Candidate<'a> {
527    overlap: f64,
528    id: &'a str,
529    /// Order among equals, from the pair rather than from the id alone.
530    tie: u64,
531    entities: BTreeSet<String>,
532}
533
534/// FNV-1a, written out: the order it decides is part of the stored graph.
535fn fnv1a(parts: &[&str]) -> u64 {
536    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
537    for (index, part) in parts.iter().enumerate() {
538        if index > 0 {
539            hash = (hash ^ 0xff).wrapping_mul(0x0000_0100_0000_01b3);
540        }
541        for byte in part.as_bytes() {
542            hash = (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3);
543        }
544    }
545    hash
546}
547
548/// How many sorted candidates the diversifying selection looks at; the
549/// selection is quadratic in this.
550const LINK_POOL: usize = LINK_MAX * 8;
551
552/// Relative-neighbourhood pruning (the HNSW neighbour heuristic): a candidate
553/// earns an edge only when no kept neighbour is closer to it than the atom is.
554/// Candidates arrive sorted by decreasing overlap; rejected ones fill any
555/// places left, in order.
556fn diversified(candidates: &[Candidate<'_>], cap: usize) -> Vec<String> {
557    let mut kept: Vec<&Candidate<'_>> = Vec::with_capacity(cap);
558    let mut rejected: Vec<&Candidate<'_>> = Vec::new();
559    for candidate in candidates {
560        if kept.len() >= cap {
561            break;
562        }
563        let refs: Vec<&str> = candidate.entities.iter().map(String::as_str).collect();
564        let spreads = kept.iter().all(|near| {
565            let theirs: Vec<&str> = near.entities.iter().map(String::as_str).collect();
566            candidate.overlap
567                > crate::atom::entity_jaccard(refs.iter().copied(), theirs.iter().copied())
568        });
569        if spreads {
570            kept.push(candidate);
571        } else {
572            rejected.push(candidate);
573        }
574    }
575    let mut out: Vec<String> = kept.iter().map(|c| c.id.to_string()).collect();
576    for filler in rejected {
577        if out.len() >= cap {
578            break;
579        }
580        out.push(filler.id.to_string());
581    }
582    out
583}
584
585/// Rank peers by overlap with `mine`, best first. Ties break on a hash of the
586/// pair, not the id: an id tie-break lets the first-sorting atoms win every
587/// tie and the graph collapses onto them.
588fn ranked<'a>(
589    base: &str,
590    mine: &BTreeSet<String>,
591    peers: impl IntoIterator<Item = (&'a str, BTreeSet<String>)>,
592    threshold: f64,
593) -> Vec<Candidate<'a>> {
594    let mine_refs: Vec<&str> = mine.iter().map(String::as_str).collect();
595    let mut scored: Vec<Candidate<'a>> = peers
596        .into_iter()
597        .filter_map(|(id, entities)| {
598            let theirs: Vec<&str> = entities.iter().map(String::as_str).collect();
599            let overlap =
600                crate::atom::entity_jaccard(mine_refs.iter().copied(), theirs.iter().copied());
601            (overlap >= threshold).then_some(Candidate {
602                overlap,
603                id,
604                tie: fnv1a(&[base, id]),
605                entities,
606            })
607        })
608        .collect();
609    scored.sort_by(|a, b| {
610        b.overlap
611            .partial_cmp(&a.overlap)
612            .unwrap_or(std::cmp::Ordering::Equal)
613            .then_with(|| a.tie.cmp(&b.tie))
614            .then_with(|| a.id.cmp(b.id))
615    });
616    scored.truncate(LINK_POOL);
617    scored
618}
619
620/// The live peers this atom is most about, at most [`LINK_MAX`], chosen by
621/// `diversified`; deterministic over a corpus.
622#[must_use]
623pub fn link_targets(
624    atom: &Map<String, Value>,
625    peers: &[&Map<String, Value>],
626    threshold: f64,
627    now: &str,
628) -> Vec<String> {
629    let atom_id = atom.get("id").and_then(Value::as_str);
630    let mine = entities_of(atom);
631    let candidates = ranked(
632        atom_id.unwrap_or_default(),
633        &mine,
634        peers.iter().filter_map(|other| {
635            let other_id = other.get("id").and_then(Value::as_str)?;
636            if Some(other_id) == atom_id || !is_live(other, now) {
637                return None;
638            }
639            Some((other_id, entities_of(other)))
640        }),
641        threshold,
642    );
643    diversified(&candidates, LINK_MAX)
644}
645
646/// Set overlap links on `atom`, symmetrically, and return the peers to write
647/// back. A peer pushed past [`LINK_MAX`] by incoming edges is re-selected by
648/// the same rule, and each dropped edge goes from both ends.
649pub fn apply_links(
650    atom: &mut Map<String, Value>,
651    live: &[Map<String, Value>],
652    threshold: f64,
653    now: &str,
654) -> Vec<Map<String, Value>> {
655    let atom_id = atom
656        .get("id")
657        .and_then(Value::as_str)
658        .unwrap_or_default()
659        .to_string();
660    // Borrowed; only the peers that change are cloned.
661    let peers: Vec<&Map<String, Value>> = live
662        .iter()
663        .filter(|other| {
664            other.get("id").and_then(Value::as_str) != Some(atom_id.as_str()) && is_live(other, now)
665        })
666        .collect();
667    let mut targets: BTreeSet<String> = link_targets(atom, &peers, threshold, now)
668        .into_iter()
669        .collect();
670
671    // What every atom in play is about, so a link id can be scored without
672    // going back to the store for it.
673    let mut about: BTreeMap<String, BTreeSet<String>> = peers
674        .iter()
675        .filter_map(|peer| {
676            let id = peer.get("id").and_then(Value::as_str)?;
677            Some((id.to_string(), entities_of(peer)))
678        })
679        .collect();
680    about.insert(atom_id.clone(), entities_of(atom));
681
682    let before: BTreeMap<String, BTreeSet<String>> = peers
683        .iter()
684        .filter_map(|peer| {
685            let id = peer.get("id").and_then(Value::as_str)?;
686            Some((id.to_string(), links_of(peer)))
687        })
688        .collect();
689    let mut after = before.clone();
690    for (id, links) in &mut after {
691        if targets.contains(id) {
692            links.insert(atom_id.clone());
693        } else {
694            links.remove(&atom_id);
695        }
696    }
697
698    // An id nothing live answers to is left for [`filter_live_links`].
699    let mut cut: Vec<(String, String)> = Vec::new();
700    for (id, links) in &after {
701        if links.len() <= LINK_MAX {
702            continue;
703        }
704        let Some(mine) = about.get(id) else { continue };
705        let candidates = ranked(
706            id,
707            mine,
708            links
709                .iter()
710                .filter_map(|link| Some((link.as_str(), about.get(link)?.clone()))),
711            0.0,
712        );
713        let keep: BTreeSet<String> = diversified(&candidates, LINK_MAX).into_iter().collect();
714        for link in links {
715            if about.contains_key(link) && !keep.contains(link) {
716                cut.push((id.clone(), link.clone()));
717            }
718        }
719    }
720    for (from, to) in cut {
721        if let Some(links) = after.get_mut(&from) {
722            links.remove(&to);
723        }
724        if to == atom_id {
725            targets.remove(&from);
726        } else if let Some(links) = after.get_mut(&to) {
727            links.remove(&from);
728        }
729    }
730
731    atom.insert(
732        "links".into(),
733        Value::Array(
734            targets
735                .iter()
736                .map(|id| Value::String(id.clone()))
737                .collect::<Vec<_>>(),
738        ),
739    );
740
741    let mut rewritten = Vec::new();
742    for other in peers {
743        let Some(other_id) = other.get("id").and_then(Value::as_str) else {
744            continue;
745        };
746        let (Some(was), Some(now_links)) = (before.get(other_id), after.get(other_id)) else {
747            continue;
748        };
749        if was == now_links {
750            continue;
751        }
752        let mut changed = other.clone();
753        changed.insert(
754            "links".into(),
755            Value::Array(now_links.iter().cloned().map(Value::String).collect()),
756        );
757        rewritten.push(changed);
758    }
759    rewritten
760}
761
762/// The ids one atom links to.
763fn links_of(atom: &Map<String, Value>) -> BTreeSet<String> {
764    atom.get("links")
765        .and_then(Value::as_array)
766        .map(|items| items.iter().map(value_text).collect())
767        .unwrap_or_default()
768}
769
770/// Drop links pointing outside the supplied live set.
771pub fn filter_live_links(atoms: &mut [Map<String, Value>]) {
772    let live: BTreeSet<String> = atoms
773        .iter()
774        .filter_map(|a| a.get("id").and_then(Value::as_str).map(str::to_string))
775        .collect();
776    for atom in atoms.iter_mut() {
777        let kept: Vec<Value> = atom
778            .get("links")
779            .and_then(Value::as_array)
780            .map(|items| {
781                items
782                    .iter()
783                    .filter(|item| live.contains(&value_text(item)))
784                    .cloned()
785                    .collect()
786            })
787            .unwrap_or_default();
788        atom.insert("links".into(), Value::Array(kept));
789    }
790}
791
792/// How a review turned out.
793#[derive(Debug, Clone, Copy, PartialEq, Eq)]
794pub enum Grade {
795    /// First scheduling, or a re-schedule that is not a review.
796    Initial,
797    /// The atom came back.
798    Recalled,
799    /// It did not.
800    Lapsed,
801}
802
803/// Words used to tell a rewrite from a neighbour.
804fn tokens(text: &str) -> BTreeSet<String> {
805    text.split(|c: char| !c.is_ascii_alphanumeric())
806        .filter(|w| !w.is_empty())
807        .map(str::to_ascii_lowercase)
808        .collect()
809}
810
811/// Jaccard on the token sets.
812#[must_use]
813pub fn token_jaccard(left: &str, right: &str) -> f64 {
814    let a = tokens(left);
815    let b = tokens(right);
816    if a.is_empty() && b.is_empty() {
817        return 1.0;
818    }
819    let inter = a.intersection(&b).count() as f64;
820    let union = a.union(&b).count() as f64;
821    if union == 0.0 {
822        0.0
823    } else {
824        inter / union
825    }
826}
827
828/// The words of a claim in order, lowercased, punctuation dropped and the
829/// function words kept: the shape [`same_head`] compares.
830#[must_use]
831pub fn head_tokens(text: &str) -> Vec<String> {
832    text.split(|c: char| !c.is_alphanumeric())
833        .filter(|w| !w.is_empty())
834        .map(str::to_lowercase)
835        .collect()
836}
837
838/// The least a shared head must cover of the shorter claim.
839pub const HEAD_SHARE: f64 = 0.6;
840/// The least words a shared head has.
841pub const HEAD_MIN: usize = 3;
842
843/// Whether two claims say the same thing about the same subject with a
844/// different object: they open with the same words for at least
845/// [`HEAD_MIN`] words and [`HEAD_SHARE`] of the shorter claim, and each
846/// goes on to say something the other does not. `The default fuse is
847/// Borda` and `The default fuse is CombMNZ` share a head; so do `Roy
848/// Rogers is married to Dale Evans` and `Roy Rogers is married to John
849/// McVie`, where a set measure misses them because the object is two
850/// words. Two claims that open alike and then diverge for most of their
851/// length are two claims.
852#[must_use]
853pub fn same_head(a: &[String], b: &[String]) -> bool {
854    let shared = a.iter().zip(b).take_while(|(x, y)| x == y).count();
855    let shorter = a.len().min(b.len());
856    if shorter == 0 || shared < HEAD_MIN || shared == a.len() || shared == b.len() {
857        return false;
858    }
859    (shared as f64) >= HEAD_SHARE * (shorter as f64) && a[shared..] != b[shared..]
860}
861
862/// Whether `new` is a replacement for `old`, not a neighbour and not a retry.
863///
864/// Same kind, different text, and either an explicit `supersedes` id, a
865/// `correction` that shares an entity, a rewrite of the same claim (token
866/// Jaccard at least 0.6), or the same head with a new object
867/// ([`same_head`]). When both carry entities they must share one; a claim
868/// without entities is read by its text alone, because most claims a seat
869/// remembers name none. Linked atoms about the same entities with
870/// different sentences stay both live.
871#[must_use]
872pub fn replaces(new: &Map<String, Value>, old: &Map<String, Value>) -> bool {
873    if new.get("kind") != old.get("kind") {
874        return false;
875    }
876    let new_text = new.get("text").and_then(Value::as_str).unwrap_or("");
877    let old_text = old.get("text").and_then(Value::as_str).unwrap_or("");
878    if new_text.is_empty() || new_text == old_text {
879        return false;
880    }
881    let old_id = old.get("id").and_then(Value::as_str).unwrap_or("");
882    if !old_id.is_empty() {
883        if let Some(Value::Array(ids)) = new.get("supersedes") {
884            if ids.iter().any(|v| value_text(v) == old_id) {
885                return true;
886            }
887        }
888        if let Some(Value::String(id)) = new.get("supersedes") {
889            if id == old_id {
890                return true;
891            }
892        }
893    }
894    let new_entities = entities_of(new);
895    let old_entities = entities_of(old);
896    let shared: BTreeSet<_> = new_entities.intersection(&old_entities).cloned().collect();
897    if !new_entities.is_empty() && !old_entities.is_empty() && shared.is_empty() {
898        return false;
899    }
900    if new.get("kind").and_then(Value::as_str) == Some("correction") && !shared.is_empty() {
901        return true;
902    }
903    token_jaccard(new_text, old_text) >= 0.6
904        || same_head(&head_tokens(new_text), &head_tokens(old_text))
905}
906
907/// Close the live window. Search already drops atoms whose `valid_to` is past.
908pub fn close_valid_to(atom: &mut Map<String, Value>, now: &str) {
909    atom.insert("valid_to".into(), Value::String(now.to_string()));
910}
911
912/// Set `due_at` from stability and difficulty: a lapse halves stability, a
913/// recall grows it by how overdue the atom was. `valid_to` is left alone.
914pub fn schedule_review(
915    atom: &mut Map<String, Value>,
916    now: &str,
917    grade: Grade,
918    interval_s: Option<i64>,
919) {
920    let previous = atom
921        .get("review")
922        .and_then(Value::as_object)
923        .cloned()
924        .unwrap_or_default();
925    let read = |key: &str, fallback: f64| -> f64 {
926        previous
927            .get(key)
928            .and_then(Value::as_f64)
929            .filter(|v| *v != 0.0)
930            .unwrap_or(fallback)
931    };
932    let mut stability = read("stability", DEFAULT_STABILITY);
933    let mut difficulty = read("difficulty", DEFAULT_DIFFICULTY);
934
935    let mut review = Map::new();
936    let span;
937    match grade {
938        Grade::Lapsed => {
939            difficulty = (difficulty + 0.2).clamp(1.0, 10.0);
940            stability = (stability * 0.5).max(0.1);
941            span = (stability.max(1.0) * 86_400.0) as i64;
942            review.insert("reps".into(), 0.into());
943            review.insert("interval_s".into(), span.into());
944            review.insert("ease".into(), number_or_null(Some(REVIEW_EASE)));
945            review.insert("stability".into(), number_or_null(Some(stability)));
946            review.insert("difficulty".into(), number_or_null(Some(difficulty)));
947            review.insert("last".into(), Value::String(now.to_string()));
948        }
949        Grade::Recalled => {
950            let reps = previous
951                .get("reps")
952                .and_then(Value::as_i64)
953                .unwrap_or(0)
954                .saturating_add(1);
955            let last = previous.get("last").and_then(Value::as_str).unwrap_or("");
956            let elapsed = if last.is_empty() {
957                0.0
958            } else {
959                clock::elapsed_days(last, now)
960            };
961            let retr = if stability > 0.0 {
962                0.9f64.powf(elapsed / stability)
963            } else {
964                0.0
965            }
966            .clamp(0.01, 0.99);
967            difficulty = (difficulty - 0.15).clamp(1.0, 10.0);
968            stability *= 1.0 + (1.0 - difficulty / 10.0).exp() * (1.0 - retr);
969            span = (stability.max(1.0) * 86_400.0) as i64;
970            review.insert("reps".into(), reps.into());
971            review.insert("interval_s".into(), span.into());
972            review.insert("ease".into(), number_or_null(Some(REVIEW_EASE)));
973            review.insert("stability".into(), number_or_null(Some(stability)));
974            review.insert("difficulty".into(), number_or_null(Some(difficulty)));
975            review.insert("last".into(), Value::String(now.to_string()));
976        }
977        Grade::Initial => {
978            span = interval_s.unwrap_or(DEFAULT_REVIEW_INTERVAL_S);
979            review = previous;
980            review.entry("reps").or_insert_with(|| 0.into());
981            review.entry("interval_s").or_insert_with(|| span.into());
982            review
983                .entry("ease")
984                .or_insert_with(|| number_or_null(Some(REVIEW_EASE)));
985            review
986                .entry("stability")
987                .or_insert_with(|| number_or_null(Some(DEFAULT_STABILITY)));
988            review
989                .entry("difficulty")
990                .or_insert_with(|| number_or_null(Some(DEFAULT_DIFFICULTY)));
991            review
992                .entry("last")
993                .or_insert_with(|| Value::String(now.to_string()));
994        }
995    }
996    if let Some(due) = clock::shift(now, span) {
997        atom.insert("due_at".into(), Value::String(due));
998    }
999    atom.insert("review".into(), Value::Object(review));
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::*;
1005
1006    /// A prediction names its issue, agent and forecast; a rule names a
1007    /// pattern and a verdict that is deny or ask.
1008    #[test]
1009    fn predictions_and_rules_are_checked() {
1010        let ok = |v: Value| validate(&mut atom(v)).is_ok();
1011        assert!(ok(
1012            json!({"kind": "prediction", "text": "a expects ship.", "workspace": "w",
1013            "issue": "p-1", "agent": "a", "expect": "ship"})
1014        ));
1015        assert!(ok(
1016            json!({"kind": "prediction", "text": "a expects ship.", "workspace": "w",
1017            "issue": "p-1", "agent": "a", "expect": {"ship": 0.7, "hold": 0.3}})
1018        ));
1019        assert!(!ok(
1020            json!({"kind": "prediction", "text": "a expects ship.", "workspace": "w",
1021            "issue": "p-1", "agent": "a"})
1022        ));
1023        assert!(!ok(
1024            json!({"kind": "prediction", "text": "a expects ship.", "workspace": "w",
1025            "agent": "a", "expect": "ship"})
1026        ));
1027        assert!(ok(
1028            json!({"kind": "rule", "text": "Never outside tmp.", "workspace": "w",
1029            "pattern": "rm -rf *", "verdict": "deny"})
1030        ));
1031        assert!(ok(
1032            json!({"kind": "rule", "text": "Ask first.", "workspace": "w",
1033            "pattern": "git push*", "verdict": "ask"})
1034        ));
1035        assert!(!ok(
1036            json!({"kind": "rule", "text": "Ask first.", "workspace": "w",
1037            "pattern": "rm *", "verdict": "allow"})
1038        ));
1039        assert!(!ok(
1040            json!({"kind": "rule", "text": "Ask first.", "workspace": "w",
1041            "verdict": "deny"})
1042        ));
1043    }
1044    use serde_json::json;
1045
1046    fn atom(value: Value) -> Map<String, Value> {
1047        value.as_object().unwrap().clone()
1048    }
1049
1050    #[test]
1051    fn quoting_prefers_single_quotes() {
1052        assert_eq!(quoted("plain"), "'plain'");
1053        assert_eq!(quoted("it's"), "\"it's\"");
1054        assert_eq!(quoted("say \"hi\""), "'say \"hi\"'");
1055        assert_eq!(quoted("both ' and \""), "'both \\' and \"'");
1056        assert_eq!(quoted("a\nb"), "'a\\nb'");
1057    }
1058
1059    #[test]
1060    fn a_credential_shape_is_caught_and_a_word_containing_one_is_not() {
1061        assert!(looks_like_a_secret("api_key=abcd1234"));
1062        assert!(looks_like_a_secret("Secret: hunter2"));
1063        assert!(looks_like_a_secret("authorization bearer abcdefghij"));
1064        assert!(looks_like_a_secret("use sk-abcdefghij for this"));
1065        // The word alone is not a leak, and neither is a longer name that
1066        // merely ends in one.
1067        assert!(!looks_like_a_secret("the token is rotated weekly"));
1068        assert!(!looks_like_a_secret("my_secret_sauce = onions"));
1069        assert!(!looks_like_a_secret("sk-short"));
1070    }
1071
1072    #[test]
1073    fn invisible_unicode_is_refused() {
1074        assert!(reject_unsafe("plain text").is_ok());
1075        assert!(reject_unsafe("hidden\u{200b}text").is_err());
1076        assert!(reject_unsafe("\u{feff}bom").is_err());
1077    }
1078
1079    #[test]
1080    fn a_rewrite_of_the_same_claim_replaces_and_a_neighbour_does_not() {
1081        let old = atom(json!({
1082            "text": "The default fuse is Borda.",
1083            "kind": "habit",
1084            "entities": ["fuse", "Borda"]
1085        }));
1086        let rewrite = atom(json!({
1087            "text": "The default fuse is CombMNZ.",
1088            "kind": "habit",
1089            "entities": ["fuse", "CombMNZ"]
1090        }));
1091        // Same entities, different sentence: a neighbour, not a replacement.
1092        let neighbour = atom(json!({
1093            "text": "The Header comes before the Parser body.",
1094            "kind": "habit",
1095            "entities": ["Parser", "Header"]
1096        }));
1097        let first = atom(json!({
1098            "text": "The Parser reads the Header.",
1099            "kind": "habit",
1100            "entities": ["Parser", "Header"]
1101        }));
1102        assert!(replaces(&rewrite, &old), "shared stem, new object");
1103        assert!(
1104            !replaces(&neighbour, &first),
1105            "linked claims stay both live"
1106        );
1107        assert!(
1108            !replaces(&old, &old),
1109            "the same text is a retry, not a close"
1110        );
1111    }
1112
1113    #[test]
1114    fn a_new_object_under_the_same_head_replaces_without_entities() {
1115        let old = atom(json!({"text": "Roy Rogers is married to Dale Evans.", "kind": "lesson"}));
1116        let new = atom(json!({"text": "Roy Rogers is married to John McVie.", "kind": "lesson"}));
1117        assert!(replaces(&new, &old), "same head, two-word object");
1118        let fuse_old = atom(json!({"text": "The default fuse is Borda.", "kind": "lesson"}));
1119        let fuse_new = atom(json!({"text": "The default fuse is CombMNZ.", "kind": "lesson"}));
1120        assert!(replaces(&fuse_new, &fuse_old));
1121        let other = atom(json!({
1122            "text": "The pack refuses free text where a deed accession belongs.",
1123            "kind": "lesson"
1124        }));
1125        let alike = atom(json!({
1126            "text": "The pack refuses a claim over two sentences.",
1127            "kind": "lesson"
1128        }));
1129        assert!(!replaces(&alike, &other), "alike openings, two claims");
1130        // Entities on both sides still have to meet.
1131        let tagged_old =
1132            atom(json!({"text": "The capital is Oslo.", "kind": "lesson", "entities": ["norway"]}));
1133        let tagged_new =
1134            atom(json!({"text": "The capital is Bern.", "kind": "lesson", "entities": ["swiss"]}));
1135        assert!(
1136            !replaces(&tagged_new, &tagged_old),
1137            "different subjects by entity"
1138        );
1139    }
1140
1141    #[test]
1142    fn a_head_is_shared_by_order_not_by_set() {
1143        let h = |t: &str| head_tokens(t);
1144        assert!(same_head(
1145            &h("X is located in the continent of Asia"),
1146            &h("X is located in the continent of Europe")
1147        ));
1148        assert!(
1149            !same_head(&h("a b c"), &h("a b c")),
1150            "a retry is not a rewrite"
1151        );
1152        assert!(
1153            !same_head(&h("a b c d"), &h("a b c")),
1154            "a prefix of the other is not a new object"
1155        );
1156        assert!(
1157            !same_head(&h("the cat sat"), &h("the cat ran far away from home now")),
1158            "the head must cover the shorter"
1159        );
1160    }
1161
1162    #[test]
1163    fn the_live_set_reads_the_tombstone_and_the_window() {
1164        let now = "2026-01-01T00:00:00.000Z";
1165        assert!(is_live(&atom(json!({})), now));
1166        assert!(is_live(&atom(json!({"valid_to": null})), now));
1167        assert!(is_live(&atom(json!({"valid_to": ""})), now));
1168        assert!(is_live(
1169            &atom(json!({"valid_to": "2099-01-01T00:00:00.000Z"})),
1170            now
1171        ));
1172        assert!(!is_live(
1173            &atom(json!({"valid_to": "2020-01-01T00:00:00.000Z"})),
1174            now
1175        ));
1176        assert!(!is_live(&atom(json!({"tombstone": true})), now));
1177    }
1178
1179    #[test]
1180    fn a_dated_retrieve_reads_the_window_not_now() {
1181        let at = "2024-06-01T00:00:00.000Z";
1182        let closed = atom(json!({
1183            "valid_from": "2024-01-01T00:00:00.000Z",
1184            "valid_to": "2024-12-01T00:00:00.000Z"
1185        }));
1186        let later = atom(json!({
1187            "valid_from": "2025-01-01T00:00:00.000Z"
1188        }));
1189        let open = atom(json!({
1190            "valid_from": "2024-01-01T00:00:00.000Z"
1191        }));
1192        let by_ts = atom(json!({"ts": "2024-03-01T00:00:00.000Z"}));
1193        let too_new = atom(json!({"ts": "2025-01-01T00:00:00.000Z"}));
1194        assert!(is_live_at(&closed, at), "closed later, live then");
1195        assert!(!is_live(&closed, "2026-01-01T00:00:00.000Z"));
1196        assert!(!is_live_at(&later, at), "not yet valid");
1197        assert!(is_live_at(&open, at));
1198        assert!(
1199            is_live_at(&by_ts, at),
1200            "ts is the start when valid_from is missing"
1201        );
1202        assert!(!is_live_at(&too_new, at));
1203        assert!(!is_live_at(
1204            &atom(json!({"tombstone": true, "valid_from": "2020-01-01T00:00:00.000Z"})),
1205            at
1206        ));
1207    }
1208
1209    #[test]
1210    fn the_review_clock_ignores_the_live_window() {
1211        let now = "2026-01-01T00:00:00.000Z";
1212        assert!(!is_due(&atom(json!({})), now));
1213        assert!(!is_due(&atom(json!({"due_at": ""})), now));
1214        assert!(is_due(
1215            &atom(json!({"due_at": "2025-01-01T00:00:00.000Z"})),
1216            now
1217        ));
1218        assert!(!is_due(
1219            &atom(json!({"due_at": "2099-01-01T00:00:00.000Z"})),
1220            now
1221        ));
1222        // Tombstoned and due at once: the two questions do not consult each
1223        // other, which is why the store asks both.
1224        let both = atom(json!({"tombstone": true, "due_at": "2025-01-01T00:00:00.000Z"}));
1225        assert!(!is_live(&both, now));
1226        assert!(is_due(&both, now));
1227    }
1228
1229    #[test]
1230    fn a_declared_entity_list_wins_over_the_text() {
1231        let declared = atom(json!({"text": "The Parser reads it.", "entities": ["only-this"]}));
1232        assert_eq!(
1233            entities_of(&declared).into_iter().collect::<Vec<_>>(),
1234            vec!["only-this".to_string()]
1235        );
1236        // An empty declared list is still a declaration, so the text is not
1237        // mined behind the author's back.
1238        let empty = atom(json!({"text": "The Parser reads it.", "entities": []}));
1239        assert!(entities_of(&empty).is_empty());
1240    }
1241
1242    #[test]
1243    fn validate_normalizes_the_set_name_and_drops_an_empty_one() {
1244        let mut a =
1245            atom(json!({"kind": "voice", "text": "A claim.", "workspace": "w", "set": "Review"}));
1246        validate(&mut a).unwrap();
1247        assert_eq!(a["set"], json!("review"));
1248
1249        let mut b = atom(json!({"kind": "voice", "text": "A claim.", "workspace": "w", "set": ""}));
1250        validate(&mut b).unwrap();
1251        assert!(!b.contains_key("set"));
1252    }
1253
1254    #[test]
1255    fn validate_leaves_fields_it_does_not_own_alone() {
1256        // The store is shared with another writer, so an unmodelled field has
1257        // to survive the round trip rather than be dropped as unknown.
1258        let mut a = atom(json!({
1259            "kind": "voice",
1260            "text": "A claim.",
1261            "workspace": "w",
1262            "something_else": {"nested": [1, 2, 3]}
1263        }));
1264        validate(&mut a).unwrap();
1265        assert_eq!(a["something_else"], json!({"nested": [1, 2, 3]}));
1266    }
1267
1268    #[test]
1269    fn a_trust_atom_is_one_weighted_edge() {
1270        let row = |from: &str, to: &str, weight: Value| {
1271            atom(json!({
1272                "kind": "trust",
1273                "text": format!("{from} trusts {to}."),
1274                "workspace": "w",
1275                "from": from,
1276                "to": to,
1277                "weight": weight,
1278            }))
1279        };
1280        assert!(validate(&mut row("a", "b", json!(0.5))).is_ok());
1281        assert!(validate(&mut row("a", "b", json!(1))).is_ok());
1282        assert!(validate(&mut row("a", "a", json!(0.5))).is_err());
1283        assert!(validate(&mut row("a", "", json!(0.5))).is_err());
1284        assert!(validate(&mut row("a", "b", json!(0))).is_err());
1285        assert!(validate(&mut row("a", "b", json!(1.5))).is_err());
1286        assert!(validate(&mut row("a", "b", json!("0.5"))).is_err());
1287        let mut bare = atom(json!({"kind": "trust", "text": "a trusts b.", "workspace": "w"}));
1288        assert!(validate(&mut bare).is_err());
1289    }
1290
1291    #[test]
1292    fn a_persona_atom_is_a_named_anchor() {
1293        let who = |anchor: Value| {
1294            atom(json!({
1295                "kind": "persona", "text": "Reads for the general reader.", "workspace": "w",
1296                "name": "broad", "anchor": anchor,
1297            }))
1298        };
1299        assert!(validate(&mut who(json!(0.8))).is_ok());
1300        assert!(validate(&mut who(json!(0))).is_ok());
1301        assert!(validate(&mut who(json!(1.2))).is_err());
1302        assert!(validate(&mut who(json!("0.5"))).is_err());
1303        let mut nameless =
1304            atom(json!({"kind": "persona", "text": "A view.", "workspace": "w", "anchor": 0.5}));
1305        assert!(validate(&mut nameless).is_err());
1306    }
1307
1308    #[test]
1309    fn the_text_cap_counts_characters_not_bytes() {
1310        let wide = "\u{4e00}".repeat(TEXT_SOFT_CAP);
1311        let mut ok = atom(json!({"kind": "voice", "text": wide, "workspace": "w"}));
1312        assert!(validate(&mut ok).is_ok());
1313        let over = "\u{4e00}".repeat(TEXT_SOFT_CAP + 1);
1314        let mut bad = atom(json!({"kind": "voice", "text": over, "workspace": "w"}));
1315        assert!(validate(&mut bad).is_err());
1316    }
1317
1318    #[test]
1319    fn scheduling_never_touches_the_live_window() {
1320        let mut a = atom(json!({"id": "a", "valid_to": "2099-01-01T00:00:00.000Z"}));
1321        schedule_review(&mut a, "2026-01-01T00:00:00.000Z", Grade::Recalled, None);
1322        assert_eq!(a["valid_to"], json!("2099-01-01T00:00:00.000Z"));
1323        assert!(a.contains_key("due_at"));
1324    }
1325
1326    #[test]
1327    fn a_lapse_shortens_and_a_recall_lengthens() {
1328        let now = "2026-01-01T00:00:00.000Z";
1329        let block = json!({"reps": 3, "stability": 4.0, "difficulty": 6.0, "last": "2025-12-20T00:00:00.000Z"});
1330        let mut lapsed = atom(json!({"id": "a", "review": block}));
1331        let mut recalled = lapsed.clone();
1332        schedule_review(&mut lapsed, now, Grade::Lapsed, None);
1333        schedule_review(&mut recalled, now, Grade::Recalled, None);
1334        let s_lapsed = lapsed["review"]["stability"].as_f64().unwrap();
1335        let s_recalled = recalled["review"]["stability"].as_f64().unwrap();
1336        assert!(s_lapsed < 4.0, "{s_lapsed}");
1337        assert!(s_recalled > 4.0, "{s_recalled}");
1338        assert_eq!(
1339            lapsed["review"]["reps"],
1340            json!(0),
1341            "a lapse restarts the count"
1342        );
1343        assert_eq!(recalled["review"]["reps"], json!(4));
1344    }
1345
1346    /// An atom about `n` things, so overlap is a set question with a knob.
1347    fn about(id: &str, entities: &[&str]) -> Map<String, Value> {
1348        atom(serde_json::json!({
1349            "id": id,
1350            "workspace": "w",
1351            "kind": "conclusion",
1352            "text": format!("Atom {id} says something."),
1353            "entities": entities,
1354        }))
1355    }
1356
1357    fn links(atom: &Map<String, Value>) -> Vec<String> {
1358        links_of(atom).into_iter().collect()
1359    }
1360
1361    /// The neighbourhood reaches both clusters, not eight from the larger.
1362    #[test]
1363    fn a_neighbourhood_spreads_over_what_an_atom_is_about() {
1364        let mut subject = about("mine", &["parser", "overlay"]);
1365        let mut peers: Vec<Map<String, Value>> = Vec::new();
1366        for n in 0..12 {
1367            peers.push(about(&format!("parser{n}"), &["parser", "shared"]));
1368        }
1369        for n in 0..12 {
1370            peers.push(about(&format!("overlay{n}"), &["overlay", "other"]));
1371        }
1372        apply_links(&mut subject, &peers, 0.2, &clock::utcnow());
1373
1374        let chosen = links(&subject);
1375        assert_eq!(chosen.len(), LINK_MAX);
1376        assert!(
1377            chosen.iter().any(|id| id.starts_with("parser")),
1378            "{chosen:?}"
1379        );
1380        assert!(
1381            chosen.iter().any(|id| id.starts_with("overlay")),
1382            "{chosen:?}"
1383        );
1384    }
1385
1386    /// Every atom that picks the same peer adds an edge to it, so the bound has
1387    /// to hold on the peer's side too.
1388    #[test]
1389    fn no_atom_collects_more_neighbours_than_the_bound() {
1390        let hub = about("hub", &["parser"]);
1391        let mut store: Vec<Map<String, Value>> = vec![hub];
1392        for n in 0..40 {
1393            let mut fresh = about(&format!("a{n}"), &["parser"]);
1394            let rewritten = apply_links(&mut fresh, &store, LINK_THRESHOLD, &clock::utcnow());
1395            for peer in rewritten {
1396                let id = peer.get("id").and_then(Value::as_str).unwrap().to_string();
1397                if let Some(slot) = store
1398                    .iter_mut()
1399                    .find(|s| s.get("id").and_then(Value::as_str) == Some(id.as_str()))
1400                {
1401                    *slot = peer;
1402                }
1403            }
1404            store.push(fresh);
1405        }
1406        for held in &store {
1407            let degree = links_of(held).len();
1408            assert!(
1409                degree <= LINK_MAX,
1410                "{} has {degree}",
1411                held.get("id").and_then(Value::as_str).unwrap_or("?")
1412            );
1413        }
1414    }
1415
1416    /// A link the pack cannot walk in both directions is half an edge, and a
1417    /// dropped one has to go from both ends.
1418    #[test]
1419    fn dropping_an_edge_drops_it_on_both_sides() {
1420        let mut store: Vec<Map<String, Value>> = Vec::new();
1421        for n in 0..24 {
1422            let mut fresh = about(&format!("a{n}"), &["parser"]);
1423            let rewritten = apply_links(&mut fresh, &store, LINK_THRESHOLD, &clock::utcnow());
1424            for peer in rewritten {
1425                let id = peer.get("id").and_then(Value::as_str).unwrap().to_string();
1426                if let Some(slot) = store
1427                    .iter_mut()
1428                    .find(|s| s.get("id").and_then(Value::as_str) == Some(id.as_str()))
1429                {
1430                    *slot = peer;
1431                }
1432            }
1433            store.push(fresh);
1434        }
1435        let by_id: std::collections::BTreeMap<String, BTreeSet<String>> = store
1436            .iter()
1437            .map(|a| {
1438                (
1439                    a.get("id").and_then(Value::as_str).unwrap().to_string(),
1440                    links_of(a),
1441                )
1442            })
1443            .collect();
1444        for (id, theirs) in &by_id {
1445            for link in theirs {
1446                assert!(
1447                    by_id[link].contains(id),
1448                    "{id} links {link} but not the other way"
1449                );
1450            }
1451        }
1452    }
1453
1454    /// A corpus too dense for the spread rule still gets a full neighbourhood.
1455    #[test]
1456    fn identical_atoms_still_fill_the_places() {
1457        let mut subject = about("mine", &["parser"]);
1458        let peers: Vec<Map<String, Value>> = (0..20)
1459            .map(|n| about(&format!("same{n}"), &["parser"]))
1460            .collect();
1461        apply_links(&mut subject, &peers, LINK_THRESHOLD, &clock::utcnow());
1462        assert_eq!(links(&subject).len(), LINK_MAX);
1463    }
1464
1465    /// Ties settled by id alone collapse the graph onto whichever atoms sort
1466    /// first: they win every tie, and then drop the newcomer that chose them.
1467    #[test]
1468    fn a_newcomer_to_a_saturated_corpus_still_has_neighbours() {
1469        let mut store: Vec<Map<String, Value>> = Vec::new();
1470        for n in 0..60 {
1471            let mut fresh = about(&format!("a{n:03}"), &["parser"]);
1472            let rewritten = apply_links(&mut fresh, &store, LINK_THRESHOLD, &clock::utcnow());
1473            for peer in rewritten {
1474                let id = peer.get("id").and_then(Value::as_str).unwrap().to_string();
1475                if let Some(slot) = store
1476                    .iter_mut()
1477                    .find(|s| s.get("id").and_then(Value::as_str) == Some(id.as_str()))
1478                {
1479                    *slot = peer;
1480                }
1481            }
1482            store.push(fresh);
1483        }
1484        let isolated = store.iter().filter(|a| links_of(a).is_empty()).count();
1485        assert_eq!(
1486            isolated,
1487            0,
1488            "{isolated} of {} have no neighbour",
1489            store.len()
1490        );
1491        let edges: usize = store.iter().map(|a| links_of(a).len()).sum();
1492        assert!(edges > store.len() * 4, "only {edges} edges");
1493    }
1494}