core_api/memory/remember.rs
1//! `remember` — write a `Note` the graph can later `recall`.
2//!
3//! Unlike the code-graph `remember` this module superseded, deleted in 0.7 with
4//! the rest of that module, an `about` key here is not required to already
5//! exist: a fact may name its subject before anything has described that
6//! subject. A missing key is stubbed as a provisional entity
7//! (`memory_schema::PROVISIONAL_LABEL`, `memory_schema::PROVISIONAL_PROP`)
8//! rather than refusing the whole call — the store learns the shape of what it
9//! does not yet know. `facts[]` endpoints get the identical stub, not a bare
10//! `insert_edge_upsert` auto-create: an unnamed, unmarked node from a typo'd
11//! `object` would be unfindable by the same `provisional` query that surfaces
12//! every other guess this module makes (fix round 1, 0.7).
13//!
14//! Everything one call writes — the entities the caller recognised, the note,
15//! the `ABOUT` edges linking the note to `about`, and the facts among those
16//! entities — goes through one [`GraphDb::batch`] commit, in that order: an
17//! `about` key or a fact endpoint that is also named in `entities` (or, for a
18//! fact endpoint, also in `about`) is created as the real entity the caller
19//! described, not a provisional stub, because the entity ops are queued (and
20//! their keys become visible to the batch's own validation) before the
21//! `about` keys and the facts' own endpoints are resolved.
22
23use crate::memory::identity::{
24 aliases_value, derive_aliases, identity_props_after_write, same_as_lost, same_as_pairs,
25 SameAsPair, ALIASES_FIELD, MAX_ALIASES,
26};
27use crate::memory_schema::{NAME_FIELD, PROVISIONAL_LABEL, PROVISIONAL_PROP};
28use crate::{GraphDb, NS_PROP};
29use core_storage::fs::Fs;
30use core_storage::{GraphError, Result, Value};
31use std::collections::BTreeMap;
32use std::collections::BTreeSet;
33
34/// Text length bounds, in characters, after trimming.
35const MIN_TEXT_CHARS: usize = 1;
36const MAX_TEXT_CHARS: usize = 4000;
37
38/// The `kind` values a `Note` may carry.
39pub const NOTE_KINDS: [&str; 3] = ["note", "decision", "todo"];
40
41/// The `kind` a note gets when the caller names none.
42pub const DEFAULT_NOTE_KIND: &str = "note";
43
44/// Unix seconds now — the `ts` a note gets when the caller names none. `0`
45/// on a clock set before 1970 rather than a panic.
46#[must_use]
47pub fn unix_now_secs() -> i64 {
48 std::time::SystemTime::now()
49 .duration_since(std::time::UNIX_EPOCH)
50 .map_or(0, |d| d.as_secs() as i64)
51}
52
53/// Drop repeats from `keys`, keeping the first of each and the order — the
54/// order `remember` reports `provisional` keys in.
55pub fn dedup_keep_order(keys: &mut Vec<String>) {
56 let mut seen = BTreeSet::new();
57 keys.retain(|k| seen.insert(k.clone()));
58}
59
60/// The edge type linking a `Note` to what it is about.
61const ABOUT_EDGE: &str = "ABOUT";
62
63/// The `source` a note is stamped with when the caller supplies none.
64const DEFAULT_SOURCE: &str = "agent";
65
66/// Ceiling on the store's total declared full-text surface — the defaults
67/// `memory_defaults()` ships (8: the five entity labels, `Note.text`,
68/// `Concept.summary`, `Entity.name`) plus whatever `remember` self-declares
69/// for an `entities[].label` outside those. See the self-declare comment in
70/// [`remember`] for why this is bounded rather than unlimited.
71const MAX_FULLTEXT_PAIRS: usize = 32;
72
73/// Ceiling on provisional stubs one `remember` call may create — `about`
74/// keys and `facts[]` endpoints combined, counted in resolution order
75/// (`about` before `facts`). Spec §3.2's own guard: "a cap per commit, so a
76/// malformed batch cannot flood the graph."
77///
78/// A real extraction from one conversation turn names at most a handful of
79/// unknown subjects — `about` typically carries one to a few keys, and
80/// `facts[]` rarely introduces more than a couple more that `entities`
81/// didn't already cover — so this is generous against that shape and tight
82/// against the shape of a bug: a loop that built `facts` from a cross
83/// product, or a caller that fed a whole document's worth of names into
84/// `about` at once. 20 is comfortably above any single legitimate call and
85/// well short of "the graph is now full of typos."
86///
87/// Past the cap, the note and everything else valid in the call still
88/// commits — a caller's one bad key should not cost the whole write, the
89/// same reasoning that makes an unknown key provisional instead of a refusal
90/// in the first place — but a key that would have been stubbed is not: no
91/// node is created for it, and no edge (`ABOUT` or a fact's own) names it
92/// either, since that edge's endpoint would not exist. It is listed in
93/// [`RememberReport::provisional_capped`], so the caller is told plainly
94/// rather than discovering a silently short digest later — the same
95/// "unmistakable, not a quiet field" standard the label-mismatch fix in
96/// `upsert_entity` was held to.
97const MAX_PROVISIONAL_PER_COMMIT: usize = 20;
98
99/// One entity the caller recognised in the text.
100///
101/// Create-or-update, same as [`describe_entity`]: a `key` already in the
102/// store is described (its `props` set, its provisional mark cleared if it
103/// had one) rather than re-created.
104#[derive(Debug, Clone)]
105pub struct EntityIn {
106 pub key: String,
107 pub label: String,
108 pub props: BTreeMap<String, Value>,
109 /// Other names the caller knows this entity by. Kept as declared in the
110 /// node's `alias_keys` list, which links a provisional stub keyed exactly
111 /// so. They do not enter `aliases`, which holds only what the key and the
112 /// name imply; see [`crate::memory::identity`].
113 pub aliases: Vec<String>,
114}
115
116/// One relationship the caller recognised, between keys named in `entities`
117/// or `about`.
118///
119/// `subject`/`object` need not already exist — same provisional treatment as
120/// an unknown `about` key — so a fact can arrive before either endpoint has
121/// been described.
122#[derive(Debug, Clone)]
123pub struct FactIn {
124 pub subject: String,
125 pub predicate: String,
126 pub object: String,
127}
128
129/// What to remember.
130pub struct RememberInput<'a> {
131 /// The note's text, trimmed to [`MIN_TEXT_CHARS`]..=[`MAX_TEXT_CHARS`]
132 /// characters.
133 pub text: &'a str,
134 /// Keys the note is about. A key that does not exist yet is created as a
135 /// provisional entity rather than refusing the call.
136 pub about: &'a [String],
137 /// One of [`NOTE_KINDS`].
138 pub kind: &'a str,
139 /// Unix seconds the note was written at. Part of the note's key, so
140 /// remembering the same text again at the same `ts` is a no-op rather
141 /// than a duplicate. Caller-supplied so a fact imported from a
142 /// transcript is stamped with when it was said, not when it was
143 /// imported.
144 pub ts: i64,
145 /// Where this came from — a session id, a file, a person. `None`
146 /// defaults to `"agent"`, the only value this ever stored before 0.7.
147 pub source: Option<&'a str>,
148 /// Entities the caller recognised in the text, written in the same
149 /// commit as the note.
150 pub entities: &'a [EntityIn],
151 /// Relationships the caller recognised, written in the same commit as
152 /// the note.
153 pub facts: &'a [FactIn],
154}
155
156/// Everything one `remember` call writes.
157#[derive(Debug, Default, serde::Serialize)]
158pub struct RememberReport {
159 /// The note's key.
160 pub note: String,
161 /// Entities this call brought into existence.
162 pub created: usize,
163 /// `about` keys, entities and fact endpoints that already existed. A key
164 /// is counted once across the three; an `entities` entry is counted each
165 /// time it is listed.
166 pub matched: usize,
167 /// Derived edges the rules produced for this commit, incident on the
168 /// *note* — edges the engine's rule provenance attributes to a rule,
169 /// never one this call inserted itself (`ABOUT`, a fact's own edge, or a
170 /// provisional stub have no rule and never count here). `memory_defaults()`
171 /// ships with zero rules, so this is `0` against a plain memory store — and
172 /// it stays `0` with the identity preset applied: its `SAME_AS` rules link
173 /// entity labels, never a note, and are reported in
174 /// [`same_as`](Self::same_as) instead. This field only moves once a rule
175 /// exists whose predicate can match the note itself — such as an
176 /// `about_<label>` rule `ingest-git` declared before 0.7, which derives the
177 /// note's `ABOUT` edge (counted here, on the commit that derived it) in
178 /// place of the insert this call would otherwise make.
179 pub derived: usize,
180 /// `about` keys and fact endpoints that had to be stubbed, in the order
181 /// each was first seen (`about` before `facts`).
182 pub provisional: Vec<String>,
183 /// `about` keys and fact endpoints that would have been stubbed but were
184 /// refused instead — [`MAX_PROVISIONAL_PER_COMMIT`] was already spent by
185 /// the time this call reached them. Neither the node nor any edge naming
186 /// it exists; everything else in the call still committed. Empty on
187 /// every call this cap does not bind.
188 pub provisional_capped: Vec<String>,
189 /// Entity labels full-text search had never seen before this call, now
190 /// declared on `(label, "name")` so `recall` can reach them — the same
191 /// self-declare `Note.text` gets, extended to `entities[].label`. Empty
192 /// on every call that named no unseen label, or that found
193 /// [`MAX_FULLTEXT_PAIRS`] already spent.
194 pub fulltext_declared: Vec<String>,
195 /// `SAME_AS` claims this call created, between an entity or stub it
196 /// wrote and any other node — one entry per pair, whichever directions
197 /// the rules derived. Empty on a store without the identity preset
198 /// (`memory_schema::memory_identity`). This is what tells the caller its
199 /// extraction agreed with something the store already held.
200 pub same_as: Vec<SameAsPair>,
201 /// `SAME_AS` claims this call retracted: pairs that linked an entity it
202 /// wrote before the commit and do not after it, each with the score it
203 /// had. `aliases` is recomputed from the key and the current name, so a
204 /// write that changes a name can take a full-name link below the floor,
205 /// and so can the first write to a node whose `aliases` held items its
206 /// key and name do not imply. Reported so a write that costs an identity
207 /// says so, rather than only naming what it gained.
208 pub same_as_lost: Vec<SameAsPair>,
209}
210
211/// Refuse a key that is empty, or only whitespace: it names nothing, and a
212/// node created under it is one no caller meant (defect 76). The same goes
213/// for a fact's predicate and an entity's label, which become an edge type
214/// and a node label (defect 77). `argument` says which argument held it and
215/// is only built for the refusal.
216///
217/// So is one made only of characters that print as nothing — a zero-width
218/// space, a control character, a byte-order mark (defect 78). `str::trim`
219/// does not count those as whitespace; the definition used is the digest's,
220/// [`crate::digest::sanitize`], which prints each of them as a space.
221///
222/// A value with text between its padding is not this function's business: it
223/// is stored as given.
224fn refuse_blank(value: &str, argument: impl FnOnce() -> String) -> Result<()> {
225 if value.trim().is_empty() {
226 return Err(GraphError::IngestError {
227 detail: format!(
228 "{} must not be empty or only whitespace, got {value:?}",
229 argument()
230 ),
231 });
232 }
233 if crate::digest::sanitize(value).trim().is_empty() {
234 return Err(GraphError::IngestError {
235 detail: format!(
236 "{} must not be only invisible characters, got {value:?}",
237 argument()
238 ),
239 });
240 }
241 Ok(())
242}
243
244/// Create-or-update one entity, clearing any provisional mark.
245///
246/// Upsert semantics lived only in `tool_upsert_entity` before 0.7, so HTTP
247/// and Python had no upsert and could not clear a provisional mark. This is
248/// the one implementation: [`upsert_entity`] adds the caller-facing policy on
249/// top of it, and both the MCP tool and the Python binding's `upsert_entity`
250/// call that. HTTP still has no entity upsert — `POST /nodes` is a raw node
251/// write that does not come through here.
252///
253/// `label` is used only when `key` does not already exist — an update never
254/// changes a node's label. A subject stops being provisional the moment
255/// anything describes it, whoever does the describing, so the clearing lives
256/// here rather than in any one caller: `remove_prop` is `Ok(false)` and logs
257/// nothing when the field is already absent, so this is unconditional and
258/// free on a node that was never provisional.
259///
260/// A create passes `props` straight to [`GraphDb::insert_node`] rather than
261/// inserting bare and following up with [`GraphDb::set_props`]: a namespace
262/// is set at insert and immutable after, so a caller-supplied `ns` reaching
263/// `set_props` on a node that already exists (even one this same call just
264/// created) is the engine's `NamespaceImmutable` refusal rather than the
265/// no-op it should be.
266pub fn describe_entity<F: Fs>(
267 db: &mut GraphDb<F>,
268 key: &str,
269 label: Option<&str>,
270 props: &[(String, Value)],
271) -> Result<bool> {
272 describe_entity_with_aliases(db, key, label, props, &[])
273}
274
275/// [`describe_entity`], with aliases the caller knows the entity by.
276///
277/// Either way the node's `aliases` list is recomputed from its key and its
278/// name ([`crate::memory::identity`]), and left untouched when it already
279/// says exactly that; `aliases` as declared are added to its `alias_keys`
280/// list and to nothing else. A `props` entry named `aliases` or `alias_keys`
281/// is refused before anything is written, and so is a `key` or a `label`
282/// that is empty or only whitespace.
283pub fn describe_entity_with_aliases<F: Fs>(
284 db: &mut GraphDb<F>,
285 key: &str,
286 label: Option<&str>,
287 props: &[(String, Value)],
288 aliases: &[String],
289) -> Result<bool> {
290 refuse_blank(key, || "key".to_string())?;
291 if let Some(label) = label {
292 refuse_blank(label, || "label".to_string())?;
293 }
294 let mut props = props.to_vec();
295 let identity = identity_props_after_write(db, key, &props, aliases)?;
296 props.extend(identity);
297 if db.has_node(key) {
298 if !props.is_empty() {
299 db.set_props(key, props)?;
300 }
301 let _ = db.remove_prop(key, PROVISIONAL_PROP)?;
302 Ok(false)
303 } else {
304 db.insert_node(label.unwrap_or(PROVISIONAL_LABEL), key, props)?;
305 let _ = db.remove_prop(key, PROVISIONAL_PROP)?;
306 Ok(true)
307 }
308}
309
310/// What one [`upsert_entity`] call did.
311#[derive(Debug, Clone, PartialEq)]
312pub struct UpsertOutcome {
313 /// The label the node carries: the one given on a create, the stored one
314 /// on an update.
315 pub label: String,
316 /// True when the node did not exist before this call.
317 pub created: bool,
318 /// Properties this call set on an existing node. `0` on a create. The
319 /// namespace a node is already in is not counted: it writes no record.
320 pub updated_fields: usize,
321 /// `SAME_AS` claims the node held before an update and does not after it.
322 /// Empty on a create and on a store without the identity preset.
323 pub same_as_lost: Vec<SameAsPair>,
324}
325
326/// Create or update one entity by key, with the policy every surface shares.
327///
328/// - `id` is never taken from `row`: on a create it is set to `key`, and on
329/// an update it is left alone.
330/// - A create needs `label`. An update never changes one: a `label` that
331/// differs from the stored one is refused before anything is written, so
332/// nothing in `row` is applied either.
333/// - A `row` entry `ns` equal to the namespace the node is already in is the
334/// engine's no-op and is not counted as an update; a different one is the
335/// engine's `NamespaceImmutable` refusal.
336/// - Aliases are maintained and a provisional mark is cleared, as
337/// [`describe_entity_with_aliases`] does.
338/// - A `key` that is empty or only whitespace is refused before anything is
339/// read or written, whether or not a node is stored under it.
340/// - A create under a `label` that is empty or only whitespace is refused
341/// before anything is written, by [`describe_entity_with_aliases`]. On an
342/// update such a label is the relabel refusal above, like any other label
343/// the node does not carry.
344///
345/// Reads and writes on the one `&mut` handle, so the existence check and the
346/// write cannot be separated by another writer.
347///
348/// # Errors
349/// [`GraphError::IngestError`] for the four policy refusals, with the whole
350/// sentence in `detail`; any engine refusal, as it came.
351pub fn upsert_entity<F: Fs>(
352 db: &mut GraphDb<F>,
353 key: &str,
354 label: Option<&str>,
355 mut row: BTreeMap<String, Value>,
356 aliases: &[String],
357) -> Result<UpsertOutcome> {
358 refuse_blank(key, || "key".to_string())?;
359 row.remove("id");
360 if db.has_node(key) {
361 // The label actually stored — an update never changes it. Checked,
362 // and refused, before anything else: a `label` that disagrees is not
363 // a request this call can honor at all, so it does not partially
364 // apply the rest of `row` either.
365 let stored_label = db
366 .node_ref(key)
367 .map(|n| n.label().to_string())
368 .unwrap_or_default();
369 if let Some(requested) = label {
370 if requested != stored_label {
371 return Err(GraphError::IngestError {
372 detail: format!(
373 "'{key}' exists as {stored_label:?}, not {requested:?}; this release \
374 cannot relabel a node. Pass the label in remember's 'entities' when you \
375 know it (at first mention, before it goes provisional), or use a \
376 different key."
377 ),
378 });
379 }
380 }
381 let mut to_set: Vec<(String, Value)> = Vec::new();
382 for (field, v) in row {
383 // The namespace a node is already in is the engine's no-op: it
384 // writes no record and takes no commit, so counting it as an
385 // updated field would report an update that did not happen.
386 // Asking first also keeps the refusal for a *different* namespace
387 // coming from the engine rather than from a second rule stated
388 // here.
389 if field == NS_PROP && Some(&v) == db.namespace_of(key).map(Value::Str).as_ref() {
390 continue;
391 }
392 to_set.push((field, v));
393 }
394 let updated_fields = to_set.len();
395 // The node's `SAME_AS` claims either side of the write, so the
396 // outcome can name the links this update retracted: a changed name
397 // takes a full-name link below the floor.
398 let keys = [key.to_string()];
399 let same_as_before = same_as_pairs(db, &keys);
400 describe_entity_with_aliases(db, key, None, &to_set, aliases)?;
401 let same_as_lost = same_as_lost(&same_as_before, &same_as_pairs(db, &keys));
402 Ok(UpsertOutcome {
403 label: stored_label,
404 created: false,
405 updated_fields,
406 same_as_lost,
407 })
408 } else {
409 let Some(label) = label else {
410 return Err(GraphError::IngestError {
411 detail: "label required when creating a new entity".into(),
412 });
413 };
414 row.insert("id".to_string(), Value::Str(key.to_string()));
415 let props: Vec<(String, Value)> = row.into_iter().collect();
416 describe_entity_with_aliases(db, key, Some(label), &props, aliases)?;
417 Ok(UpsertOutcome {
418 label: label.to_string(),
419 created: true,
420 updated_fields: 0,
421 same_as_lost: Vec::new(),
422 })
423 }
424}
425
426/// Write `input` as a `Note`, describing whatever it names along the way.
427///
428/// Validated before anything is written: `text` must be
429/// [`MIN_TEXT_CHARS`]..=[`MAX_TEXT_CHARS`] characters after trimming, and
430/// `kind` must be one of [`NOTE_KINDS`]. Unlike the code-graph `remember`
431/// 0.7 deleted, an unknown `about` key is never an error — see the module
432/// docs. A key that is empty or only whitespace is one: in `about`, as an
433/// `entities[].key` or as a fact's `subject` or `object` it refuses the
434/// whole call, naming the argument and its position, and nothing is written.
435/// So is an `entities[].label` or a fact's `predicate` with nothing in it.
436///
437/// The note's key is `"note:"` followed by 16 hex characters of a stable
438/// 64-bit hash of `ts` and `text` (see [`note_key`]), so remembering the
439/// same text at the same `ts` again returns the same key without writing a
440/// second node.
441///
442/// Also ensures full-text search is enabled on `Note.text`, so a store whose
443/// very first write is a `remember` call can still be recalled from.
444pub fn remember<F: Fs>(db: &mut GraphDb<F>, input: &RememberInput<'_>) -> Result<RememberReport> {
445 let text = input.text.trim();
446 let len = text.chars().count();
447 if !(MIN_TEXT_CHARS..=MAX_TEXT_CHARS).contains(&len) {
448 return Err(GraphError::IngestError {
449 detail: format!(
450 "remember: text must be {MIN_TEXT_CHARS}..={MAX_TEXT_CHARS} characters \
451 after trimming, got {len}"
452 ),
453 });
454 }
455 if !NOTE_KINDS.contains(&input.kind) {
456 return Err(GraphError::IngestError {
457 detail: format!(
458 "remember: kind must be one of {}, got {:?}",
459 NOTE_KINDS.join(", "),
460 input.kind
461 ),
462 });
463 }
464 // Every key this call could create a node under or link a note to, and
465 // every label and predicate it could create a node or an edge under,
466 // before anything below reads the store or declares full-text: one with
467 // nothing in it would otherwise be written like any other.
468 for (i, k) in input.about.iter().enumerate() {
469 refuse_blank(k, || format!("remember: about[{i}]"))?;
470 }
471 for (i, entity) in input.entities.iter().enumerate() {
472 refuse_blank(&entity.key, || format!("remember: entities[{i}].key"))?;
473 refuse_blank(&entity.label, || format!("remember: entities[{i}].label"))?;
474 }
475 for (i, fact) in input.facts.iter().enumerate() {
476 refuse_blank(&fact.subject, || format!("remember: facts[{i}].subject"))?;
477 refuse_blank(&fact.predicate, || {
478 format!("remember: facts[{i}].predicate")
479 })?;
480 refuse_blank(&fact.object, || format!("remember: facts[{i}].object"))?;
481 }
482
483 // Each entity's `aliases` and `alias_keys` after this call, as the
484 // properties to set: `aliases` recomputed from the key and the name,
485 // `alias_keys` what the node holds united with what this call declares,
486 // each absent when the stored list is already exactly that. Computed before anything below declares full-text, so a
487 // call refused here (an `aliases` or `alias_keys` property, or more than
488 // either cap) declares nothing new either; and before `db.batch()` takes
489 // `db` mutably, because it reads the store.
490 let entity_identity: Vec<Vec<(String, Value)>> = input
491 .entities
492 .iter()
493 .map(|e| {
494 let props: Vec<(String, Value)> = e
495 .props
496 .iter()
497 .map(|(f, v)| (f.clone(), v.clone()))
498 .collect();
499 identity_props_after_write(db, &e.key, &props, &e.aliases)
500 })
501 .collect::<Result<_>>()?;
502
503 let mut fulltext = db.fulltext_pairs();
504 if !fulltext.contains(&("Note".to_string(), "text".to_string())) {
505 db.enable_fulltext("Note", "text")?;
506 fulltext.push(("Note".to_string(), "text".to_string()));
507 }
508
509 // Self-declare full-text for an `entities[].label` full-text has never
510 // seen — the same fix `Note.text` gets above, extended to entities.
511 // `memory_defaults()` declares `(label, "name")` for exactly the five
512 // built-in labels (`Person`, `Org`, `Project`, `Concept`, `Event`) plus
513 // the provisional label; `entities[].label` is free-form (the spec's own
514 // worked example, `{key:"v0.7", label:"Release"}`, uses a sixth), so an
515 // entity under any other label landed with a `name` no `recall` could
516 // ever reach — spec §1.1(b)'s defect, reproduced through this release's
517 // own new feature (fix round 2, 0.7).
518 //
519 // Bounded by `MAX_FULLTEXT_PAIRS`: `enable_fulltext`'s declaration is
520 // rebuilt from scratch on every re-open (227 ms measured against 3.8 ms
521 // with none, at a small declared surface — `memory_schema`'s module
522 // doc), and `entities[].label` is a caller-supplied string with no
523 // schema behind it — a well-behaved caller uses a handful of distinct
524 // labels, but nothing stops a run of calls from feeding a fresh one each
525 // time and growing the declared surface, and every future open's cost,
526 // without bound. Past the cap a new label's entity is still written and
527 // reported exactly as any other — only made not full-text-searchable
528 // yet, a bounded-cost degradation rather than a refusal.
529 let mut fulltext_declared: Vec<String> = Vec::new();
530 {
531 let mut declared_this_call: BTreeSet<&str> = BTreeSet::new();
532 for entity in input.entities {
533 let label = entity.label.as_str();
534 // A key the store already holds under another label is matched
535 // as it is stored, and this label goes unused (defect 64):
536 // declaring it would spend a pair on a label with no node.
537 if db.node_ref(&entity.key).is_some_and(|n| n.label() != label) {
538 continue;
539 }
540 if !declared_this_call.insert(label) {
541 continue;
542 }
543 let pair = (label.to_string(), NAME_FIELD.to_string());
544 if fulltext.contains(&pair) || fulltext.len() >= MAX_FULLTEXT_PAIRS {
545 continue;
546 }
547 db.enable_fulltext(label, NAME_FIELD)?;
548 fulltext.push(pair);
549 fulltext_declared.push(label.to_string());
550 }
551 }
552
553 let source = input.source.unwrap_or(DEFAULT_SOURCE).to_string();
554 let key = note_key(input.ts, text);
555
556 // Deduped, first-occurrence order kept: that is the order
557 // `RememberReport::provisional` promises the caller.
558 let mut about: Vec<String> = Vec::with_capacity(input.about.len());
559 {
560 let mut seen: BTreeSet<&str> = BTreeSet::new();
561 for k in input.about {
562 if seen.insert(k.as_str()) {
563 about.push(k.clone());
564 }
565 }
566 }
567 let entity_keys: BTreeSet<&str> = input.entities.iter().map(|e| e.key.as_str()).collect();
568
569 // Every existence check happens before `db.batch()` takes `db` mutably
570 // below — batch validation sees only ops queued earlier in the same
571 // frame, not the live store, so "does this already exist" has to be
572 // asked of the store now.
573 let entity_existed: Vec<bool> = input.entities.iter().map(|e| db.has_node(&e.key)).collect();
574 let about_existed: Vec<Option<bool>> = about
575 .iter()
576 .map(|k| {
577 if entity_keys.contains(k.as_str()) {
578 // Resolved by the `entities` loop below instead.
579 None
580 } else {
581 Some(db.has_node(k))
582 }
583 })
584 .collect();
585 let note_existed = db.has_node(&key);
586 // The note's edges as they stand, for two answers below: which `about`
587 // links already exist (step 4), and how many derived edges were there
588 // before this commit (so `derived` counts only what it produced).
589 let (already_about, derived_before): (BTreeSet<String>, usize) = if note_existed {
590 let edges = db.node_edges(&key)?;
591 (
592 edges
593 .iter()
594 .filter(|e| e.edge_type == ABOUT_EDGE && e.src_key == key)
595 .map(|e| e.dst_key.clone())
596 .collect(),
597 edges.iter().filter(|e| e.derived).count(),
598 )
599 } else {
600 (BTreeSet::new(), 0)
601 };
602
603 // Facts' endpoints not already named in `entities` or `about`: existence
604 // is checked now, before the batch, same as above. One not seen anywhere
605 // gets exactly the `about` path's provisional treatment — a stub with a
606 // `name` and `provisional: true`, reported in `provisional` — rather than
607 // the bare, unmarked node `insert_edge_upsert`'s own auto-create would
608 // otherwise leave behind with no signal anywhere that it was guessed.
609 let known: BTreeSet<&str> = entity_keys
610 .iter()
611 .copied()
612 .chain(about.iter().map(String::as_str))
613 .collect();
614 let mut fact_endpoints: Vec<&str> = Vec::new();
615 {
616 let mut seen: BTreeSet<&str> = BTreeSet::new();
617 for fact in input.facts {
618 for k in [fact.subject.as_str(), fact.object.as_str()] {
619 if !known.contains(k) && seen.insert(k) {
620 fact_endpoints.push(k);
621 }
622 }
623 }
624 }
625 let fact_endpoint_existed: Vec<bool> = fact_endpoints.iter().map(|k| db.has_node(k)).collect();
626
627 // `SAME_AS` claims already touching what this call writes, so the report
628 // names only the ones this commit made — and the ones it retracted. Stubs
629 // are new by definition, so only the entities can have any yet.
630 let entity_key_list: Vec<String> = input.entities.iter().map(|e| e.key.clone()).collect();
631 let same_as_before = same_as_pairs(db, &entity_key_list);
632
633 let mut report = RememberReport {
634 note: key.clone(),
635 fulltext_declared,
636 ..Default::default()
637 };
638
639 let mut batch = db.batch();
640
641 // Shared across steps 2 and 5: one `MAX_PROVISIONAL_PER_COMMIT` budget
642 // for `about` and `facts[]` combined, `about` spent first. `capped`
643 // collects a key the budget ran out on so steps 4 and 6 can skip the
644 // edge that would otherwise name a node this call never created.
645 let mut provisional_count = 0usize;
646 let mut capped: BTreeSet<String> = BTreeSet::new();
647
648 // 1. Entities first, so an `about` key also named here lands as the real
649 // entity rather than a provisional stub.
650 for ((entity, existed), identity) in input
651 .entities
652 .iter()
653 .zip(&entity_existed)
654 .zip(&entity_identity)
655 {
656 if *existed {
657 for (field, value) in &entity.props {
658 batch.set_prop(&entity.key, field, value.clone());
659 }
660 for (field, value) in identity {
661 batch.set_prop(&entity.key, field, value.clone());
662 }
663 report.matched += 1;
664 } else {
665 // Straight to `insert_node`, not an empty insert followed by
666 // `set_props` — see `describe_entity`'s doc comment for why an
667 // insert-time-only field (a namespace) would otherwise be
668 // refused rather than accepted.
669 let mut props: Vec<(String, Value)> = entity
670 .props
671 .iter()
672 .map(|(f, v)| (f.clone(), v.clone()))
673 .collect();
674 props.extend(identity.iter().cloned());
675 batch.insert_node(&entity.label, &entity.key, props);
676 report.created += 1;
677 }
678 batch.remove_prop(&entity.key, PROVISIONAL_PROP);
679 }
680
681 // 2. Provisional stubs for `about` keys `entities` did not already cover
682 // — bounded by `MAX_PROVISIONAL_PER_COMMIT` (see its doc comment).
683 for (k, status) in about.iter().zip(&about_existed) {
684 match status {
685 None => {}
686 Some(true) => report.matched += 1,
687 Some(false) => {
688 if provisional_count < MAX_PROVISIONAL_PER_COMMIT {
689 batch.insert_node(PROVISIONAL_LABEL, k, stub_props(k));
690 report.provisional.push(k.clone());
691 provisional_count += 1;
692 } else {
693 report.provisional_capped.push(k.clone());
694 capped.insert(k.clone());
695 }
696 }
697 }
698 }
699
700 // 3. The note. `about` here is the caller's full, literal claim — a key
701 // the cap refused stays listed, even though its edge (step 4) is not
702 // written: the note is a record of what it was told, not only of what
703 // could be linked, matching this store's audit-trail default (spec
704 // §3.2, O-4). `report.provisional_capped` is the place that tells the
705 // caller which of these has no edge.
706 if !note_existed {
707 let mut props: Vec<(String, Value)> = vec![
708 ("id".into(), Value::Str(key.clone())),
709 ("text".into(), Value::Str(text.to_string())),
710 ("kind".into(), Value::Str(input.kind.to_string())),
711 ("ts".into(), Value::Int(input.ts)),
712 ("source".into(), Value::Str(source)),
713 ];
714 if !about.is_empty() {
715 props.push((
716 "about".into(),
717 Value::List(about.iter().cloned().map(Value::Str).collect()),
718 ));
719 }
720 batch.insert_node("Note", &key, props);
721 }
722
723 // 4. ABOUT edges, note -> each about key that actually exists. A key the
724 // cap refused (step 2) is skipped — its node was never created, so
725 // the edge can't be either — and a link that already exists is left
726 // alone. That covers the duplicate (the note already existed and
727 // named the same `about` before) and, on a store carrying a 0.6
728 // `about_*` rule, an edge that rule derived and owns: inserting it
729 // again would be refused as a write to a rule-owned edge.
730 for k in &about {
731 if capped.contains(k) || already_about.contains(k) {
732 continue;
733 }
734 batch.insert_edge(ABOUT_EDGE, &key, k);
735 }
736
737 // 5. Provisional stubs for fact endpoints `entities`/`about` did not
738 // already cover — same shape as step 2, same shared
739 // `MAX_PROVISIONAL_PER_COMMIT` budget (`about` already spent its
740 // share above), so a typo'd `facts[].object` is as visible and as
741 // findable as an unknown `about` key, not a nameless, unmarked node
742 // with no report entry anywhere — up to the same per-commit cap.
743 for (k, existed) in fact_endpoints.iter().zip(&fact_endpoint_existed) {
744 if *existed {
745 // Matched like an `about` key that exists (defect 48): the list
746 // holds no key `entities` or `about` already counted.
747 report.matched += 1;
748 continue;
749 }
750 if provisional_count < MAX_PROVISIONAL_PER_COMMIT {
751 batch.insert_node(PROVISIONAL_LABEL, k, stub_props(k));
752 report.provisional.push((*k).to_string());
753 provisional_count += 1;
754 } else {
755 report.provisional_capped.push((*k).to_string());
756 capped.insert((*k).to_string());
757 }
758 }
759
760 // 6. Facts whose endpoints all exist — described above, already in the
761 // store, or just stubbed. A fact naming an endpoint the cap refused
762 // (step 5) is skipped whole: that endpoint does not exist, so the
763 // edge can't either.
764 for fact in input.facts {
765 if capped.contains(&fact.subject) || capped.contains(&fact.object) {
766 continue;
767 }
768 batch.insert_edge(&fact.predicate, &fact.subject, &fact.object);
769 }
770
771 batch.commit()?;
772
773 // `commit()`'s own (nodes, edges) counts are the ops this call queued,
774 // not what any live rule derived from them — read the note's edges back
775 // and ask the engine which ones it owns.
776 let derived_after = db
777 .node_edges(&key)?
778 .into_iter()
779 .filter(|e| e.derived)
780 .count();
781 report.derived = derived_after.saturating_sub(derived_before);
782
783 let mut touched = entity_key_list;
784 touched.extend(report.provisional.iter().cloned());
785 let same_as_after = same_as_pairs(db, &touched);
786 report.same_as_lost = same_as_lost(&same_as_before, &same_as_after);
787 report.same_as = same_as_after
788 .into_iter()
789 .filter(|p| !same_as_before.iter().any(|b| b.a == p.a && b.b == p.b))
790 .collect();
791
792 Ok(report)
793}
794
795/// A provisional stub's properties: its key as its `name`, the
796/// `provisional` mark, and the aliases that name implies.
797///
798/// Every word of the key is an alias, so a long key — a sentence, a URL with
799/// a slug and a query string — implies more than a node may carry
800/// ([`MAX_ALIASES`], defect 53). Such a stub is linked by its whole key alone
801/// and not by its words. It is still written: an unknown subject is never a
802/// reason to lose the note.
803fn stub_props(key: &str) -> Vec<(String, Value)> {
804 let mut aliases = derive_aliases(key, Some(key));
805 if aliases.len() > MAX_ALIASES {
806 aliases = derive_aliases(key, None);
807 }
808 vec![
809 (NAME_FIELD.to_string(), Value::Str(key.to_string())),
810 (PROVISIONAL_PROP.to_string(), Value::Bool(true)),
811 (ALIASES_FIELD.to_string(), aliases_value(&aliases)),
812 ]
813}
814
815/// The key one `remember` call writes to: `"note:"` followed by 16 hex
816/// characters of a 64-bit FNV-1a hash of `ts` and `text`.
817///
818/// Same construction as the code-graph `note_key` 0.7 deleted, so a note
819/// written by 0.6 and the same `ts` and `text` remembered by 0.7 share one
820/// key. FNV-1a rather than `blake3` for the
821/// same dependency reason: `blake3` is confined to `crates/code-extract`,
822/// which `core-api` cannot depend on.
823fn note_key(ts: i64, text: &str) -> String {
824 const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
825 const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
826 let mut h = FNV_OFFSET;
827 for b in ts.to_string().bytes().chain(text.bytes()) {
828 h ^= u64::from(b);
829 h = h.wrapping_mul(FNV_PRIME);
830 }
831 format!("note:{h:016x}")
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837
838 #[test]
839 fn the_key_is_a_function_of_ts_and_text_alone() {
840 assert_eq!(note_key(1, "a"), note_key(1, "a"));
841 assert_ne!(note_key(1, "a"), note_key(2, "a"));
842 assert_ne!(note_key(1, "a"), note_key(1, "b"));
843 assert!(note_key(1, "a").strip_prefix("note:").unwrap().len() == 16);
844 }
845}