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