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}