1use std::collections::BTreeMap;
7use std::sync::Mutex;
8
9use packset_core::clock;
10use packset_core::record::{self, AtomError, MEMORY_CAP, USER_CAP};
11use serde_json::{json, Map, Value};
12
13use crate::cards;
14use crate::home::Home;
15use crate::store::{Record, Store};
16
17pub const ATTACH_CAP: usize = 200_000;
19
20#[derive(Debug, Clone, Default)]
22pub struct Attachment {
23 pub text: String,
25 pub label: String,
27}
28
29const ACTIVATION_SEEDS: usize = 5;
32const ACTIVATION_HOPS: usize = 2;
34const CONSOLIDATE_BUCKET_MAX: usize = 64;
38const FIRE_TOP: usize = 8;
40
41pub struct Service {
42 home: Home,
43 store: Store,
44 attach: Mutex<BTreeMap<String, Attachment>>,
45 writes: Mutex<()>,
49 live_cap: usize,
54}
55
56pub const DEFAULT_LIVE_CAP: usize = 20_000;
59
60pub const FORGETTABLE_KINDS: &[&str] = &[
64 "lesson",
65 "conclusion",
66 "summary",
67 "card_line",
68 "belief",
69 "voice",
70 "cache-pointer",
71 "correction",
72];
73
74fn live_cap_from_env() -> usize {
75 match std::env::var("PACKSET_LIVE_CAP") {
76 Ok(raw) => match raw.trim() {
77 "" => DEFAULT_LIVE_CAP,
78 "off" | "none" => 0,
79 n => n.parse().unwrap_or(DEFAULT_LIVE_CAP),
80 },
81 Err(_) => DEFAULT_LIVE_CAP,
82 }
83}
84
85impl Service {
86 pub fn open(home: Home) -> anyhow::Result<Self> {
92 let store = Store::open(home.root())?;
93 Ok(Self {
94 home,
95 store,
96 attach: Mutex::new(BTreeMap::new()),
97 writes: Mutex::new(()),
98 live_cap: live_cap_from_env(),
99 })
100 }
101
102 #[must_use]
104 pub fn with_live_cap(mut self, cap: usize) -> Self {
105 self.live_cap = cap;
106 self
107 }
108
109 #[must_use]
112 pub fn live_cap(&self) -> usize {
113 self.live_cap
114 }
115
116 fn enforce_cap(&self, workspace: &str, now: &str) -> anyhow::Result<usize> {
122 if self.live_cap == 0 {
123 return Ok(0);
124 }
125 let live = self.store.live(workspace)?;
126 let over = live.len().saturating_sub(self.live_cap);
127 if over == 0 {
128 return Ok(0);
129 }
130 let mut ranked: Vec<(f64, String, Record)> = live
131 .iter()
132 .filter(|a| {
133 FORGETTABLE_KINDS.contains(&a.get("kind").and_then(Value::as_str).unwrap_or(""))
134 && a.get("pinned").and_then(Value::as_bool) != Some(true)
135 })
136 .map(|a| {
137 let review = a.get("review");
138 let last = review
139 .and_then(|r| r.get("last"))
140 .and_then(Value::as_str)
141 .or_else(|| a.get("ts").and_then(Value::as_str))
142 .unwrap_or(now);
143 let stability = review
144 .and_then(|r| r.get("stability"))
145 .and_then(Value::as_f64)
146 .unwrap_or(record::DEFAULT_STABILITY);
147 let r =
148 packset_core::decay::retrievability(clock::elapsed_days(last, now), stability);
149 (r, last.to_string(), a.clone())
150 })
151 .collect();
152 ranked.sort_by(|a, b| {
153 a.0.partial_cmp(&b.0)
154 .unwrap_or(std::cmp::Ordering::Equal)
155 .then_with(|| a.1.cmp(&b.1))
156 });
157 let tombs: Vec<Record> = ranked
158 .into_iter()
159 .take(over)
160 .map(|(_, _, mut a)| {
161 a.insert("tombstone".into(), Value::Bool(true));
162 a.insert("ts".into(), Value::String(now.to_string()));
163 a.insert("forgotten".into(), Value::String("the live cap".into()));
164 a
165 })
166 .collect();
167 if tombs.is_empty() {
168 return Ok(0);
169 }
170 self.store.upsert_many(&tombs)?;
171 self.project_atoms(&tombs);
172 Ok(tombs.len())
173 }
174
175 #[must_use]
177 pub fn home(&self) -> &Home {
178 &self.home
179 }
180
181 #[must_use]
183 pub fn store(&self) -> &Store {
184 &self.store
185 }
186
187 pub fn add(&self, atom: Record) -> anyhow::Result<Record> {
194 let atom = self.prepare(atom)?;
197 let _write = self.writes.lock().unwrap_or_else(|e| e.into_inner());
198 self.add_prepared(atom)
199 }
200
201 fn prepare(&self, mut atom: Record) -> anyhow::Result<Record> {
203 let text = atom
204 .get("text")
205 .and_then(Value::as_str)
206 .unwrap_or("")
207 .to_string();
208 if packset_core::extract::is_tool_dump(&text) {
209 anyhow::bail!(AtomError("tool dump is attach, not an atom".into()));
210 }
211 record::validate(&mut atom).map_err(anyhow::Error::new)?;
212
213 if atom
214 .get("id")
215 .and_then(Value::as_str)
216 .unwrap_or("")
217 .is_empty()
218 {
219 atom.insert("id".into(), Value::String(new_id()));
220 }
221 if atom
222 .get("ts")
223 .and_then(Value::as_str)
224 .unwrap_or("")
225 .is_empty()
226 {
227 atom.insert("ts".into(), Value::String(clock::utcnow()));
228 }
229 if atom
230 .get("valid_from")
231 .and_then(Value::as_str)
232 .unwrap_or("")
233 .is_empty()
234 {
235 let from = atom
236 .get("ts")
237 .and_then(Value::as_str)
238 .filter(|s| !s.is_empty())
239 .unwrap_or("")
240 .to_string();
241 atom.insert("valid_from".into(), Value::String(from));
242 }
243 atom.insert("tombstone".into(), Value::Bool(false));
244 atom.entry("embedding").or_insert(Value::Null);
245 self.encode_into(&mut atom);
246 Ok(atom)
247 }
248
249 fn add_prepared(&self, mut atom: Record) -> anyhow::Result<Record> {
251 let workspace = atom
252 .get("workspace")
253 .and_then(Value::as_str)
254 .unwrap_or("")
255 .to_string();
256 let named = atom.get("set").and_then(Value::as_str).map(str::to_string);
257
258 let snapshot = self.store.live(&workspace)?;
261 let narrowed: Vec<Record>;
262 let live: &[Record] = match named.as_deref() {
263 Some(name) => {
264 narrowed = snapshot
265 .iter()
266 .filter(|peer| peer.get("set").and_then(Value::as_str) == Some(name))
267 .cloned()
268 .collect();
269 &narrowed
270 }
271 None if snapshot.iter().any(|peer| peer.contains_key("set")) => {
272 narrowed = snapshot
273 .iter()
274 .filter(|peer| !peer.contains_key("set"))
275 .cloned()
276 .collect();
277 &narrowed
278 }
279 None => &snapshot,
280 };
281 for existing in live {
282 if existing.get("text") == atom.get("text")
283 && existing.get("kind") == atom.get("kind")
284 && existing.get("set") == atom.get("set")
285 {
286 return Ok(existing.clone());
287 }
288 }
289
290 let now = clock::utcnow();
291 let mut closed: Vec<String> = Vec::new();
292 let mut batch = Vec::new();
293 if record::is_live(&atom, &now) {
294 for existing in live {
295 if record::replaces(&atom, existing) {
296 let mut peer = existing.clone();
297 record::close_valid_to(&mut peer, &now);
298 peer.insert("ts".into(), Value::String(clock::utcnow()));
299 if let Some(id) = peer.get("id").and_then(Value::as_str) {
300 closed.push(id.to_string());
301 }
302 batch.push(peer);
303 }
304 }
305 if !closed.is_empty() {
306 let supersedes = atom
307 .entry("supersedes")
308 .or_insert_with(|| Value::Array(Vec::new()));
309 if let Value::Array(ids) = supersedes {
310 for id in &closed {
311 if !ids.iter().any(|v| v.as_str() == Some(id)) {
312 ids.push(Value::String(id.clone()));
313 }
314 }
315 }
316 }
317 }
318 if record::is_live(&atom, &now) {
319 let remaining: Vec<Record>;
322 let peers: &[Record] = if closed.is_empty() {
323 live
324 } else {
325 remaining = live
326 .iter()
327 .filter(|peer| {
328 peer.get("id")
329 .and_then(Value::as_str)
330 .map(|id| !closed.iter().any(|c| c == id))
331 .unwrap_or(true)
332 })
333 .cloned()
334 .collect();
335 &remaining
336 };
337 let rewritten = record::apply_links(&mut atom, peers, record::LINK_THRESHOLD, &now);
338 for mut peer in rewritten {
339 peer.insert("ts".into(), Value::String(clock::utcnow()));
340 batch.push(peer);
341 }
342 } else if !atom.contains_key("links") {
343 atom.insert("links".into(), Value::Array(Vec::new()));
344 }
345 if record::is_live(&atom, &now)
348 && atom.get("kind").and_then(Value::as_str) != Some("trust")
349 && atom.get("kind").and_then(Value::as_str) != Some("persona")
350 && atom
351 .get("due_at")
352 .and_then(Value::as_str)
353 .unwrap_or("")
354 .is_empty()
355 {
356 record::schedule_review(&mut atom, &now, record::Grade::Initial, None);
357 }
358 let mut all = vec![atom.clone()];
359 all.append(&mut batch);
360 self.store.upsert_many(&all)?;
361 self.project_atoms(&all);
362 let forgot = self.enforce_cap(&workspace, &now)?;
363 if forgot > 0 {
364 atom.insert("forgot".into(), json!(forgot));
365 }
366 Ok(atom)
367 }
368
369 fn encode_into(&self, atom: &mut Record) {
371 let text = atom.get("text").and_then(Value::as_str).unwrap_or_default();
372 let Some(vector) = crate::embed::encode_document(text) else {
373 return;
374 };
375 atom.insert(
376 "embedding".into(),
377 Value::Array(vector.into_iter().map(|f| json!(f)).collect()),
378 );
379 }
380
381 pub fn update(
388 &self,
389 workspace: &str,
390 id: &str,
391 fields: &Map<String, Value>,
392 ) -> anyhow::Result<Record> {
393 let _write = self.writes.lock().unwrap_or_else(|e| e.into_inner());
394 self.update_unlocked(workspace, id, fields)
395 }
396
397 fn update_unlocked(
398 &self,
399 workspace: &str,
400 id: &str,
401 fields: &Map<String, Value>,
402 ) -> anyhow::Result<Record> {
403 let current = self.store.live(workspace)?;
404 let mut updated = current
405 .iter()
406 .find(|a| a.get("id").and_then(Value::as_str) == Some(id))
407 .cloned()
408 .ok_or_else(|| anyhow::Error::new(AtomError(format!("no current atom {id}"))))?;
409 for (key, value) in fields {
410 updated.insert(key.clone(), value.clone());
411 }
412 updated.insert("id".into(), Value::String(id.to_string()));
413 updated.insert("workspace".into(), Value::String(workspace.to_string()));
414 updated.insert("ts".into(), Value::String(clock::utcnow()));
415 updated.insert("tombstone".into(), Value::Bool(false));
416 record::validate(&mut updated).map_err(anyhow::Error::new)?;
417
418 let now = clock::utcnow();
419 let mut batch = Vec::new();
420 if record::is_live(&updated, &now) {
421 let rewritten =
422 record::apply_links(&mut updated, ¤t, record::LINK_THRESHOLD, &now);
423 for mut peer in rewritten {
424 peer.insert("ts".into(), Value::String(clock::utcnow()));
425 batch.push(peer);
426 }
427 } else if !updated.contains_key("links") {
428 updated.insert("links".into(), Value::Array(Vec::new()));
429 }
430 let mut all = vec![updated.clone()];
431 all.append(&mut batch);
432 self.store.upsert_many(&all)?;
433 self.project_atoms(&all);
434 Ok(updated)
435 }
436
437 pub fn grade(&self, workspace: &str, id: &str, recalled: bool) -> anyhow::Result<Record> {
443 let _write = self.writes.lock().unwrap_or_else(|e| e.into_inner());
444 let mut atom = self
445 .store
446 .live(workspace)?
447 .iter()
448 .find(|a| a.get("id").and_then(Value::as_str) == Some(id))
449 .cloned()
450 .ok_or_else(|| anyhow::Error::new(AtomError(format!("no current atom {id}"))))?;
451 let grade = if recalled {
452 record::Grade::Recalled
453 } else {
454 record::Grade::Lapsed
455 };
456 record::schedule_review(&mut atom, &clock::utcnow(), grade, None);
457 let mut fields = Map::new();
458 fields.insert("due_at".into(), atom["due_at"].clone());
459 fields.insert("review".into(), atom["review"].clone());
460 self.update_unlocked(workspace, id, &fields)
461 }
462
463 pub fn delete_atom(
474 &self,
475 workspace: &str,
476 id: &str,
477 why: Option<&str>,
478 ) -> anyhow::Result<Record> {
479 let _write = self.writes.lock().unwrap_or_else(|e| e.into_inner());
480 let why = match why.map(str::trim).filter(|w| !w.is_empty()) {
481 Some(w) if !packset_core::atom::is_accession(w) => {
482 return Err(anyhow::Error::new(AtomError(format!(
483 "{w} is not a deed accession; a retraction cites deed-<kind>-<slug> or sha256:<hash>"
484 ))))
485 }
486 other => other,
487 };
488 let tomb = self.store.delete(workspace, id, why)?;
489 let _ = crate::milli::delete(&[id.to_string()], &self.home.milli_dir());
490 Ok(tomb)
491 }
492
493 pub fn pack(&self, workspace: &str, set: Option<&str>) -> anyhow::Result<Value> {
502 match set {
503 Some(raw) => {
504 let named = packset_core::set_name::check(raw)
505 .map_err(|e| anyhow::Error::new(AtomError(e)))?;
506 Ok(json!({
507 "workspace": workspace,
508 "set": named,
509 "user": cards::read_text(&self.home.set_user_path(workspace, &named)),
510 "memory": cards::read_text(&self.home.set_memory_path(workspace, &named)),
511 "instructions": cards::read_text(
512 &self.home.set_instructions_path(workspace, &named)
513 ),
514 "atoms": self.store.current(workspace, Some(&named))?,
515 }))
516 }
517 None => Ok(json!({
518 "workspace": workspace,
519 "user": cards::read_text(&self.home.user_path()),
520 "memory": cards::read_text(&self.home.memory_path(workspace)),
521 "atoms": self.store.current(workspace, None)?,
522 })),
523 }
524 }
525
526 #[must_use]
528 pub fn pin(&self, workspace: &str) -> String {
529 let raw = cards::read_text(&self.home.pin_path(workspace));
530 raw.lines()
531 .next()
532 .map(str::trim)
533 .filter(|line| !line.is_empty())
534 .and_then(|line| packset_core::set_name::check(line).ok())
535 .unwrap_or_default()
536 }
537
538 pub fn set_pin(&self, workspace: &str, name: &str) -> anyhow::Result<String> {
544 let _write = self.writes.lock().unwrap_or_else(|e| e.into_inner());
545 let path = self.home.pin_path(workspace);
546 if let Some(parent) = path.parent() {
547 std::fs::create_dir_all(parent)?;
548 }
549 if name.trim().is_empty() {
550 std::fs::write(&path, "")?;
551 return Ok(String::new());
552 }
553 let stored =
554 packset_core::set_name::check(name).map_err(|e| anyhow::Error::new(AtomError(e)))?;
555 std::fs::write(&path, format!("{stored}\n"))?;
556 Ok(stored)
557 }
558
559 pub fn pin_payload(&self, workspace: &str) -> anyhow::Result<Value> {
565 let named = self.pin(workspace);
566 let instructions = if named.is_empty() {
567 String::new()
568 } else {
569 cards::read_text(&self.home.set_instructions_path(workspace, &named))
570 };
571 Ok(json!({
572 "workspace": workspace,
573 "set": named,
574 "instructions": instructions,
575 }))
576 }
577
578 pub fn write_set(
587 &self,
588 workspace: &str,
589 name: &str,
590 body: &Map<String, Value>,
591 ) -> Result<String, cards::WriteError> {
592 let stored = packset_core::set_name::check(name).map_err(AtomError)?;
593 for (key, path) in [
594 ("user", self.home.set_user_path(workspace, &stored)),
595 ("memory", self.home.set_memory_path(workspace, &stored)),
596 (
597 "instructions",
598 self.home.set_instructions_path(workspace, &stored),
599 ),
600 ] {
601 let Some(value) = body.get(key) else { continue };
602 let text = value.as_str().unwrap_or("");
603 let cap = if key == "memory" {
604 MEMORY_CAP
605 } else {
606 USER_CAP
607 };
608 cards::write_capped(&path, text, cap)?;
609 }
610 Ok(stored)
611 }
612
613 pub fn set_user(&self, text: &str) -> Result<(), cards::WriteError> {
619 match cards::write_capped(&self.home.user_path(), text, USER_CAP) {
620 Err(cards::WriteError::Overflow(o)) => {
621 self.archive("global", text)?;
624 Err(cards::WriteError::Overflow(o))
625 }
626 Ok(()) => {
627 self.project_cards(None);
628 Ok(())
629 }
630 other => other,
631 }
632 }
633
634 pub fn set_memory(&self, workspace: &str, text: &str) -> Result<(), cards::WriteError> {
640 match cards::write_capped(&self.home.memory_path(workspace), text, MEMORY_CAP) {
641 Err(cards::WriteError::Overflow(o)) => {
642 self.archive(workspace, text)?;
643 Err(cards::WriteError::Overflow(o))
644 }
645 Ok(()) => {
646 self.project_cards(Some(workspace));
647 Ok(())
648 }
649 other => other,
650 }
651 }
652
653 pub fn archive(&self, workspace: &str, text: &str) -> Result<(), cards::WriteError> {
659 let day = clock::utcnow()[..10].to_string();
660 let path = self.home.archive_path(workspace, &day);
661 cards::add_entry(&path, text, 1_000_000)
662 }
663
664 pub fn put_attach(&self, workspace: &str, text: &str, label: &str) -> Value {
666 let mut body = text.to_string();
667 if body.chars().count() > ATTACH_CAP {
668 body = body.chars().take(ATTACH_CAP).collect();
669 }
670 let slot = Attachment {
671 text: body,
672 label: label.trim().to_string(),
673 };
674 let mut held = self.attach.lock().expect("attach lock");
675 held.insert(workspace.to_string(), slot.clone());
676 json!({"workspace": workspace, "text": slot.text, "label": slot.label})
677 }
678
679 pub fn take_attach(&self, workspace: &str) -> Option<Attachment> {
681 let mut held = self.attach.lock().expect("attach lock");
682 held.remove(workspace)
683 }
684
685 pub fn peek_attach(&self, workspace: &str) -> Option<Attachment> {
687 let held = self.attach.lock().expect("attach lock");
688 held.get(workspace).cloned()
689 }
690
691 fn project_atoms(&self, atoms: &[Record]) {
694 let dir = self.home.milli_dir();
695 let now = clock::utcnow();
696 let mut live = Vec::new();
697 let mut dead = Vec::new();
698 for atom in atoms {
699 let Some(id) = atom.get("id").and_then(Value::as_str) else {
700 continue;
701 };
702 if record::is_live(atom, &now) || record::is_due(atom, &now) {
703 live.push(crate::milli::atom_document(atom));
704 } else {
705 dead.push(id.to_string());
706 }
707 }
708 if !live.is_empty() {
709 let _ = crate::milli::upsert(&live, &dir);
710 }
711 if !dead.is_empty() {
712 let _ = crate::milli::delete(&dead, &dir);
713 }
714 }
715
716 fn project_cards(&self, workspace: Option<&str>) {
718 let dir = self.home.milli_dir();
719 let workspace = workspace.unwrap_or("");
720 let user = cards::read_text(&self.home.user_path());
721 let memory = if workspace.is_empty() {
722 String::new()
723 } else {
724 cards::read_text(&self.home.memory_path(workspace))
725 };
726 let docs = crate::milli::pack_documents(workspace, &user, &memory, &[]);
727 if !docs.is_empty() {
728 let _ = crate::milli::upsert(&docs, &dir);
729 }
730 }
731
732 pub fn as_of(&self, workspace: &str, at: &str) -> anyhow::Result<Value> {
738 let at =
739 clock::canonicalize(at).ok_or_else(|| anyhow::anyhow!("as_of must be a timestamp"))?;
740 let atoms = self.store.as_of(workspace, &at)?;
741 Ok(json!({ "atoms": atoms, "as_of": at }))
742 }
743
744 #[allow(clippy::too_many_arguments)]
754 pub fn search(
755 &self,
756 workspace: &str,
757 query: &str,
758 limit: usize,
759 set: Option<&str>,
760 panel: &packset_core::Panel,
761 as_of: Option<&str>,
762 rerank: bool,
763 ) -> anyhow::Result<Value> {
764 let named = match set {
765 Some(raw) => Some(
766 packset_core::set_name::check(raw).map_err(|e| anyhow::Error::new(AtomError(e)))?,
767 ),
768 None => None,
769 };
770 let scope = named.as_deref();
771 let (user, memory) = match scope {
775 Some(name) => (
776 cards::read_text(&self.home.set_user_path(workspace, name)),
777 cards::read_text(&self.home.set_memory_path(workspace, name)),
778 ),
779 None => (
780 cards::read_text(&self.home.user_path()),
781 cards::read_text(&self.home.memory_path(workspace)),
782 ),
783 };
784 let as_of = match as_of {
789 Some(raw) => Some(
790 clock::canonicalize(raw)
791 .ok_or_else(|| anyhow::anyhow!("as_of must be a timestamp"))?,
792 ),
793 None => None,
794 };
795 let now = as_of.clone().unwrap_or_else(clock::utcnow);
796 let dated = as_of.as_deref().map(|at| self.store.as_of(workspace, at));
797 let (atoms, index, documents) = match dated {
798 Some(scan) => {
799 let atoms = std::sync::Arc::new(scan?);
800 let documents: std::sync::Arc<Vec<Vec<String>>> = std::sync::Arc::new(
801 atoms
802 .iter()
803 .map(packset_core::search::atom_tokens)
804 .collect(),
805 );
806 let index = std::sync::Arc::new(packset_core::bm25::Index::build(
807 documents.iter().map(Vec::as_slice),
808 ));
809 (atoms, index, documents)
810 }
811 None => self.store.searchable(workspace)?,
812 };
813
814 if packset_core::search::tokens(query).is_empty() {
815 return Ok(json!({"hits": [], "engine": "linear", "as_of": as_of, "rerank": "off"}));
819 }
820
821 let dir = self.home.milli_dir();
822 let corpus = crate::milli::Corpus {
823 workspace,
824 user: &user,
825 memory: &memory,
826 atoms: &atoms,
827 };
828 let first_limit = if rerank {
837 limit.max(crate::embed::RERANK_DEPTH)
838 } else {
839 limit
840 };
841 let ask = packset_core::search::Ask {
842 user: &user,
843 memory: &memory,
844 atoms: &atoms,
845 query,
846 limit: first_limit,
847 set: scope,
848 now: &now,
849 };
850 let ranked_terms = packset_core::search::search_bm25(&ask, &index);
851 let ranked_meaning = crate::embed::encode_query(query)
855 .map(|vector| packset_core::search::search_dense(&ask, &vector))
856 .filter(|hits| !hits.is_empty());
857
858 let projected = if as_of.is_some() {
861 None
862 } else {
863 crate::milli::search(corpus, query, first_limit, &dir, scope)
864 };
865 let (mut ranked, engine) = match projected {
866 Some(atom_hits) => {
867 let prose = packset_core::search::search_linear(&packset_core::search::Ask {
870 atoms: &[],
871 ..ask
872 });
873 let mut ballots = vec![prose, atom_hits, ranked_terms];
874 ballots.extend(ranked_meaning);
875 (
876 packset_core::search::merge_ballots(&ballots, first_limit, panel, &now),
877 "milli",
878 )
879 }
880 None => {
881 let lexical = packset_core::search::search_linear_with(&ask, &documents);
882 let mut ballots = vec![lexical, ranked_terms];
883 ballots.extend(ranked_meaning);
884 let engine = if ballots.len() > 2 { "dense" } else { "linear" };
885 (
886 packset_core::search::merge_ballots(&ballots, first_limit, panel, &now),
887 engine,
888 )
889 }
890 };
891 let stage = if rerank && !ranked.is_empty() {
895 match crate::embed::rerank_hits(query, &ranked) {
896 Some(reordered) => {
897 ranked = reordered;
898 "cross-encoder"
899 }
900 None => "absent",
901 }
902 } else {
903 "off"
904 };
905 ranked.truncate(limit);
906 Ok(json!({"hits": ranked, "engine": engine, "as_of": as_of, "rerank": stage}))
909 }
910
911 pub fn compact(
917 &self,
918 workspace: &str,
919 day: Option<&str>,
920 transcript: Option<&str>,
921 ) -> anyhow::Result<Value> {
922 let live = self.store.live(workspace)?;
923 let proposed =
924 crate::proposals::compact_day(&self.home, workspace, day, &live, transcript, new_id)?;
925 Ok(json!({"n": proposed.len(), "proposals": proposed}))
926 }
927
928 pub fn propose(&self, body: &Map<String, Value>) -> anyhow::Result<Value> {
934 let workspace = body
935 .get("workspace")
936 .and_then(Value::as_str)
937 .filter(|w| !w.is_empty())
938 .ok_or_else(|| anyhow::Error::new(AtomError("workspace required".into())))?;
939 let text = body.get("text").and_then(Value::as_str).unwrap_or("");
940 let when = body
941 .get("when")
942 .and_then(Value::as_str)
943 .filter(|w| !w.is_empty())
944 .unwrap_or("onDemand");
945 let job = body
946 .get("job")
947 .and_then(Value::as_str)
948 .filter(|j| !j.is_empty())
949 .unwrap_or("extract");
950 let transcript = body.get("transcript").and_then(Value::as_str);
951 let live = self.store.live(workspace)?;
952 let wall = crate::proposals::fence(&self.home, workspace, &live);
953 let rec = crate::proposals::propose(
954 &self.home,
955 crate::proposals::Mining {
956 workspace,
957 job,
958 when,
959 wall: &wall,
960 transcript,
961 },
962 text,
963 new_id,
964 )?;
965 rec.ok_or_else(|| anyhow::Error::new(AtomError("nothing to propose".into())))
966 }
967
968 pub fn accept(&self, workspace: &str, proposal_id: &str) -> anyhow::Result<Record> {
974 let _write = self.writes.lock().unwrap_or_else(|e| e.into_inner());
975 let (atom, rec) = crate::proposals::accept(&self.home, workspace, proposal_id)?;
976 let stored = self.add_prepared(self.prepare(atom)?)?;
977 let atom_id = stored.get("id").and_then(Value::as_str).unwrap_or("");
978 crate::proposals::mark_accepted(&self.home, workspace, &rec, atom_id)?;
979 Ok(stored)
980 }
981
982 pub fn islands(&self, workspace: &str) -> anyhow::Result<Value> {
989 let atoms = self.store.live(workspace)?;
990 let graph = packset_core::island::Graph::from_atoms(&atoms);
991 let found = packset_core::island::communities(&graph);
994 let modularity = packset_core::island::modularity(&graph, &found);
995 let propagated = packset_core::island::islands(&graph);
996 let propagated_modularity = packset_core::island::modularity(&graph, &propagated);
997 let islands: Vec<Value> = found
998 .into_iter()
999 .map(|members| {
1000 let signature = packset_core::island::signature(&graph, &members);
1001 let atoms: Vec<Value> = members
1002 .iter()
1003 .map(|&i| {
1004 json!({
1005 "id": atoms[i].get("id").cloned().unwrap_or(Value::Null),
1006 "kind": atoms[i].get("kind").cloned().unwrap_or(Value::Null),
1007 "text": atoms[i].get("text").cloned().unwrap_or(Value::Null),
1008 })
1009 })
1010 .collect();
1011 json!({"size": members.len(), "signature": format!("{signature:016x}"), "atoms": atoms})
1012 })
1013 .collect();
1014 Ok(json!({
1015 "islands": islands,
1016 "atoms": atoms.len(),
1017 "method": "modularity",
1018 "modularity": modularity,
1019 "propagation": {"islands": propagated.len(), "modularity": propagated_modularity},
1020 }))
1021 }
1022
1023 pub fn hubs(&self, workspace: &str, limit: usize) -> anyhow::Result<Value> {
1031 let atoms = self.store.live(workspace)?;
1032 let graph = packset_core::island::Graph::from_atoms(&atoms);
1033 let hubs: Vec<Value> = packset_core::island::hubs(&graph)
1034 .into_iter()
1035 .take(limit)
1036 .map(|(i, score)| {
1037 json!({
1038 "id": atoms[i].get("id").cloned().unwrap_or(Value::Null),
1039 "kind": atoms[i].get("kind").cloned().unwrap_or(Value::Null),
1040 "text": atoms[i].get("text").cloned().unwrap_or(Value::Null),
1041 "score": score,
1042 "links": atoms[i].get("links").and_then(Value::as_array).map_or(0, Vec::len),
1043 })
1044 })
1045 .collect();
1046 Ok(json!({"hubs": hubs, "atoms": atoms.len()}))
1047 }
1048
1049 pub fn fire(&self, workspace: &str, ids: &[String]) -> anyhow::Result<Value> {
1057 let _write = self.writes.lock().unwrap_or_else(|e| e.into_inner());
1058 let live = self.store.live(workspace)?;
1059 let mut atoms: Vec<Record> = live.iter().cloned().collect();
1060 let fired: Vec<usize> = ids
1061 .iter()
1062 .filter_map(|id| {
1063 atoms
1064 .iter()
1065 .position(|a| a.get("id").and_then(Value::as_str) == Some(id.as_str()))
1066 })
1067 .collect();
1068 let changed = packset_core::island::fire(&mut atoms, &fired);
1069 if !changed.is_empty() {
1070 let now = clock::utcnow();
1071 let batch: Vec<Record> = changed
1072 .iter()
1073 .map(|&i| {
1074 let mut atom = atoms[i].clone();
1075 atom.insert("ts".into(), Value::String(now.clone()));
1076 atom
1077 })
1078 .collect();
1079 self.store.upsert_many(&batch)?;
1080 self.project_atoms(&batch);
1081 }
1082 Ok(json!({"fired": fired.len(), "changed": changed.len()}))
1083 }
1084
1085 pub fn consolidate(&self, workspace: &str, apply: bool) -> anyhow::Result<Value> {
1097 let _write = self.writes.lock().unwrap_or_else(|e| e.into_inner());
1098 let live = self.store.live(workspace)?;
1099 let mut atoms: Vec<Record> = live.iter().cloned().collect();
1100 atoms.sort_by(|a, b| {
1101 a.get("ts")
1102 .and_then(Value::as_str)
1103 .unwrap_or("")
1104 .cmp(b.get("ts").and_then(Value::as_str).unwrap_or(""))
1105 });
1106 let now = clock::utcnow();
1107 let mut buckets: std::collections::HashMap<String, Vec<usize>> =
1113 std::collections::HashMap::new();
1114 let mut keys_of: Vec<Vec<String>> = Vec::with_capacity(atoms.len());
1115 for (i, atom) in atoms.iter().enumerate() {
1116 let text = atom.get("text").and_then(Value::as_str).unwrap_or("");
1117 let head = record::head_tokens(text);
1118 let mut keys = Vec::new();
1119 if head.len() >= record::HEAD_MIN {
1120 keys.push(format!("h:{}", head[..record::HEAD_MIN].join(" ")));
1121 }
1122 if let Some(Value::Array(items)) = atom.get("entities") {
1126 for entity in items.iter().filter_map(Value::as_str) {
1127 keys.push(format!("e:{}", entity.to_lowercase()));
1128 }
1129 }
1130 for key in &keys {
1131 buckets.entry(key.clone()).or_default().push(i);
1132 }
1133 keys_of.push(keys);
1134 }
1135 let mut open = vec![true; atoms.len()];
1136 let mut pairs: Vec<(usize, usize)> = Vec::new();
1137 for i in 0..atoms.len() {
1138 if !record::is_live(&atoms[i], &now) {
1139 open[i] = false;
1140 continue;
1141 }
1142 let mut candidates: Vec<usize> = keys_of[i]
1143 .iter()
1144 .filter_map(|key| buckets.get(key))
1145 .filter(|members| members.len() <= CONSOLIDATE_BUCKET_MAX)
1147 .flat_map(|members| members.iter().copied())
1148 .filter(|&j| j < i && open[j])
1149 .collect();
1150 candidates.sort_unstable();
1151 candidates.dedup();
1152 for j in candidates {
1153 if open[j] && record::replaces(&atoms[i], &atoms[j]) {
1154 open[j] = false;
1155 pairs.push((i, j));
1156 }
1157 }
1158 }
1159 let closed: Vec<Value> = pairs
1160 .iter()
1161 .map(|(i, j)| {
1162 json!({
1163 "old": atoms[*j].get("id").cloned().unwrap_or(Value::Null),
1164 "old_text": atoms[*j].get("text").cloned().unwrap_or(Value::Null),
1165 "new": atoms[*i].get("id").cloned().unwrap_or(Value::Null),
1166 "new_text": atoms[*i].get("text").cloned().unwrap_or(Value::Null),
1167 })
1168 })
1169 .collect();
1170 if apply && !pairs.is_empty() {
1171 let mut touched: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
1172 for (i, j) in &pairs {
1173 record::close_valid_to(&mut atoms[*j], &now);
1174 let old_id = atoms[*j]
1175 .get("id")
1176 .and_then(Value::as_str)
1177 .unwrap_or("")
1178 .to_string();
1179 let supersedes = atoms[*i]
1180 .entry("supersedes")
1181 .or_insert_with(|| Value::Array(Vec::new()));
1182 if let Value::Array(ids) = supersedes {
1183 if !ids.iter().any(|v| v.as_str() == Some(old_id.as_str())) {
1184 ids.push(Value::String(old_id));
1185 }
1186 }
1187 touched.insert(*i);
1188 touched.insert(*j);
1189 }
1190 let batch: Vec<Record> = touched
1191 .into_iter()
1192 .map(|k| {
1193 let mut atom = atoms[k].clone();
1194 atom.insert("ts".into(), Value::String(now.clone()));
1195 atom
1196 })
1197 .collect();
1198 self.store.upsert_many(&batch)?;
1199 self.project_atoms(&batch);
1200 }
1201 Ok(json!({
1202 "live": live.len(),
1203 "closed": closed.len(),
1204 "applied": apply,
1205 "pairs": closed,
1206 }))
1207 }
1208
1209 pub fn activate(
1217 &self,
1218 workspace: &str,
1219 query: &str,
1220 limit: usize,
1221 panel: &packset_core::Panel,
1222 fire: bool,
1223 ) -> anyhow::Result<Value> {
1224 let seeds = self.search(workspace, query, ACTIVATION_SEEDS, None, panel, None, false)?;
1225 let atoms = self.store.live(workspace)?;
1226 let graph = packset_core::island::Graph::from_atoms(&atoms);
1227 let weighted: Vec<(usize, f64)> = seeds["hits"]
1228 .as_array()
1229 .map(|hits| {
1230 hits.iter()
1231 .filter_map(|hit| {
1232 let id = hit["id"].as_str()?;
1233 let at = graph.position(id)?;
1234 Some((
1235 at,
1236 hit["score"].as_f64().unwrap_or(1.0).max(f64::MIN_POSITIVE),
1237 ))
1238 })
1239 .collect()
1240 })
1241 .unwrap_or_default();
1242 let agreed = seeds["hits"]
1247 .as_array()
1248 .map(|hits| {
1249 hits.iter()
1250 .filter(|hit| {
1251 let of = hit["of"].as_u64().unwrap_or(1);
1252 let named = hit["ballots"].as_u64().unwrap_or(1);
1253 of < 2 || named >= 2
1254 })
1255 .filter(|hit| {
1256 hit["id"]
1257 .as_str()
1258 .and_then(|id| graph.position(id))
1259 .is_some()
1260 })
1261 .count()
1262 })
1263 .unwrap_or(0);
1264 let weak = agreed < 2;
1265 let lit = packset_core::island::activate(&graph, &weighted, ACTIVATION_HOPS);
1266 let strongest = lit.first().map_or(1.0, |(_, a)| *a);
1267 let island: Vec<Value> = lit
1268 .iter()
1269 .take(limit)
1270 .map(|(at, activation)| {
1271 json!({
1272 "id": atoms[*at].get("id").cloned().unwrap_or(Value::Null),
1273 "kind": atoms[*at].get("kind").cloned().unwrap_or(Value::Null),
1274 "text": atoms[*at].get("text").cloned().unwrap_or(Value::Null),
1275 "ts": atoms[*at].get("ts").cloned().unwrap_or(Value::Null),
1276 "activation": activation / strongest,
1277 "seed": weighted.iter().any(|(s, _)| s == at),
1278 })
1279 })
1280 .collect();
1281 let fired = if fire && !weak {
1282 let ids: Vec<String> = lit
1283 .iter()
1284 .take(FIRE_TOP)
1285 .filter_map(|(at, _)| atoms[*at].get("id").and_then(Value::as_str))
1286 .map(str::to_string)
1287 .collect();
1288 self.fire(workspace, &ids)?["changed"].as_u64().unwrap_or(0)
1289 } else {
1290 0
1291 };
1292 Ok(json!({
1293 "island": island,
1294 "seeds": weighted.len(),
1295 "agreed_seeds": agreed,
1296 "weak": weak,
1297 "dense": crate::embed::binary().is_some(),
1298 "hops": ACTIVATION_HOPS,
1299 "fired": fired,
1300 }))
1301 }
1302
1303 #[must_use]
1305 pub fn proposals(&self, workspace: &str) -> Vec<Value> {
1306 crate::proposals::list_open(&self.home, workspace)
1307 }
1308
1309 pub fn accessions(&self, workspace: &str) -> anyhow::Result<Vec<String>> {
1320 let atoms = self.store.live(workspace)?;
1321 let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1322 for atom in atoms.iter() {
1323 let Some(entities) = atom.get("entities").and_then(Value::as_array) else {
1324 continue;
1325 };
1326 for entity in entities {
1327 let Some(text) = entity.as_str() else {
1328 continue;
1329 };
1330 let text = text.trim();
1331 if record::is_accession(text) {
1332 seen.insert(text);
1333 }
1334 }
1335 }
1336 Ok(seen.into_iter().map(ToString::to_string).collect())
1337 }
1338
1339 pub fn citers(&self, workspace: &str, accession: &str) -> anyhow::Result<Vec<Value>> {
1350 let wanted = accession.trim();
1351 if wanted.is_empty() {
1352 return Ok(Vec::new());
1353 }
1354 let atoms = self.store.live(workspace)?;
1355 Ok(atoms
1356 .iter()
1357 .filter(|atom| {
1358 atom.get("entities")
1359 .and_then(Value::as_array)
1360 .is_some_and(|entities| {
1361 entities
1362 .iter()
1363 .filter_map(Value::as_str)
1364 .any(|entity| entity.trim() == wanted)
1365 })
1366 })
1367 .map(|atom| {
1368 json!({
1369 "id": atom.get("id").cloned().unwrap_or(Value::Null),
1370 "kind": atom.get("kind").cloned().unwrap_or(Value::Null),
1371 "text": atom.get("text").cloned().unwrap_or(Value::Null),
1372 "ts": atom.get("ts").cloned().unwrap_or(Value::Null),
1373 })
1374 })
1375 .collect())
1376 }
1377
1378 pub fn status(
1384 &self,
1385 workspace: Option<&str>,
1386 panel: &packset_core::Panel,
1387 ) -> anyhow::Result<Value> {
1388 let now = clock::utcnow();
1389 let mut live: BTreeMap<String, usize> = BTreeMap::new();
1390 let mut tomb: BTreeMap<String, usize> = BTreeMap::new();
1391 let mut expired: BTreeMap<String, usize> = BTreeMap::new();
1392 let mut last_write = String::new();
1393 self.store.for_each(workspace, |rec| {
1394 let kind = rec
1395 .get("kind")
1396 .and_then(Value::as_str)
1397 .unwrap_or("unknown")
1398 .to_string();
1399 if let Some(ts) = rec.get("ts").and_then(Value::as_str) {
1400 if ts > last_write.as_str() {
1401 last_write = ts.to_string();
1402 }
1403 }
1404 if rec
1405 .get("tombstone")
1406 .and_then(Value::as_bool)
1407 .unwrap_or(false)
1408 {
1409 *tomb.entry(kind).or_insert(0) += 1;
1410 } else if record::is_live(rec, &now) {
1411 *live.entry(kind).or_insert(0) += 1;
1412 } else {
1413 *expired.entry(kind).or_insert(0) += 1;
1414 }
1415 })?;
1416 let milli_dir = self.home.milli_dir();
1417 let index_ready = crate::milli::index_ready(&milli_dir);
1418 let pin = workspace.map(|w| self.pin(w)).unwrap_or_default();
1419 Ok(json!({
1420 "home": self.home.root().display().to_string(),
1421 "version": env!("CARGO_PKG_VERSION"),
1425 "commit": env!("PACKSET_COMMIT"),
1426 "workspace": workspace.unwrap_or(""),
1427 "set": pin,
1428 "live": live.values().sum::<usize>(),
1429 "live_cap": self.live_cap,
1430 "tombstone": tomb.values().sum::<usize>(),
1431 "expired": expired.values().sum::<usize>(),
1432 "live_by_kind": live,
1433 "tombstone_by_kind": tomb,
1434 "expired_by_kind": expired,
1435 "last_write_ts": if last_write.is_empty() { Value::Null } else { Value::String(last_write) },
1436 "milli": {
1437 "binary": milli_binary(),
1438 "index_dir": milli_dir.display().to_string(),
1439 "index_ready": index_ready,
1440 },
1441 "embedder": {
1442 "enabled": embed_enabled(),
1443 "binary": crate::embed::binary().map(|path| path.display().to_string()),
1444 "available": embed_enabled() && crate::embed::binary().is_some(),
1445 },
1446 "rerank": {
1449 "enabled": crate::embed::wanted(),
1450 "available": crate::embed::binary().is_some(),
1451 "depth": crate::embed::RERANK_DEPTH,
1452 },
1453 "panel": {
1457 "fuse": panel.fuse.as_str(),
1458 "diversify": panel.diversify.as_str(),
1459 "decay": panel.decay.as_str(),
1460 },
1461 }))
1462 }
1463}
1464
1465fn embed_enabled() -> bool {
1471 let raw = std::env::var("INSIDE_EMBED").unwrap_or_else(|_| "on".into());
1472 !matches!(
1473 raw.trim().to_ascii_lowercase().as_str(),
1474 "0" | "off" | "none" | "false" | "no"
1475 )
1476}
1477
1478fn milli_binary() -> Value {
1483 crate::milli::binary().map_or(Value::Null, |p| Value::String(p.display().to_string()))
1484}
1485
1486fn new_id() -> String {
1488 use std::time::{SystemTime, UNIX_EPOCH};
1489 let nanos = SystemTime::now()
1490 .duration_since(UNIX_EPOCH)
1491 .map_or(0u128, |d| d.as_nanos());
1492 let pid = u128::from(std::process::id());
1493 let counter = {
1494 use std::sync::atomic::{AtomicU64, Ordering};
1495 static NEXT: AtomicU64 = AtomicU64::new(0);
1496 u128::from(NEXT.fetch_add(1, Ordering::Relaxed))
1497 };
1498 let mut state = nanos ^ (pid << 64) ^ (counter << 32);
1501 let mut out = String::with_capacity(32);
1502 for _ in 0..32 {
1503 state = state
1504 .wrapping_mul(6_364_136_223_846_793_005)
1505 .wrapping_add(1_442_695_040_888_963_407);
1506 let nibble = ((state >> 64) & 0xf) as u8;
1507 out.push(char::from_digit(u32::from(nibble), 16).unwrap_or('0'));
1508 }
1509 out
1510}
1511
1512#[cfg(test)]
1513mod tests {
1514 use super::*;
1515
1516 fn service() -> (tempfile::TempDir, Service) {
1517 let dir = tempfile::tempdir().unwrap();
1518 let svc = Service::open(Home::new(dir.path())).unwrap();
1519 (dir, svc)
1520 }
1521
1522 fn atom(text: &str) -> Record {
1523 json!({
1524 "workspace": "w",
1525 "text": text,
1526 "kind": "voice",
1527 "about_peer": "rgoswami",
1528 "by_peer": "hermes"
1529 })
1530 .as_object()
1531 .unwrap()
1532 .clone()
1533 }
1534
1535 #[test]
1536 fn the_live_cap_forgets_the_least_retrievable_lessons_first() {
1537 let (_dir, svc) = service();
1538 let svc = svc.with_live_cap(3);
1539 let mut rows = Vec::new();
1542 for (i, day) in ["01", "02", "03", "04"].iter().enumerate() {
1543 let mut a = atom(&format!(
1544 "Lesson number {i} stands alone on its own words {day}."
1545 ));
1546 a.insert(
1547 "id".into(),
1548 json!(format!("lesson000000000000000000000000000{i}")),
1549 );
1550 a.insert("ts".into(), json!(format!("2026-{day}-01T00:00:00.000Z")));
1551 a.insert("kind".into(), json!("lesson"));
1552 a.insert(
1553 "review".into(),
1554 json!({"last": format!("2026-{day}-01T00:00:00.000Z"), "stability": 1.0}),
1555 );
1556 rows.push(a);
1557 }
1558 let mut pref = atom("Prefer CombMNZ over RRF for two ballots.");
1559 pref.insert("id".into(), json!("pref00000000000000000000000000000001"));
1560 pref.insert("ts".into(), json!("2025-06-01T00:00:00.000Z"));
1561 pref.insert("kind".into(), json!("preference"));
1562 rows.push(pref);
1563 svc.store().upsert_many(&rows).unwrap();
1564 let mut fifth = atom("A fifth lesson arrives and the pack is over its cap today.");
1567 fifth.insert("kind".into(), json!("lesson"));
1568 let written = svc.add(fifth).unwrap();
1569 assert_eq!(written["forgot"], json!(3), "{written:?}");
1570 let live = svc.store().live("w").unwrap();
1571 let ids: Vec<&str> = live
1572 .iter()
1573 .filter_map(|a| a.get("id").and_then(Value::as_str))
1574 .collect();
1575 assert_eq!(live.len(), 3, "{ids:?}");
1576 assert!(
1577 ids.contains(&"pref00000000000000000000000000000001"),
1578 "{ids:?}"
1579 );
1580 assert!(
1581 ids.contains(&"lesson0000000000000000000000000003"),
1582 "{ids:?}"
1583 );
1584 assert!(ids.contains(&written["id"].as_str().unwrap()), "{ids:?}");
1585 for gone in [
1586 "lesson0000000000000000000000000000",
1587 "lesson0000000000000000000000000001",
1588 "lesson0000000000000000000000000002",
1589 ] {
1590 assert!(!ids.contains(&gone), "{ids:?}");
1591 }
1592 let forgotten = svc
1593 .store()
1594 .scan(Some("w"))
1595 .unwrap()
1596 .iter()
1597 .filter(|a| a.get("forgotten").is_some())
1598 .count();
1599 assert_eq!(forgotten, 3);
1600 let panel = packset_core::Panel::named("rrf", "none", "off").unwrap();
1601 assert_eq!(svc.status(None, &panel).unwrap()["live_cap"], json!(3));
1602 }
1603
1604 #[test]
1605 fn consolidate_closes_what_arrival_never_saw_and_reports_first() {
1606 let (_dir, svc) = service();
1607 let mut older = atom("The default fuse is Borda.");
1611 older.insert("id".into(), json!("older0000000000000000000000000001"));
1612 older.insert("ts".into(), json!("2026-01-01T00:00:00.000Z"));
1613 older.insert("kind".into(), json!("lesson"));
1614 let mut newer = atom("The default fuse is CombMNZ.");
1615 newer.insert("id".into(), json!("newer0000000000000000000000000002"));
1616 newer.insert("ts".into(), json!("2026-02-01T00:00:00.000Z"));
1617 newer.insert("kind".into(), json!("lesson"));
1618 svc.store()
1619 .upsert_many(&[older.clone(), newer.clone()])
1620 .unwrap();
1621
1622 let report = svc.consolidate("w", false).unwrap();
1623 assert_eq!(report["closed"], json!(1), "{report}");
1624 assert_eq!(report["applied"], json!(false));
1625 assert_eq!(report["pairs"][0]["old"], older["id"]);
1626 assert_eq!(report["pairs"][0]["new"], newer["id"]);
1627 assert_eq!(
1628 svc.store().live("w").unwrap().len(),
1629 2,
1630 "a report writes nothing"
1631 );
1632
1633 let applied = svc.consolidate("w", true).unwrap();
1634 assert_eq!(applied["closed"], json!(1), "{applied}");
1635 let live = svc.store().live("w").unwrap();
1636 assert_eq!(live.len(), 1, "{live:?}");
1637 assert_eq!(live[0]["id"], newer["id"]);
1638 assert!(
1639 live[0]["supersedes"]
1640 .as_array()
1641 .unwrap()
1642 .iter()
1643 .any(|v| v == &older["id"]),
1644 "{:?}",
1645 live[0]
1646 );
1647 let again = svc.consolidate("w", true).unwrap();
1648 assert_eq!(again["closed"], json!(0), "nothing left to close");
1649 }
1650
1651 #[test]
1652 fn an_id_is_thirty_two_hex_characters_and_does_not_repeat() {
1653 let a = new_id();
1654 assert_eq!(a.len(), 32, "{a}");
1655 assert!(a.chars().all(|c| c.is_ascii_hexdigit()), "{a}");
1656 let many: std::collections::HashSet<String> = (0..1000).map(|_| new_id()).collect();
1657 assert_eq!(many.len(), 1000, "ids collided");
1658 }
1659
1660 #[test]
1661 fn the_same_claim_twice_is_one_atom() {
1662 let (_dir, svc) = service();
1663 let first = svc.add(atom("Reviews open with a check.")).unwrap();
1664 let again = svc.add(atom("Reviews open with a check.")).unwrap();
1665 assert_eq!(first["id"], again["id"], "a retry is not a second claim");
1666 assert_eq!(svc.store().current("w", None).unwrap().len(), 1);
1667 }
1668
1669 #[test]
1670 fn a_set_scoped_claim_is_not_a_duplicate_of_an_unscoped_one() {
1671 let (_dir, svc) = service();
1672 let plain = svc.add(atom("Reviews open with a check.")).unwrap();
1673 let mut scoped = atom("Reviews open with a check.");
1674 scoped.insert("set".into(), json!("review"));
1675 let scoped = svc.add(scoped).unwrap();
1676 assert_ne!(plain["id"], scoped["id"]);
1677 assert_eq!(svc.store().current("w", None).unwrap().len(), 2);
1678 }
1679
1680 #[test]
1681 fn a_tool_dump_is_refused_as_an_atom() {
1682 let (_dir, svc) = service();
1683 let listing = std::iter::once("total 48".to_string())
1684 .chain((0..7).map(|i| format!("-rw-r--r-- 1 x x 0 Jan 1 00:00 file{i}")))
1685 .collect::<Vec<_>>()
1686 .join("\n");
1687 let err = svc.add(atom(&listing)).unwrap_err();
1688 assert!(err.to_string().contains("attach"), "{err}");
1689
1690 let fenced = atom("Here is the run:\n```\nstdout: everything fine\n```");
1692 assert!(svc.add(fenced).is_err());
1693 }
1694
1695 #[test]
1696 fn linking_is_symmetric_across_a_write() {
1697 let (_dir, svc) = service();
1698 let mut one = atom("The Parser reads the Header.");
1699 one.insert("entities".into(), json!(["Parser", "Header"]));
1700 let one = svc.add(one).unwrap();
1701 let mut two = atom("The Header comes before the Parser body.");
1702 two.insert("entities".into(), json!(["Parser", "Header"]));
1703 let two = svc.add(two).unwrap();
1704
1705 let live = svc.store().current("w", None).unwrap();
1706 let first = live.iter().find(|a| a["id"] == one["id"]).unwrap();
1707 let second = live.iter().find(|a| a["id"] == two["id"]).unwrap();
1708 assert_eq!(second["links"], json!([one["id"].as_str().unwrap()]));
1709 assert_eq!(
1710 first["links"],
1711 json!([two["id"].as_str().unwrap()]),
1712 "the peer was rewritten, not just the newcomer"
1713 );
1714 }
1715
1716 #[test]
1717 fn a_contrary_remember_closes_the_live_window() {
1718 let (_dir, svc) = service();
1719 let mut old = atom("The default fuse is Borda.");
1720 old.insert("entities".into(), json!(["fuse", "Borda"]));
1721 let old = svc.add(old).unwrap();
1722 let mut neu = atom("The default fuse is CombMNZ.");
1723 neu.insert("entities".into(), json!(["fuse", "CombMNZ"]));
1724 let neu = svc.add(neu).unwrap();
1725 let now = packset_core::clock::utcnow();
1726 let live: Vec<_> = svc
1727 .store()
1728 .current("w", None)
1729 .unwrap()
1730 .into_iter()
1731 .filter(|a| packset_core::record::is_live(a, &now))
1732 .collect();
1733 assert_eq!(live.len(), 1, "the old claim is no longer live");
1734 assert_eq!(live[0]["id"], neu["id"]);
1735 let closed = svc
1736 .store()
1737 .get("w", old["id"].as_str().unwrap())
1738 .unwrap()
1739 .expect("the closed atom stays on disk");
1740 assert!(
1741 closed.get("valid_to").and_then(Value::as_str).is_some(),
1742 "{closed:?}"
1743 );
1744 let supersedes = neu["supersedes"].as_array().expect("supersedes");
1745 assert!(
1746 supersedes.iter().any(|v| v.as_str() == old["id"].as_str()),
1747 "{neu:?}"
1748 );
1749 let found = svc
1750 .search(
1751 "w",
1752 "Borda",
1753 8,
1754 None,
1755 &packset_core::Panel::default(),
1756 None,
1757 false,
1758 )
1759 .unwrap();
1760 let hits = found["hits"].as_array().expect("hits");
1761 assert!(
1762 hits.iter()
1763 .all(|h| h.get("id").and_then(Value::as_str) != old["id"].as_str()),
1764 "search filters the closed atom: {found}"
1765 );
1766 let found_new = svc
1767 .search(
1768 "w",
1769 "CombMNZ",
1770 8,
1771 None,
1772 &packset_core::Panel::default(),
1773 None,
1774 false,
1775 )
1776 .unwrap();
1777 let new_hits = found_new["hits"].as_array().expect("hits");
1778 assert!(
1779 new_hits
1780 .iter()
1781 .any(|h| h.get("id").and_then(Value::as_str) == neu["id"].as_str()),
1782 "{found_new}"
1783 );
1784 let linked_to_closed = neu
1785 .get("links")
1786 .and_then(Value::as_array)
1787 .is_some_and(|links| links.iter().any(|v| v.as_str() == old["id"].as_str()));
1788 assert!(!linked_to_closed, "a close is not a link: {neu:?}");
1789 }
1790
1791 #[test]
1792 fn add_writes_the_start_of_the_window() {
1793 let (_dir, svc) = service();
1794 let stored = svc.add(atom("Reviews open with a check.")).unwrap();
1795 assert!(
1796 stored.get("valid_from").and_then(Value::as_str).is_some(),
1797 "{stored:?}"
1798 );
1799 }
1800
1801 #[test]
1802 fn add_seeds_the_review_clock_except_for_trust_and_persona() {
1803 let (_dir, svc) = service();
1804 let stored = svc.add(atom("Reviews open with a check.")).unwrap();
1805 let due = stored.get("due_at").and_then(Value::as_str).unwrap_or("");
1806 assert!(!due.is_empty(), "{stored:?}");
1807 assert_eq!(stored["review"]["reps"], 0);
1808 let mut row = atom("a weighs b.");
1809 row.insert("kind".into(), "trust".into());
1810 row.insert("from".into(), "a".into());
1811 row.insert("to".into(), "b".into());
1812 row.insert("weight".into(), 0.5.into());
1813 let stored = svc.add(row).unwrap();
1814 assert!(stored.get("due_at").is_none(), "{stored:?}");
1815 let mut persona = atom("A voter with a view of its own.");
1816 persona.insert("kind".into(), "persona".into());
1817 persona.insert("name".into(), "rev-honest".into());
1818 persona.insert("view".into(), "Reject habitat leaks.".into());
1819 persona.insert("anchor".into(), 0.2.into());
1820 let stored = svc.add(persona).unwrap();
1821 assert!(stored.get("due_at").is_none(), "{stored:?}");
1822 }
1823
1824 #[test]
1825 fn a_dated_retrieve_returns_the_atom_that_was_live_then() {
1826 let (_dir, svc) = service();
1827 svc.store()
1828 .upsert(
1829 &json!({
1830 "id": "old",
1831 "workspace": "w",
1832 "text": "The default fuse is Borda.",
1833 "kind": "voice",
1834 "valid_from": "2024-01-01T00:00:00.000Z",
1835 "valid_to": "2024-12-01T00:00:00.000Z"
1836 })
1837 .as_object()
1838 .unwrap()
1839 .clone(),
1840 )
1841 .unwrap();
1842 svc.store()
1843 .upsert(
1844 &json!({
1845 "id": "neu",
1846 "workspace": "w",
1847 "text": "The default fuse is CombMNZ.",
1848 "kind": "voice",
1849 "valid_from": "2024-12-01T00:00:00.000Z"
1850 })
1851 .as_object()
1852 .unwrap()
1853 .clone(),
1854 )
1855 .unwrap();
1856 let then = svc.as_of("w", "2024-06-01T00:00:00.000Z").unwrap();
1857 let then_ids: Vec<&str> = then["atoms"]
1858 .as_array()
1859 .unwrap()
1860 .iter()
1861 .filter_map(|a| a["id"].as_str())
1862 .collect();
1863 assert_eq!(then_ids, vec!["old"], "{then}");
1864 assert_eq!(then["as_of"], json!("2024-06-01T00:00:00.000Z"));
1865 let offset = svc.as_of("w", "2024-06-01T00:00:00+00:00").unwrap();
1866 let offset_ids: Vec<&str> = offset["atoms"]
1867 .as_array()
1868 .unwrap()
1869 .iter()
1870 .filter_map(|a| a["id"].as_str())
1871 .collect();
1872 assert_eq!(offset_ids, then_ids, "{offset}");
1873 assert_eq!(offset["as_of"], json!("2024-06-01T00:00:00.000Z"));
1874 let later = svc.as_of("w", "2025-01-01T00:00:00.000Z").unwrap();
1875 let later_ids: Vec<&str> = later["atoms"]
1876 .as_array()
1877 .unwrap()
1878 .iter()
1879 .filter_map(|a| a["id"].as_str())
1880 .collect();
1881 assert_eq!(later_ids, vec!["neu"], "{later}");
1882
1883 let panel = packset_core::Panel::default();
1884 let hits = svc
1885 .search(
1886 "w",
1887 "Borda",
1888 8,
1889 None,
1890 &panel,
1891 Some("2024-06-01T00:00:00.000Z"),
1892 false,
1893 )
1894 .unwrap();
1895 let hit_ids: Vec<&str> = hits["hits"]
1896 .as_array()
1897 .unwrap()
1898 .iter()
1899 .filter_map(|h| h["id"].as_str())
1900 .collect();
1901 assert_eq!(hit_ids, vec!["old"], "{hits}");
1902 assert_eq!(hits["as_of"], json!("2024-06-01T00:00:00.000Z"));
1903 let offset_hits = svc
1904 .search(
1905 "w",
1906 "Borda",
1907 8,
1908 None,
1909 &panel,
1910 Some("2024-06-01T00:00:00+00:00"),
1911 false,
1912 )
1913 .unwrap();
1914 let offset_hit_ids: Vec<&str> = offset_hits["hits"]
1915 .as_array()
1916 .unwrap()
1917 .iter()
1918 .filter_map(|h| h["id"].as_str())
1919 .collect();
1920 assert_eq!(offset_hit_ids, hit_ids, "{offset_hits}");
1921 assert_eq!(offset_hits["as_of"], json!("2024-06-01T00:00:00.000Z"));
1922 let now_hits = svc
1923 .search("w", "Borda", 8, None, &panel, None, false)
1924 .unwrap();
1925 let now_ids: Vec<&str> = now_hits["hits"]
1926 .as_array()
1927 .unwrap()
1928 .iter()
1929 .filter_map(|h| h["id"].as_str())
1930 .collect();
1931 assert!(
1932 !now_ids.contains(&"old"),
1933 "live-now search still drops it: {now_hits}"
1934 );
1935 }
1936
1937 #[test]
1938 fn updating_a_missing_atom_says_so() {
1939 let (_dir, svc) = service();
1940 let err = svc.update("w", "nope", &Map::new()).unwrap_err();
1941 assert_eq!(err.to_string(), "no current atom nope");
1942 }
1943
1944 #[test]
1945 fn grading_moves_the_review_clock_and_not_the_live_window() {
1946 let (_dir, svc) = service();
1947 let stored = svc.add(atom("Reviews open with a check.")).unwrap();
1948 let id = stored["id"].as_str().unwrap();
1949 let graded = svc.grade("w", id, true).unwrap();
1950 assert!(graded["due_at"].is_string(), "{graded:?}");
1951 assert_eq!(graded["review"]["reps"], json!(1));
1952 assert!(
1953 graded.get("valid_to").is_none() || graded["valid_to"].is_null(),
1954 "the live window is a different question"
1955 );
1956 }
1957
1958 #[test]
1959 fn the_pack_is_one_shape_scoped_or_not() {
1960 let (_dir, svc) = service();
1961 svc.add(atom("Reviews open with a check.")).unwrap();
1962 let plain = svc.pack("w", None).unwrap();
1963 for key in ["workspace", "user", "memory", "atoms"] {
1964 assert!(plain.get(key).is_some(), "{key} missing from {plain}");
1965 }
1966 assert!(plain.get("set").is_none());
1967
1968 let scoped = svc.pack("w", Some("Review")).unwrap();
1969 assert_eq!(scoped["set"], json!("review"), "the name is normalized");
1970 for key in ["workspace", "user", "memory", "atoms", "instructions"] {
1971 assert!(scoped.get(key).is_some(), "{key} missing from {scoped}");
1972 }
1973 assert!(svc.pack("w", Some("../etc")).is_err());
1974 }
1975
1976 #[test]
1977 fn a_pin_round_trips_and_clears() {
1978 let (_dir, svc) = service();
1979 assert_eq!(svc.pin("w"), "");
1980 assert_eq!(svc.set_pin("w", "Review").unwrap(), "review");
1981 assert_eq!(svc.pin("w"), "review");
1982 assert_eq!(svc.set_pin("w", "").unwrap(), "");
1983 assert_eq!(svc.pin("w"), "");
1984 assert!(svc.set_pin("w", "../etc").is_err());
1985 }
1986
1987 #[test]
1988 fn an_overflowing_card_is_archived_before_it_is_refused() {
1989 let (_dir, svc) = service();
1990 let long = "One small claim. ".repeat(USER_CAP / 8);
1993 let err = svc.set_user(&long).unwrap_err();
1994 assert!(matches!(err, cards::WriteError::Overflow(_)), "{err}");
1995 let day = clock::utcnow()[..10].to_string();
1997 let archived = cards::read_text(&svc.home().archive_path("global", &day));
1998 assert!(
1999 archived.contains("One small claim."),
2000 "the overflow was dropped rather than archived"
2001 );
2002 }
2003
2004 #[test]
2005 fn a_retraction_cites_a_deed_or_nothing() {
2006 let (_dir, svc) = service();
2007 let stored = svc.add(atom("The overlay landed.")).unwrap();
2008 let id = stored["id"].as_str().unwrap().to_string();
2009 let refused = svc.delete_atom("w", &id, Some("because I said so"));
2010 assert!(refused.is_err(), "free text passed as a citation");
2011 assert!(svc
2013 .delete_atom("w", &id, Some("deed-patch-overlay"))
2014 .is_ok());
2015 }
2016
2017 #[test]
2018 fn an_attachment_is_one_shot() {
2019 let (_dir, svc) = service();
2020 svc.put_attach("w", "a log body", "build.log");
2021 assert_eq!(svc.peek_attach("w").unwrap().text, "a log body");
2022 assert_eq!(svc.peek_attach("w").unwrap().label, "build.log");
2023 assert_eq!(svc.take_attach("w").unwrap().text, "a log body");
2024 assert!(
2025 svc.take_attach("w").is_none(),
2026 "context for the next turn, not for every turn after it"
2027 );
2028 }
2029
2030 #[test]
2031 fn an_attachment_is_capped() {
2032 let (_dir, svc) = service();
2033 let huge = "x".repeat(ATTACH_CAP + 100);
2034 let slot = svc.put_attach("w", &huge, "");
2035 assert_eq!(slot["text"].as_str().unwrap().chars().count(), ATTACH_CAP);
2036 }
2037
2038 #[test]
2039 fn status_counts_by_kind_and_names_the_home() {
2040 let (_dir, svc) = service();
2041 let stored = svc.add(atom("Reviews open with a check.")).unwrap();
2042 svc.store()
2043 .delete("w", stored["id"].as_str().unwrap(), None)
2044 .unwrap();
2045 let mut second = atom("Prefer ripgrep for search.");
2046 second.insert("kind".into(), json!("preference"));
2047 svc.add(second).unwrap();
2048
2049 let panel = packset_core::Panel::named("rrf", "none", "off").unwrap();
2050 let status = svc.status(Some("w"), &panel).unwrap();
2051 assert_eq!(status["live"], json!(1));
2052 assert_eq!(status["panel"]["fuse"], json!("rrf"));
2055 assert!(!status["commit"].as_str().unwrap_or_default().is_empty());
2058 assert_eq!(status["panel"]["diversify"], json!("none"));
2059 assert_eq!(status["tombstone"], json!(1));
2060 assert_eq!(status["live_by_kind"]["preference"], json!(1));
2061 assert_eq!(status["workspace"], json!("w"));
2062 assert!(status["home"].is_string());
2063 assert!(status["last_write_ts"].is_string());
2064 assert_eq!(status["rerank"]["depth"], json!(crate::embed::RERANK_DEPTH));
2065 if std::env::var_os("PACKSET_RERANK").is_none() {
2066 assert_eq!(status["rerank"]["enabled"], json!(false));
2067 }
2068 }
2069
2070 #[test]
2074 fn search_leaves_the_cross_encoder_off() {
2075 let (_dir, svc) = service();
2076 svc.add(atom("Reviews open with a check.")).unwrap();
2077 let panel = packset_core::Panel::default();
2078 let found = svc
2079 .search("w", "reviews", 8, None, &panel, None, false)
2080 .unwrap();
2081 assert_eq!(found["rerank"], json!("off"), "{found}");
2082 assert_eq!(found["hits"].as_array().map(Vec::len), Some(1));
2083 let empty = svc.search("w", "", 8, None, &panel, None, true).unwrap();
2084 assert_eq!(empty["rerank"], json!("off"), "{empty}");
2085 assert!(empty["hits"].as_array().unwrap().is_empty());
2086 }
2087
2088 #[test]
2092 fn a_requested_rerank_without_an_encoder_leaves_the_ranking() {
2093 let (_dir, svc) = service();
2094 svc.add(atom("Reviews open with a check.")).unwrap();
2095 svc.add(atom("Prefer ripgrep for search.")).unwrap();
2096 let panel = packset_core::Panel::default();
2097 let _guard = EMBED.lock().unwrap_or_else(|e| e.into_inner());
2100 crate::embed::reset_for_test();
2101 let stub = broken_reranker();
2102 let old = std::env::var_os("PACKSET_EMBED");
2103 unsafe { std::env::set_var("PACKSET_EMBED", &stub.path) };
2105 let off = svc
2106 .search("w", "reviews search", 8, None, &panel, None, false)
2107 .unwrap();
2108 let on = svc
2109 .search("w", "reviews search", 8, None, &panel, None, true)
2110 .unwrap();
2111 unsafe {
2112 match old {
2113 Some(value) => std::env::set_var("PACKSET_EMBED", value),
2114 None => std::env::remove_var("PACKSET_EMBED"),
2115 }
2116 }
2117 crate::embed::reset_for_test();
2118 drop(_guard);
2119 assert_eq!(on["rerank"], json!("absent"), "{on}");
2120 assert_eq!(off["rerank"], json!("off"));
2121 let ids = |found: &Value| {
2125 found["hits"]
2126 .as_array()
2127 .unwrap()
2128 .iter()
2129 .map(|hit| hit["id"].clone())
2130 .collect::<Vec<_>>()
2131 };
2132 assert_eq!(ids(&on), ids(&off), "{on} vs {off}");
2133 }
2134
2135 #[test]
2138 fn a_stub_cross_encoder_reorders_the_first_stage() {
2139 let (_dir, svc) = service();
2140 svc.add(atom("Reviews open with a check.")).unwrap();
2141 svc.add(atom("Prefer ripgrep for search.")).unwrap();
2142 let panel = packset_core::Panel::default();
2143 let _guard = EMBED.lock().unwrap_or_else(|e| e.into_inner());
2144 crate::embed::reset_for_test();
2145 let stub = scoring_reranker();
2146 let old = std::env::var_os("PACKSET_EMBED");
2147 unsafe { std::env::set_var("PACKSET_EMBED", &stub.path) };
2149 let off = svc
2150 .search("w", "reviews search", 8, None, &panel, None, false)
2151 .unwrap();
2152 let on = svc
2153 .search("w", "reviews search", 8, None, &panel, None, true)
2154 .unwrap();
2155 unsafe {
2156 match old {
2157 Some(value) => std::env::set_var("PACKSET_EMBED", value),
2158 None => std::env::remove_var("PACKSET_EMBED"),
2159 }
2160 }
2161 crate::embed::reset_for_test();
2162 drop(_guard);
2163 assert_eq!(on["rerank"], json!("cross-encoder"), "{on}");
2164 let off_ids: Vec<_> = off["hits"]
2165 .as_array()
2166 .unwrap()
2167 .iter()
2168 .map(|h| h["id"].clone())
2169 .collect();
2170 let on_ids: Vec<_> = on["hits"]
2171 .as_array()
2172 .unwrap()
2173 .iter()
2174 .map(|h| h["id"].clone())
2175 .collect();
2176 assert_eq!(off_ids.len(), 2, "{off}");
2177 assert_eq!(on_ids.len(), 2, "{on}");
2178 assert_eq!(on_ids[0], off_ids[1], "{on} vs {off}");
2181 assert_eq!(on_ids[1], off_ids[0], "{on} vs {off}");
2182 }
2183
2184 #[test]
2187 fn a_short_limit_still_reranks_the_measured_window() {
2188 let (_dir, svc) = service();
2189 svc.add(atom("Reviews open with a check.")).unwrap();
2190 svc.add(atom("Prefer ripgrep for search.")).unwrap();
2191 svc.add(atom("Prefer fd for finding files.")).unwrap();
2192 let panel = packset_core::Panel::default();
2193 let _guard = EMBED.lock().unwrap_or_else(|e| e.into_inner());
2194 crate::embed::reset_for_test();
2195 let stub = scoring_reranker();
2196 let old = std::env::var_os("PACKSET_EMBED");
2197 unsafe { std::env::set_var("PACKSET_EMBED", &stub.path) };
2199 let off = svc
2200 .search("w", "Prefer reviews", 1, None, &panel, None, false)
2201 .unwrap();
2202 let on = svc
2203 .search("w", "Prefer reviews", 1, None, &panel, None, true)
2204 .unwrap();
2205 unsafe {
2206 match old {
2207 Some(value) => std::env::set_var("PACKSET_EMBED", value),
2208 None => std::env::remove_var("PACKSET_EMBED"),
2209 }
2210 }
2211 crate::embed::reset_for_test();
2212 drop(_guard);
2213 assert_eq!(on["rerank"], json!("cross-encoder"), "{on}");
2214 assert_eq!(on["hits"].as_array().map(Vec::len), Some(1), "{on}");
2215 assert_eq!(off["hits"].as_array().map(Vec::len), Some(1), "{off}");
2216 assert_ne!(on["hits"][0]["id"], off["hits"][0]["id"], "{on} vs {off}");
2219 }
2220
2221 static EMBED: std::sync::Mutex<()> = std::sync::Mutex::new(());
2222
2223 struct StubEmbed {
2224 path: std::path::PathBuf,
2225 _dir: tempfile::TempDir,
2226 }
2227
2228 fn broken_reranker() -> StubEmbed {
2229 write_stub(
2230 r#"#!/bin/sh
2231exit 1
2232"#,
2233 )
2234 }
2235
2236 fn scoring_reranker() -> StubEmbed {
2237 write_stub(
2238 r#"#!/usr/bin/env python3
2239import json, sys
2240if "--rerank" not in sys.argv:
2241 sys.exit(1)
2242for line in sys.stdin:
2243 line = line.strip()
2244 if not line:
2245 continue
2246 req = json.loads(line)
2247 n = len(req.get("d") or [])
2248 print(json.dumps({"id": req.get("id", "q"), "s": [float(i) for i in range(n)]}), flush=True)
2249"#,
2250 )
2251 }
2252
2253 fn write_stub(body: &str) -> StubEmbed {
2254 let dir = tempfile::tempdir().unwrap();
2255 let path = dir.path().join("packset-embed");
2256 std::fs::write(&path, body).unwrap();
2257 use std::os::unix::fs::PermissionsExt;
2258 let mut perm = std::fs::metadata(&path).unwrap().permissions();
2259 perm.set_mode(0o755);
2260 std::fs::set_permissions(&path, perm).unwrap();
2261 StubEmbed { path, _dir: dir }
2262 }
2263
2264 #[test]
2266 fn only_the_atom_that_cites_an_accession_is_named() {
2267 let (_dir, svc) = service();
2268 let mut cites = atom("The overlay landed as a frozen deed.");
2269 cites.insert("entities".into(), json!(["deed-patch-overlay", "overlay"]));
2270 let stored = svc.add(cites).unwrap();
2271 let mut elsewhere = atom("The parser was rewritten.");
2272 elsewhere.insert("entities".into(), json!(["parser"]));
2273 svc.add(elsewhere).unwrap();
2274
2275 let found = svc.citers("w", "deed-patch-overlay").unwrap();
2276 assert_eq!(found.len(), 1, "{found:?}");
2277 assert_eq!(found[0]["id"], stored["id"]);
2278 }
2279
2280 #[test]
2283 fn an_uncited_accession_names_nobody() {
2284 let (_dir, svc) = service();
2285 let mut cites = atom("The overlay landed as a frozen deed.");
2286 cites.insert("entities".into(), json!(["deed-patch-overlay"]));
2287 svc.add(cites).unwrap();
2288 assert!(svc.citers("w", "deed-nothing-here").unwrap().is_empty());
2289 assert!(svc.citers("w", " ").unwrap().is_empty());
2290 }
2291
2292 #[test]
2296 fn a_longer_accession_is_not_a_citation_of_the_shorter_one() {
2297 let (_dir, svc) = service();
2298 let mut cites = atom("The second overlay landed.");
2299 cites.insert("entities".into(), json!(["deed-patch-overlay-v2"]));
2300 svc.add(cites).unwrap();
2301 assert!(svc.citers("w", "deed-patch-overlay").unwrap().is_empty());
2302 }
2303
2304 #[test]
2306 fn what_a_pack_cites_and_who_cites_it_agree() {
2307 let (_dir, svc) = service();
2308 let mut cites = atom("The overlay landed as a frozen deed.");
2309 cites.insert(
2310 "entities".into(),
2311 json!(["deed-patch-overlay", "sha256:abc"]),
2312 );
2313 svc.add(cites).unwrap();
2314
2315 let listed = svc.accessions("w").unwrap();
2316 assert_eq!(listed, vec!["deed-patch-overlay", "sha256:abc"]);
2317 for accession in listed {
2318 assert_eq!(svc.citers("w", &accession).unwrap().len(), 1, "{accession}");
2319 }
2320 }
2321}