1use std::collections::{BTreeMap, BTreeSet};
5
6use serde_json::{Map, Value};
7
8use crate::atom::{check_entity, EntityRefusal};
9use crate::clock;
10use crate::prose;
11
12pub const SCHEMA: &str = crate::atom::SCHEMA;
14pub const USER_CAP: usize = 1375;
16pub const MEMORY_CAP: usize = 2200;
18pub const TEXT_SOFT_CAP: usize = 500;
20pub const LINK_THRESHOLD: f64 = 0.3;
22pub const LINK_MAX: usize = 8;
24pub const DEFAULT_REVIEW_INTERVAL_S: i64 = 86_400;
26pub const REVIEW_EASE: f64 = 2.5;
28pub const DEFAULT_STABILITY: f64 = 1.0;
30pub const DEFAULT_DIFFICULTY: f64 = 5.0;
32
33pub 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
52pub const LEVELS: &[&str] = &["explicit", "derived"];
54
55const DEED_PREFIXES: &[&str] = &["deed-", "sha256:"];
57
58#[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#[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#[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
117fn 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#[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
174pub 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
214pub 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
302fn 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
325fn 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
340fn 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
356fn 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
419fn 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#[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#[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#[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#[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
480fn 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 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
509fn 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
525struct Candidate<'a> {
527 overlap: f64,
528 id: &'a str,
529 tie: u64,
531 entities: BTreeSet<String>,
532}
533
534fn 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
548const LINK_POOL: usize = LINK_MAX * 8;
551
552fn 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
585fn 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#[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
646pub 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 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 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 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
762fn 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
770pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
794pub enum Grade {
795 Initial,
797 Recalled,
799 Lapsed,
801}
802
803fn 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#[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#[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
838pub const HEAD_SHARE: f64 = 0.6;
840pub const HEAD_MIN: usize = 3;
842
843#[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#[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
907pub fn close_valid_to(atom: &mut Map<String, Value>, now: &str) {
909 atom.insert("valid_to".into(), Value::String(now.to_string()));
910}
911
912pub 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 #[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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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}