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, 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}