Skip to main content

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}