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}