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}