Skip to main content

packset_daemon/
service.rs

1//! What the writer does, apart from how a client asked.
2//!
3//! The HTTP layer decodes and encodes; everything a verb actually means lives
4//! here, so the rules can be tested without a socket.
5
6use 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
17/// The cap on one attached body.
18pub const ATTACH_CAP: usize = 200_000;
19
20/// One workspace's pending attachment.
21#[derive(Debug, Clone, Default)]
22pub struct Attachment {
23    /// The body.
24    pub text: String,
25    /// What it is, for a reader.
26    pub label: String,
27}
28
29/// The writer: the store, the cards, and the one-shot attach slots.
30/// How many search hits seed an activation.
31const ACTIVATION_SEEDS: usize = 5;
32/// How far activation spreads along the links.
33const ACTIVATION_HOPS: usize = 2;
34/// The most claims one consolidation bucket may hold before it is passed
35/// over; a shared head of three words that a thousand claims open with is
36/// not a rewrite candidate list.
37const CONSOLIDATE_BUCKET_MAX: usize = 64;
38/// How many of an island's strongest claims fire together when asked.
39const FIRE_TOP: usize = 8;
40
41pub struct Service {
42    home: Home,
43    store: Store,
44    attach: Mutex<BTreeMap<String, Attachment>>,
45    /// One logical write at a time. LMDB serialises transactions, not the
46    /// read-check-write a dedupe or a grade is, so two identical remembers
47    /// arriving together must not both be stored.
48    writes: Mutex<()>,
49    /// Most live claims a workspace holds; zero is no cap. Past it, the
50    /// least retrievable forgettable claims are tombstoned on the write
51    /// that crossed it, so a herd of seats writing into one pack cannot
52    /// grow it without bound.
53    live_cap: usize,
54}
55
56/// The live cap when `PACKSET_LIVE_CAP` says nothing: twenty thousand, a
57/// size at which the hook still answers a prompt in a tenth of a second.
58pub const DEFAULT_LIVE_CAP: usize = 20_000;
59
60/// Kinds the cap may forget. A standing choice, a rule, a reading, a goal, a
61/// trust row and a persona are what the seat is; a lesson it has not
62/// recalled is what it can afford to lose.
63pub 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    /// Open the pack at `home`.
87    ///
88    /// # Errors
89    ///
90    /// Fails when the store cannot be opened or the lock is held.
91    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    /// The same service with another live cap; zero is none.
103    #[must_use]
104    pub fn with_live_cap(mut self, cap: usize) -> Self {
105        self.live_cap = cap;
106        self
107    }
108
109    /// Most live claims a workspace holds before the least retrievable are
110    /// forgotten; zero is no cap.
111    #[must_use]
112    pub fn live_cap(&self) -> usize {
113        self.live_cap
114    }
115
116    /// Hold a workspace at the live cap: tombstone the least retrievable
117    /// forgettable claims until it fits. Retrievability is the review
118    /// model's, from the last review or the write and the claim's
119    /// stability, so a lesson recalled last week outlives one written a
120    /// month ago and never asked for. Returns how many were forgotten.
121    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    /// The pack home.
176    #[must_use]
177    pub fn home(&self) -> &Home {
178        &self.home
179    }
180
181    /// The atom store.
182    #[must_use]
183    pub fn store(&self) -> &Store {
184        &self.store
185    }
186
187    /// Store one atom, or return the live one with the same text, kind and set.
188    ///
189    /// # Errors
190    ///
191    /// [`AtomError`] when the text is a tool dump or the record does not
192    /// validate, else the store's.
193    pub fn add(&self, atom: Record) -> anyhow::Result<Record> {
194        // Validation and the encoder run before the lock: the encode is the
195        // slow part of a write and depends on the text alone.
196        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    /// Check a claim and fill the fields that depend on nothing stored.
202    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    /// Store a prepared claim, or return the live one that already says it.
250    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        // Compared within its own scope; the shared snapshot is narrowed only
259        // when the scope excludes something.
260        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            // Closed peers stay out of apply_links: a rewrite of links would
320            // otherwise write them back without valid_to.
321            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        // A new claim enters the review clock at once; a trust row is not
346        // recalled, it is weighed.
347        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    /// Fill the vector slot when this seat has an encoder; null otherwise.
370    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    /// Merge `fields` into one current atom.
382    ///
383    /// # Errors
384    ///
385    /// [`AtomError`] when the id is not current or the result does not
386    /// validate, else the store's.
387    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, &current, 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    /// Move one atom along the review clock.
438    ///
439    /// # Errors
440    ///
441    /// As [`Service::update`].
442    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    /// Tombstone one atom and drop it from the projection.
464    ///
465    /// `why` names the deed that withdrew the claim. A retraction cites a deed
466    /// or nothing, so unlike an entity it is refused when it is free text: the
467    /// point of writing it is that `deedar evidence` can be asked about it, and
468    /// a name no deed store answers for is a citation that only looks like one.
469    ///
470    /// # Errors
471    ///
472    /// [`AtomError`] when `why` is not a deed accession, else the store's.
473    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    /// The workspace pack, or the same shape scoped to one set.
494    ///
495    /// Always `user` / `memory` / `atoms`, so a client splices one shape
496    /// whether or not a set is pinned.
497    ///
498    /// # Errors
499    ///
500    /// [`AtomError`] for a bad set name, else the store's.
501    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    /// The active set for a workspace, or empty when nothing is pinned.
527    #[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    /// Pin a set, or clear the pin when the name is empty.
539    ///
540    /// # Errors
541    ///
542    /// [`AtomError`] for a bad name, else the write's.
543    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    /// The pin plus that set's standing instructions.
560    ///
561    /// # Errors
562    ///
563    /// Never, in practice: an unreadable set reads as empty.
564    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    /// Write whichever of a set's three cards the body carried.
579    ///
580    /// Absent and empty are different: a key that is not there leaves that card
581    /// alone, and a key set to an empty string clears it.
582    ///
583    /// # Errors
584    ///
585    /// [`AtomError`] for a bad set name, else the write's.
586    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    /// Write the seat card, archiving and refusing on overflow.
614    ///
615    /// # Errors
616    ///
617    /// The write's, with overflow distinguishable so the caller can answer 413.
618    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                // Archived first, so refused text is not lost; an archive
622                // failure is reported over the overflow.
623                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    /// Write a workspace card, archiving and refusing on overflow.
635    ///
636    /// # Errors
637    ///
638    /// As [`Service::set_user`].
639    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    /// Append to today's archive for a workspace.
654    ///
655    /// # Errors
656    ///
657    /// The write's.
658    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    /// Hold one body for a workspace, capped.
665    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    /// Take the held body, leaving the slot empty: an attachment is one turn's.
680    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    /// Read the held body without taking it.
686    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    /// Keep the projection level with a write: upsert a live atom, delete one
692    /// that left the live set. No-op without a search binary.
693    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    /// Keep the projection's copy of the cards level with a write.
717    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    /// The atoms that were live at `at`, over the `valid_from` / `valid_to` window.
733    ///
734    /// # Errors
735    ///
736    /// The store's, or a stamp `parse_millis` will not accept.
737    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    /// Ranked hits, and which engine produced them: the projection when
745    /// present, else the linear scan; any projection failure falls back whole.
746    /// `as_of` retrieves the atoms live then; `rerank` runs the cross-encoder
747    /// second stage.
748    ///
749    /// # Errors
750    ///
751    /// [`AtomError`] for a bad set name, a stamp `parse_millis` will not
752    /// accept, else the store's.
753    #[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        // A named set swaps the prose for that set's cards. The atom list stays
772        // the whole live set, because the scope is a filter in the scorer and
773        // not a smaller corpus.
774        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        // The snapshot and the index over it come as a pair: an ordinal in the
785        // index means a position in that snapshot and in no other. A dated
786        // retrieve cannot use the live cache: that cache already dropped the
787        // closed window.
788        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            // Nothing to score, so the second stage does not run. Reporting
816            // it as off rather than as a stage that ran over an empty list
817            // keeps a client from thinking a model was asked.
818            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        // Two scorers over the same pack, because they are strong at different
829        // queries: one finds an atom through a typo or a prefix and weighs every
830        // word alike, the other weighs a word by how much it narrows the pack
831        // down and normalises for length. The panel is what turns the two
832        // rankings into one, and two lists agreeing about a hit is a vote for
833        // it rather than a duplicate.
834        // When the second stage will read the head, the first stage has to
835        // return at least that many, or nothing below `limit` can be promoted.
836        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        // A third ballot when this seat has an encoder. The two lexical
852        // scorers both need the question and the atom to share words, and this
853        // one does not, which is the gap it exists to close.
854        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        // The milli projection is live-now. A dated question over a closed
859        // window would otherwise miss the atom the retrieve just found.
860        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                // Prose always comes from the pack, so the index copy of a card
868                // can be stale without anyone reading it.
869                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        // The same stage the locomo arm measures. Off unless asked. An absent
892        // or broken reranker leaves the first-stage order, the same way an
893        // absent encoder leaves the dense ballot out.
894        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        // Due is the clock (`/v1/due`, `ljos due`). Mixing it into search
907        // put 212 due personas at score 3.1 on every query.
908        Ok(json!({"hits": ranked, "engine": engine, "as_of": as_of, "rerank": stage}))
909    }
910
911    /// Mine one archived day into proposals.
912    ///
913    /// # Errors
914    ///
915    /// The miner's, or the store's.
916    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    /// Propose one claim from one piece of text.
929    ///
930    /// # Errors
931    ///
932    /// The miner's, or [`AtomError`] when there is nothing to propose.
933    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    /// Turn an accepted proposal into a stored atom.
969    ///
970    /// # Errors
971    ///
972    /// The miner's, or the store's.
973    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    /// The islands of a workspace: the link graph's communities, largest
983    /// first, each as the atoms it holds.
984    ///
985    /// # Errors
986    ///
987    /// The store's.
988    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        // Communities by modularity; label propagation stands beside it so
992        // the two can be compared on the same pack.
993        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    /// The claims the link graph turns on, highest first: a weighted
1024    /// PageRank over the links. What matters in the pack by its own
1025    /// connections, before any query.
1026    ///
1027    /// # Errors
1028    ///
1029    /// The store's.
1030    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    /// Claims that fired together: every pair's link gains weight, their
1050    /// other links lose a little, and a missing link is made. Returns how
1051    /// many records changed.
1052    ///
1053    /// # Errors
1054    ///
1055    /// The store's.
1056    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    /// Consolidate the live set: in the order they were written, every
1086    /// claim that replaces an earlier one (`record::replaces`: an explicit
1087    /// `supersedes`, a correction sharing an entity, a rewrite, or a new
1088    /// object under the same head) closes the earlier one's window and
1089    /// names it. What a write does on arrival, run over what is already
1090    /// held, for a pack written before the rule or filled by import. With
1091    /// `apply` false nothing is written; the pairs are reported.
1092    ///
1093    /// # Errors
1094    ///
1095    /// The store's.
1096    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        // Candidates share their first words or an entity; the rule is then
1108        // asked of each pair. A pack of ten thousand claims is buckets of a
1109        // few, not fifty million comparisons, and the nudge that counts the
1110        // pairs on every prompt stays cheap. A rewrite that shares neither
1111        // is not seen here, as it is not seen by a read.
1112        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            // Only entities the claim carries; the ones read off its text
1123            // would put every claim that says "the" into one bucket and
1124            // make this a pass over every pair.
1125            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                // A bucket the size of the pack is no bucket.
1146                .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    /// The memories a cue activates: the top search hits as seeds, spread
1210    /// two hops along the links, strongest first. With `fire`, the top
1211    /// [`FIRE_TOP`] of them fire together.
1212    ///
1213    /// # Errors
1214    ///
1215    /// As [`Service::search`], else the store's.
1216    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        // Seeds two scorers agreed on, when two or more ran. Activation
1243        // from weak seeds flows to the best-connected cluster, whatever
1244        // the cue was; an island seeded that way is reported weak and is
1245        // not fired, because firing it wires the wrong links tighter.
1246        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    /// The open proposals for a workspace.
1304    #[must_use]
1305    pub fn proposals(&self, workspace: &str) -> Vec<Value> {
1306        crate::proposals::list_open(&self.home, workspace)
1307    }
1308
1309    /// Every deed accession cited by a live atom in a workspace, sorted.
1310    ///
1311    /// The accession is the only identifier that crosses the tracker, the pack
1312    /// and the deed store, so a pack has to be able to list its own citations
1313    /// the way a tracker does. A product cited by one atom and by nothing else
1314    /// is exactly the citation that goes stale unnoticed.
1315    ///
1316    /// # Errors
1317    ///
1318    /// The store's.
1319    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    /// The live atoms that cite one deed accession.
1340    ///
1341    /// The other direction of [`Service::accessions`], and the pack's half of
1342    /// the backwards walk: a tracker answers which issues cite a product, and
1343    /// this answers which remembered claims do. Neither store opens the other,
1344    /// so what composes them is a caller holding one accession.
1345    ///
1346    /// # Errors
1347    ///
1348    /// The store's.
1349    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    /// Seat home, atom counts by kind, pin, index and embedder.
1379    ///
1380    /// # Errors
1381    ///
1382    /// The scan's.
1383    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            // Which build is answering. The version does not move between
1422            // releases and the code does, so a seat comparing an installed
1423            // daemon against a repository needs the commit to compare.
1424            "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            // Off unless the host asked. The locomo cost lives in the README;
1447            // status only says whether this writer will spend it.
1448            "rerank": {
1449                "enabled": crate::embed::wanted(),
1450                "available": crate::embed::binary().is_some(),
1451                "depth": crate::embed::RERANK_DEPTH,
1452            },
1453            // Which voters are running, because the panel is host
1454            // configuration a client cannot see and a wrong one changes every
1455            // answer without changing any of them into an error.
1456            "panel": {
1457                "fuse": panel.fuse.as_str(),
1458                "diversify": panel.diversify.as_str(),
1459                "decay": panel.decay.as_str(),
1460            },
1461        }))
1462    }
1463}
1464
1465/// Whether the dense-rank embedder is switched on.
1466///
1467/// Switched on and present are different questions. The runtime behind the
1468/// encoder lives in its own binary, so a seat can have it enabled with nothing
1469/// to run; keyword search does not depend on either.
1470fn 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
1478/// The search binary as `/v1/status` reports it.
1479///
1480/// One lookup, shared with the search path, so status cannot say the
1481/// projection is available while search fails to find it.
1482fn milli_binary() -> Value {
1483    crate::milli::binary().map_or(Value::Null, |p| Value::String(p.display().to_string()))
1484}
1485
1486/// A fresh atom id: thirty-two hex characters, the shape already in the store.
1487fn 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    // Not a v4 uuid and not claiming to be: unique on this seat is the whole
1499    // requirement, and the store keys on workspace and id together.
1500    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        // Four lessons written straight to the store with review stamps a
1540        // month apart, and one preference, which the cap never touches.
1541        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        // Six live after the write, cap three: the three least retrievable
1565        // lessons go; the preference and the newest lessons stay.
1566        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        // Two claims written straight to the store, as an import or an older
1608        // writer would leave them: the later rewrites the earlier and
1609        // nothing closed it.
1610        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        // A fenced capture naming a stream is the other shape.
1691        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        // Plain short sentences: the point is the length, and prose the
1991        // archive would itself refuse tests something else.
1992        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        // Refused, not lost: the day file has it for the miner.
1996        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        // Refusing the citation refuses the whole write; the atom is still live.
2012        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        // The panel is host configuration a client cannot see, so status is
2053        // where an operator finds out which voters answered.
2054        assert_eq!(status["panel"]["fuse"], json!("rrf"));
2055        // A build that cannot say which commit it is says so, rather than
2056        // saying nothing and reading as current.
2057        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    /// The seat search path does not run the measured second stage unless
2071    /// it is asked. The default is the first-stage ranking a pack already
2072    /// returns.
2073    #[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    /// A requested stage with no working reranker leaves the first-stage
2089    /// order and says so. Silently reordering by nothing would be worse
2090    /// than leaving the stage off.
2091    #[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        // Point at a program that is not a reranker, so PATH cannot supply
2098        // a real packset-embed and turn this into a model call.
2099        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        // Safety: EMBED is held, so no other test mutates this variable.
2104        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        // Recency is a function of now, so two searches a moment apart
2122        // disagree in the last digits of the score. The order is the
2123        // ranking, and that is what a missing stage must not change.
2124        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    /// A working child that implements the same protocol as
2136    /// `packset-embed --rerank` reorders the first-stage list.
2137    #[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        // Safety: EMBED is held, so no other test mutates this variable.
2148        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        // The stub scores the last candidate highest, so the first-stage
2179        // tail becomes the head.
2180        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    /// The first stage has to return the locomo window or a `limit` below
2185    /// that window cannot be promoted into view.
2186    #[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        // Safety: EMBED is held, so no other test mutates this variable.
2198        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        // The stub scores later candidates higher. With first_limit at the
2217        // measured depth, the last of the three can become the only hit.
2218        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    /// One accession, cited by one atom and not the other.
2265    #[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    /// An accession nothing cites is an empty answer, not a missing one: that
2281    /// is the whole point of asking.
2282    #[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    /// A prefix is not a citation. `deed-patch-overlay-v2` is a different
2293    /// product, and naming it as a citer of the first would be a wrong answer
2294    /// that looks right.
2295    #[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    /// Both directions of the join, over one pack.
2305    #[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}