Skip to main content

topodb_json/
compose.rs

1//! Composed write planning shared by TopoDB's front ends: the `remember`
2//! verb (store + find-or-create + link + supersede as ONE atomic batch) and
3//! the entity/memory lookups it is built from. Unlike the rest of this
4//! crate, functions here READ from a `Db` — but never write: every function
5//! returns planned `Op`s (or a lookup result) and the caller submits.
6
7use std::collections::{BTreeMap, BTreeSet, HashMap};
8
9use serde_json::Value;
10use topodb::{Db, EdgeId, NodeId, NodeRecord, Op, PropValue, Props, Scope, ScopeSet, TopoError};
11
12use crate::{
13    merge_required_prop, normalize_edge_type, scopes_to_scope_set, ALIAS_EDGE_TYPE, ALIAS_LABEL,
14    ALIAS_NAME_PROP, ENTITY_LABEL, ENTITY_NAME_PROP, MEMORY_CONTENT_HASH_PROP, MEMORY_CONTENT_PROP,
15    MEMORY_FORGOTTEN_AT_PROP, MEMORY_LABEL, MEMORY_SUPERSEDED_AT_PROP,
16};
17
18/// Edge type `remember` uses when the caller doesn't name one.
19pub const DEFAULT_REMEMBER_EDGE_TYPE: &str = "about";
20
21/// Planning failure: `Invalid` is a caller-fixable input problem (surface the
22/// message verbatim); `Engine` is a database failure unrelated to the input.
23#[derive(Debug)]
24pub enum ComposeError {
25    Invalid(String),
26    Engine(TopoError),
27}
28
29impl From<TopoError> for ComposeError {
30    fn from(e: TopoError) -> Self {
31        ComposeError::Engine(e)
32    }
33}
34
35// --- moved verbatim from topodb-mcp/src/server.rs (self.db -> db) ---
36// Keep the original doc comments from server.rs on each of these when moving.
37
38/// In-call dedup key for `remember`'s entity names: whitespace-collapsed,
39/// lowercased — mirroring the engine's prop-index normalization
40/// (`prop_index::normalize_str`, which is pub(crate) and thus can't be
41/// called from here). Drift between the two only weakens IN-CALL dedup
42/// (["Drew", "drew"] in one call); cross-call dedup always goes through the
43/// engine's own normalized index via find_existing_entity.
44pub fn entity_dedup_key(name: &str) -> String {
45    name.split_whitespace()
46        .collect::<Vec<_>>()
47        .join(" ")
48        .to_lowercase()
49}
50
51/// Normalize memory content for dedup: trim and collapse internal whitespace.
52/// Deliberately NOT lowercased — casing can carry meaning in a stored fact.
53pub fn normalize_content(content: &str) -> String {
54    content.split_whitespace().collect::<Vec<_>>().join(" ")
55}
56
57/// Stable FNV-1a 64-bit hash of normalized content, hex-encoded. PERSISTED
58/// (equality-indexed as `content_hash`) — the algorithm must never change.
59/// Collisions are harmless: dedup always verifies exact normalized content.
60pub fn content_hash(content: &str) -> String {
61    let normalized = normalize_content(content);
62    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
63    for b in normalized.as_bytes() {
64        h ^= *b as u64;
65        h = h.wrapping_mul(0x0000_0100_0000_01b3);
66    }
67    format!("{h:016x}")
68}
69
70/// Builds a new Memory node's props: caller `extra` is validated (the
71/// system-maintained keys below are rejected, and `content` collides via
72/// merge_required_prop), then the whitespace-normalized content hash is
73/// stamped. The ONE constructor for every front end's new-memory write
74/// (plan_remember, MCP create_memory, CLI create-memory) — so the reserved
75/// set cannot drift between surfaces.
76pub fn memory_props(content: &str, extra: Option<&Value>) -> Result<Props, String> {
77    if let Some(Value::Object(map)) = extra {
78        for reserved in [
79            MEMORY_CONTENT_HASH_PROP,
80            MEMORY_SUPERSEDED_AT_PROP,
81            MEMORY_FORGOTTEN_AT_PROP,
82        ] {
83            if map.contains_key(reserved) {
84                return Err(format!(
85                    "props must not include {reserved:?}: it is maintained by the engine write path"
86                ));
87            }
88        }
89        if map.contains_key(crate::MEMORY_KIND_PROP) {
90            return Err(format!(
91                "props must not include {:?}: set it via remember's kind parameter \
92                 (episodic | semantic | procedural)",
93                crate::MEMORY_KIND_PROP
94            ));
95        }
96    }
97    let mut props = merge_required_prop(
98        MEMORY_CONTENT_PROP,
99        PropValue::Str(content.to_string()),
100        extra,
101    )?;
102    props.insert(
103        MEMORY_CONTENT_HASH_PROP.into(),
104        PropValue::Str(content_hash(content)),
105    );
106    Ok(props)
107}
108
109/// Canonical entities for `name`: direct (Entity, name) matches plus
110/// (Alias, name) matches followed through alias_of. Deduped by id,
111/// oldest first.
112///
113/// Returns the raw `TopoError` (not `ErrorData`) rather than swallowing
114/// it: the two existing call sites disagree on what an undeclared
115/// (Entity, name) index should mean. `find_by_prop` must still surface it
116/// as a caller error — that is the exact contract tests pin down (an
117/// undeclared-index probe on a custom spec must error, not silently return
118/// empty, or a clobbered spec reopen would go undetected). `create_entity`
119/// instead treats it as "can't dedup on this spec" and degrades to
120/// create-always. Only the (Alias, name) probe's `Rejected` is
121/// unconditionally swallowed here — a spec that predates the Alias index
122/// (or a custom spec that never declared it) simply has no aliases to
123/// resolve, which is never a caller error.
124pub fn resolve_entities_by_name(
125    db: &Db,
126    scopes: &ScopeSet,
127    name: &str,
128) -> Result<Vec<NodeRecord>, TopoError> {
129    let value = PropValue::Str(name.to_string());
130    let mut out = db.nodes_by_prop_normalized(scopes, ENTITY_LABEL, ENTITY_NAME_PROP, &value)?;
131    let aliases = match db.nodes_by_prop_normalized(scopes, ALIAS_LABEL, ALIAS_NAME_PROP, &value) {
132        Ok(hits) => hits,
133        Err(TopoError::Rejected(_)) => Vec::new(),
134        Err(e) => return Err(e),
135    };
136    for alias in aliases {
137        for edge in db.edges_from(scopes, alias.id, None, Some(ALIAS_EDGE_TYPE), true)? {
138            if let Some(canonical) = db.node(scopes, edge.to) {
139                if canonical.label == ENTITY_LABEL {
140                    out.push(canonical);
141                }
142            }
143        }
144    }
145    out.sort_by_key(|n| n.id);
146    out.dedup_by_key(|n| n.id);
147    Ok(out)
148}
149
150/// `lookup` is the caller's collision surface (MCP: default read scopes +
151/// write scope + shared; CLI: write scope + shared). `Ok(None)` means
152/// "create it" — covering both no-visible-match and a custom spec without
153/// the (Entity, name) equality index (`Rejected`), which degrades to
154/// create-always rather than failing the write.
155pub fn find_existing_entity(
156    db: &Db,
157    lookup: &ScopeSet,
158    name: &str,
159) -> Result<Option<NodeRecord>, TopoError> {
160    match resolve_entities_by_name(db, lookup, name) {
161        Ok(hits) => Ok(hits.into_iter().min_by_key(|n| n.id)),
162        Err(TopoError::Rejected(_)) => Ok(None),
163        Err(e) => Err(e),
164    }
165}
166
167/// The id of a Memory in `write_scope` whose normalized content equals
168/// `content`. Hash-bucket lookup, then exact normalized-content verify on
169/// every candidate; oldest id wins. Superseded or forgotten memories …
170/// are excluded — re-learning a retired fact is a NEW fact, and the
171/// tombstone's `as_of` history stays intact.
172pub fn existing_memory(
173    db: &Db,
174    write_scope: Scope,
175    content: &str,
176) -> Result<Option<NodeId>, TopoError> {
177    let hash = content_hash(content);
178    let want = normalize_content(content);
179    let scope_set = scopes_to_scope_set(&[write_scope]);
180    let candidates = db.nodes_by_prop(
181        &scope_set,
182        MEMORY_LABEL,
183        MEMORY_CONTENT_HASH_PROP,
184        &PropValue::Str(hash),
185    )?;
186    Ok(candidates
187        .into_iter()
188        .filter(|n| {
189            crate::MEMORY_TOMBSTONE_PROPS
190                .iter()
191                .all(|p| !n.props.contains_key(*p))
192                && matches!(n.props.get(MEMORY_CONTENT_PROP), Some(PropValue::Str(c)) if normalize_content(c) == want)
193        })
194        .min_by_key(|n| n.id)
195        .map(|n| n.id))
196}
197
198/// Ops marking `ids` superseded (stamp + close open out-edges) plus the ids
199/// actually marked. Moved from server.rs `supersede_ops`; `now_ms` is a
200/// parameter so tests are deterministic. Error strings must stay identical.
201/// Public building block for layers (e.g. topodb-obsidian) that supersede
202/// without going through `plan_remember`.
203pub fn plan_supersede(
204    db: &Db,
205    write_scope: Scope,
206    ids: &[String],
207    now_ms: i64,
208) -> Result<(Vec<Op>, Vec<String>), ComposeError> {
209    let mut ops = Vec::new();
210    let mut marked = Vec::new();
211    if ids.is_empty() {
212        return Ok((ops, marked));
213    }
214    let scope_set = scopes_to_scope_set(&[write_scope]);
215    let mut seen = BTreeSet::new();
216    for raw in ids {
217        let id: NodeId = raw
218            .parse()
219            .map_err(|e| ComposeError::Invalid(format!("invalid node id {raw:?}: {e}")))?;
220        if !seen.insert(id) {
221            continue;
222        }
223        let node = db.node(&scope_set, id).ok_or_else(|| {
224            ComposeError::Invalid(format!(
225                "supersedes id {raw} is not a node in the write scope"
226            ))
227        })?;
228        if node.label != MEMORY_LABEL {
229            return Err(ComposeError::Invalid(format!(
230                "supersedes id {raw} is a {}, not a Memory",
231                node.label
232            )));
233        }
234        if node.props.contains_key(MEMORY_SUPERSEDED_AT_PROP) {
235            continue;
236        }
237        let mut props: BTreeMap<String, Option<PropValue>> = BTreeMap::new();
238        props.insert(
239            MEMORY_SUPERSEDED_AT_PROP.into(),
240            Some(PropValue::Int(now_ms)),
241        );
242        ops.push(Op::SetNodeProps { id, props });
243        for e in db.edges_from(&scope_set, id, None, None, true)? {
244            ops.push(Op::CloseEdge {
245                id: e.id,
246                valid_to: None,
247            });
248        }
249        marked.push(id.to_string());
250    }
251    Ok((ops, marked))
252}
253
254/// Ops marking `ids` forgotten (stamp `forgotten_at` + close open out-edges)
255/// plus the forgotten ids. The disconnect motion is `plan_supersede`'s, but
256/// the contract is STRICTER: supersede is a bulk mark that skips
257/// already-retired ids; `forget` is an explicit judgment, so EVERY id must
258/// be a live Memory in the write scope and any violation rejects the whole
259/// call before ops build. `now_ms` is a parameter so tests are deterministic.
260pub fn plan_forget(
261    db: &Db,
262    write_scope: Scope,
263    ids: &[String],
264    now_ms: i64,
265) -> Result<(Vec<Op>, Vec<String>), ComposeError> {
266    if ids.is_empty() {
267        return Err(ComposeError::Invalid(
268            "forget requires at least one memory id".into(),
269        ));
270    }
271    let scope_set = scopes_to_scope_set(&[write_scope]);
272    let mut ops = Vec::new();
273    let mut forgotten = Vec::new();
274    let mut seen = BTreeSet::new();
275    for raw in ids {
276        let id: NodeId = raw
277            .parse()
278            .map_err(|e| ComposeError::Invalid(format!("invalid node id {raw:?}: {e}")))?;
279        if !seen.insert(id) {
280            continue;
281        }
282        let node = db.node(&scope_set, id).ok_or_else(|| {
283            ComposeError::Invalid(format!("forget id {raw} is not a node in the write scope"))
284        })?;
285        if node.label != MEMORY_LABEL {
286            return Err(ComposeError::Invalid(format!(
287                "forget id {raw} is a {}, not a Memory",
288                node.label
289            )));
290        }
291        if node.props.contains_key(MEMORY_FORGOTTEN_AT_PROP) {
292            return Err(ComposeError::Invalid(format!(
293                "forget id {raw} is already forgotten"
294            )));
295        }
296        if node.props.contains_key(MEMORY_SUPERSEDED_AT_PROP) {
297            return Err(ComposeError::Invalid(format!(
298                "forget id {raw} is already superseded — it has already left recall"
299            )));
300        }
301        let mut props: BTreeMap<String, Option<PropValue>> = BTreeMap::new();
302        props.insert(
303            MEMORY_FORGOTTEN_AT_PROP.into(),
304            Some(PropValue::Int(now_ms)),
305        );
306        ops.push(Op::SetNodeProps { id, props });
307        for e in db.edges_from(&scope_set, id, None, None, true)? {
308            ops.push(Op::CloseEdge {
309                id: e.id,
310                valid_to: None,
311            });
312        }
313        forgotten.push(id.to_string());
314    }
315    Ok((ops, forgotten))
316}
317
318// --- the composed verb ---
319
320pub struct RememberRequest {
321    pub content: String,
322    pub entities: Vec<String>,
323    pub edge_type: Option<String>,
324    pub supersedes: Vec<String>,
325    /// Extra memory metadata as a JSON object (same contract as
326    /// `merge_required_prop`'s `extra`).
327    pub props: Option<Value>,
328    /// Declared taxonomy kind for a NEW memory: `episodic`, `semantic`, or
329    /// `procedural`. `None` = unstamped (reads as `semantic`). Ignored on a
330    /// dedup hit — the existing node's stored kind wins.
331    pub kind: Option<String>,
332}
333
334impl RememberRequest {
335    /// Input-only validation (no db access): the edge type normalizes and
336    /// at least one non-blank entity name is present. `plan_remember` runs
337    /// this itself; front ends call it FIRST when they must report input
338    /// errors ahead of scope/db errors (the pre-refactor precedence).
339    /// Returns the normalized edge type.
340    pub fn validate(&self) -> Result<String, String> {
341        let ty = normalize_edge_type(
342            self.edge_type
343                .as_deref()
344                .unwrap_or(DEFAULT_REMEMBER_EDGE_TYPE),
345        )?;
346        if self.entities.is_empty() {
347            return Err(
348                "entities must contain at least one name — use create_memory for a deliberately unlinked note".into(),
349            );
350        }
351        if self.entities.iter().any(|n| n.trim().is_empty()) {
352            return Err("entity names must be non-empty".into());
353        }
354        if let Some(kind) = &self.kind {
355            crate::validate_memory_kind(kind)?;
356        }
357        Ok(ty)
358    }
359}
360
361pub struct PlannedEntity {
362    pub name: String,
363    pub id: NodeId,
364    pub created: bool,
365}
366
367pub struct RememberPlan {
368    /// ONE atomic batch; possibly empty (pure no-op). The caller submits.
369    pub ops: Vec<Op>,
370    pub memory_id: NodeId,
371    /// True iff content was found in an existing node in the write scope.
372    /// Note: if that node is in `superseded`, deduplicated is still false —
373    /// superseding a node you would otherwise dedup to means "replace it".
374    pub deduplicated: bool,
375    /// The content, iff a new Memory node is planned (callers with an
376    /// embedder append `SetEmbedding` ops keyed on this).
377    pub new_memory: Option<String>,
378    /// (id, name) of every planned Entity create, for the same purpose.
379    pub new_entities: Vec<(NodeId, String)>,
380    pub entities: Vec<PlannedEntity>,
381    pub edge_ids: Vec<String>,
382    pub superseded: Vec<String>,
383}
384
385pub fn plan_remember(
386    db: &Db,
387    write_scope: Scope,
388    lookup: &ScopeSet,
389    now_ms: i64,
390    req: &RememberRequest,
391) -> Result<RememberPlan, ComposeError> {
392    let ty = req.validate().map_err(ComposeError::Invalid)?;
393
394    // Validate reserved keys BEFORE the dedup check (so reserved keys are always rejected).
395    let mut memory_props_result =
396        memory_props(&req.content, req.props.as_ref()).map_err(ComposeError::Invalid)?;
397    if let Some(kind) = &req.kind {
398        memory_props_result.insert(crate::MEMORY_KIND_PROP.into(), PropValue::Str(kind.clone()));
399    }
400
401    // Parse the supersedes list early to detect self-supersede.
402    // If the dedup hit is in the supersedes list, treat it as a fresh node creation.
403    // Error strings match plan_supersede's parse errors exactly.
404    let mut supersedes_ids = BTreeSet::new();
405    for raw in &req.supersedes {
406        let id: NodeId = raw
407            .parse()
408            .map_err(|e| ComposeError::Invalid(format!("invalid node id {raw:?}: {e}")))?;
409        supersedes_ids.insert(id);
410    }
411
412    let existing = existing_memory(db, write_scope, &req.content)?;
413    // If we found a dedup match but it's in the supersedes list, treat as NOT deduplicated.
414    let deduplicated = existing.is_some() && !supersedes_ids.contains(&existing.unwrap());
415    let memory_id = if deduplicated {
416        existing.unwrap()
417    } else {
418        NodeId::new()
419    };
420    let (supersede_ops, superseded) = plan_supersede(db, write_scope, &req.supersedes, now_ms)?;
421
422    struct Resolved {
423        name: String,
424        id: NodeId,
425        created: bool,
426        op: Option<Op>,
427    }
428    let mut seen = BTreeSet::new();
429    let mut resolved: Vec<Resolved> = Vec::new();
430    for name in req
431        .entities
432        .iter()
433        .filter(|n| seen.insert(entity_dedup_key(n)))
434    {
435        match find_existing_entity(db, lookup, name)? {
436            Some(node) => resolved.push(Resolved {
437                name: name.clone(),
438                id: node.id,
439                created: false,
440                op: None,
441            }),
442            None => {
443                let id = NodeId::new();
444                let props =
445                    merge_required_prop(ENTITY_NAME_PROP, PropValue::Str(name.clone()), None)
446                        .map_err(ComposeError::Invalid)?;
447                resolved.push(Resolved {
448                    name: name.clone(),
449                    id,
450                    created: true,
451                    op: Some(Op::CreateNode {
452                        id,
453                        scope: write_scope,
454                        label: ENTITY_LABEL.into(),
455                        props,
456                    }),
457                });
458            }
459        }
460    }
461
462    let mut ops: Vec<Op> = Vec::new();
463    let mut new_memory = None;
464    if !deduplicated {
465        ops.push(Op::CreateNode {
466            id: memory_id,
467            scope: write_scope,
468            label: MEMORY_LABEL.into(),
469            props: memory_props_result,
470        });
471        new_memory = Some(req.content.clone());
472    }
473
474    // Two names resolving to one node (e.g. via alias) collapse to one edge.
475    let mut seen_ids = BTreeSet::new();
476    resolved.retain(|r| seen_ids.insert(r.id));
477
478    // On a dedup hit, entities already linked keep their edge.
479    let already_linked: HashMap<NodeId, EdgeId> = if deduplicated {
480        let scope_set = scopes_to_scope_set(&[write_scope]);
481        db.edges_from(&scope_set, memory_id, None, Some(ty.as_str()), true)?
482            .into_iter()
483            .map(|e| (e.to, e.id))
484            .collect()
485    } else {
486        HashMap::new()
487    };
488
489    let mut entities = Vec::with_capacity(resolved.len());
490    let mut edge_ids = Vec::with_capacity(resolved.len());
491    let mut new_entities = Vec::new();
492    for r in resolved {
493        if let Some(op) = r.op {
494            new_entities.push((r.id, r.name.clone()));
495            ops.push(op);
496        }
497        let edge_id = match already_linked.get(&r.id) {
498            Some(existing_edge) => existing_edge.to_string(),
499            None => {
500                let id = EdgeId::new();
501                ops.push(Op::CreateEdge {
502                    id,
503                    scope: write_scope,
504                    ty: ty.clone().into(),
505                    from: memory_id,
506                    to: r.id,
507                    props: Props::new(),
508                    valid_from: None,
509                });
510                id.to_string()
511            }
512        };
513        edge_ids.push(edge_id);
514        entities.push(PlannedEntity {
515            name: r.name,
516            id: r.id,
517            created: r.created,
518        });
519    }
520    ops.extend(supersede_ops);
521    Ok(RememberPlan {
522        ops,
523        memory_id,
524        deduplicated,
525        new_memory,
526        new_entities,
527        entities,
528        edge_ids,
529        superseded,
530    })
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use topodb::Op;
537
538    #[test]
539    fn plan_supersede_is_public_and_stamps() {
540        let dir = tempfile::tempdir().unwrap();
541        let db = Db::open(dir.path().join("t.redb")).unwrap();
542        let id = NodeId::new();
543        db.submit(vec![Op::CreateNode {
544            id,
545            scope: Scope::Shared,
546            label: MEMORY_LABEL.into(),
547            props: memory_props("old fact", None).unwrap(),
548        }])
549        .unwrap();
550        let (ops, marked) = plan_supersede(&db, Scope::Shared, &[id.to_string()], 42).unwrap();
551        assert_eq!(marked, vec![id.to_string()]);
552        assert!(!ops.is_empty());
553    }
554}