1mod batch;
13pub use batch::resolve_batch;
14
15mod compose;
16pub use compose::{
17 apply_upsert_remap, content_hash, entity_dedup_key, existing_memory, find_existing_entity,
18 memory_props, normalize_content, plan_forget, plan_remember, plan_supersede,
19 resolve_entities_by_name, ComposeError, PlannedEntity, RememberPlan, RememberRequest,
20 DEFAULT_REMEMBER_EDGE_TYPE,
21};
22
23mod dup;
24pub use dup::{
25 containment_of_sets, dup_band, dup_relation, is_supersession, text_dup_band, tokens,
26 NEAR_DUP_K, NEAR_DUP_REVIEW, NEAR_DUP_THRESHOLD, TEXT_BAND_MIN_TOKENS,
27 TEXT_NEAR_DUP_CANDIDATES, TEXT_NEAR_DUP_CONTAINMENT,
28};
29
30mod lifecycle;
31pub use lifecycle::{
32 lifecycle_candidates, memory_kind_half_life, plan_purge, staleness, LifecycleCandidate,
33 LifecycleParams, LIFECYCLE_DEFAULT_LIMIT, LIFECYCLE_HALF_LIFE_DECISION_DAYS,
34 LIFECYCLE_HALF_LIFE_EPISODIC_DAYS, LIFECYCLE_HALF_LIFE_PROCEDURAL_DAYS,
35 LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS,
36};
37
38mod retry;
39pub use retry::open_with_busy_retry;
40
41mod graph;
42pub use graph::{
43 build_ego, build_scope, graph_edge, graph_node, node_superseded, node_title, to_canonical_json,
44 to_dot, to_html, to_mermaid, EgoParams, GraphEdge, GraphNode, GraphSnapshot, GraphTruncation,
45 GraphView, GRAPH_DEFAULT_LIMIT, GRAPH_MERMAID_INLINE_MAX_NODES, GRAPH_SNAPSHOT_VERSION,
46 GRAPH_TITLE_MAX_CHARS,
47};
48
49mod temporal;
50pub use temporal::{parse_iso_instant, parse_temporal_query, TemporalRewrite};
51
52use serde_json::{Map, Value};
53use std::collections::BTreeMap;
54use std::str::FromStr;
55use topodb::{
56 EdgeRecord, IndexSpec, NodeRecord, PropIndex, PropValue, Props, Scope, ScopeId, ScopeSet,
57 Subgraph,
58};
59
60pub const ENTITY_LABEL: &str = "Entity";
68pub const ENTITY_NAME_PROP: &str = "name";
69pub const MEMORY_LABEL: &str = "Memory";
70pub const MEMORY_CONTENT_PROP: &str = "content";
71pub const MEMORY_CONTENT_HASH_PROP: &str = "content_hash";
75pub const MEMORY_SUPERSEDED_AT_PROP: &str = "superseded_at";
80pub const MEMORY_FORGOTTEN_AT_PROP: &str = "forgotten_at";
85pub const MEMORY_TOMBSTONE_PROPS: [&str; 2] = [MEMORY_SUPERSEDED_AT_PROP, MEMORY_FORGOTTEN_AT_PROP];
90
91pub const MEMORY_KIND_PROP: &str = "kind";
100pub const MEMORY_KIND_EPISODIC: &str = "episodic";
101pub const MEMORY_KIND_SEMANTIC: &str = "semantic";
102pub const MEMORY_KIND_PROCEDURAL: &str = "procedural";
103pub const MEMORY_KIND_DECISION: &str = "decision";
104pub const MEMORY_KINDS: [&str; 4] = [
106 MEMORY_KIND_EPISODIC,
107 MEMORY_KIND_SEMANTIC,
108 MEMORY_KIND_PROCEDURAL,
109 MEMORY_KIND_DECISION,
110];
111pub const MEMORY_KIND_DEFAULT: &str = MEMORY_KIND_SEMANTIC;
113
114pub fn validate_memory_kind(kind: &str) -> Result<(), String> {
117 if MEMORY_KINDS.contains(&kind) {
118 Ok(())
119 } else {
120 Err(format!(
121 "kind must be one of \"episodic\", \"semantic\", \"procedural\", \"decision\" — got {kind:?}"
122 ))
123 }
124}
125
126pub const ALIAS_LABEL: &str = "Alias";
127pub const ALIAS_NAME_PROP: &str = "name";
128pub const ALIAS_EDGE_TYPE: &str = "alias_of";
129pub const SYNONYM_LABEL: &str = "Synonym";
130pub const SYNONYM_TERM_PROP: &str = "term";
131pub const SYNONYM_EXPANSION_PROP: &str = "expansion";
132
133pub const ARTIFACT_LABEL: &str = "Artifact";
136pub const CHUNK_LABEL: &str = "Chunk";
137pub const CHUNK_TEXT_PROP: &str = "text";
138
139pub fn default_spec() -> IndexSpec {
154 IndexSpec {
155 equality: vec![
156 PropIndex {
157 label: ENTITY_LABEL.into(),
158 prop: ENTITY_NAME_PROP.into(),
159 },
160 PropIndex {
163 label: ALIAS_LABEL.into(),
164 prop: ALIAS_NAME_PROP.into(),
165 },
166 PropIndex {
167 label: SYNONYM_LABEL.into(),
168 prop: SYNONYM_TERM_PROP.into(),
169 },
170 PropIndex {
173 label: MEMORY_LABEL.into(),
174 prop: MEMORY_CONTENT_HASH_PROP.into(),
175 },
176 ],
177 text: vec![
178 PropIndex {
179 label: MEMORY_LABEL.into(),
180 prop: MEMORY_CONTENT_PROP.into(),
181 },
182 PropIndex {
188 label: ENTITY_LABEL.into(),
189 prop: ENTITY_NAME_PROP.into(),
190 },
191 PropIndex {
192 label: ALIAS_LABEL.into(),
193 prop: ALIAS_NAME_PROP.into(),
194 },
195 PropIndex {
197 label: CHUNK_LABEL.into(),
198 prop: CHUNK_TEXT_PROP.into(),
199 },
200 ],
201 }
202}
203
204fn stock_generations() -> Vec<IndexSpec> {
209 let g0 = IndexSpec {
211 equality: vec![PropIndex {
212 label: ENTITY_LABEL.into(),
213 prop: ENTITY_NAME_PROP.into(),
214 }],
215 text: vec![PropIndex {
216 label: MEMORY_LABEL.into(),
217 prop: MEMORY_CONTENT_PROP.into(),
218 }],
219 };
220 let g1 = IndexSpec {
222 equality: g0.equality.clone(),
223 text: vec![
224 PropIndex {
225 label: MEMORY_LABEL.into(),
226 prop: MEMORY_CONTENT_PROP.into(),
227 },
228 PropIndex {
229 label: ENTITY_LABEL.into(),
230 prop: ENTITY_NAME_PROP.into(),
231 },
232 ],
233 };
234 let g2 = IndexSpec {
236 equality: vec![
237 PropIndex {
238 label: ENTITY_LABEL.into(),
239 prop: ENTITY_NAME_PROP.into(),
240 },
241 PropIndex {
242 label: ALIAS_LABEL.into(),
243 prop: ALIAS_NAME_PROP.into(),
244 },
245 PropIndex {
246 label: SYNONYM_LABEL.into(),
247 prop: SYNONYM_TERM_PROP.into(),
248 },
249 PropIndex {
250 label: MEMORY_LABEL.into(),
251 prop: MEMORY_CONTENT_HASH_PROP.into(),
252 },
253 ],
254 text: vec![
255 PropIndex {
256 label: MEMORY_LABEL.into(),
257 prop: MEMORY_CONTENT_PROP.into(),
258 },
259 PropIndex {
260 label: ENTITY_LABEL.into(),
261 prop: ENTITY_NAME_PROP.into(),
262 },
263 PropIndex {
264 label: ALIAS_LABEL.into(),
265 prop: ALIAS_NAME_PROP.into(),
266 },
267 ],
268 };
269 vec![g0, g1, g2]
270}
271
272pub fn upgraded_spec(persisted: IndexSpec) -> IndexSpec {
280 let sorted = |spec: &IndexSpec| {
281 let mut eq: Vec<(String, String)> = spec
282 .equality
283 .iter()
284 .map(|p| (p.label.to_string(), p.prop.clone()))
285 .collect();
286 let mut text: Vec<(String, String)> = spec
287 .text
288 .iter()
289 .map(|p| (p.label.to_string(), p.prop.clone()))
290 .collect();
291 eq.sort();
292 text.sort();
293 (eq, text)
294 };
295 let p = sorted(&persisted);
296 if stock_generations().iter().any(|g| sorted(g) == p) {
297 default_spec()
298 } else {
299 persisted
300 }
301}
302
303pub fn normalize_edge_type(raw: &str) -> Result<String, String> {
313 let lowered = raw.to_lowercase();
314 let mut out = String::with_capacity(lowered.len());
315 let mut pending_sep = false;
316 for c in lowered.chars() {
317 if c.is_whitespace() || c == '-' || c == '_' {
318 if !out.is_empty() {
319 pending_sep = true;
320 }
321 } else {
322 if pending_sep {
323 out.push('_');
324 pending_sep = false;
325 }
326 out.push(c);
327 }
328 }
329 if out.is_empty() {
330 return Err(format!(
331 "edge type {raw:?} is empty once normalized (lowercase, separators collapsed to '_')"
332 ));
333 }
334 Ok(out)
335}
336
337pub fn scope_label(scope: &Scope) -> String {
344 match scope {
345 Scope::Shared => "shared".to_string(),
346 Scope::Id(id) => id.to_string(),
347 }
348}
349
350pub const UNSUPPORTED: &str = "unsupported over MCP v0";
355
356pub fn prop_value_to_json(v: &PropValue) -> Result<Value, String> {
360 match v {
361 PropValue::Str(s) => Ok(Value::String(s.clone())),
362 PropValue::Int(i) => Ok(Value::Number((*i).into())),
363 PropValue::Float(f) => serde_json::Number::from_f64(*f)
364 .map(Value::Number)
365 .ok_or_else(|| format!("{UNSUPPORTED}: non-finite float")),
366 PropValue::Bool(b) => Ok(Value::Bool(*b)),
367 PropValue::Bytes(_) | PropValue::DateTime(_) => Err(UNSUPPORTED.to_string()),
368 }
369}
370
371pub fn json_to_prop_value(v: &Value) -> Result<PropValue, String> {
380 match v {
381 Value::String(s) => Ok(PropValue::Str(s.clone())),
382 Value::Bool(b) => Ok(PropValue::Bool(*b)),
383 Value::Number(n) => {
384 if let Some(i) = n.as_i64() {
385 Ok(PropValue::Int(i))
386 } else if n.is_u64() {
387 Err(format!("integer out of supported range (max {})", i64::MAX))
390 } else if let Some(f) = n.as_f64() {
391 Ok(PropValue::Float(f))
392 } else {
393 Err(format!("{UNSUPPORTED}: number out of range"))
394 }
395 }
396 Value::Array(_) | Value::Object(_) | Value::Null => Err(UNSUPPORTED.to_string()),
397 }
398}
399
400pub fn props_to_json(props: &Props) -> Result<Value, String> {
403 let mut map = Map::with_capacity(props.len());
404 for (k, v) in props {
405 map.insert(k.clone(), prop_value_to_json(v)?);
406 }
407 Ok(Value::Object(map))
408}
409
410pub fn json_to_props(v: &Value) -> Result<Props, String> {
427 let obj = v
428 .as_object()
429 .ok_or_else(|| "expected a JSON object for props".to_string())?;
430 let mut props = Props::new();
431 for (k, val) in obj {
432 props.insert(k.clone(), json_to_prop_value(val)?);
433 }
434 Ok(props)
435}
436
437pub fn json_to_prop_changes(v: &Value) -> Result<BTreeMap<String, Option<PropValue>>, String> {
444 let obj = v
445 .as_object()
446 .ok_or_else(|| "expected a JSON object for props".to_string())?;
447 let mut out = BTreeMap::new();
448 for (k, val) in obj {
449 let entry = match val {
450 Value::Null => None,
451 other => Some(json_to_prop_value(other)?),
452 };
453 out.insert(k.clone(), entry);
454 }
455 Ok(out)
456}
457
458pub fn json_to_f32_vec(v: &Value) -> Result<Vec<f32>, String> {
463 let arr = v
464 .as_array()
465 .ok_or_else(|| "expected a JSON array of numbers".to_string())?;
466 let mut out = Vec::with_capacity(arr.len());
467 for (i, el) in arr.iter().enumerate() {
468 let f = el
469 .as_f64()
470 .ok_or_else(|| format!("vector element {i} is not a number: {el}"))?;
471 let f = f as f32;
472 if !f.is_finite() {
473 return Err(format!("vector element {i} is not finite"));
474 }
475 out.push(f);
476 }
477 Ok(out)
478}
479
480pub fn merge_required_prop(
491 key: &str,
492 value: PropValue,
493 extra: Option<&Value>,
494) -> Result<Props, String> {
495 let mut props = match extra {
496 Some(v) => json_to_props(v)?,
497 None => Props::new(),
498 };
499 if props.contains_key(key) {
500 return Err(format!(
501 "props must not include {key:?}: it is already set from the tool's own parameter"
502 ));
503 }
504 props.insert(key.to_string(), value);
505 Ok(props)
506}
507
508pub fn scope_to_json(scope: Scope) -> Value {
512 Value::String(match scope {
513 Scope::Shared => "shared".to_string(),
514 Scope::Id(id) => id.to_string(),
515 })
516}
517
518pub fn node_to_json(n: &NodeRecord) -> Result<Value, String> {
523 let mut map = Map::new();
524 map.insert("id".into(), Value::String(n.id.to_string()));
525 map.insert("scope".into(), scope_to_json(n.scope));
526 map.insert("label".into(), Value::String(n.label.to_string()));
527 map.insert("props".into(), props_to_json(&n.props)?);
528 Ok(Value::Object(map))
529}
530
531pub fn edge_to_json(e: &EdgeRecord) -> Result<Value, String> {
539 let mut map = Map::new();
540 map.insert("id".into(), Value::String(e.id.to_string()));
541 map.insert("scope".into(), scope_to_json(e.scope));
542 map.insert("type".into(), Value::String(e.ty.to_string()));
543 map.insert("from".into(), Value::String(e.from.to_string()));
544 map.insert("to".into(), Value::String(e.to.to_string()));
545 map.insert("props".into(), props_to_json(&e.props)?);
546 map.insert("valid_from".into(), Value::Number(e.valid_from.into()));
547 map.insert(
548 "valid_to".into(),
549 match e.valid_to {
550 Some(t) => Value::Number(t.into()),
551 None => Value::Null,
552 },
553 );
554 map.insert("recorded_at".into(), Value::Number(e.recorded_at.into()));
555 map.insert(
556 "superseded_at".into(),
557 match e.superseded_at {
558 Some(t) => Value::Number(t.into()),
559 None => Value::Null,
560 },
561 );
562 Ok(Value::Object(map))
563}
564
565pub fn edge_live_at(e: &EdgeRecord, t: i64) -> bool {
574 e.valid_from <= t && e.valid_to.is_none_or(|vt| vt > t)
575}
576
577pub fn edge_believed_at(e: &EdgeRecord, t: i64) -> bool {
585 e.recorded_at <= t && e.superseded_at.is_none_or(|st| st > t)
586}
587
588pub fn subgraph_to_json(sg: &Subgraph) -> Result<Value, String> {
591 let nodes: Vec<Value> = sg
592 .nodes
593 .iter()
594 .map(node_to_json)
595 .collect::<Result<_, _>>()?;
596 let edges: Vec<Value> = sg
597 .edges
598 .iter()
599 .map(edge_to_json)
600 .collect::<Result<_, _>>()?;
601 Ok(serde_json::json!({ "nodes": nodes, "edges": edges }))
602}
603
604pub fn resolve_scope(scope: Option<&str>, default: Scope) -> Result<Scope, String> {
610 match scope {
611 None => Ok(default),
612 Some(s) if s.eq_ignore_ascii_case("shared") => Ok(Scope::Shared),
613 Some(s) => ScopeId::from_str(s)
614 .map(Scope::Id)
615 .map_err(|e| format!("invalid scope {s:?} (expected \"shared\" or a ULID): {e}")),
616 }
617}
618
619pub const DEFAULT_LOCK_WAIT_MS: u64 = 3000;
623
624pub fn lock_wait_budget_ms(env: Option<&str>) -> (u64, Option<String>) {
631 match env {
632 None => (DEFAULT_LOCK_WAIT_MS, None),
633 Some(raw) => match raw.parse::<u64>() {
634 Ok(v) => (v, None),
635 Err(_) => (
636 DEFAULT_LOCK_WAIT_MS,
637 Some(format!(
638 "ignoring unparseable TOPODB_LOCK_WAIT_MS={raw:?}; using {DEFAULT_LOCK_WAIT_MS}"
639 )),
640 ),
641 },
642 }
643}
644
645pub fn scope_to_scope_set(scope: Scope) -> ScopeSet {
648 match scope {
649 Scope::Shared => ScopeSet::default().with_shared(),
650 Scope::Id(id) => ScopeSet::of(&[id]),
651 }
652}
653
654#[derive(Debug, Clone, PartialEq, Eq)]
658pub struct ReadScopes(Vec<Scope>);
659
660impl ReadScopes {
661 pub fn new(scopes: Vec<Scope>) -> Result<Self, String> {
663 if scopes.is_empty() {
664 return Err(
665 "read scope set is empty; expected at least one of \"shared\" or a scope ULID"
666 .to_string(),
667 );
668 }
669 Ok(Self(scopes))
670 }
671
672 pub fn as_slice(&self) -> &[Scope] {
674 &self.0
675 }
676}
677
678pub fn parse_read_scopes(s: &str) -> Result<ReadScopes, String> {
684 let scopes: Vec<Scope> = s
685 .split(',')
686 .map(str::trim)
687 .filter(|part| !part.is_empty())
688 .map(|token| resolve_scope(Some(token), Scope::Shared))
689 .collect::<Result<_, _>>()?;
690 if scopes.is_empty() {
691 return Err(format!(
692 "read scope list {s:?} is empty; expected a comma-separated list of \"shared\" or scope ULIDs"
693 ));
694 }
695 ReadScopes::new(scopes)
696}
697
698pub fn scopes_to_scope_set(scopes: &[Scope]) -> ScopeSet {
708 let ids: Vec<ScopeId> = scopes
709 .iter()
710 .filter_map(|s| match s {
711 Scope::Id(id) => Some(*id),
712 Scope::Shared => None,
713 })
714 .collect();
715 let set = ScopeSet::of(&ids);
716 if scopes.iter().any(|s| matches!(s, Scope::Shared)) {
717 set.with_shared()
718 } else {
719 set
720 }
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726 use topodb::NodeId;
727
728 fn props(pairs: &[(&str, PropValue)]) -> Props {
729 pairs
730 .iter()
731 .cloned()
732 .map(|(k, v)| (k.to_string(), v))
733 .collect()
734 }
735
736 #[test]
739 fn str_round_trips() {
740 let v = PropValue::Str("hello".into());
741 let j = prop_value_to_json(&v).unwrap();
742 assert_eq!(j, Value::String("hello".into()));
743 assert_eq!(json_to_prop_value(&j).unwrap(), v);
744 }
745
746 #[test]
747 fn int_round_trips() {
748 let v = PropValue::Int(-42);
749 let j = prop_value_to_json(&v).unwrap();
750 assert_eq!(j, serde_json::json!(-42));
751 assert_eq!(json_to_prop_value(&j).unwrap(), v);
752 }
753
754 #[test]
755 fn bool_round_trips() {
756 for b in [true, false] {
757 let v = PropValue::Bool(b);
758 let j = prop_value_to_json(&v).unwrap();
759 assert_eq!(j, Value::Bool(b));
760 assert_eq!(json_to_prop_value(&j).unwrap(), v);
761 }
762 }
763
764 #[test]
767 fn float_to_json_is_a_json_number() {
768 let v = PropValue::Float(3.5);
769 let j = prop_value_to_json(&v).unwrap();
770 assert_eq!(j, serde_json::json!(3.5));
771 }
772
773 #[test]
774 fn json_integer_literal_decodes_to_int_not_float() {
775 let j = serde_json::json!(7);
776 assert_eq!(json_to_prop_value(&j).unwrap(), PropValue::Int(7));
777 }
778
779 #[test]
780 fn json_float_literal_decodes_to_float() {
781 let j = serde_json::json!(7.5);
782 assert_eq!(json_to_prop_value(&j).unwrap(), PropValue::Float(7.5));
783 }
784
785 #[test]
786 fn i64_max_round_trips_as_int() {
787 let v = PropValue::Int(i64::MAX);
788 let j = prop_value_to_json(&v).unwrap();
789 assert_eq!(j, serde_json::json!(i64::MAX));
790 assert_eq!(json_to_prop_value(&j).unwrap(), v);
791 }
792
793 #[test]
794 fn json_integer_above_i64_max_is_an_error_not_a_lossy_float() {
795 let j = serde_json::json!(u64::MAX);
796 let err = json_to_prop_value(&j).unwrap_err();
797 assert!(
798 err.contains("integer out of supported range"),
799 "expected a clear out-of-range error, got: {err}"
800 );
801 let j = serde_json::json!(i64::MAX as u64 + 1);
803 assert!(json_to_prop_value(&j).is_err());
804 }
805
806 #[test]
807 fn non_finite_float_to_json_is_an_error() {
808 assert!(prop_value_to_json(&PropValue::Float(f64::NAN)).is_err());
809 assert!(prop_value_to_json(&PropValue::Float(f64::INFINITY)).is_err());
810 }
811
812 #[test]
815 fn bytes_to_json_is_unsupported() {
816 let err = prop_value_to_json(&PropValue::Bytes(vec![1, 2, 3])).unwrap_err();
817 assert_eq!(err, UNSUPPORTED);
818 }
819
820 #[test]
821 fn datetime_to_json_is_unsupported() {
822 let err = prop_value_to_json(&PropValue::DateTime(123)).unwrap_err();
823 assert_eq!(err, UNSUPPORTED);
824 }
825
826 #[test]
827 fn json_array_to_propvalue_is_unsupported() {
828 let err = json_to_prop_value(&serde_json::json!([1, 2])).unwrap_err();
829 assert_eq!(err, UNSUPPORTED);
830 }
831
832 #[test]
833 fn json_object_to_propvalue_is_unsupported() {
834 let err = json_to_prop_value(&serde_json::json!({"a": 1})).unwrap_err();
835 assert_eq!(err, UNSUPPORTED);
836 }
837
838 #[test]
839 fn json_null_to_propvalue_is_unsupported() {
840 let err = json_to_prop_value(&Value::Null).unwrap_err();
841 assert_eq!(err, UNSUPPORTED);
842 }
843
844 #[test]
847 fn props_round_trip() {
848 let p = props(&[
849 ("name", PropValue::Str("ada".into())),
850 ("age", PropValue::Int(30)),
851 ("active", PropValue::Bool(true)),
852 ("score", PropValue::Float(1.5)),
853 ]);
854 let j = props_to_json(&p).unwrap();
855 assert!(j.is_object());
856 let back = json_to_props(&j).unwrap();
857 assert_eq!(back, p);
858 }
859
860 #[test]
861 fn props_to_json_propagates_unsupported_value() {
862 let p = props(&[("blob", PropValue::Bytes(vec![9]))]);
863 assert!(props_to_json(&p).is_err());
864 }
865
866 #[test]
867 fn json_to_props_rejects_non_object() {
868 assert!(json_to_props(&serde_json::json!([1, 2])).is_err());
869 }
870
871 #[test]
872 fn json_to_props_propagates_unsupported_field() {
873 let j = serde_json::json!({"bad": [1, 2]});
874 assert!(json_to_props(&j).is_err());
875 }
876
877 #[test]
880 fn merge_required_prop_with_no_extra_just_sets_the_key() {
881 let props = merge_required_prop("content", PropValue::Str("hi".into()), None).unwrap();
882 assert_eq!(props.len(), 1);
883 assert_eq!(props["content"], PropValue::Str("hi".into()));
884 }
885
886 #[test]
887 fn merge_required_prop_merges_additional_fields() {
888 let extra = serde_json::json!({"source": "chat", "confidence": 3});
889 let props =
890 merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).unwrap();
891 assert_eq!(props.len(), 3);
892 assert_eq!(props["content"], PropValue::Str("hi".into()));
893 assert_eq!(props["source"], PropValue::Str("chat".into()));
894 assert_eq!(props["confidence"], PropValue::Int(3));
895 }
896
897 #[test]
898 fn merge_required_prop_rejects_collision_with_required_key() {
899 let extra = serde_json::json!({"content": "sneaky overwrite"});
900 let err =
901 merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).unwrap_err();
902 assert!(
903 err.contains("content"),
904 "error should name the colliding key: {err}"
905 );
906 let extra = serde_json::json!({"name": "sneaky"});
909 assert!(merge_required_prop("name", PropValue::Str("ada".into()), Some(&extra)).is_err());
910 }
911
912 #[test]
913 fn merge_required_prop_does_not_overwrite_on_collision() {
914 let extra = serde_json::json!({"content": "other"});
917 let result = merge_required_prop("content", PropValue::Str("mine".into()), Some(&extra));
918 assert!(result.is_err());
919 }
920
921 #[test]
922 fn merge_required_prop_propagates_non_object_extra() {
923 let extra = serde_json::json!([1, 2]);
924 assert!(merge_required_prop("content", PropValue::Str("hi".into()), Some(&extra)).is_err());
925 }
926
927 fn sample_node(scope: Scope) -> NodeRecord {
930 NodeRecord {
931 id: NodeId::new(),
932 scope,
933 label: "Entity".into(),
934 props: props(&[("name", PropValue::Str("ada".into()))]),
935 embedding: None,
936 }
937 }
938
939 fn sample_edge(scope: Scope, from: NodeId, to: NodeId) -> EdgeRecord {
940 EdgeRecord {
941 id: topodb::EdgeId::new(),
942 scope,
943 ty: "ABOUT".into(),
944 from,
945 to,
946 props: Props::new(),
947 valid_from: 1_000,
948 valid_to: None,
949 recorded_at: 1_000,
950 superseded_at: None,
951 }
952 }
953
954 #[test]
955 fn node_to_json_has_ulid_id_and_declared_fields() {
956 let scope = Scope::Id(ScopeId::new());
957 let n = sample_node(scope);
958 let j = node_to_json(&n).unwrap();
959 assert_eq!(j["id"], Value::String(n.id.to_string()));
960 assert_eq!(j["label"], Value::String("Entity".into()));
961 assert_eq!(j["scope"], scope_to_json(scope));
962 assert_eq!(j["props"]["name"], Value::String("ada".into()));
963 let parsed: NodeId = j["id"].as_str().unwrap().parse().unwrap();
965 assert_eq!(parsed, n.id);
966 }
967
968 #[test]
969 fn node_to_json_propagates_unsupported_prop() {
970 let mut n = sample_node(Scope::Shared);
971 n.props.insert("blob".into(), PropValue::Bytes(vec![1]));
972 assert!(node_to_json(&n).is_err());
973 }
974
975 #[test]
976 fn edge_to_json_has_ulid_ids_and_temporal_bounds() {
977 let scope = Scope::Shared;
978 let a = NodeId::new();
979 let b = NodeId::new();
980 let e = sample_edge(scope, a, b);
981 let j = edge_to_json(&e).unwrap();
982 assert_eq!(j["id"], Value::String(e.id.to_string()));
983 assert_eq!(j["from"], Value::String(a.to_string()));
984 assert_eq!(j["to"], Value::String(b.to_string()));
985 assert_eq!(j["type"], Value::String("ABOUT".into()));
986 assert_eq!(j["valid_from"], serde_json::json!(1_000));
987 assert_eq!(j["valid_to"], Value::Null);
988 assert_eq!(j["recorded_at"], serde_json::json!(1_000));
989 assert_eq!(j["superseded_at"], Value::Null);
990 }
991
992 #[test]
993 fn edge_live_at_gates_on_valid_axis_inclusive_lower_exclusive_upper() {
994 let a = NodeId::new();
995 let b = NodeId::new();
996 let mut e = sample_edge(Scope::Shared, a, b);
997 e.valid_from = 1_000;
998 e.valid_to = Some(2_000);
999 assert!(!edge_live_at(&e, 999), "before valid_from: not live");
1000 assert!(edge_live_at(&e, 1_000), "at valid_from: live (inclusive)");
1001 assert!(edge_live_at(&e, 1_999), "just before valid_to: live");
1002 assert!(
1003 !edge_live_at(&e, 2_000),
1004 "at valid_to: not live (exclusive)"
1005 );
1006 e.valid_to = None;
1007 assert!(edge_live_at(&e, i64::MAX), "open valid_to: eternally live");
1008 }
1009
1010 #[test]
1011 fn edge_believed_at_gates_on_recorded_axis_inclusive_lower_exclusive_upper() {
1012 let a = NodeId::new();
1013 let b = NodeId::new();
1014 let mut e = sample_edge(Scope::Shared, a, b);
1015 e.recorded_at = 1_000;
1016 e.superseded_at = Some(2_000);
1017 assert!(
1018 !edge_believed_at(&e, 999),
1019 "before recorded_at: not believed"
1020 );
1021 assert!(
1022 edge_believed_at(&e, 1_000),
1023 "at recorded_at: believed (inclusive)"
1024 );
1025 assert!(
1026 edge_believed_at(&e, 1_999),
1027 "just before superseded_at: believed"
1028 );
1029 assert!(
1030 !edge_believed_at(&e, 2_000),
1031 "at superseded_at: not believed (exclusive)"
1032 );
1033 e.superseded_at = None;
1034 assert!(
1035 edge_believed_at(&e, i64::MAX),
1036 "never superseded: believed indefinitely"
1037 );
1038 }
1039
1040 #[test]
1044 fn edge_live_at_and_edge_believed_at_diverge_for_a_late_recorded_fact() {
1045 let a = NodeId::new();
1046 let b = NodeId::new();
1047 let mut e = sample_edge(Scope::Shared, a, b);
1048 e.valid_from = 1_000; e.recorded_at = 5_000; e.superseded_at = None;
1051 let t = 3_000; assert!(edge_live_at(&e, t), "valid axis: world truth already held");
1053 assert!(
1054 !edge_believed_at(&e, t),
1055 "recorded axis: not yet written at t"
1056 );
1057 }
1058
1059 #[test]
1060 fn edge_to_json_closed_edge_has_numeric_valid_to() {
1061 let mut e = sample_edge(Scope::Shared, NodeId::new(), NodeId::new());
1062 e.valid_to = Some(2_000);
1063 e.superseded_at = Some(2_500);
1064 let j = edge_to_json(&e).unwrap();
1065 assert_eq!(j["valid_to"], serde_json::json!(2_000));
1066 assert_eq!(j["superseded_at"], serde_json::json!(2_500));
1067 }
1068
1069 #[test]
1070 fn subgraph_to_json_nests_nodes_and_edges() {
1071 let scope = Scope::Shared;
1072 let a = sample_node(scope);
1073 let b = sample_node(scope);
1074 let e = sample_edge(scope, a.id, b.id);
1075 let sg = Subgraph {
1076 nodes: vec![a.clone(), b.clone()],
1077 edges: vec![e.clone()],
1078 };
1079 let j = subgraph_to_json(&sg).unwrap();
1080 assert_eq!(j["nodes"].as_array().unwrap().len(), 2);
1081 assert_eq!(j["edges"].as_array().unwrap().len(), 1);
1082 assert_eq!(j["edges"][0]["id"], Value::String(e.id.to_string()));
1083 }
1084
1085 #[test]
1088 fn edge_type_variants_normalize_to_one_form() {
1089 for raw in [
1090 "works_at",
1091 "Works At",
1092 "works-at",
1093 "WORKS_AT",
1094 " works at ",
1095 "works--at",
1096 "works_-at",
1097 ] {
1098 assert_eq!(
1099 normalize_edge_type(raw).unwrap(),
1100 "works_at",
1101 "{raw:?} should normalize to works_at"
1102 );
1103 }
1104 assert_eq!(normalize_edge_type("about").unwrap(), "about");
1105 }
1106
1107 #[test]
1108 fn edge_type_empty_after_normalization_is_an_error() {
1109 for raw in ["", " ", "---", "_", " - _ "] {
1110 assert!(normalize_edge_type(raw).is_err(), "{raw:?} should error");
1111 }
1112 }
1113
1114 #[test]
1117 fn legacy_stock_spec_upgrades_to_current_default() {
1118 let legacy = IndexSpec {
1119 equality: vec![PropIndex {
1120 label: ENTITY_LABEL.into(),
1121 prop: ENTITY_NAME_PROP.into(),
1122 }],
1123 text: vec![PropIndex {
1124 label: MEMORY_LABEL.into(),
1125 prop: MEMORY_CONTENT_PROP.into(),
1126 }],
1127 };
1128 assert_eq!(upgraded_spec(legacy), default_spec());
1129 assert_eq!(upgraded_spec(default_spec()), default_spec());
1132 }
1133
1134 #[test]
1135 fn customized_spec_is_never_rewritten() {
1136 let custom = IndexSpec {
1137 equality: vec![PropIndex {
1138 label: "Person".into(),
1139 prop: "handle".into(),
1140 }],
1141 text: vec![PropIndex {
1142 label: MEMORY_LABEL.into(),
1143 prop: MEMORY_CONTENT_PROP.into(),
1144 }],
1145 };
1146 assert_eq!(upgraded_spec(custom.clone()), custom);
1147 }
1148
1149 #[test]
1152 fn resolve_scope_none_uses_default() {
1153 let id = ScopeId::new();
1154 assert_eq!(resolve_scope(None, Scope::Shared).unwrap(), Scope::Shared);
1155 assert_eq!(resolve_scope(None, Scope::Id(id)).unwrap(), Scope::Id(id));
1156 }
1157
1158 #[test]
1159 fn resolve_scope_shared_is_case_insensitive() {
1160 assert_eq!(
1161 resolve_scope(Some("shared"), Scope::Id(ScopeId::new())).unwrap(),
1162 Scope::Shared
1163 );
1164 assert_eq!(
1165 resolve_scope(Some("SHARED"), Scope::Id(ScopeId::new())).unwrap(),
1166 Scope::Shared
1167 );
1168 }
1169
1170 #[test]
1171 fn resolve_scope_ulid_parses_to_id() {
1172 let id = ScopeId::new();
1173 let s = id.to_string();
1174 assert_eq!(
1175 resolve_scope(Some(&s), Scope::Shared).unwrap(),
1176 Scope::Id(id)
1177 );
1178 }
1179
1180 #[test]
1181 fn resolve_scope_garbage_is_a_clear_error() {
1182 let err = resolve_scope(Some("not-a-ulid"), Scope::Shared).unwrap_err();
1183 assert!(err.contains("not-a-ulid"));
1184 }
1185
1186 #[test]
1187 fn scope_to_scope_set_shared_admits_only_shared() {
1188 let set = scope_to_scope_set(Scope::Shared);
1189 assert!(set.contains(Scope::Shared));
1190 assert!(!set.contains(Scope::Id(ScopeId::new())));
1191 }
1192
1193 #[test]
1194 fn scope_to_scope_set_id_admits_only_that_id() {
1195 let id = ScopeId::new();
1196 let set = scope_to_scope_set(Scope::Id(id));
1197 assert!(set.contains(Scope::Id(id)));
1198 assert!(!set.contains(Scope::Shared));
1199 assert!(!set.contains(Scope::Id(ScopeId::new())));
1200 }
1201
1202 #[test]
1203 fn scopes_to_scope_set_admits_every_member() {
1204 let a = ScopeId::new();
1205 let b = ScopeId::new();
1206 let set = scopes_to_scope_set(&[Scope::Id(a), Scope::Shared, Scope::Id(b)]);
1207 assert!(set.contains(Scope::Id(a)));
1208 assert!(set.contains(Scope::Id(b)));
1209 assert!(set.contains(Scope::Shared));
1210 }
1211
1212 #[test]
1213 fn scopes_to_scope_set_without_shared_excludes_shared() {
1214 let a = ScopeId::new();
1215 let set = scopes_to_scope_set(&[Scope::Id(a)]);
1216 assert!(set.contains(Scope::Id(a)));
1217 assert!(!set.contains(Scope::Shared));
1218 }
1219
1220 #[test]
1221 fn scopes_to_scope_set_matches_singleton_for_one_member() {
1222 let a = ScopeId::new();
1227 let multi = scopes_to_scope_set(&[Scope::Id(a)]);
1228 let single = scope_to_scope_set(Scope::Id(a));
1229 assert_eq!(multi.contains(Scope::Id(a)), single.contains(Scope::Id(a)));
1230 assert_eq!(
1231 multi.contains(Scope::Shared),
1232 single.contains(Scope::Shared)
1233 );
1234
1235 let multi_shared = scopes_to_scope_set(&[Scope::Shared]);
1236 let single_shared = scope_to_scope_set(Scope::Shared);
1237 assert_eq!(
1238 multi_shared.contains(Scope::Shared),
1239 single_shared.contains(Scope::Shared)
1240 );
1241 }
1242
1243 #[test]
1244 fn scopes_to_scope_set_empty_admits_nothing() {
1245 let a = ScopeId::new();
1246 let set = scopes_to_scope_set(&[]);
1247 assert!(!set.contains(Scope::Shared));
1248 assert!(!set.contains(Scope::Id(a)));
1249 }
1250
1251 #[test]
1254 fn read_scopes_new_rejects_empty_and_accepts_nonempty() {
1255 assert!(ReadScopes::new(vec![]).is_err());
1256 assert!(ReadScopes::new(vec![Scope::Shared]).is_ok());
1257 assert!(ReadScopes::new(vec![Scope::Id(ScopeId::new()), Scope::Shared]).is_ok());
1258 }
1259
1260 #[test]
1261 fn parse_read_scopes_comma_separated_shared_and_ulid() {
1262 let a = ScopeId::new();
1263 let rs = parse_read_scopes(&format!("{a},shared")).unwrap();
1264 assert_eq!(rs.as_slice(), &[Scope::Id(a), Scope::Shared]);
1265 }
1266
1267 #[test]
1268 fn parse_read_scopes_trims_whitespace_around_entries() {
1269 let a = ScopeId::new();
1270 let rs = parse_read_scopes(&format!(" {a} , shared ")).unwrap();
1271 assert_eq!(rs.as_slice(), &[Scope::Id(a), Scope::Shared]);
1272 }
1273
1274 #[test]
1275 fn parse_read_scopes_preserves_order_and_duplicates() {
1276 let rs = parse_read_scopes("shared,shared").unwrap();
1277 assert_eq!(rs.as_slice(), &[Scope::Shared, Scope::Shared]);
1278 }
1279
1280 #[test]
1281 fn parse_read_scopes_rejects_empty_list() {
1282 assert!(parse_read_scopes("").is_err());
1283 assert!(parse_read_scopes(" , ").is_err());
1284 }
1285
1286 #[test]
1287 fn parse_read_scopes_rejects_bad_ulid() {
1288 assert!(parse_read_scopes("shared,not-a-ulid").is_err());
1289 }
1290
1291 #[test]
1294 fn prop_changes_null_is_remove_scalar_is_set() {
1295 let j = serde_json::json!({ "status": "active", "stale": null, "n": 3 });
1296 let changes = json_to_prop_changes(&j).unwrap();
1297 assert_eq!(changes["status"], Some(PropValue::Str("active".into())));
1298 assert_eq!(changes["stale"], None);
1299 assert_eq!(changes["n"], Some(PropValue::Int(3)));
1300 }
1301
1302 #[test]
1303 fn prop_changes_rejects_non_object() {
1304 assert!(json_to_prop_changes(&serde_json::json!([1, 2])).is_err());
1305 }
1306
1307 #[test]
1308 fn prop_changes_propagates_unsupported_value() {
1309 assert!(json_to_prop_changes(&serde_json::json!({ "x": [1, 2] })).is_err());
1311 }
1312
1313 #[test]
1316 fn f32_vec_parses_numbers() {
1317 let j = serde_json::json!([0.0, 1.5, -2, 3]);
1318 assert_eq!(json_to_f32_vec(&j).unwrap(), vec![0.0f32, 1.5, -2.0, 3.0]);
1319 }
1320
1321 #[test]
1322 fn f32_vec_rejects_non_array() {
1323 assert!(json_to_f32_vec(&serde_json::json!({"a": 1})).is_err());
1324 }
1325
1326 #[test]
1327 fn f32_vec_rejects_non_number_element() {
1328 assert!(json_to_f32_vec(&serde_json::json!([1.0, "x"])).is_err());
1329 }
1330
1331 #[test]
1332 fn f32_vec_rejects_overflow_to_infinity() {
1333 assert!(json_to_f32_vec(&serde_json::json!([1e40])).is_err());
1335 }
1336
1337 #[test]
1338 fn default_spec_covers_alias_and_synonym() {
1339 let s = default_spec();
1340 let has = |list: &[PropIndex], l: &str, p: &str| {
1341 list.iter().any(|pi| pi.label == l && pi.prop == p)
1342 };
1343 assert!(has(&s.equality, ALIAS_LABEL, ALIAS_NAME_PROP));
1344 assert!(has(&s.equality, SYNONYM_LABEL, SYNONYM_TERM_PROP));
1345 assert!(has(&s.text, ALIAS_LABEL, ALIAS_NAME_PROP));
1346 }
1347
1348 #[test]
1349 fn every_stock_generation_upgrades_to_current_default() {
1350 let v0 = IndexSpec {
1352 equality: vec![PropIndex {
1353 label: ENTITY_LABEL.into(),
1354 prop: ENTITY_NAME_PROP.into(),
1355 }],
1356 text: vec![PropIndex {
1357 label: MEMORY_LABEL.into(),
1358 prop: MEMORY_CONTENT_PROP.into(),
1359 }],
1360 };
1361 let v1 = IndexSpec {
1363 equality: v0.equality.clone(),
1364 text: vec![
1365 PropIndex {
1366 label: MEMORY_LABEL.into(),
1367 prop: MEMORY_CONTENT_PROP.into(),
1368 },
1369 PropIndex {
1370 label: ENTITY_LABEL.into(),
1371 prop: ENTITY_NAME_PROP.into(),
1372 },
1373 ],
1374 };
1375 assert_eq!(upgraded_spec(v0), default_spec());
1376 assert_eq!(upgraded_spec(v1), default_spec());
1377 assert_eq!(upgraded_spec(default_spec()), default_spec());
1378 }
1379
1380 #[test]
1381 fn default_spec_text_indexes_chunk_text_and_previous_default_upgrades() {
1382 let spec = default_spec();
1383 assert!(spec
1384 .text
1385 .iter()
1386 .any(|p| p.label == CHUNK_LABEL && p.prop == CHUNK_TEXT_PROP));
1387 let g2 = IndexSpec {
1389 equality: spec.equality.clone(),
1390 text: spec
1391 .text
1392 .iter()
1393 .filter(|p| p.label != CHUNK_LABEL)
1394 .cloned()
1395 .collect(),
1396 };
1397 assert_ne!(g2, spec);
1398 assert_eq!(upgraded_spec(g2), spec);
1399 }
1400}