lunaris_extract/types.rs
1//! Extractor DTOs — [`EntityId`] content-hash + [`Entity`] / [`Relation`] /
2//! [`Fact`] / [`ChunkInput`] / [`RawExtraction`] / [`RawExtractionBatch`] /
3//! [`ExtractionBatch`].
4//!
5//! These are the extractor-side DTOs that flow through
6//! [`crate::Extractor::extract`] → [`crate::validator::validate`] → Plan 03-03
7//! ingest fan-out. They are intentionally distinct from
8//! [`lunaris_core::Entity`] / [`lunaris_core::Relation`] / [`lunaris_core::Fact`]
9//! (which use [`Ulid`] ids) because:
10//!
11//! - The extractor produces **content-hash IDs** (D-06: `EntityId =
12//! blake3(canonical_name_normalized || "::" || entity_type)[..16]`) so
13//! re-ingest of the same entity yields the same id without a round trip.
14//! - Plan 03-03 maps `EntityId` → `Ulid` at the WriteOp boundary via
15//! `Ulid::from_bytes(entity_id.0)` (16 bytes ↔ 16 bytes is a clean cast).
16//!
17//! Keeping the two type families separate makes the conversion site explicit
18//! and lets the extractor evolve its DTO shape without churning the locked
19//! Phase 1 primitives.
20
21use std::convert::TryInto;
22
23use serde::{Deserialize, Serialize};
24use ulid::Ulid;
25
26/// Deterministic 16-byte content-hash identifier per D-06.
27///
28/// ```text
29/// EntityId = blake3(canonical_name_normalized || "::" || entity_type)[..16]
30/// ```
31///
32/// Where `canonical_name_normalized` is:
33/// 1. lowercase
34/// 2. trim leading + trailing whitespace
35/// 3. collapse internal runs of whitespace to a single space
36/// 4. drop trailing punctuation `.` `,` `;` `:` `!` `?`
37///
38/// 16 bytes = 128 bits of entropy — collision probability ≈ 2^-64 for ~1B
39/// entities (birthday bound). Cross-Episode disambiguation (e.g., "Alice" in
40/// Episode A vs "Alice Smith" in Episode B referring to the same person) is
41/// D-09 deferred to the Phase 4 Verifier; v0 keeps them as distinct EntityIds
42/// — the conservative default per blueprint §5.2 "graph default-off".
43///
44/// `Display` renders the lowercase hex; useful for tracing logs and Cypher
45/// `WHERE n.id = '...'` literals.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
47pub struct EntityId(pub [u8; 16]);
48
49impl EntityId {
50 /// Derive a deterministic [`EntityId`] from `(name, entity_type)`.
51 ///
52 /// See struct rustdoc for the canonicalization steps. The output is byte-
53 /// identical across calls for the same input (proven by
54 /// `entity_id_is_deterministic` test) and treats `("Alice Smith. ",
55 /// "Person")` and `("alice smith", "Person")` as the same id (proven by
56 /// `entity_id_normalizes_whitespace_and_punctuation` test). The
57 /// `entity_type` is appended verbatim (no normalization) so `("Alice",
58 /// "Person")` and `("Alice", "Place")` stay distinct (proven by
59 /// `entity_id_distinguishes_by_type` test).
60 pub fn from_name_and_type(name: &str, entity_type: &str) -> Self {
61 let normalized = canonicalize_name(name);
62 let key = format!("{normalized}::{entity_type}");
63 let h = blake3::hash(key.as_bytes());
64 let bytes: [u8; 16] =
65 h.as_bytes()[..16].try_into().expect("blake3 output is 32 bytes; first 16 always fit");
66 EntityId(bytes)
67 }
68
69 /// Decode an [`EntityId`] from its 32-char lowercase-hex `Display` form.
70 ///
71 /// Returns `None` if `s` is not exactly 32 ASCII hex chars. This is the
72 /// inverse of `Display` and is the round-trip path used by `Graph::anchored`
73 /// to map the Cypher-emitted `source_entity_id` (rendered with the same
74 /// `format!("{}", id)` shape on the write path) back to the seed key
75 /// for confidence lookups.
76 pub fn from_hex(s: &str) -> Option<Self> {
77 if s.len() != 32 {
78 return None;
79 }
80 let mut bytes = [0u8; 16];
81 hex::decode_to_slice(s, &mut bytes).ok()?;
82 Some(EntityId(bytes))
83 }
84}
85
86impl std::fmt::Display for EntityId {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.write_str(&hex::encode(self.0))
89 }
90}
91
92/// Deterministic 16-byte content-hash identifier for a `(subject, predicate,
93/// object)` fact triple — the **sync-dedup key** for memory-update convergence.
94///
95/// ```text
96/// FactId = blake3(subject_id.0 || 0x1F || predicate || 0x1F || object_id.0)[..16]
97/// ```
98///
99/// `0x1F` (ASCII Unit Separator) delimits the three fields so a predicate that
100/// happens to contain the byte sequence of an adjacent id cannot byte-alias
101/// into a neighbouring triple's key. Mirrors [`EntityId::from_name_and_type`]:
102/// re-asserting the identical triple yields the same `FactId`, so the
103/// structured-ingest write path can mint a stable `Ulid::from_bytes(fact_id.0)`
104/// and an exact re-ingest overwrites the same row in place (idempotent NOOP)
105/// rather than accruing a duplicate fact. A different object OR predicate yields
106/// a different id (a value change is NOT a dup) — proven by
107/// `factid_from_triple_is_deterministic_and_distinct`.
108///
109/// 16 bytes ↔ `Ulid` (both 128-bit) is a clean cast at the WriteOp boundary,
110/// same as the `EntityId` → `Ulid` mapping.
111#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
112pub struct FactId(pub [u8; 16]);
113
114impl FactId {
115 /// Derive the deterministic [`FactId`] for a `(subject_id, predicate,
116 /// object_id)` triple. See the struct rustdoc for the formula and rationale.
117 pub fn from_triple(subject_id: EntityId, predicate: &str, object_id: EntityId) -> Self {
118 let mut hasher = blake3::Hasher::new();
119 hasher.update(&subject_id.0);
120 hasher.update(&[0x1F]);
121 hasher.update(predicate.as_bytes());
122 hasher.update(&[0x1F]);
123 hasher.update(&object_id.0);
124 let h = hasher.finalize();
125 let bytes: [u8; 16] =
126 h.as_bytes()[..16].try_into().expect("blake3 output is 32 bytes; first 16 always fit");
127 FactId(bytes)
128 }
129}
130
131impl std::fmt::Display for FactId {
132 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 f.write_str(&hex::encode(self.0))
134 }
135}
136
137/// Canonicalize an entity name per D-06:
138/// lowercase + trim + collapse internal whitespace + drop trailing punctuation.
139///
140/// Pulled out of [`EntityId::from_name_and_type`] for unit-test clarity.
141fn canonicalize_name(raw: &str) -> String {
142 // 1. lowercase + 2. trim leading/trailing whitespace
143 let lower = raw.to_lowercase();
144 let trimmed = lower.trim();
145 // 3. collapse internal whitespace runs to single space
146 let mut out = String::with_capacity(trimmed.len());
147 let mut prev_was_ws = false;
148 for ch in trimmed.chars() {
149 if ch.is_whitespace() {
150 if !prev_was_ws {
151 out.push(' ');
152 prev_was_ws = true;
153 }
154 } else {
155 out.push(ch);
156 prev_was_ws = false;
157 }
158 }
159 // 4. drop trailing punctuation (D-06 spec: `.` `,` `;` `:` `!` `?`)
160 while let Some(last) = out.chars().last() {
161 if matches!(last, '.' | ',' | ';' | ':' | '!' | '?') {
162 out.pop();
163 } else {
164 break;
165 }
166 }
167 // re-trim in case punctuation drop exposed trailing whitespace
168 out.trim_end().to_string()
169}
170
171// ----------------------------- Chunk input -----------------------------------
172
173/// What [`crate::Extractor::extract`] consumes. The Plan 03-03 ingest fan-out
174/// builds these from `lunaris_core::Chunk` after the markdown chunker runs.
175///
176/// `chunk_id` is the deterministic chunk Ulid from Plan 02-01; it flows back
177/// out as [`RawExtraction::source_chunk_id`] so the validator + ingest fan-out
178/// can route extractions back to their source chunk for provenance.
179///
180/// `heading_path` is preserved from the chunker output so the prompt can
181/// surface the document section to the model (improves entity disambiguation
182/// when the same name means different things in different sections).
183#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
184pub struct ChunkInput {
185 pub chunk_id: Ulid,
186 pub text: String,
187 #[serde(default)]
188 pub heading_path: Vec<String>,
189 /// Date-only ISO reference time (`YYYY-MM-DD`) of the episode this chunk
190 /// came from (`Episode::t_ref`), when known. Rendered by `build_prompt`
191 /// as a `REFERENCE_TIME:` line so the model can ground `valid_from_iso`/
192 /// `valid_to_iso` and resolve relative expressions ("yesterday", "last
193 /// week") — Mechanism B of the 2026-07-29 LME diagnosis: without it, 78%
194 /// of extracted dates were the model's own hallucinated "today"
195 /// (Graphiti-style fix; see tmp/sota_extractor_comparison.md §3).
196 ///
197 /// NOTE: this participates in `build_prompt` and therefore in
198 /// `CachedExtractor`'s content-addressed key — changing an episode's
199 /// reference time correctly invalidates its cached extractions.
200 #[serde(default)]
201 pub reference_time_iso: Option<String>,
202}
203
204// ------------------------------- Entity --------------------------------------
205
206/// Extractor-side entity DTO. Carries the deterministic content-hash
207/// [`EntityId`] (D-06) instead of the [`Ulid`] used by [`lunaris_core::Entity`].
208///
209/// Plan 03-03 maps `EntityId` → `Ulid` at the WriteOp boundary via
210/// `Ulid::from_bytes(entity_id.0)`. The 16-byte ↔ 16-byte cast is lossless.
211///
212/// `valid_from_iso` / `valid_to_iso` are RFC3339 strings — the validator's
213/// bi-temporal sanity check uses lexicographic comparison which is correct
214/// when both timestamps share the same UTC offset (as RFC3339 produces by
215/// convention with `Z`).
216#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
217pub struct Entity {
218 pub id: EntityId,
219 pub name: String,
220 #[serde(default)]
221 pub aliases: Vec<String>,
222 pub entity_type: String,
223 pub confidence: f32,
224 pub valid_from_iso: String,
225 #[serde(default)]
226 pub valid_to_iso: Option<String>,
227}
228
229// ------------------------------ Relation -------------------------------------
230
231/// Extractor-side relation DTO. Per D-07 relations reference [`EntityId`]s
232/// directly — no separate id-resolution pass.
233#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
234pub struct Relation {
235 pub subject_id: EntityId,
236 pub predicate: String,
237 pub object_id: EntityId,
238 pub confidence: f32,
239 pub valid_from_iso: String,
240 #[serde(default)]
241 pub valid_to_iso: Option<String>,
242}
243
244// -------------------------------- Fact ---------------------------------------
245
246/// Extractor-side fact DTO. The fact's natural-language text lives alongside
247/// the structured `(subject, predicate, object)` triple so downstream callers
248/// can render the fact verbatim in retrieval results without a round trip.
249#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
250pub struct Fact {
251 pub id: Ulid,
252 pub subject_id: EntityId,
253 pub predicate: String,
254 pub object_id: EntityId,
255 pub fact_text: String,
256 pub confidence: f32,
257 pub valid_from_iso: String,
258 #[serde(default)]
259 pub valid_to_iso: Option<String>,
260}
261
262// ------------------------------- Batches -------------------------------------
263
264/// One chunk's raw extraction before validation. Preserves the source
265/// `chunk_id` so the Plan 03-03 fan-out can attach provenance per-extraction.
266#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
267pub struct RawExtraction {
268 pub source_chunk_id: Ulid,
269 #[serde(default)]
270 pub entities: Vec<Entity>,
271 #[serde(default)]
272 pub relations: Vec<Relation>,
273 #[serde(default)]
274 pub facts: Vec<Fact>,
275}
276
277/// What [`crate::Extractor::extract`] returns. Holds one [`RawExtraction`] per
278/// input chunk, in input order, so the caller can correlate by index when
279/// needed.
280#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
281pub struct RawExtractionBatch {
282 pub by_chunk: Vec<RawExtraction>,
283}
284
285/// Flattened, post-validation extraction. The Plan 03-03 ingest fan-out
286/// converts a [`crate::ValidatedExtraction`] into this shape via
287/// [`crate::into_batch`] for direct consumption by the WriteOp builder.
288#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
289pub struct ExtractionBatch {
290 pub entities: Vec<Entity>,
291 pub relations: Vec<Relation>,
292 pub facts: Vec<Fact>,
293}
294
295// ------------------------------- Tests ---------------------------------------
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn entity_id_is_deterministic() {
303 let a = EntityId::from_name_and_type("Alice Smith", "Person");
304 let b = EntityId::from_name_and_type("Alice Smith", "Person");
305 assert_eq!(a, b, "same input MUST produce byte-identical EntityId");
306 // Display roundtrip — hex encoding stable across calls
307 assert_eq!(a.to_string(), b.to_string());
308 assert_eq!(a.to_string().len(), 32, "16 bytes → 32 hex chars");
309 }
310
311 #[test]
312 fn entity_id_normalizes_whitespace_and_punctuation() {
313 // D-06 canonicalization spec:
314 // lowercase + trim + collapse-whitespace + drop-trailing-punct
315 let a = EntityId::from_name_and_type("alice smith", "Person");
316 let b = EntityId::from_name_and_type("Alice Smith. ", "Person");
317 let c = EntityId::from_name_and_type("ALICE\t SMITH!", "Person");
318 let d = EntityId::from_name_and_type(" alice smith ?", "Person");
319 assert_eq!(a, b, "internal-whitespace + trailing-period must canonicalize");
320 assert_eq!(a, c, "tabs + uppercase + trailing-bang must canonicalize");
321 assert_eq!(a, d, "leading/trailing space + trailing-question must canonicalize");
322 }
323
324 #[test]
325 fn entity_id_distinguishes_by_type() {
326 let person = EntityId::from_name_and_type("Alice", "Person");
327 let place = EntityId::from_name_and_type("Alice", "Place");
328 assert_ne!(person, place, "(name, type) pair must matter for id");
329 }
330
331 #[test]
332 fn entity_id_distinguishes_different_names() {
333 let alice = EntityId::from_name_and_type("Alice", "Person");
334 let bob = EntityId::from_name_and_type("Bob", "Person");
335 assert_ne!(alice, bob);
336 }
337
338 #[test]
339 fn entity_id_from_hex_roundtrips_display() {
340 let id = EntityId::from_name_and_type("Alice", "Person");
341 let hex = format!("{}", id);
342 let decoded = EntityId::from_hex(&hex).expect("valid 32-char hex must decode");
343 assert_eq!(id, decoded, "from_hex MUST be the inverse of Display");
344 }
345
346 #[test]
347 fn entity_id_from_hex_rejects_malformed() {
348 // Wrong length (33 chars instead of 32).
349 assert_eq!(EntityId::from_hex(&"a".repeat(33)), None);
350 // Wrong length (31 chars).
351 assert_eq!(EntityId::from_hex(&"a".repeat(31)), None);
352 // Right length, non-hex chars.
353 assert_eq!(EntityId::from_hex(&"z".repeat(32)), None);
354 // Empty string.
355 assert_eq!(EntityId::from_hex(""), None);
356 }
357
358 #[test]
359 fn entity_id_display_is_lowercase_hex() {
360 let id = EntityId([
361 0xAB, 0xCD, 0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
362 0x0C, 0x0D,
363 ]);
364 assert_eq!(id.to_string(), "abcdef0102030405060708090a0b0c0d");
365 }
366
367 #[test]
368 fn canonicalize_handles_empty_and_only_punct() {
369 // Defensive — extractor backends should never emit empty names but
370 // the validator enforces it at the next stage. Here we just prove
371 // canonicalization doesn't panic.
372 assert_eq!(canonicalize_name(""), "");
373 assert_eq!(canonicalize_name(" "), "");
374 assert_eq!(canonicalize_name("!?.,"), "");
375 }
376
377 #[test]
378 fn entity_id_to_ulid_bytes_are_lossless() {
379 // Plan 03-03 maps EntityId → Ulid at the WriteOp boundary via
380 // `Ulid::from_bytes(entity_id.0)`. Prove the round trip is lossless.
381 let id = EntityId::from_name_and_type("Alice", "Person");
382 let as_ulid = Ulid::from_bytes(id.0);
383 let bytes_back: [u8; 16] = as_ulid.to_bytes();
384 assert_eq!(id.0, bytes_back);
385 }
386
387 #[test]
388 fn raw_extraction_serde_roundtrip() {
389 let raw = RawExtraction {
390 source_chunk_id: Ulid::new(),
391 entities: vec![Entity {
392 id: EntityId::from_name_and_type("Alice", "Person"),
393 name: "Alice".into(),
394 aliases: vec!["Al".into()],
395 entity_type: "Person".into(),
396 confidence: 0.92,
397 valid_from_iso: "2024-01-01T00:00:00Z".into(),
398 valid_to_iso: None,
399 }],
400 relations: vec![],
401 facts: vec![],
402 };
403 let json = serde_json::to_string(&raw).unwrap();
404 let parsed: RawExtraction = serde_json::from_str(&json).unwrap();
405 assert_eq!(raw, parsed);
406 }
407}