Skip to main content

formal_ai/
link_store.rs

1//! Swappable Links Notation and doublet-links storage boundary.
2//!
3//! Default native builds use the `doublets-rs` backend through the
4//! `doublets-native` feature. The human-reviewable `.lino` memory and bundle
5//! formats remain the deterministic export/import projection, and native
6//! callers can still compile with `--no-default-features` to use the
7//! [`crate::memory::MemoryStore`] Links Notation projection directly. Browser
8//! builds expose the same shape via the `IndexedDB` mirror in
9//! `src/web/memory.js`.
10
11use std::collections::BTreeMap;
12use std::error::Error;
13use std::fmt;
14use std::fmt::Write as _;
15
16use lino_objects_codec::format::parse_indented;
17
18use crate::engine::{stable_id, KNOWLEDGE_SCHEMA_VERSION};
19use crate::memory::{import_full_memory, MemoryEvent, MemoryStore, BUNDLE_HEADER, ROOT_HEADER};
20
21/// A single doublet edge in the canonical `from -> to` projection.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct DoubletLink {
24    pub index: String,
25    pub from: String,
26    pub to: String,
27}
28
29/// One content-addressed record and its reducible doublet projection.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct LinkRecord {
32    pub stable_id: String,
33    pub schema_version: String,
34    pub record_type: String,
35    pub source_id: String,
36    pub links: Vec<DoubletLink>,
37}
38
39/// Physical backend selected for a build or surface.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum LinkStoreBackend {
42    LinoProjection,
43    DoubletsRs,
44    DoubletsWeb,
45}
46
47/// Import or backend failure for a link store.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum LinkStoreError {
50    IllFormedLinksNotation(String),
51    Backend(String),
52}
53
54impl fmt::Display for LinkStoreError {
55    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Self::IllFormedLinksNotation(message) => {
58                write!(formatter, "ill-formed Links Notation: {message}")
59            }
60            Self::Backend(message) => write!(formatter, "link-store backend error: {message}"),
61        }
62    }
63}
64
65impl Error for LinkStoreError {}
66
67/// Store abstraction used by memory and event-log projections.
68pub trait LinkStore {
69    /// Returns the active physical backend.
70    fn backend(&self) -> LinkStoreBackend;
71
72    /// Append a memory event and return the stable record id assigned to it.
73    fn append_memory_event(&mut self, event: MemoryEvent) -> Result<String, LinkStoreError>;
74
75    /// Strictly import a `.lino` memory or bundle document.
76    fn import_memory_links_notation(&mut self, text: &str) -> Result<usize, LinkStoreError>;
77
78    /// Export the current memory projection as Links Notation.
79    fn export_memory_links_notation(&self) -> String;
80
81    /// Return every stored record as doublet-reducible metadata.
82    fn records(&self) -> Vec<LinkRecord>;
83}
84
85/// Select the backend implied by this build.
86#[must_use]
87pub const fn selected_link_store_backend() -> LinkStoreBackend {
88    if cfg!(target_arch = "wasm32") {
89        LinkStoreBackend::DoubletsWeb
90    } else if cfg!(feature = "doublets-native") {
91        LinkStoreBackend::DoubletsRs
92    } else {
93        LinkStoreBackend::LinoProjection
94    }
95}
96
97#[cfg(all(not(target_arch = "wasm32"), feature = "doublets-native"))]
98pub type DefaultNativeLinkStore = DoubletsLinkStore;
99
100#[cfg(any(target_arch = "wasm32", not(feature = "doublets-native")))]
101pub type DefaultNativeLinkStore = MemoryStore;
102
103/// Create the default Rust-side link store for this build.
104///
105/// Native default builds return `DoubletsLinkStore`. Builds compiled with
106/// `--no-default-features` keep the explicit `.lino` projection fallback.
107pub fn default_native_link_store() -> Result<DefaultNativeLinkStore, LinkStoreError> {
108    #[cfg(all(not(target_arch = "wasm32"), feature = "doublets-native"))]
109    {
110        DoubletsLinkStore::new()
111    }
112
113    #[cfg(any(target_arch = "wasm32", not(feature = "doublets-native")))]
114    {
115        Ok(MemoryStore::new())
116    }
117}
118
119/// Validate that a memory import is a syntactically valid supported `.lino`
120/// document before mutating the store.
121pub fn validate_memory_links_notation(text: &str) -> Result<(), LinkStoreError> {
122    let trimmed = text.trim();
123    if trimmed.is_empty() {
124        return Err(LinkStoreError::IllFormedLinksNotation(String::from(
125            "document is empty",
126        )));
127    }
128    parse_indented(trimmed)
129        .map_err(|error| LinkStoreError::IllFormedLinksNotation(format!("{error:?}")))?;
130    let header = trimmed.lines().find(|line| !line.trim().is_empty());
131    match header.map(str::trim) {
132        Some(ROOT_HEADER) => validate_demo_memory_document(trimmed),
133        Some(BUNDLE_HEADER) => Ok(()),
134        Some(other) => Err(LinkStoreError::IllFormedLinksNotation(format!(
135            "expected {ROOT_HEADER} or {BUNDLE_HEADER}, got {other}"
136        ))),
137        None => Err(LinkStoreError::IllFormedLinksNotation(String::from(
138            "document is empty",
139        ))),
140    }
141}
142
143/// Project memory events into content-addressed records.
144#[must_use]
145pub fn memory_events_to_link_records(events: &[MemoryEvent]) -> Vec<LinkRecord> {
146    events
147        .iter()
148        .enumerate()
149        .map(|(index, event)| memory_event_to_link_record(event, index))
150        .collect()
151}
152
153/// Project one memory event into a `Type -> SubType -> Value` doublet graph.
154#[must_use]
155pub fn memory_event_to_link_record(event: &MemoryEvent, sequence: usize) -> LinkRecord {
156    let canonical = canonical_memory_event(event);
157    let source_id = event_source_id(event, sequence, &canonical);
158    let record_id = stable_id(
159        "memory_event",
160        &format!("{sequence}:{}:{canonical}", source_id.as_str()),
161    );
162    let subtype = event
163        .kind
164        .as_deref()
165        .or(event.role.as_deref())
166        .or(event.intent.as_deref())
167        .unwrap_or("memory_event");
168
169    let mut links = Vec::new();
170    push_doublet(&mut links, &record_id, "Type");
171    push_doublet(&mut links, "Type", "MemoryEvent");
172    push_doublet(&mut links, "MemoryEvent", "SubType");
173    push_doublet(&mut links, "SubType", subtype);
174    push_doublet(&mut links, subtype, "Value");
175    push_doublet(&mut links, &record_id, &source_id);
176    push_doublet(
177        &mut links,
178        &record_id,
179        &format!("schema_version:{KNOWLEDGE_SCHEMA_VERSION}"),
180    );
181    push_optional_field(&mut links, &record_id, "id", Some(source_id.as_str()));
182    push_optional_field(&mut links, &record_id, "kind", event.kind.as_deref());
183    push_optional_field(&mut links, &record_id, "role", event.role.as_deref());
184    push_optional_field(&mut links, &record_id, "intent", event.intent.as_deref());
185    push_optional_field(&mut links, &record_id, "tool", event.tool.as_deref());
186    push_optional_field(&mut links, &record_id, "inputs", event.inputs.as_deref());
187    push_optional_field(&mut links, &record_id, "outputs", event.outputs.as_deref());
188    push_optional_field(&mut links, &record_id, "content", event.content.as_deref());
189    push_optional_field(&mut links, &record_id, "sentAt", event.sent_at.as_deref());
190    push_optional_field(
191        &mut links,
192        &record_id,
193        "demoLabel",
194        event.demo_label.as_deref(),
195    );
196    push_optional_field(
197        &mut links,
198        &record_id,
199        "conversationId",
200        event.conversation_id.as_deref(),
201    );
202    push_optional_field(
203        &mut links,
204        &record_id,
205        "conversationTitle",
206        event.conversation_title.as_deref(),
207    );
208    for evidence in &event.evidence {
209        push_optional_field(&mut links, &record_id, "evidence", Some(evidence));
210    }
211    let access_count = event.access_count.to_string();
212    if event.access_count > 0 {
213        push_optional_field(&mut links, &record_id, "accessCount", Some(&access_count));
214    }
215    let write_count = event.write_count.max(1).to_string();
216    push_optional_field(&mut links, &record_id, "writeCount", Some(&write_count));
217
218    LinkRecord {
219        stable_id: record_id,
220        schema_version: String::from(KNOWLEDGE_SCHEMA_VERSION),
221        record_type: String::from("MemoryEvent"),
222        source_id,
223        links,
224    }
225}
226
227impl LinkStore for MemoryStore {
228    fn backend(&self) -> LinkStoreBackend {
229        LinkStoreBackend::LinoProjection
230    }
231
232    fn append_memory_event(&mut self, mut event: MemoryEvent) -> Result<String, LinkStoreError> {
233        ensure_event_id(&mut event, self.len());
234        let id = event.id.clone();
235        self.append(event);
236        Ok(id)
237    }
238
239    fn import_memory_links_notation(&mut self, text: &str) -> Result<usize, LinkStoreError> {
240        validate_memory_links_notation(text)?;
241        let parsed = import_full_memory(text);
242        let count = parsed.events.len();
243        for event in parsed.events {
244            self.append_memory_event(event)?;
245        }
246        Ok(count)
247    }
248
249    fn export_memory_links_notation(&self) -> String {
250        Self::export_links_notation(self)
251    }
252
253    fn records(&self) -> Vec<LinkRecord> {
254        memory_events_to_link_records(self.events())
255    }
256}
257
258impl MemoryStore {
259    /// Strictly import a `.lino` memory document, rejecting malformed input.
260    pub fn try_import_links_notation(&mut self, text: &str) -> Result<usize, LinkStoreError> {
261        <Self as LinkStore>::import_memory_links_notation(self, text)
262    }
263
264    /// Strictly replace current memory from a `.lino` document.
265    pub fn try_replace_from_links_notation(&mut self, text: &str) -> Result<(), LinkStoreError> {
266        validate_memory_links_notation(text)?;
267        let parsed = import_full_memory(text);
268        let mut replacement = Self::new();
269        for event in parsed.events {
270            replacement.append_memory_event(event)?;
271        }
272        *self = replacement;
273        Ok(())
274    }
275
276    /// Return the doublet-reducible projection of every memory event.
277    #[must_use]
278    pub fn link_records(&self) -> Vec<LinkRecord> {
279        memory_events_to_link_records(self.events())
280    }
281}
282
283/// Native `doublets`-backed mirror for Rust builds.
284#[cfg(feature = "doublets-native")]
285type NativeDoubletsStore =
286    doublets::unit::Store<usize, mem::Global<doublets::parts::LinkPart<usize>>>;
287
288/// Native `doublets`-backed mirror for Rust builds.
289#[cfg(feature = "doublets-native")]
290pub struct DoubletsLinkStore {
291    events: Vec<MemoryEvent>,
292    records: Vec<LinkRecord>,
293    nodes: BTreeMap<String, usize>,
294    native: NativeDoubletsStore,
295}
296
297#[cfg(feature = "doublets-native")]
298impl DoubletsLinkStore {
299    /// Create an empty in-memory native doublets store.
300    pub fn new() -> Result<Self, LinkStoreError> {
301        let native = doublets::unit::Store::<usize, _>::new(mem::Global::new())
302            .map_err(|error| format_backend_error(&error))?;
303        Ok(Self {
304            events: Vec::new(),
305            records: Vec::new(),
306            nodes: BTreeMap::new(),
307            native,
308        })
309    }
310
311    /// Build a native doublets store from a `.lino` memory or bundle document.
312    pub fn from_links_notation(text: &str) -> Result<Self, LinkStoreError> {
313        let mut store = Self::new()?;
314        store.import_memory_links_notation(text)?;
315        Ok(store)
316    }
317
318    /// Return the imported or appended memory events in append order.
319    #[must_use]
320    pub fn events(&self) -> &[MemoryEvent] {
321        &self.events
322    }
323
324    /// Number of memory events mirrored into native doublets.
325    #[must_use]
326    pub const fn len(&self) -> usize {
327        self.events.len()
328    }
329
330    /// Whether this native store currently has no memory events.
331    #[must_use]
332    pub const fn is_empty(&self) -> bool {
333        self.events.is_empty()
334    }
335
336    /// Number of raw native doublets links, including point nodes.
337    #[must_use]
338    pub fn native_link_count(&self) -> usize {
339        use doublets::Doublets as _;
340        self.native.count()
341    }
342
343    fn insert_record(&mut self, record: LinkRecord) -> Result<(), LinkStoreError> {
344        for link in &record.links {
345            self.append_native_doublet(&link.from, &link.to)?;
346        }
347        self.records.push(record);
348        Ok(())
349    }
350
351    fn append_native_doublet(&mut self, from: &str, to: &str) -> Result<(), LinkStoreError> {
352        use doublets::Doublets as _;
353        let source = self.node_id(from)?;
354        let target = self.node_id(to)?;
355        self.native
356            .create_link(source, target)
357            .map_err(|error| format_backend_error(&error))?;
358        Ok(())
359    }
360
361    fn node_id(&mut self, node: &str) -> Result<usize, LinkStoreError> {
362        use doublets::Doublets as _;
363        if let Some(id) = self.nodes.get(node) {
364            return Ok(*id);
365        }
366        let id = self
367            .native
368            .create_point()
369            .map_err(|error| format_backend_error(&error))?;
370        self.nodes.insert(node.to_owned(), id);
371        Ok(id)
372    }
373}
374
375#[cfg(feature = "doublets-native")]
376impl LinkStore for DoubletsLinkStore {
377    fn backend(&self) -> LinkStoreBackend {
378        LinkStoreBackend::DoubletsRs
379    }
380
381    fn append_memory_event(&mut self, mut event: MemoryEvent) -> Result<String, LinkStoreError> {
382        ensure_event_id(&mut event, self.events.len());
383        let id = event.id.clone();
384        let record = memory_event_to_link_record(&event, self.events.len());
385        self.insert_record(record)?;
386        self.events.push(event);
387        Ok(id)
388    }
389
390    fn import_memory_links_notation(&mut self, text: &str) -> Result<usize, LinkStoreError> {
391        validate_memory_links_notation(text)?;
392        let parsed = import_full_memory(text);
393        let count = parsed.events.len();
394        for event in parsed.events {
395            self.append_memory_event(event)?;
396        }
397        Ok(count)
398    }
399
400    fn export_memory_links_notation(&self) -> String {
401        crate::memory::export_links_notation(&self.events)
402    }
403
404    fn records(&self) -> Vec<LinkRecord> {
405        self.records.clone()
406    }
407}
408
409#[cfg(feature = "doublets-native")]
410fn format_backend_error(error: &doublets::Error<usize>) -> LinkStoreError {
411    LinkStoreError::Backend(format!("{error:?}"))
412}
413
414fn ensure_event_id(event: &mut MemoryEvent, sequence: usize) {
415    if !event.id.is_empty() {
416        return;
417    }
418    let canonical = canonical_memory_event(event);
419    event.id = stable_id("memory_event", &format!("{sequence}:{canonical}"));
420}
421
422fn validate_demo_memory_document(text: &str) -> Result<(), LinkStoreError> {
423    for line in text.lines().filter(|line| !line.trim().is_empty()) {
424        let indent = line.chars().take_while(|ch| *ch == ' ').count();
425        let content = &line[indent..];
426        match indent {
427            0 if content == ROOT_HEADER => {}
428            2 => validate_event_line(content)?,
429            4 => validate_field_line(content)?,
430            _ => {
431                return Err(LinkStoreError::IllFormedLinksNotation(format!(
432                    "unexpected indentation or record line: {content}"
433                )));
434            }
435        }
436    }
437    Ok(())
438}
439
440fn validate_event_line(content: &str) -> Result<(), LinkStoreError> {
441    let Some(rest) = content.strip_prefix("event ") else {
442        return Err(LinkStoreError::IllFormedLinksNotation(format!(
443            "expected event record, got {content}"
444        )));
445    };
446    validate_strict_quoted(rest)
447}
448
449fn validate_field_line(content: &str) -> Result<(), LinkStoreError> {
450    let Some((key, rest)) = content.split_once(' ') else {
451        return Err(LinkStoreError::IllFormedLinksNotation(format!(
452            "expected field value, got {content}"
453        )));
454    };
455    if !key
456        .chars()
457        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
458    {
459        return Err(LinkStoreError::IllFormedLinksNotation(format!(
460            "invalid field name {key}"
461        )));
462    }
463    validate_strict_quoted(rest)
464}
465
466fn validate_strict_quoted(rest: &str) -> Result<(), LinkStoreError> {
467    let trimmed = rest.trim_start();
468    let bytes = trimmed.as_bytes();
469    if bytes.first() != Some(&b'"') {
470        return Err(LinkStoreError::IllFormedLinksNotation(format!(
471            "expected quoted value, got {rest}"
472        )));
473    }
474    let mut index = 1;
475    while index < bytes.len() {
476        match bytes[index] {
477            b'\\' => index += 2,
478            b'"' => {
479                if trimmed[index + 1..].trim().is_empty() {
480                    return Ok(());
481                }
482                return Err(LinkStoreError::IllFormedLinksNotation(format!(
483                    "unexpected trailing content after quoted value: {}",
484                    &trimmed[index + 1..]
485                )));
486            }
487            _ => index += 1,
488        }
489    }
490    Err(LinkStoreError::IllFormedLinksNotation(String::from(
491        "unterminated quoted value",
492    )))
493}
494
495fn event_source_id(event: &MemoryEvent, sequence: usize, canonical: &str) -> String {
496    if event.id.is_empty() {
497        stable_id("memory_event", &format!("{sequence}:{canonical}"))
498    } else {
499        event.id.clone()
500    }
501}
502
503fn canonical_memory_event(event: &MemoryEvent) -> String {
504    let mut fields = BTreeMap::new();
505    push_canonical(&mut fields, "id", Some(event.id.as_str()));
506    push_canonical(&mut fields, "kind", event.kind.as_deref());
507    push_canonical(&mut fields, "role", event.role.as_deref());
508    push_canonical(&mut fields, "intent", event.intent.as_deref());
509    push_canonical(&mut fields, "tool", event.tool.as_deref());
510    push_canonical(&mut fields, "inputs", event.inputs.as_deref());
511    push_canonical(&mut fields, "outputs", event.outputs.as_deref());
512    push_canonical(&mut fields, "content", event.content.as_deref());
513    push_canonical(&mut fields, "sentAt", event.sent_at.as_deref());
514    push_canonical(&mut fields, "demoLabel", event.demo_label.as_deref());
515    push_canonical(
516        &mut fields,
517        "conversationId",
518        event.conversation_id.as_deref(),
519    );
520    push_canonical(
521        &mut fields,
522        "conversationTitle",
523        event.conversation_title.as_deref(),
524    );
525    for (index, evidence) in event.evidence.iter().enumerate() {
526        let key = format!("evidence_{index:04}");
527        fields.insert(key, evidence.clone());
528    }
529    fields.insert(String::from("accessCount"), event.access_count.to_string());
530    fields.insert(
531        String::from("writeCount"),
532        event.write_count.max(1).to_string(),
533    );
534    let mut out = String::new();
535    for (key, value) in fields {
536        let _ = write!(out, "{key}={}:{};", value.len(), value);
537    }
538    out
539}
540
541fn push_canonical(fields: &mut BTreeMap<String, String>, key: &str, value: Option<&str>) {
542    let Some(value) = value else { return };
543    if value.is_empty() {
544        return;
545    }
546    fields.insert(key.to_owned(), value.to_owned());
547}
548
549fn push_optional_field(
550    links: &mut Vec<DoubletLink>,
551    record_id: &str,
552    key: &str,
553    value: Option<&str>,
554) {
555    let Some(value) = value else { return };
556    if value.is_empty() {
557        return;
558    }
559    let field = format!("field:{key}");
560    let field_value = format!("value:{value}");
561    push_doublet(links, record_id, &field);
562    push_doublet(links, &field, &field_value);
563}
564
565fn push_doublet(links: &mut Vec<DoubletLink>, from: &str, to: &str) {
566    links.push(DoubletLink {
567        index: stable_id("doublet", &format!("{from}->{to}")),
568        from: from.to_owned(),
569        to: to.to_owned(),
570    });
571}