Skip to main content

lunaris_core/
primitives.rs

1//! Six bi-temporal primitives — verbatim per blueprint §3.3.
2//!
3//! Every primitive carries a `BiTemporal { valid, sys }` stamp from a shared `HlcClock`.
4//! Every primitive is `Send + Sync + 'static`, `Debug`, `Clone`, `PartialEq`, and serde-roundtrippable.
5//!
6//! RFC 0001 (v0.2): every primitive now carries `pub scope: Scope` as a first-class
7//! partition key for multi-agent / multi-tenant isolation. Constructors take `scope`
8//! as the first argument. Existing call sites use `Scope::dev()` during the Wave 0
9//! migration; Wave 1 replaces those with real per-agent scopes.
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use ulid::Ulid;
14
15use crate::bitemporal::BiTemporal;
16use crate::hlc::{Hlc, HlcClock};
17use crate::scope::Scope;
18
19// ---------------- Episode ----------------
20
21#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
22pub struct Episode {
23    pub id: Ulid,
24    /// RFC 0001 — partition key for multi-agent / multi-tenant isolation.
25    pub scope: Scope,
26    pub source: String,
27    pub content: String,
28    pub t_ref: Option<DateTime<Utc>>,
29    pub bt: BiTemporal,
30    #[serde(default)]
31    pub metadata: serde_json::Map<String, serde_json::Value>,
32}
33
34impl Episode {
35    /// Ground the **valid** axis on `t_ref`, leaving the **system** axis alone.
36    ///
37    /// `t_ref` is the caller's declared real-world date for this content —
38    /// a chat session's date, a commit's author date, a document's dateline.
39    /// [`Episode::new`] cannot know it, so it stamps `BiTemporal::now`, which
40    /// puts BOTH axes on the ingest instant. Until this runs, the store is
41    /// mono-temporal with a spare field: `Filter::ValidTimeRange` answers
42    /// "what did we WRITE in this window" instead of "what was TRUE in this
43    /// window", and a corpus of last year's events matches nothing dated last
44    /// year (F21).
45    ///
46    /// Idempotent, and a no-op without a `t_ref` — an undated episode has
47    /// nothing better to say than "now", and saying "now" is correct rather
48    /// than merely a fallback.
49    ///
50    /// The system axis is deliberately untouched. It records when Lunaris
51    /// learned the thing, and no caller-supplied value may move it: an
52    /// `as_of` system query that could be talked into claiming we knew
53    /// something before we recorded it is not an audit trail.
54    pub fn ground_valid_axis(&mut self) {
55        if let Some(t) = self.t_ref {
56            self.bt.valid.0 = Hlc::from_utc(t);
57        }
58    }
59
60    /// Construct a new [`Episode`].
61    ///
62    /// `scope` is the partition key (RFC 0001). Use [`Scope::dev()`] at Wave 0
63    /// call sites where the real scope has not yet been threaded through.
64    pub fn new(
65        scope: Scope,
66        source: impl Into<String>,
67        content: impl Into<String>,
68        clock: &HlcClock,
69    ) -> Self {
70        Self {
71            id: Ulid::new(),
72            scope,
73            source: source.into(),
74            content: content.into(),
75            t_ref: None,
76            bt: BiTemporal::now(clock),
77            metadata: serde_json::Map::new(),
78        }
79    }
80}
81
82// ---------------- Chunk ----------------
83
84#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
85pub struct Chunk {
86    pub id: Ulid,
87    /// RFC 0001 — partition key, inherited from the parent [`Episode`].
88    pub scope: Scope,
89    pub episode_id: Ulid,
90    pub text: String,
91    pub tokens: u32,
92    pub offset: u32,
93    #[serde(default)]
94    pub heading_path: Vec<String>,
95    #[serde(default)]
96    pub overlap_tail: String,
97    /// The 768-d embedding vector.
98    ///
99    /// ## W3 embedding double-store fix (moon-v051-perf-exploit)
100    ///
101    /// `skip_serializing` is deliberate: the KV `KvPut` JSON payload used to
102    /// carry this as a raw JSON float array — ~80% of the document's bytes
103    /// and a straight duplicate of the binary vector Moon's FT index
104    /// already stores. Nothing
105    /// on the read path (`lunaris-retrieve::hydrate`, the `tree.rs` RAPTOR
106    /// descent, the `detail.rs` inspector route) reads `.embedding` back off
107    /// a KV-deserialized primitive — verified via `find_referencing_symbols`
108    /// before this cut.
109    ///
110    /// `#[serde(default)]` keeps deserialization tolerant of BOTH shapes:
111    /// legacy payloads written before this fix (field present) still
112    /// populate `Some(..)`; payloads written after it (field absent)
113    /// deserialize to `None`. This is a one-way, additive-compatible
114    /// serialize-side change — never a breaking wire format change.
115    #[serde(default, skip_serializing)]
116    pub embedding: Option<Vec<f32>>,
117    /// Optional link to the nearest parent `TocNode` in the document tree.
118    ///
119    /// `None` in Phase 27 (field + migration + serde-compat delivered here;
120    /// full parent wiring — setting a non-None value — lands in Phase 29).
121    /// Pre-existing rows serialised without this field deserialise to `None`
122    /// via `#[serde(default)]` (STRUCT-03 serde back-compat contract).
123    ///
124    /// Travels in the existing JSONB KvPut payload — no DDL, no index change.
125    /// (Through 0.6.x the Postgres backend carried it as
126    /// `chunks.parent_id BYTEA NULL`; that backend was removed in 0.7.0.)
127    #[serde(default)]
128    pub parent_id: Option<Ulid>,
129    pub bt: BiTemporal,
130}
131
132impl Chunk {
133    /// Construct a new [`Chunk`].
134    ///
135    /// `scope` must match the parent [`Episode::scope`] (RFC 0001 §3.2).
136    pub fn new(
137        scope: Scope,
138        episode_id: Ulid,
139        text: impl Into<String>,
140        tokens: u32,
141        offset: u32,
142        heading_path: Vec<String>,
143        clock: &HlcClock,
144    ) -> Self {
145        Self {
146            id: Ulid::new(),
147            scope,
148            episode_id,
149            text: text.into(),
150            tokens,
151            offset,
152            heading_path,
153            overlap_tail: String::new(),
154            embedding: None,
155            parent_id: None,
156            bt: BiTemporal::now(clock),
157        }
158    }
159}
160
161// ---------------- Entity ----------------
162
163#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
164pub struct Entity {
165    pub id: Ulid,
166    /// RFC 0001 — partition key for multi-agent / multi-tenant isolation.
167    pub scope: Scope,
168    pub name: String,
169    #[serde(default)]
170    pub aliases: Vec<String>,
171    pub entity_type: String,
172    /// See the W3 skip_serializing rationale on [`Chunk::embedding`].
173    #[serde(default, skip_serializing)]
174    pub embedding: Option<Vec<f32>>,
175    pub bt: BiTemporal,
176    pub confidence: f32,
177}
178
179impl Entity {
180    /// Construct a new [`Entity`].
181    ///
182    /// `scope` is the partition key (RFC 0001). `src` and `dst` of any
183    /// [`Relation`] referencing this entity MUST share the same scope —
184    /// cross-scope relations are disallowed by construction in v0.2.
185    pub fn new(
186        scope: Scope,
187        name: impl Into<String>,
188        entity_type: impl Into<String>,
189        confidence: f32,
190        clock: &HlcClock,
191    ) -> Self {
192        Self {
193            id: Ulid::new(),
194            scope,
195            name: name.into(),
196            aliases: Vec::new(),
197            entity_type: entity_type.into(),
198            embedding: None,
199            bt: BiTemporal::now(clock),
200            confidence,
201        }
202    }
203}
204
205// ---------------- Relation ----------------
206
207#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
208pub struct Relation {
209    pub id: Ulid,
210    /// RFC 0001 — partition key. `src` and `dst` MUST resolve within this scope.
211    pub scope: Scope,
212    pub src: Ulid,
213    pub dst: Ulid,
214    pub rel_type: String,
215    pub bt: BiTemporal,
216    pub confidence: f32,
217    #[serde(default)]
218    pub provenance: Vec<Ulid>,
219}
220
221impl Relation {
222    /// Construct a new [`Relation`].
223    ///
224    /// `src` and `dst` MUST resolve within `scope` — cross-scope graph
225    /// references are disallowed by construction in v0.2 (RFC 0001 §2.3).
226    pub fn new(
227        scope: Scope,
228        src: Ulid,
229        dst: Ulid,
230        rel_type: impl Into<String>,
231        confidence: f32,
232        clock: &HlcClock,
233    ) -> Self {
234        Self {
235            id: Ulid::new(),
236            scope,
237            src,
238            dst,
239            rel_type: rel_type.into(),
240            bt: BiTemporal::now(clock),
241            confidence,
242            provenance: Vec::new(),
243        }
244    }
245}
246
247// ---------------- Fact ----------------
248
249#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
250pub struct Fact {
251    pub id: Ulid,
252    /// RFC 0001 — partition key for multi-agent / multi-tenant isolation.
253    pub scope: Scope,
254    pub subject: Ulid,
255    pub predicate: String,
256    pub object: Ulid,
257    pub fact_text: String,
258    /// See the W3 skip_serializing rationale on [`Chunk::embedding`].
259    #[serde(default, skip_serializing)]
260    pub embedding: Option<Vec<f32>>,
261    pub bt: BiTemporal,
262    pub confidence: f32,
263    #[serde(default)]
264    pub provenance: Vec<Ulid>,
265    pub activation: f32,
266}
267
268impl Fact {
269    /// Construct a new [`Fact`].
270    ///
271    /// `scope` is the partition key (RFC 0001). Corresponds to "Claim" in the
272    /// RFC §3.2 primitive list (the codebase uses `Fact` as the canonical name).
273    pub fn new(
274        scope: Scope,
275        subject: Ulid,
276        predicate: impl Into<String>,
277        object: Ulid,
278        fact_text: impl Into<String>,
279        confidence: f32,
280        clock: &HlcClock,
281    ) -> Self {
282        Self {
283            id: Ulid::new(),
284            scope,
285            subject,
286            predicate: predicate.into(),
287            object,
288            fact_text: fact_text.into(),
289            embedding: None,
290            bt: BiTemporal::now(clock),
291            confidence,
292            provenance: Vec::new(),
293            activation: 0.0,
294        }
295    }
296}
297
298// ---------------- Community ----------------
299
300#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
301pub struct Community {
302    pub id: Ulid,
303    /// RFC 0001 — partition key. Corresponds to "Source" in the RFC §3.2
304    /// primitive list (the codebase uses `Community` as the canonical name).
305    pub scope: Scope,
306    pub level: u8,
307    pub parent: Option<Ulid>,
308    #[serde(default)]
309    pub members: Vec<Ulid>,
310    pub summary: String,
311    /// See the W3 skip_serializing rationale on [`Chunk::embedding`]. Populated
312    /// in-memory at ingest (Phase-30 B1) and written to the `communities`
313    /// vector index; never round-trips through the KV JSON blob.
314    #[serde(default, skip_serializing)]
315    pub summary_embedding: Option<Vec<f32>>,
316    pub bt: BiTemporal,
317}
318
319impl Community {
320    /// Construct a new [`Community`].
321    ///
322    /// `scope` is the partition key (RFC 0001). All member entity IDs in
323    /// `members` MUST resolve within this scope.
324    pub fn new(scope: Scope, level: u8, summary: impl Into<String>, clock: &HlcClock) -> Self {
325        Self {
326            id: Ulid::new(),
327            scope,
328            level,
329            parent: None,
330            members: Vec::new(),
331            summary: summary.into(),
332            summary_embedding: None,
333            bt: BiTemporal::now(clock),
334        }
335    }
336}
337
338// ---------------------------------------------------------------------------
339// STRUCT-03 tests — Chunk.parent_id serde back-compat + construction
340// ---------------------------------------------------------------------------
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use crate::hlc::HlcClock;
346    use crate::scope::Scope;
347
348    fn dev_scope() -> Scope {
349        Scope::dev()
350    }
351
352    fn test_clock() -> std::sync::Arc<HlcClock> {
353        HlcClock::new(0)
354    }
355
356    #[test]
357    fn chunk_deserializes_without_parent_id() {
358        // Prove serde back-compat: a JSON row serialized without "parent_id"
359        // (pre-Phase 27 row) must deserialize successfully with parent_id=None.
360        // Strategy: serialize a real Chunk, remove "parent_id" from the JSON
361        // object, then deserialize — format-agnostic, no hardcoded BiTemporal layout.
362        let clock = test_clock();
363        let chunk = Chunk::new(dev_scope(), ulid::Ulid::new(), "hello world", 2, 0, vec![], &clock);
364        let mut map: serde_json::Map<String, serde_json::Value> =
365            serde_json::from_str(&serde_json::to_string(&chunk).unwrap()).unwrap();
366        // Simulate a pre-Phase-27 row by dropping the parent_id field entirely.
367        map.remove("parent_id");
368        let stripped = serde_json::to_string(&map).unwrap();
369        let back: Chunk = serde_json::from_str(&stripped)
370            .expect("back-compat: must deserialize without parent_id");
371        assert!(chunk.parent_id.is_none(), "parent_id must be None for pre-existing rows");
372        assert_eq!(back.text, chunk.text);
373    }
374
375    #[test]
376    fn chunk_deserializes_with_parent_id() {
377        // Prove that a row WITH parent_id set deserializes correctly.
378        let clock = test_clock();
379        let mut chunk =
380            Chunk::new(dev_scope(), ulid::Ulid::new(), "hello world", 2, 0, vec![], &clock);
381        let parent = ulid::Ulid::new();
382        chunk.parent_id = Some(parent);
383        let json = serde_json::to_string(&chunk).unwrap();
384        let back: Chunk = serde_json::from_str(&json).expect("must deserialize with parent_id");
385        assert_eq!(back.parent_id, Some(parent), "parent_id must be Some when present in JSON");
386    }
387
388    #[test]
389    fn chunk_new_has_none_parent_id() {
390        let clock = test_clock();
391        let chunk = Chunk::new(dev_scope(), ulid::Ulid::new(), "test text", 3, 0, vec![], &clock);
392        assert!(chunk.parent_id.is_none(), "Chunk::new must produce parent_id = None");
393    }
394
395    #[test]
396    fn chunk_with_parent_id_roundtrips_serde() {
397        let clock = test_clock();
398        let mut chunk = Chunk::new(
399            dev_scope(),
400            ulid::Ulid::new(),
401            "test text",
402            3,
403            0,
404            vec!["Section 1".to_string()],
405            &clock,
406        );
407        let parent = ulid::Ulid::new();
408        chunk.parent_id = Some(parent);
409
410        let json = serde_json::to_string(&chunk).expect("serialize");
411        let back: Chunk = serde_json::from_str(&json).expect("deserialize");
412        assert_eq!(back.parent_id, Some(parent));
413        assert_eq!(back.text, chunk.text);
414    }
415}