1use 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#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct DoubletLink {
24 pub index: String,
25 pub from: String,
26 pub to: String,
27}
28
29#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum LinkStoreBackend {
42 LinoProjection,
43 DoubletsRs,
44 DoubletsWeb,
45}
46
47#[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
67pub trait LinkStore {
69 fn backend(&self) -> LinkStoreBackend;
71
72 fn append_memory_event(&mut self, event: MemoryEvent) -> Result<String, LinkStoreError>;
74
75 fn import_memory_links_notation(&mut self, text: &str) -> Result<usize, LinkStoreError>;
77
78 fn export_memory_links_notation(&self) -> String;
80
81 fn records(&self) -> Vec<LinkRecord>;
83}
84
85#[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
103pub 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
119pub 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#[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#[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 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 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 #[must_use]
278 pub fn link_records(&self) -> Vec<LinkRecord> {
279 memory_events_to_link_records(self.events())
280 }
281}
282
283#[cfg(feature = "doublets-native")]
285type NativeDoubletsStore =
286 doublets::unit::Store<usize, mem::Global<doublets::parts::LinkPart<usize>>>;
287
288#[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 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 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 #[must_use]
320 pub fn events(&self) -> &[MemoryEvent] {
321 &self.events
322 }
323
324 #[must_use]
326 pub const fn len(&self) -> usize {
327 self.events.len()
328 }
329
330 #[must_use]
332 pub const fn is_empty(&self) -> bool {
333 self.events.is_empty()
334 }
335
336 #[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}