Skip to main content

velesdb_memory/
service_graph_wiring.rs

1//! The *construction* half of [`MemoryService`]'s graph surface — the
2//! `remember*`/`autograph*` family and the `wire_*`/`store_extract*`
3//! plumbing that turns facts and extractions into hub nodes and edges.
4//! Split out of `service.rs` under the file-budget rule, completing the cut
5//! `service_graph.rs` started (#2021): every `S: GraphStore`-bounded method
6//! now lives in one of the two `service_graph*` modules — walks and
7//! destruction there, wiring here (either alone would blow the same budget
8//! the split serves). A `#[path]` child of `service`, sharing full access
9//! to `MemoryService`'s private fields and methods.
10
11use crate::id;
12
13use super::{
14    canonical_entity_name, reject_self_links, strip_reserved_keys, validate_relation, AutographJob,
15    Embedder, EntityProfile, EntityRelation, ExtractedRelation, Extractor, FactStore, GraphStore,
16    HashMap, HashSet, Link, MemoryError, MemoryService, Metadata, RememberedExtraction,
17    ABOUT_RELATION, HUB_ID_SALT, MENTIONS_RELATION,
18};
19
20// Only the spawned-worker pair at the bottom touches this; that whole impl
21// block is compiled out on wasm32, so the import is gated with it.
22#[cfg(not(target_arch = "wasm32"))]
23use super::AutographWorkerHandle;
24
25/// The three wiring stages of one autograph enrichment, in the order they
26/// run. Named for the failure log: which stage a partial enrichment stopped
27/// at is the one fact a reader needs to judge what is missing.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29enum AutographStage {
30    /// Fact → topic hubs (`about`/`mentions` pairs).
31    Entities,
32    /// Hub → hub typed edges.
33    Relations,
34    /// Hub attribute writes.
35    Attributes,
36}
37
38impl std::fmt::Display for AutographStage {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.write_str(match self {
41            Self::Entities => "entities",
42            Self::Relations => "relations",
43            Self::Attributes => "attributes",
44        })
45    }
46}
47
48impl<E: Embedder, S: FactStore> MemoryService<E, S> {
49    // Autograph observability lives with autograph (this module), not in the
50    // service assembler: `service.rs` is over the file budget and only ever
51    // shrinks (scripts/check-file-budgets.py), so the counters' accessors
52    // moved here beside the worker that feeds them.
53
54    /// How many autograph enrichments a FULL queue refused since this
55    /// service was built (#1846). The facts themselves were stored; only
56    /// their graph wiring was skipped, and re-remembering a fact rebuilds it.
57    #[must_use]
58    pub fn autograph_dropped(&self) -> u64 {
59        self.autograph_queue
60            .dropped
61            .load(std::sync::atomic::Ordering::Relaxed)
62    }
63
64    /// How many autograph enrichments RAN and failed part-way through
65    /// wiring since this service was built. Distinct from
66    /// [`Self::autograph_dropped`], which counts the ones a full queue never
67    /// ran. A failure here leaves the fact stored and its graph structure
68    /// PARTIAL — the entities wired before the failing write are in, the
69    /// rest are not — and re-remembering the fact completes it. Until this
70    /// counter existed such failures were discarded with `let _`: not
71    /// counted, not logged, invisible to `memory_status`, while the doc
72    /// promised "never silent" — a promise that covered only the queue.
73    #[must_use]
74    pub fn autograph_failed(&self) -> u64 {
75        self.autograph_queue
76            .failed
77            .load(std::sync::atomic::Ordering::Relaxed)
78    }
79
80    /// Whether the background autograph queue is OPEN — a worker is spawned
81    /// and `remember` enqueues instead of running the enrichment inline.
82    /// Turns false the moment a worker handle's drop closes the queue.
83    #[must_use]
84    pub fn autograph_queue_open(&self) -> bool {
85        self.autograph_queue.tx.lock().is_some()
86    }
87
88    /// Remember a `fact`, optionally tagging it with structured `metadata`
89    /// (`ColumnStore` facet) and linking it to existing memories (graph facet).
90    /// Returns the stable id of the fact (idempotent on identical content).
91    ///
92    /// The stored metadata is auto-stamped with today's date under
93    /// [`crate::storage::AUTO_DATE_FIELD`] unless `metadata` already carries
94    /// that key — see [`Self::remember_with_ttl`] (this method's only caller)
95    /// for the full contract.
96    ///
97    /// Every link is validated — target existence AND relation label —
98    /// *before* the fact is stored, so bad link input never leaves the fact
99    /// half-written. If an edge write itself fails afterwards (e.g. a target
100    /// expiring concurrently), a freshly-created fact is rolled back; a
101    /// re-remembered fact keeps its updated payload (re-remembering updates
102    /// metadata by design, and deleting it would destroy prior state).
103    /// Concurrent `remember`s of identical content are last-writer-wins,
104    /// not transactional.
105    ///
106    /// # Errors
107    /// Returns [`MemoryError::EmptyFact`] for empty/whitespace facts,
108    /// [`MemoryError::FactTooLarge`] if the fact exceeds
109    /// [`crate::limits::MAX_EMBEDDABLE_TEXT_BYTES`],
110    /// [`MemoryError::SelfRelation`] if a link points the fact at itself,
111    /// [`MemoryError::ReservedKey`] if `metadata` names a reserved key
112    /// (`content` or any `_veles_`-prefixed system key, [`crate::storage::AUTO_DATE_FIELD`]
113    /// excepted),
114    /// [`MemoryError::MetadataTooLarge`] if `metadata` exceeds
115    /// [`crate::limits::MAX_METADATA_BYTES`],
116    /// [`MemoryError::UnknownMemory`] if a link points at a missing memory,
117    /// [`MemoryError::InvalidRelation`] for a bad relation label,
118    /// [`MemoryError::RollbackFailed`] if an edge write failed and the
119    /// compensating delete also failed (the fact remains stored),
120    /// or a storage error if persistence fails.
121    pub fn remember(
122        &self,
123        fact: &str,
124        links: &[Link],
125        metadata: Option<&Metadata>,
126    ) -> Result<u64, MemoryError>
127    where
128        S: GraphStore,
129    {
130        let _generation = self.enter_generation();
131        self.remember_inner(fact, links, metadata, None, true)
132    }
133
134    /// Like [`Self::remember`], but the fact **expires after `ttl_seconds`**.
135    ///
136    /// The expiry is a durable TTL — persisted with the fact (reserved
137    /// `_veles_expires_at` payload field), so it survives a process restart, and
138    /// expired facts stop being recalled. `None` stores the fact permanently,
139    /// exactly like [`Self::remember`]; an explicit `Some(0)` is **refused**
140    /// ([`MemoryError::ZeroTtl`]) rather than silently normalised to
141    /// "permanent", which is the opposite of what a caller writing `0` means.
142    /// Metadata and a TTL combine: the metadata is written and the expiry
143    /// preserved.
144    ///
145    /// The stored metadata is **auto-stamped with today's date** under
146    /// [`crate::storage::AUTO_DATE_FIELD`] (`_veles_date`, a `YYYYMMDD`
147    /// integer read from the system clock at write time — see
148    /// [`crate::clock::today_ymd`]) whenever `metadata` doesn't already carry
149    /// that key; an explicit value in `metadata` (e.g. to date a fact
150    /// retroactively) is never overwritten. No clock is available on
151    /// `wasm32-unknown-unknown`, so that target stamps nothing and `metadata`
152    /// passes through unchanged. This is the ONE place in the crate that
153    /// reads wall-clock time on the write path — the context compiler
154    /// (`compile_context` and friends) stays clock-free and deterministic,
155    /// unaffected by this stamp (it never re-derives a date from `now()`,
156    /// only ever reads whatever a fact already carries).
157    ///
158    /// Because [`Self::remember_extracted`] stores each extracted fact via
159    /// [`Self::remember`] (which delegates here), it gets the same auto-stamp
160    /// for free — entity hubs it also creates go through [`Self::store_fact`]
161    /// directly and are never stamped, since they are internal graph
162    /// scaffolding, not caller facts.
163    ///
164    /// # Errors
165    /// Same as [`Self::remember`].
166    pub fn remember_with_ttl(
167        &self,
168        fact: &str,
169        links: &[Link],
170        metadata: Option<&Metadata>,
171        ttl_seconds: Option<u64>,
172    ) -> Result<u64, MemoryError>
173    where
174        S: GraphStore,
175    {
176        let _generation = self.enter_generation();
177        self.remember_inner(fact, links, metadata, ttl_seconds, true)
178    }
179
180    /// The shared write path. `run_autograph` is false for the one caller that
181    /// has ALREADY extracted the passage — [`Self::remember_extracted`] — so a
182    /// service with autograph on does not run a second generation per stored
183    /// fact, re-deriving what it just computed.
184    fn remember_inner(
185        &self,
186        fact: &str,
187        links: &[Link],
188        metadata: Option<&Metadata>,
189        ttl_seconds: Option<u64>,
190        run_autograph: bool,
191    ) -> Result<u64, MemoryError>
192    where
193        S: GraphStore,
194    {
195        let fact = fact.trim();
196        self.validate_write(fact, links, metadata, ttl_seconds)?;
197        let fact_id = id::stable_id(fact);
198        reject_self_links(fact_id, links)?;
199        let existed_before = !links.is_empty() && self.store.get(fact_id)?.is_some();
200        self.write_fact(fact_id, fact, metadata, ttl_seconds)?;
201        self.link_or_rollback(fact_id, links, existed_before)?;
202        self.autograph_if(run_autograph, fact_id, fact);
203        Ok(fact_id)
204    }
205
206    /// Write the edges, undoing a freshly-created fact if one of them fails.
207    ///
208    /// Links are fully pre-validated by [`Self::validate_links`], so an edge
209    /// write can only fail here on a race (e.g. a target's TTL lapsing since
210    /// the pre-check). Roll a FRESH fact back (delete cascades any edges
211    /// already created); a fact that existed before the call is kept —
212    /// deleting it would destroy prior state, and its updated payload stands
213    /// per re-remember's update semantics. The existence probe and the delete
214    /// are not one atomic unit: a concurrent remember of identical content
215    /// between them is last-writer-wins (documented on [`Self::remember`]).
216    fn link_or_rollback(
217        &self,
218        fact_id: u64,
219        links: &[Link],
220        existed_before: bool,
221    ) -> Result<(), MemoryError>
222    where
223        S: GraphStore,
224    {
225        let Err(cause) = self.relate_links(fact_id, links) else {
226            return Ok(());
227        };
228        if existed_before {
229            return Err(cause);
230        }
231        match self.store.delete(fact_id) {
232            Ok(()) => Err(cause),
233            Err(rollback) => Err(MemoryError::RollbackFailed {
234                cause: Box::new(cause),
235                rollback: Box::new(rollback),
236            }),
237        }
238    }
239
240    /// Run [`Self::autograph`] only when this write path asked for it — the
241    /// branch lives here rather than in the write path itself.
242    ///
243    /// With a worker spawned ([`Self::spawn_autograph_worker`]), the job is
244    /// ENQUEUED and this returns immediately: the enrichment leaves the
245    /// caller's response path (#1846). A FULL queue drops the job, counted
246    /// in [`Self::autograph_dropped`] — losing structure is recoverable by
247    /// re-remembering, stalling every write behind a slow model is not. A
248    /// disconnected queue (worker gone) falls back inline, so the graph
249    /// keeps building even if the worker died.
250    fn autograph_if(&self, run: bool, fact_id: u64, fact: &str)
251    where
252        S: GraphStore,
253    {
254        if !run {
255            return;
256        }
257        let guard = self.autograph_queue.tx.lock();
258        if let Some(tx) = guard.as_ref() {
259            use std::sync::mpsc::TrySendError;
260            match tx.try_send(AutographJob {
261                fact_id,
262                fact: fact.to_owned(),
263            }) {
264                Ok(()) => return,
265                Err(TrySendError::Full(_)) => {
266                    self.autograph_queue
267                        .dropped
268                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
269                    #[cfg(feature = "mcp")]
270                    tracing::warn!(
271                        fact_id,
272                        "autograph queue full: enrichment dropped — the fact is \
273                         stored, its graph structure is not; re-remembering \
274                         rebuilds it"
275                    );
276                    return;
277                }
278                Err(TrySendError::Disconnected(_)) => {
279                    // fall through to the inline path below
280                }
281            }
282        }
283        drop(guard);
284        self.autograph(fact_id, fact);
285    }
286
287    /// The total number of graph edges, when the backend can say —
288    /// [`GraphStore::edge_count`], relayed for `memory_status`. `None`
289    /// means "cannot say", never "zero": the two answers tell a caller
290    /// different things about `why()`.
291    #[must_use]
292    pub fn edge_count(&self) -> Option<usize>
293    where
294        S: GraphStore,
295    {
296        let _generation = self.enter_generation();
297        self.store.edge_count()
298    }
299
300    /// Autograph one just-stored fact: read the entities, entity→entity edges
301    /// and attributes it states, and wire them around it.
302    ///
303    /// **Deliberately infallible.** The caller's fact is already durably
304    /// stored by the time this runs, and the caller asked to remember a fact —
305    /// not to run a model. Propagating an extraction failure would turn a
306    /// successful write into a reported error, and an agent that sees
307    /// `remember` fail will sensibly retry it, re-running the generation and
308    /// failing again. So a model that is down, slow, or talking nonsense costs
309    /// the *graph enrichment* and nothing else: the memory is kept, the id is
310    /// returned, and the next `remember` tries again.
311    ///
312    /// The trade-off is that a persistently broken extractor degrades silently
313    /// to plain `remember`. That is the right way round — losing structure is
314    /// recoverable by re-remembering, losing the fact is not.
315    pub(super) fn autograph(&self, fact_id: u64, fact: &str)
316    where
317        S: GraphStore,
318    {
319        let Some(extractor) = self.autograph.as_ref() else {
320            return;
321        };
322        let Ok(mut extraction) = extractor.extract_graph(fact) else {
323            return;
324        };
325        // Privacy invariant: autograph derives structure FROM a stored fact, so
326        // if the fact no longer exists, none of its derived structure may be
327        // created. With a background worker a job can sit queued for
328        // minutes-to-hours and its generation runs for tens of seconds, so a
329        // `forget` issued in between must win — a permanent deletion cannot be
330        // undone by a stale enrichment resurrecting the entity hubs. Re-check
331        // once the generation has returned, before any wiring: this closes the
332        // whole queue-plus-generation window, the common case, completely.
333        if !self.fact_exists(fact_id) {
334            return;
335        }
336        crate::extract::orient_kinship(fact, &mut extraction.relations);
337        let mut entity_ids: HashMap<String, u64> = HashMap::new();
338        let mut edges: HashSet<(u64, u64, String)> = HashSet::new();
339        // The caller's fact is the node the topics attach to — the extracted
340        // facts are NOT stored as separate memories here, which is what
341        // separates autograph from `remember_extracted`: one `remember` call
342        // must still produce exactly one caller-visible memory.
343        for extracted in &extraction.facts {
344            if let Err(err) =
345                self.wire_entities(fact_id, &extracted.entities, &mut entity_ids, &mut edges)
346            {
347                self.note_autograph_failure(fact_id, AutographStage::Entities, &err);
348            }
349        }
350        // A concurrent `forget` can still race the wiring writes above between
351        // the first check and here. Re-check once more before the hub↔hub and
352        // attribute writes — neither of which references the source fact, so
353        // `ensure_exists` cannot catch a retired fact for them — leaving a
354        // residual window of a single already-committed generation, not the
355        // whole job.
356        if !self.fact_exists(fact_id) {
357            return;
358        }
359        if let Err(err) = self.wire_relations(&extraction.relations, &mut entity_ids, &mut edges) {
360            self.note_autograph_failure(fact_id, AutographStage::Relations, &err);
361        }
362        if let Err(err) = self.wire_attributes(&extraction.attributes, &mut entity_ids) {
363            self.note_autograph_failure(fact_id, AutographStage::Attributes, &err);
364        }
365    }
366
367    /// One enrichment ran and a wiring write failed part-way: count it and
368    /// say so. The wiring helpers propagate with `?`, so the first failing
369    /// write ends its stage; the fact is stored and its graph structure is
370    /// partial until it is re-remembered. Discarding the error here — as
371    /// `let _` did — left that invisible everywhere: no counter, no log, and
372    /// a `memory_status` that reported a healthy worker. One line per failed
373    /// stage, not per edge (#1834's rule): the concurrent-`forget` race the
374    /// comments above describe is the common cause, and it fails the whole
375    /// stage at once.
376    fn note_autograph_failure(&self, fact_id: u64, stage: AutographStage, err: &MemoryError) {
377        self.autograph_queue
378            .failed
379            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
380        #[cfg(feature = "mcp")]
381        tracing::warn!(
382            fact_id,
383            stage = %stage,
384            error = %err,
385            "autograph enrichment failed part-way: the fact is stored, its graph \
386             structure is partial; re-remembering it completes the wiring"
387        );
388        #[cfg(not(feature = "mcp"))]
389        let _ = (fact_id, stage, err);
390    }
391
392    /// Create each outgoing link from `fact_id`.
393    ///
394    /// Precondition: every label was already validated by
395    /// [`Self::remember_with_ttl`]'s pre-write pass (its only caller) —
396    /// no re-check here, so the validation rule lives in exactly one
397    /// place on this path.
398    fn relate_links(&self, fact_id: u64, links: &[Link]) -> Result<(), MemoryError>
399    where
400        S: GraphStore,
401    {
402        for link in links {
403            self.store.relate(fact_id, link.target, &link.relation)?;
404        }
405        Ok(())
406    }
407
408    /// Remember a passage of raw `text` by running it through an [`Extractor`]
409    /// and storing every fact it yields, **auto-wiring the fact↔entity graph**.
410    ///
411    /// This is the commodity on top of [`Self::remember`]'s bring-your-own-links
412    /// core: each extracted fact is stored (tagged with `metadata`), each salient
413    /// topic becomes a deduplicated hub memory, and every fact is linked to its
414    /// topics with a bidirectional `about`/`mentions` edge. Two facts sharing a
415    /// topic therefore become reachable from one another, so [`Self::why`] has a
416    /// real graph to traverse with no manual `relate()`.
417    ///
418    /// Entity hubs are content-addressed, so the same topic seen across many
419    /// calls collapses onto one hub. Returns the ids of the stored facts (entity
420    /// hubs excluded), in extraction order, plus how many facts were skipped
421    /// for exceeding the embeddable cap — one unusable fact must not cost the
422    /// others, the policy every other stage of this pipeline already follows
423    /// (a malformed triple is skipped, a blank entity is skipped).
424    ///
425    /// # Errors
426    /// Returns [`MemoryError::EmptyFact`] for empty/whitespace `text`,
427    /// [`MemoryError::Extract`] if extraction fails, [`MemoryError::ReservedKey`]
428    /// if `metadata` names a reserved key, [`MemoryError::MetadataTooLarge`] if
429    /// `metadata` exceeds [`crate::limits::MAX_METADATA_BYTES`], or a storage
430    /// error if persistence fails. A fact past
431    /// [`crate::limits::MAX_EMBEDDABLE_TEXT_BYTES`] is NOT an error: it is
432    /// counted in [`RememberedExtraction::skipped_over_cap`] and the call
433    /// carries on.
434    pub fn remember_extracted<X: Extractor>(
435        &self,
436        text: &str,
437        extractor: &X,
438        metadata: Option<&Metadata>,
439    ) -> Result<RememberedExtraction, MemoryError>
440    where
441        S: GraphStore,
442    {
443        let _generation = self.enter_generation();
444        let extraction = Self::extract_passage(text, extractor)?;
445        self.store_extraction_inner(&extraction, metadata)
446    }
447
448    /// Store a previously generated extraction through the same idempotent
449    /// fact, hub, edge, and attribute primitives as [`Self::remember_extracted`].
450    #[cfg(feature = "mcp")]
451    pub(crate) fn store_extraction(
452        &self,
453        extraction: &crate::extract::Extraction,
454        metadata: Option<&Metadata>,
455    ) -> Result<RememberedExtraction, MemoryError>
456    where
457        S: GraphStore,
458    {
459        let _generation = self.enter_generation();
460        self.store_extraction_inner(extraction, metadata)
461    }
462
463    fn store_extraction_inner(
464        &self,
465        extraction: &crate::extract::Extraction,
466        metadata: Option<&Metadata>,
467    ) -> Result<RememberedExtraction, MemoryError>
468    where
469        S: GraphStore,
470    {
471        let mut entity_ids: HashMap<String, u64> = HashMap::new();
472        let mut edges: HashSet<(u64, u64, String)> = HashSet::new();
473        let outcome =
474            self.store_extracted_facts(&extraction.facts, metadata, &mut entity_ids, &mut edges)?;
475        self.wire_relations(&extraction.relations, &mut entity_ids, &mut edges)?;
476        self.wire_attributes(&extraction.attributes, &mut entity_ids)?;
477        Ok(outcome)
478    }
479
480    /// Look up everything known about a named entity: the attributes merged
481    /// onto its hub, and the typed edges leaving it.
482    ///
483    /// This is the *read* side of the auto-built graph, and it exists because
484    /// entity hubs are deliberately invisible to [`Self::recall`] and
485    /// [`Self::recall_where`] — a hub ranking for its own topic would evict a
486    /// real fact from the caller's results. Without this accessor an attribute
487    /// merged onto a hub would be stored correctly and yet be unreachable
488    /// through every public read path: the worst kind of feature, one that
489    /// looks done and silently returns nothing.
490    ///
491    /// `name` is canonicalized exactly like an extracted entity (trimmed,
492    /// lowercased), so the caller may pass `"Theo Durand"` and reach the node
493    /// built from `"theo durand"`. Returns `None` when no hub exists for the
494    /// name — nothing has ever mentioned that entity.
495    ///
496    /// # Errors
497    /// Returns [`MemoryError`] if the store lookup fails.
498    pub fn entity_profile(&self, name: &str) -> Result<Option<EntityProfile>, MemoryError>
499    where
500        S: GraphStore,
501    {
502        let _generation = self.enter_generation();
503        let key = canonical_entity_name(name);
504        if key.is_empty() {
505            return Ok(None);
506        }
507        let id = id::stable_id(&format!("{HUB_ID_SALT}{key}"));
508        if self.store.get(id)?.is_none() {
509            return Ok(None);
510        }
511        // Reserved system keys (the hub flag itself) are scaffolding, not
512        // attributes the caller ever wrote — strip them exactly as every other
513        // caller-facing read path does.
514        let (relations, relations_truncated) = self.outgoing_entity_relations(id)?;
515        let (relations_in, relations_in_truncated) = self.incoming_entity_relations(id)?;
516        Ok(Some(EntityProfile {
517            id,
518            name: key,
519            attributes: strip_reserved_keys(self.store.get_metadata(id)?).unwrap_or_default(),
520            relations,
521            relations_in,
522            relations_truncated,
523            relations_in_truncated,
524        }))
525    }
526
527    /// The typed edges leaving `id`, resolved to their target's content, and
528    /// whether that list is a partial view.
529    ///
530    /// Scaffolding edges (`mentions`, and `about` for symmetry — a hub never
531    /// has an outgoing `about`) are dropped: they point at the facts that
532    /// tagged this entity, not at a statement *about* it.
533    fn outgoing_entity_relations(&self, id: u64) -> Result<(Vec<EntityRelation>, bool), MemoryError>
534    where
535        S: GraphStore,
536    {
537        let scanned = self
538            .store
539            .relations_bounded(id, crate::limits::MAX_ENTITY_SCAN_EDGES)?;
540        self.resolve_entity_relations(scanned, |edge| edge.to)
541    }
542
543    /// The typed edges pointing at `id`, resolved to their SOURCE's content —
544    /// for an incoming edge the far end is where it comes *from* — and
545    /// whether that list is a partial view.
546    ///
547    /// Without these, a question is only answerable from one side: the graph
548    /// holds `camille --soeur de--> theo`, so reading Theo's outgoing edges
549    /// never finds Camille. The edge exists, it simply leaves the other node.
550    ///
551    /// The scaffolding filter is the incoming mirror of
552    /// [`Self::outgoing_entity_relations`]'s: `about` edges are dropped (they
553    /// are the fact → hub half of the `about`/`mentions` pair), and `mentions`
554    /// with them for symmetry.
555    fn incoming_entity_relations(&self, id: u64) -> Result<(Vec<EntityRelation>, bool), MemoryError>
556    where
557        S: GraphStore,
558    {
559        let scanned = self
560            .store
561            .incoming_relations_bounded(id, crate::limits::MAX_ENTITY_SCAN_EDGES)?;
562        self.resolve_entity_relations(scanned, |edge| edge.from)
563    }
564
565    /// Wire each extracted `subject -[predicate]-> object` triple as a typed
566    /// edge between the two entity hubs.
567    ///
568    /// This is the step that turns the bipartite fact↔topic graph into a real
569    /// knowledge graph. The hubs are resolved through [`Self::entity_hub`], so
570    /// an endpoint naming an entity some earlier passage already introduced
571    /// reuses that entity's existing node rather than forking a parallel one —
572    /// hub ids are content-addressed, so this holds across calls and sessions.
573    ///
574    /// Only the stated direction is written. Inferring the converse
575    /// (`father of` ⇒ `child of`) would mean inventing a label the passage
576    /// never used, and an inverted vocabulary nobody can predict is worse than
577    /// an absent edge: `why()` walks outgoing edges, so a wrong direction
578    /// silently misroutes every later traversal.
579    ///
580    /// A malformed triple is skipped, not fatal — one unusable predicate must
581    /// not cost the caller the facts stored alongside it.
582    fn wire_relations(
583        &self,
584        relations: &[ExtractedRelation],
585        entity_ids: &mut HashMap<String, u64>,
586        edges: &mut HashSet<(u64, u64, String)>,
587    ) -> Result<(), MemoryError>
588    where
589        S: GraphStore,
590    {
591        for relation in relations {
592            if validate_relation(&relation.predicate).is_err() {
593                continue;
594            }
595            let subject_id = self.entity_hub(&relation.subject, entity_ids)?;
596            let object_id = self.entity_hub(&relation.object, entity_ids)?;
597            if subject_id == object_id {
598                continue;
599            }
600            self.add_edge(subject_id, object_id, &relation.predicate, edges)?;
601        }
602        Ok(())
603    }
604
605    /// Store each extracted fact and wire it to its topics, returning their ids.
606    ///
607    /// Goes through the no-autograph path: the passage was ALREADY extracted by
608    /// the caller, so re-running a generation per stored fact would re-derive
609    /// what was just computed.
610    fn store_extracted_facts(
611        &self,
612        facts: &[crate::extract::ExtractedFact],
613        metadata: Option<&Metadata>,
614        entity_ids: &mut HashMap<String, u64>,
615        edges: &mut HashSet<(u64, u64, String)>,
616    ) -> Result<RememberedExtraction, MemoryError>
617    where
618        S: GraphStore,
619    {
620        let mut ids = Vec::with_capacity(facts.len());
621        let mut skipped_over_cap = 0;
622        for fact in facts {
623            let content = fact.text.trim();
624            if content.is_empty() {
625                continue;
626            }
627            // An over-cap fact is skipped, not fatal: aborting here used to
628            // leave the previous iterations persisted with no rollback, no
629            // graph wiring, and no ids returned — the worst of every world.
630            // Every OTHER error still aborts: they signal bad caller input
631            // (reserved keys, oversized metadata) or a failing store, where
632            // carrying on would compound the damage.
633            let fact_id = match self.remember_inner(content, &[], metadata, None, false) {
634                Ok(id) => id,
635                Err(MemoryError::FactTooLarge { .. }) => {
636                    skipped_over_cap += 1;
637                    continue;
638                }
639                Err(error) => return Err(error),
640            };
641            ids.push(fact_id);
642            self.wire_entities(fact_id, &fact.entities, entity_ids, edges)?;
643        }
644        Ok(RememberedExtraction {
645            ids,
646            skipped_over_cap,
647        })
648    }
649
650    /// Link `fact_id` to each of its topics with a deduplicated edge in *both*
651    /// directions. `why()` only follows outgoing edges, so the fact→topic edge
652    /// alone leaves hubs as dead ends; the topic→fact edge is what lets a walk
653    /// hop from one fact, through a shared topic, to its sibling facts.
654    fn wire_entities(
655        &self,
656        fact_id: u64,
657        entities: &[String],
658        entity_ids: &mut HashMap<String, u64>,
659        edges: &mut HashSet<(u64, u64, String)>,
660    ) -> Result<(), MemoryError>
661    where
662        S: GraphStore,
663    {
664        for entity in entities {
665            // Skip blank or punctuation-only topics: they would persist as junk
666            // hubs (`Entity: -`) yet can never carry a meaningful multi-hop link.
667            if entity.chars().any(char::is_alphanumeric) {
668                self.wire_entity(fact_id, entity, entity_ids, edges)?;
669            }
670        }
671        Ok(())
672    }
673
674    /// Wire one topic to `fact_id`: resolve its hub, then add the deduplicated
675    /// `about`/`mentions` pair (skipping a hub that is the fact itself).
676    fn wire_entity(
677        &self,
678        fact_id: u64,
679        entity: &str,
680        entity_ids: &mut HashMap<String, u64>,
681        edges: &mut HashSet<(u64, u64, String)>,
682    ) -> Result<(), MemoryError>
683    where
684        S: GraphStore,
685    {
686        let entity_id = self.entity_hub(entity, entity_ids)?;
687        if entity_id == fact_id {
688            return Ok(());
689        }
690        self.add_edge(fact_id, entity_id, ABOUT_RELATION, edges)?;
691        self.add_edge(entity_id, fact_id, MENTIONS_RELATION, edges)?;
692        Ok(())
693    }
694
695    /// Create the edge `from -> to` labelled `label`, unless `edges` already
696    /// records that triple for this call (in-call dedup only). `relate`
697    /// derives the edge id from `(from, relation, to)`
698    /// ([`crate::wire::hash_edge_id`] upstream in core) and is itself an O(1)
699    /// idempotent no-op against an already-persisted edge, so there is
700    /// nothing left to preload from the store — a prior preload here made
701    /// every write to a hub with `k` existing edges cost O(k), turning `n`
702    /// writes to the same hub into O(n²).
703    fn add_edge(
704        &self,
705        from: u64,
706        to: u64,
707        label: &str,
708        edges: &mut HashSet<(u64, u64, String)>,
709    ) -> Result<(), MemoryError>
710    where
711        S: GraphStore,
712    {
713        if edges.insert((from, to, label.to_string())) {
714            self.relate_inner(from, to, label)?;
715        }
716        Ok(())
717    }
718}
719
720#[cfg(not(target_arch = "wasm32"))]
721impl<E, S> MemoryService<E, S>
722where
723    E: Embedder + Send + Sync + 'static,
724    S: FactStore + Send + Sync + 'static,
725{
726    /// The autograph worker's whole life, run on the spawned thread: ends
727    /// when every sender is gone — i.e. when the handle's drop takes the
728    /// sender back out of the service. Once the closing latch is up,
729    /// still-queued jobs are SKIPPED: the exit pays for the job in flight,
730    /// never for the queue.
731    fn autograph_worker_loop(&self, rx: &std::sync::mpsc::Receiver<AutographJob>)
732    where
733        S: GraphStore,
734    {
735        let mut skipped_on_close: u64 = 0;
736        for job in rx {
737            if self
738                .autograph_queue
739                .closing
740                .load(std::sync::atomic::Ordering::Acquire)
741            {
742                skipped_on_close += 1;
743                continue;
744            }
745            let _generation = self.enter_generation();
746            self.autograph(job.fact_id, &job.fact);
747        }
748        if skipped_on_close > 0 {
749            self.autograph_queue
750                .dropped
751                .fetch_add(skipped_on_close, std::sync::atomic::Ordering::Relaxed);
752            // ONE aggregated line, not one per job (#1834's rule).
753            #[cfg(feature = "mcp")]
754            tracing::warn!(
755                skipped = skipped_on_close,
756                "autograph worker closing: queued enrichments skipped — \
757                 the facts are stored, their graph structure is not; \
758                 re-remembering rebuilds it"
759            );
760        }
761    }
762
763    /// Move autograph off the response path: spawn ONE background worker
764    /// consuming a bounded queue, so `remember` returns as soon as the fact
765    /// is durably stored and the graph is wired behind (#1846).
766    ///
767    /// Measured motivation: with the production extractor, an inline
768    /// autograph held every `remember` for 46-52 s while the embedding cost
769    /// 0.12 s — and the MCP client timed out mid-generation, making a stored
770    /// fact indistinguishable from a lost one (#1839).
771    ///
772    /// The read-after-write contract changes, deliberately and visibly: an
773    /// `entity()` issued right after `remember` may not see the new edges
774    /// yet. The fact itself is always readable immediately — only the
775    /// DERIVED structure lags by one generation.
776    ///
777    /// One worker on purpose: the store is single-writer, and a second
778    /// in-flight generation would only add contention, not throughput.
779    /// `capacity` bounds the queue ([`crate::limits::MAX_AUTOGRAPH_QUEUE`]
780    /// is the daemon's choice); a full queue DROPS new enrichments, counted
781    /// by [`Self::autograph_dropped`] and logged — never silent, never
782    /// blocking the write path.
783    ///
784    /// # Errors
785    /// Returns [`MemoryError::Extract`] when a worker is already spawned for
786    /// this service — two workers would race the single-writer store for no
787    /// gain — or when the OS refuses the thread.
788    pub fn spawn_autograph_worker(
789        self: &std::sync::Arc<Self>,
790        capacity: usize,
791    ) -> Result<AutographWorkerHandle, MemoryError>
792    where
793        S: GraphStore,
794    {
795        let (tx, rx) = std::sync::mpsc::sync_channel::<AutographJob>(capacity);
796        {
797            let mut guard = self.autograph_queue.tx.lock();
798            if guard.is_some() {
799                return Err(MemoryError::Extract(crate::extract::ExtractError::Backend(
800                    "autograph worker already spawned for this service".to_owned(),
801                )));
802            }
803            *guard = Some(tx);
804            // Re-arm the shutdown latch under the same lock that installs
805            // the sender: a previous worker's close must not poison this one
806            // into skipping every job it will ever receive.
807            self.autograph_queue
808                .closing
809                .store(false, std::sync::atomic::Ordering::Release);
810        }
811        let worker_service = std::sync::Arc::clone(self);
812        let join = std::thread::Builder::new()
813            .name("velesdb-autograph".to_owned())
814            .spawn(move || worker_service.autograph_worker_loop(&rx))
815            .map_err(|err| {
816                MemoryError::Extract(crate::extract::ExtractError::Backend(format!(
817                    "spawn autograph worker: {err}"
818                )))
819            })?;
820        let closer_service = std::sync::Arc::clone(self);
821        Ok(AutographWorkerHandle {
822            close_queue: Some(Box::new(move || {
823                // Latch FIRST, sender out second: the worker observes the
824                // latch no later than the queue's end, so it cannot start
825                // draining jobs the shutdown meant to skip.
826                closer_service
827                    .autograph_queue
828                    .closing
829                    .store(true, std::sync::atomic::Ordering::Release);
830                closer_service.autograph_queue.tx.lock().take();
831            })),
832            join: Some(join),
833        })
834    }
835}