Skip to main content

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}