Skip to main content

core_api/repograph/
remember.rs

1//! `remember` — write a `Note` the graph can later `recall`.
2//!
3//! The other half of [`recall`](super::recall): where that module reads,
4//! this one writes the one label an assistant is expected to create itself.
5//! A note's `about` list is exactly the field the `about_<label>` rules (see
6//! [`super::rules`], shared with `structure::ensure_rules_and_fulltext` in
7//! the CLI crate) match on, so writing it derives the `ABOUT` edges in the
8//! same commit — nothing here inserts an edge directly.
9//!
10//! `structure::ensure_rules_and_fulltext` only ever runs from `ingest-git`
11//! and `sync`, and even then only declares an `about_<label>` rule once a
12//! node of that label already exists. A store's very first `remember` call —
13//! or one whose `about` names a `Note` or `Concept` created since the last
14//! sync — would otherwise pass validation and silently write no `ABOUT`
15//! edge at all until the next sync backfills it. [`remember`] closes that
16//! gap itself: before writing, it ensures the specific `about_<label>`
17//! rule(s) its own `about` keys need already exist, creating whichever are
18//! missing from the same shared definitions `structure` uses.
19
20use crate::db::GraphDb;
21use crate::repograph::facts::label_of;
22use crate::repograph::rules::{about_rule, ABOUT_LABELS};
23use core_storage::fs::Fs;
24use core_storage::{GraphError, Result, Value};
25use std::collections::BTreeSet;
26
27/// Text length bounds, in characters, after trimming.
28const MIN_TEXT_CHARS: usize = 1;
29const MAX_TEXT_CHARS: usize = 4000;
30
31/// The `kind` values a `Note` may carry.
32pub const NOTE_KINDS: [&str; 3] = ["note", "decision", "todo"];
33
34/// What to remember.
35pub struct RememberInput<'a> {
36    /// The note's text, trimmed to [`MIN_TEXT_CHARS`]..=[`MAX_TEXT_CHARS`]
37    /// characters.
38    pub text: &'a str,
39    /// Keys the note is about. Every one must already exist; an
40    /// `about_<label>` rule turns each into an `ABOUT` edge.
41    pub about: &'a [String],
42    /// One of [`NOTE_KINDS`].
43    pub kind: &'a str,
44    /// Unix seconds the note was written at. Part of the note's key, so
45    /// remembering the same text again at the same `ts` is a no-op rather
46    /// than a duplicate.
47    pub ts: i64,
48}
49
50/// Write `input` as a `Note`, returning its key.
51///
52/// Validated before anything is written: `text` must be
53/// [`MIN_TEXT_CHARS`]..=[`MAX_TEXT_CHARS`] characters after trimming, `kind`
54/// must be one of [`NOTE_KINDS`], and every `about` key must already exist —
55/// [`GraphError::KeyNotFound`] names the first missing one, sorted, so a
56/// caller with several bad keys is told about the same one twice rather than
57/// a different one each retry.
58///
59/// The key is `"note:"` followed by 16 hex characters of a stable 64-bit
60/// hash of `ts` and `text` (see [`note_key`] for why it is not `blake3`),
61/// so remembering the same text at the same `ts` again returns the same key
62/// without writing a second node — the caller's insertion order into
63/// `about` does not affect the key, but does affect which edges backfill
64/// first, which the engine already makes deterministic.
65///
66/// Also ensures full-text search is enabled on `Note.text`, and that the
67/// `about_<label>` rule for every label named among `about` already exists
68/// (see the module docs), so a store whose very first write is a `remember`
69/// call — never having gone through `structure::ensure_rules_and_fulltext` —
70/// can still be recalled from and still derives its `ABOUT` edges.
71pub fn remember<F: Fs>(w: &mut GraphDb<F>, input: &RememberInput<'_>) -> Result<String> {
72    let text = input.text.trim();
73    let len = text.chars().count();
74    if !(MIN_TEXT_CHARS..=MAX_TEXT_CHARS).contains(&len) {
75        return Err(GraphError::IngestError {
76            detail: format!(
77                "remember: text must be {MIN_TEXT_CHARS}..={MAX_TEXT_CHARS} characters \
78                 after trimming, got {len}"
79            ),
80        });
81    }
82    if !NOTE_KINDS.contains(&input.kind) {
83        return Err(GraphError::IngestError {
84            detail: format!(
85                "remember: kind must be one of {}, got {:?}",
86                NOTE_KINDS.join(", "),
87                input.kind
88            ),
89        });
90    }
91    let mut about: Vec<String> = input.about.to_vec();
92    about.sort();
93    about.dedup();
94    if let Some(missing) = about.iter().find(|key| !w.has_node(key)) {
95        return Err(GraphError::KeyNotFound {
96            key: missing.clone(),
97        });
98    }
99
100    if !w
101        .fulltext_pairs()
102        .contains(&("Note".to_string(), "text".to_string()))
103    {
104        w.enable_fulltext("Note", "text")?;
105    }
106    ensure_about_rules(w, &about)?;
107
108    let key = note_key(input.ts, text);
109    if !w.has_node(&key) {
110        let mut props: Vec<(String, Value)> = vec![
111            ("id".into(), Value::Str(key.clone())),
112            ("text".into(), Value::Str(text.to_string())),
113            ("kind".into(), Value::Str(input.kind.to_string())),
114            ("ts".into(), Value::Int(input.ts)),
115            ("source".into(), Value::Str("agent".to_string())),
116        ];
117        if !about.is_empty() {
118            props.push((
119                "about".into(),
120                Value::List(about.into_iter().map(Value::Str).collect()),
121            ));
122        }
123        w.insert_node("Note", &key, props)?;
124    }
125    Ok(key)
126}
127
128/// Create whichever `about_<label>` rules `about_keys` need and do not exist
129/// yet, from the same definitions [`about_rule`] gives `structure` — so a
130/// note written before the label's rule was ever backfilled by a sync still
131/// derives its `ABOUT` edge in this commit.
132///
133/// A label outside [`ABOUT_LABELS`] has no rule to create — that key's
134/// `ABOUT` edge simply does not derive, same as it never has; `remember`
135/// only guarantees the edge for the labels the plan enumerates. Idempotent:
136/// existing rule names are read once, so a label whose rule already exists
137/// costs nothing and a repeat call creates nothing new.
138fn ensure_about_rules<F: Fs>(w: &mut GraphDb<F>, about_keys: &[String]) -> Result<()> {
139    let mut labels: BTreeSet<String> = about_keys
140        .iter()
141        .filter_map(|key| label_of(w, key))
142        .filter(|label| ABOUT_LABELS.contains(&label.as_str()))
143        .collect();
144    if labels.is_empty() {
145        return Ok(());
146    }
147    let existing: BTreeSet<String> = w.rules().into_iter().map(|r| r.name).collect();
148    labels.retain(|label| {
149        let name = format!("about_{}", label.to_lowercase());
150        !existing.contains(&name)
151    });
152    for label in labels {
153        w.create_rule(about_rule(&label))?;
154    }
155    Ok(())
156}
157
158/// The key one `remember` call writes to: `"note:"` followed by 16 hex
159/// characters of a 64-bit FNV-1a hash of `ts` and `text`.
160///
161/// The plan calls for a `blake3`-derived key, but `blake3` is a dependency
162/// this crate may not take — the workspace's dependency ruling confines it
163/// to `crates/code-extract`, which `core-api` cannot depend on either
164/// (`remember` lives here so a `WriteGuard` can call it directly). FNV-1a is
165/// already how this codebase derives a stable content hash without a
166/// dependency (see the test fixture's own `hash_of`), and a single 64-bit
167/// hash formats to exactly 16 hex characters, so the key keeps the shape the
168/// plan describes — 16 hex characters, content-derived, deterministic — with
169/// a different, dependency-free hash underneath it.
170fn note_key(ts: i64, text: &str) -> String {
171    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
172    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
173    let mut h = FNV_OFFSET;
174    for b in ts.to_string().bytes().chain(text.bytes()) {
175        h ^= u64::from(b);
176        h = h.wrapping_mul(FNV_PRIME);
177    }
178    format!("note:{h:016x}")
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn the_key_is_a_function_of_ts_and_text_alone() {
187        assert_eq!(note_key(1, "a"), note_key(1, "a"));
188        assert_ne!(note_key(1, "a"), note_key(2, "a"));
189        assert_ne!(note_key(1, "a"), note_key(1, "b"));
190        assert!(note_key(1, "a").strip_prefix("note:").unwrap().len() == 16);
191    }
192}