lunaris_extract/validator.rs
1//! Episode-local Validator (D-08). Catches three classes of failure:
2//!
3//! 1. **Bi-temporal sanity** — `valid_from >= valid_to`
4//! 2. **Structural contradictions within an Episode** — same `(subject_id,
5//! predicate)` with overlapping `[valid_from, valid_to]` and conflicting
6//! `object_id`s
7//! 3. **GBNF post-validation** — parsed entity / relation / fact must satisfy
8//! the schema (non-empty name, non-empty entity_type / predicate, confidence
9//! in `[0, 1]`)
10//!
11//! Plus a fourth class that's the result of upstream backend failure rather
12//! than schema invalidity:
13//!
14//! 4. **Transient-after-retry** — the cloud-api backend (D-21) emits a sentinel
15//! `Entity { entity_type: "__lunaris_sentinel__", name:
16//! "__transient_after_retry__", valid_from_iso: "transient: <error>" }` when
17//! the single retry exhausts; the validator detects this sentinel by
18//! `entity_type` and routes it to
19//! [`NeedsReviewReason::TransientAfterRetry`] carrying the wrapped error
20//! text. The sentinel NEVER lands in [`ValidatedExtraction::entities`].
21//!
22//! Cross-Episode contradictions (e.g., two Episodes claim different birth
23//! years for Alice) are deferred to the Phase 4 Verifier per D-09 — out of
24//! scope for v0. The validator IS Episode-local but works across all chunks
25//! in a [`crate::RawExtractionBatch`].
26//!
27//! ## NeedsReview never silently dropped
28//!
29//! Per EXTRACT-05 + ROADMAP success criteria #2: invalid items are routed to
30//! [`ValidatedExtraction::needs_review`] with a structured reason — there is
31//! no "skip validator" call site for the extractor backends. Plan 03-03's
32//! ingest fan-out reads ONLY `validated.entities/relations/facts` for
33//! WriteOps; needs_review items emit a `__lunaris_verify__` MQ message via
34//! `StoragePort::publish` so the Phase 4 Verifier worker can pick them up
35//! later (D-19 Phase 4 hook).
36
37#![allow(clippy::too_many_lines)]
38
39use std::collections::{HashMap, HashSet};
40
41use serde::{Deserialize, Serialize};
42use thiserror::Error;
43use ulid::Ulid;
44
45use crate::types::{Entity, EntityId, ExtractionBatch, Fact, RawExtractionBatch, Relation};
46
47/// Sentinel reserved for the cloud-api retry-exhaust path (D-21 + W-1 fix).
48/// The cloud-api backend emits an [`Entity`] with this `entity_type` and the
49/// reserved `name` below to communicate "transient failure after 1 retry" up
50/// to the validator without altering the trait return type. The validator
51/// detects the pair and routes to [`NeedsReviewReason::TransientAfterRetry`].
52pub const TRANSIENT_SENTINEL_TYPE: &str = "__lunaris_sentinel__";
53/// See [`TRANSIENT_SENTINEL_TYPE`].
54pub const TRANSIENT_SENTINEL_NAME: &str = "__transient_after_retry__";
55
56/// Structured reason explaining why an extraction was routed to
57/// [`ValidatedExtraction::needs_review`]. Each variant maps onto a
58/// blueprint §5.2 / D-08 / D-21 NeedsReview class.
59///
60/// Implements [`std::error::Error`] via `thiserror` so callers can format the
61/// reason directly into a `tracing::warn!` event.
62#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Error)]
63pub enum NeedsReviewReason {
64 /// `valid_from >= valid_to` for an [`Entity`] / [`Relation`] / [`Fact`].
65 #[error("invalid bi-temporal interval: valid_from {valid_from} >= valid_to {valid_to:?}")]
66 InvalidBitemporal { valid_from: String, valid_to: Option<String> },
67
68 /// Two or more [`Relation`]s on the same `(subject_id, predicate)` with
69 /// overlapping `[valid_from, valid_to]` and conflicting `object_id`s.
70 /// Per D-08 the validator demotes ALL conflicting entries (can't pick a
71 /// winner without the Phase 4 Verifier).
72 #[error(
73 "structural contradiction: ({subject}, {predicate}) has overlapping objects {conflict:?}"
74 )]
75 StructuralContradiction { subject: EntityId, predicate: String, conflict: Vec<EntityId> },
76
77 /// Parsed extraction violates the GBNF schema (empty name, empty
78 /// predicate, empty fact_text, confidence outside `[0, 1]`).
79 #[error("GBNF schema failure at {schema_path}: {error}")]
80 GbnfFailure { schema_path: String, error: String },
81
82 /// Cloud-API extractor exhausted its single retry budget per D-21.
83 /// Carries the wrapped error text so the Phase 4 Verifier worker can
84 /// triage which provider+model failed.
85 #[error("extractor transient failure after 1 retry: {0}")]
86 TransientAfterRetry(String),
87
88 /// A newly-ingested fact asserts a DIFFERENT object for an existing
89 /// `(subject, predicate)` whose validity window OVERLAPS the new one —
90 /// detected at structured ingest by reading the in-scope
91 /// `fact_spo_key` index (memory-update convergence). Unlike
92 /// [`Self::StructuralContradiction`] (single-Episode, demote-both) this is
93 /// CROSS-Episode and carries BOTH fact ids so the Phase 4 Verifier can
94 /// arbitrate a winner/loser via `cross_episode_decision` → `apply_supersede`
95 /// (latest-assertion-wins), closing the loser's bi-temporal interval.
96 #[error(
97 "cross-episode contradiction: ({subject}, {predicate}) existing object {existing_object} \
98 (fact {existing_fact_id}) vs new object {new_object} (fact {new_fact_id})"
99 )]
100 CrossEpisodeContradiction {
101 subject: EntityId,
102 predicate: String,
103 existing_fact_id: Ulid,
104 existing_object: EntityId,
105 new_fact_id: Ulid,
106 new_object: EntityId,
107 },
108}
109
110/// One failed extraction with the reason it was demoted. Preserves the raw
111/// item so the Phase 4 Verifier (Plan 04-XX) can re-extract / arbitrate.
112#[derive(Clone, Debug, PartialEq)]
113pub enum NeedsReviewItem {
114 Entity { reason: NeedsReviewReason, raw: Entity },
115 Relation { reason: NeedsReviewReason, raw: Relation },
116 Fact { reason: NeedsReviewReason, raw: Fact },
117}
118
119/// What [`validate`] produces. The `entities`/`relations`/`facts` lists hold
120/// items that passed validation and are safe to write to storage; the
121/// `needs_review` list holds items that failed with a structured reason so the
122/// ingest fan-out can publish them to the verify queue (D-19).
123#[derive(Clone, Debug, Default, PartialEq)]
124pub struct ValidatedExtraction {
125 pub entities: Vec<Entity>,
126 pub relations: Vec<Relation>,
127 pub facts: Vec<Fact>,
128 pub needs_review: Vec<NeedsReviewItem>,
129}
130
131/// Validate an Episode-local extraction batch. Routes invalid items to
132/// `needs_review` with a structured reason; never drops silently.
133///
134/// See module rustdoc for the four classes of failure handled.
135pub fn validate(batch: RawExtractionBatch) -> ValidatedExtraction {
136 let mut out = ValidatedExtraction::default();
137
138 // (object_id, (valid_from, valid_to)) — one entry per relation in a
139 // (subject_id, predicate) bucket. The interval bound is kept as Strings
140 // so the lex-sort comparison matches the bi-temporal sanity path above.
141 type RelationInterval = (EntityId, (String, Option<String>));
142
143 // (subject_id, predicate) → Vec<RelationInterval>. Built during the
144 // relation walk; used after the per-chunk loop to detect structural
145 // contradictions WITHIN this Episode.
146 let mut bucket: HashMap<(EntityId, String), Vec<RelationInterval>> = HashMap::new();
147
148 for chunk in batch.by_chunk {
149 // ---------- Entities ----------
150 for e in chunk.entities {
151 // (4) Sentinel detection — D-21 cloud-api retry-exhaust marker.
152 // Route to TransientAfterRetry with the wrapped error text from
153 // valid_from_iso. The sentinel is checked BEFORE the bi-temporal
154 // / GBNF checks so it never lands in `out.entities`.
155 if e.entity_type == TRANSIENT_SENTINEL_TYPE && e.name == TRANSIENT_SENTINEL_NAME {
156 let err_text = e
157 .valid_from_iso
158 .strip_prefix("transient: ")
159 .map(str::to_string)
160 .unwrap_or_else(|| e.valid_from_iso.clone());
161 out.needs_review.push(NeedsReviewItem::Entity {
162 reason: NeedsReviewReason::TransientAfterRetry(err_text),
163 raw: e,
164 });
165 continue;
166 }
167
168 // (1) Bi-temporal sanity. RFC3339 with the same offset (`Z`) lex-
169 // sorts correctly; same-offset is the convention emitted by the
170 // GBNF grammar (entities.gbnf valid_from_iso/valid_to_iso fields).
171 if let Some(vt) = &e.valid_to_iso
172 && &e.valid_from_iso >= vt
173 {
174 out.needs_review.push(NeedsReviewItem::Entity {
175 reason: NeedsReviewReason::InvalidBitemporal {
176 valid_from: e.valid_from_iso.clone(),
177 valid_to: e.valid_to_iso.clone(),
178 },
179 raw: e,
180 });
181 continue;
182 }
183
184 // (3) GBNF post-validation. Empty name / empty type / out-of-range
185 // confidence are all schema violations — even if grammar-constrained
186 // sampling was used, the post-hoc check catches the case where
187 // the GBNF binding silently failed (D-05 fallback path).
188 if e.name.is_empty() || e.entity_type.is_empty() || !(0.0..=1.0).contains(&e.confidence)
189 {
190 let err = format!(
191 "invalid name/type/confidence: name={:?} type={:?} conf={}",
192 e.name, e.entity_type, e.confidence
193 );
194 out.needs_review.push(NeedsReviewItem::Entity {
195 reason: NeedsReviewReason::GbnfFailure {
196 schema_path: "entities.gbnf".into(),
197 error: err,
198 },
199 raw: e,
200 });
201 continue;
202 }
203
204 out.entities.push(e);
205 }
206
207 // ---------- Relations ----------
208 for r in chunk.relations {
209 // (1) Bi-temporal sanity
210 if let Some(vt) = &r.valid_to_iso
211 && &r.valid_from_iso >= vt
212 {
213 out.needs_review.push(NeedsReviewItem::Relation {
214 reason: NeedsReviewReason::InvalidBitemporal {
215 valid_from: r.valid_from_iso.clone(),
216 valid_to: r.valid_to_iso.clone(),
217 },
218 raw: r,
219 });
220 continue;
221 }
222
223 // (3) GBNF post-validation
224 if r.predicate.is_empty() || !(0.0..=1.0).contains(&r.confidence) {
225 let err = format!(
226 "invalid predicate/confidence: pred={:?} conf={}",
227 r.predicate, r.confidence
228 );
229 out.needs_review.push(NeedsReviewItem::Relation {
230 reason: NeedsReviewReason::GbnfFailure {
231 schema_path: "relations.gbnf".into(),
232 error: err,
233 },
234 raw: r,
235 });
236 continue;
237 }
238
239 // Track for episode-local contradiction detection (D-08 #2).
240 // We push BEFORE landing in `out.relations` so the bucket sees the
241 // full set; the demotion pass below moves any conflicting entries
242 // out of `out.relations` and into `needs_review`.
243 bucket
244 .entry((r.subject_id, r.predicate.clone()))
245 .or_default()
246 .push((r.object_id, (r.valid_from_iso.clone(), r.valid_to_iso.clone())));
247
248 out.relations.push(r);
249 }
250
251 // ---------- Facts ----------
252 for f in chunk.facts {
253 // (1) Bi-temporal sanity
254 if let Some(vt) = &f.valid_to_iso
255 && &f.valid_from_iso >= vt
256 {
257 out.needs_review.push(NeedsReviewItem::Fact {
258 reason: NeedsReviewReason::InvalidBitemporal {
259 valid_from: f.valid_from_iso.clone(),
260 valid_to: f.valid_to_iso.clone(),
261 },
262 raw: f,
263 });
264 continue;
265 }
266
267 // (3) GBNF post-validation. Facts ALSO require non-empty fact_text
268 // — an empty fact body is useless to retrieval.
269 if f.predicate.is_empty()
270 || f.fact_text.is_empty()
271 || !(0.0..=1.0).contains(&f.confidence)
272 {
273 let err = format!(
274 "invalid predicate/fact_text/confidence: pred={:?} fact_text_len={} conf={}",
275 f.predicate,
276 f.fact_text.len(),
277 f.confidence
278 );
279 out.needs_review.push(NeedsReviewItem::Fact {
280 reason: NeedsReviewReason::GbnfFailure {
281 schema_path: "relations.gbnf".into(),
282 error: err,
283 },
284 raw: f,
285 });
286 continue;
287 }
288
289 out.facts.push(f);
290 }
291 }
292
293 // ---------- (2) Episode-local structural contradiction detection ----------
294 //
295 // For each (subject_id, predicate) bucket, check whether any two entries
296 // have OVERLAPPING [valid_from, valid_to] AND DIFFERENT object_ids. If so,
297 // demote ALL entries in the bucket to `needs_review` with the
298 // StructuralContradiction reason (per-bucket all-or-nothing because we
299 // can't pick a winner without the Phase 4 Verifier; D-09).
300 let mut to_demote: HashSet<(EntityId, String)> = HashSet::new();
301 for ((subj, pred), entries) in &bucket {
302 if entries.len() < 2 {
303 continue;
304 }
305 // Pairwise overlap check — O(n^2) per bucket. Episode-local buckets
306 // are tiny (typical chunk yields < 10 relations on the same
307 // subject/predicate), so the quadratic cost is fine.
308 let mut found_conflict = false;
309 for i in 0..entries.len() {
310 if found_conflict {
311 break;
312 }
313 for j in (i + 1)..entries.len() {
314 let (obj_i, (vf_i, vt_i)) = &entries[i];
315 let (obj_j, (vf_j, vt_j)) = &entries[j];
316 if obj_i == obj_j {
317 // Same object — restating, not contradicting.
318 continue;
319 }
320 // Overlap if max(vf_i, vf_j) < min(vt_i, vt_j); treat None as +infinity.
321 let later_start = std::cmp::max(vf_i, vf_j);
322 let earlier_end_opt: Option<&String> = match (vt_i, vt_j) {
323 (Some(a), Some(b)) => Some(std::cmp::min(a, b)),
324 (Some(a), None) => Some(a),
325 (None, Some(b)) => Some(b),
326 (None, None) => None,
327 };
328 let overlaps = match earlier_end_opt {
329 Some(end) => later_start < end,
330 None => true,
331 };
332 if overlaps {
333 to_demote.insert((*subj, pred.clone()));
334 found_conflict = true;
335 break;
336 }
337 }
338 }
339 }
340
341 if !to_demote.is_empty() {
342 let kept = std::mem::take(&mut out.relations);
343 for r in kept {
344 let key = (r.subject_id, r.predicate.clone());
345 if to_demote.contains(&key) {
346 let conflict: Vec<EntityId> = bucket
347 .get(&key)
348 .map(|v| v.iter().map(|(o, _)| *o).collect())
349 .unwrap_or_default();
350 out.needs_review.push(NeedsReviewItem::Relation {
351 reason: NeedsReviewReason::StructuralContradiction {
352 subject: r.subject_id,
353 predicate: r.predicate.clone(),
354 conflict,
355 },
356 raw: r,
357 });
358 } else {
359 out.relations.push(r);
360 }
361 }
362 }
363
364 // ---------- KG-RAG Wave D (2026-07-21): duplicate-triple canonicalization
365 //
366 // The same assertion extracted from adjacent chunks (or restated in one
367 // session) otherwise lands as N fact rows + N vector entries and crowds
368 // the RRF fusion window with copies. Collapse survivors that agree on the
369 // FULL identity key — (subject, predicate, object, valid_from, valid_to) —
370 // keeping the MAX confidence at the first-seen position. Differing
371 // validity intervals are distinct temporal assertions and both survive.
372 // This is canonicalization of redundancy, NOT validation rejection:
373 // EXTRACT-05's needs_review routing for invalid items is untouched.
374 dedup_by_key(&mut out.facts, |f| {
375 (
376 f.subject_id,
377 f.predicate.clone(),
378 f.object_id,
379 f.valid_from_iso.clone(),
380 f.valid_to_iso.clone(),
381 )
382 });
383 dedup_by_key(&mut out.relations, |r| {
384 (
385 r.subject_id,
386 r.predicate.clone(),
387 r.object_id,
388 r.valid_from_iso.clone(),
389 r.valid_to_iso.clone(),
390 )
391 });
392
393 out
394}
395
396/// Collapse items sharing an identity key to one survivor at the first-seen
397/// position, carrying the maximum confidence seen for that key. Order of
398/// distinct keys is preserved (deterministic output for identical input).
399fn dedup_by_key<T, K, F>(items: &mut Vec<T>, key_of: F)
400where
401 K: std::hash::Hash + Eq,
402 F: Fn(&T) -> K,
403 T: HasConfidence,
404{
405 use std::collections::HashMap;
406 let taken = std::mem::take(items);
407 let mut index_of_key: HashMap<K, usize> = HashMap::new();
408 let mut deduped_count = 0usize;
409 for item in taken {
410 match index_of_key.entry(key_of(&item)) {
411 std::collections::hash_map::Entry::Vacant(e) => {
412 e.insert(items.len());
413 items.push(item);
414 }
415 std::collections::hash_map::Entry::Occupied(e) => {
416 let survivor = &mut items[*e.get()];
417 if item.confidence() > survivor.confidence() {
418 survivor.set_confidence(item.confidence());
419 }
420 deduped_count += 1;
421 }
422 }
423 }
424 if deduped_count > 0 {
425 tracing::debug!(deduped = deduped_count, "validator_dedup_collapsed_duplicates");
426 }
427}
428
429/// Confidence accessor for [`dedup_by_key`]'s max-confidence merge.
430trait HasConfidence {
431 fn confidence(&self) -> f32;
432 fn set_confidence(&mut self, c: f32);
433}
434
435impl HasConfidence for Fact {
436 fn confidence(&self) -> f32 {
437 self.confidence
438 }
439 fn set_confidence(&mut self, c: f32) {
440 self.confidence = c;
441 }
442}
443
444impl HasConfidence for Relation {
445 fn confidence(&self) -> f32 {
446 self.confidence
447 }
448 fn set_confidence(&mut self, c: f32) {
449 self.confidence = c;
450 }
451}
452
453/// Deterministic future-date backstop (Mechanism B, 2026-07-29 LME
454/// diagnosis; see `tmp/sota_extractor_comparison.md` §3 rider 2).
455///
456/// Even with `REFERENCE_TIME` in the extraction prompt, a model can still
457/// stamp its own "today" (the observed 2025/2026 hallucination class — 78%
458/// of a 4,882-item cache audit). This pass mechanically caps any
459/// `valid_from_iso` whose DATE PART (first 10 chars, `YYYY-MM-DD`) is later
460/// than `reference_date` back to `reference_date` — semantically "known
461/// true as of this conversation", which is exactly what a present-tense
462/// hallucinated stamp meant.
463///
464/// Left untouched: items with an explicit `valid_to_iso` (a stated, bounded
465/// future plan), same-day full timestamps (date-part comparison, never
466/// lexicographic on the whole string), past dates, and empty/garbage values
467/// that don't parse as a 10-char date prefix (the validator's sanity pass
468/// owns those).
469///
470/// Call at the ingest boundary AFTER extraction (and therefore after any
471/// extraction-cache replay) so hallucinated dates in already-cached raw
472/// extractions are capped too.
473pub fn cap_future_valid_from(batch: &mut RawExtractionBatch, reference_date: &str) {
474 let ref_date = reference_date.get(..10).unwrap_or(reference_date);
475
476 fn cap_one(valid_from: &mut String, valid_to: &Option<String>, ref_date: &str) {
477 if valid_to.as_deref().is_some_and(|t| !t.is_empty()) {
478 return;
479 }
480 let Some(date_part) = valid_from.get(..10) else {
481 return;
482 };
483 let looks_like_date = date_part.as_bytes()[4] == b'-'
484 && date_part.as_bytes()[7] == b'-'
485 && date_part[..4].bytes().all(|b| b.is_ascii_digit());
486 if looks_like_date && date_part > ref_date {
487 *valid_from = ref_date.to_owned();
488 }
489 }
490
491 for raw in &mut batch.by_chunk {
492 for e in &mut raw.entities {
493 cap_one(&mut e.valid_from_iso, &e.valid_to_iso, ref_date);
494 }
495 for r in &mut raw.relations {
496 cap_one(&mut r.valid_from_iso, &r.valid_to_iso, ref_date);
497 }
498 for f in &mut raw.facts {
499 cap_one(&mut f.valid_from_iso, &f.valid_to_iso, ref_date);
500 }
501 }
502}
503
504/// Project a [`ValidatedExtraction`] into a flat [`ExtractionBatch`] (the shape
505/// the Plan 03-03 ingest fan-out converts into `WriteOp`s). `needs_review` is
506/// dropped by this conversion — the caller is expected to publish those items
507/// to `__lunaris_verify__` separately per D-19.
508pub fn into_batch(v: &ValidatedExtraction) -> ExtractionBatch {
509 ExtractionBatch {
510 entities: v.entities.clone(),
511 relations: v.relations.clone(),
512 facts: v.facts.clone(),
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 //! Smoke tests live here; the full table-driven Validator suite is in
519 //! `tests/validator.rs` (Task 3 of Plan 03-01).
520 use super::*;
521 use crate::types::RawExtraction;
522 use ulid::Ulid;
523
524 #[test]
525 fn empty_batch_produces_empty_validated() {
526 let v = validate(RawExtractionBatch::default());
527 assert!(v.entities.is_empty());
528 assert!(v.relations.is_empty());
529 assert!(v.facts.is_empty());
530 assert!(v.needs_review.is_empty());
531 }
532
533 // ── Deterministic future-date backstop (Mechanism B, 2026-07-29) ──
534 //
535 // Even with REFERENCE_TIME in the prompt, a model can still stamp its
536 // own "today" (the observed 2025/2026 class, 78% of the audited cache).
537 // The backstop mechanically caps any valid_from LATER than the episode's
538 // reference date back to the reference date — semantically "known true
539 // as of this conversation", which is exactly what a present-tense
540 // hallucinated stamp meant. Items with an explicit valid_to are left
541 // alone (a stated, bounded future plan). Applied at the ingest boundary
542 // (AFTER cache replay, so hallucinated dates in already-cached raw
543 // extractions get capped too).
544
545 fn raw_with_dates(from: &str, to: Option<&str>) -> RawExtractionBatch {
546 let e = Entity {
547 id: EntityId::from_name_and_type("Alice", "Person"),
548 name: "Alice".into(),
549 aliases: vec![],
550 entity_type: "Person".into(),
551 confidence: 0.9,
552 valid_from_iso: from.into(),
553 valid_to_iso: to.map(str::to_owned),
554 };
555 let r = Relation {
556 subject_id: EntityId::from_name_and_type("Alice", "Person"),
557 predicate: "works_at".into(),
558 object_id: EntityId::from_name_and_type("Acme", "Org"),
559 confidence: 0.9,
560 valid_from_iso: from.into(),
561 valid_to_iso: to.map(str::to_owned),
562 };
563 RawExtractionBatch {
564 by_chunk: vec![RawExtraction {
565 source_chunk_id: Ulid::new(),
566 entities: vec![e],
567 relations: vec![r],
568 facts: vec![],
569 }],
570 }
571 }
572
573 #[test]
574 fn future_valid_from_capped_to_reference_date() {
575 // The observed hallucination class: model stamps its own today.
576 let mut batch = raw_with_dates("2025-01-30", None);
577 cap_future_valid_from(&mut batch, "2023-05-30");
578 let raw = &batch.by_chunk[0];
579 assert_eq!(raw.entities[0].valid_from_iso, "2023-05-30");
580 assert_eq!(raw.relations[0].valid_from_iso, "2023-05-30");
581 }
582
583 #[test]
584 fn past_and_same_day_valid_from_untouched() {
585 let mut batch = raw_with_dates("2022-11-02", None);
586 cap_future_valid_from(&mut batch, "2023-05-30");
587 assert_eq!(batch.by_chunk[0].entities[0].valid_from_iso, "2022-11-02");
588
589 // Same-day full RFC3339 timestamp must NOT count as future (date-part
590 // comparison, not lexicographic on the whole string).
591 let mut batch = raw_with_dates("2023-05-30T23:40:00Z", None);
592 cap_future_valid_from(&mut batch, "2023-05-30");
593 assert_eq!(batch.by_chunk[0].entities[0].valid_from_iso, "2023-05-30T23:40:00Z");
594 }
595
596 #[test]
597 fn explicit_future_plan_with_valid_to_untouched() {
598 let mut batch = raw_with_dates("2023-08-01", Some("2023-09-01"));
599 cap_future_valid_from(&mut batch, "2023-05-30");
600 assert_eq!(
601 batch.by_chunk[0].entities[0].valid_from_iso, "2023-08-01",
602 "a bounded, explicitly stated future plan must survive the cap"
603 );
604 }
605
606 #[test]
607 fn empty_and_garbage_valid_from_untouched() {
608 let mut batch = raw_with_dates("", None);
609 cap_future_valid_from(&mut batch, "2023-05-30");
610 assert_eq!(batch.by_chunk[0].entities[0].valid_from_iso, "");
611
612 // Non-date junk (shorter than a date, non-numeric) is the
613 // validator's problem, not the cap's — leave it for the sanity pass.
614 let mut batch = raw_with_dates("unknown", None);
615 cap_future_valid_from(&mut batch, "2023-05-30");
616 assert_eq!(batch.by_chunk[0].entities[0].valid_from_iso, "unknown");
617 }
618
619 #[test]
620 fn into_batch_drops_needs_review() {
621 let v = ValidatedExtraction {
622 entities: vec![],
623 relations: vec![],
624 facts: vec![],
625 needs_review: vec![NeedsReviewItem::Entity {
626 reason: NeedsReviewReason::TransientAfterRetry("HTTP 503".into()),
627 raw: Entity {
628 id: EntityId::from_name_and_type(
629 TRANSIENT_SENTINEL_NAME,
630 TRANSIENT_SENTINEL_TYPE,
631 ),
632 name: TRANSIENT_SENTINEL_NAME.into(),
633 aliases: vec![],
634 entity_type: TRANSIENT_SENTINEL_TYPE.into(),
635 confidence: 0.0,
636 valid_from_iso: "transient: HTTP 503".into(),
637 valid_to_iso: None,
638 },
639 }],
640 };
641 let b = into_batch(&v);
642 assert!(b.entities.is_empty());
643 assert!(b.relations.is_empty());
644 assert!(b.facts.is_empty());
645 }
646
647 #[test]
648 fn happy_path_single_entity_passes() {
649 let entity = Entity {
650 id: EntityId::from_name_and_type("Alice", "Person"),
651 name: "Alice".into(),
652 aliases: vec![],
653 entity_type: "Person".into(),
654 confidence: 0.9,
655 valid_from_iso: "2024-01-01T00:00:00Z".into(),
656 valid_to_iso: None,
657 };
658 let batch = RawExtractionBatch {
659 by_chunk: vec![RawExtraction {
660 source_chunk_id: Ulid::new(),
661 entities: vec![entity.clone()],
662 relations: vec![],
663 facts: vec![],
664 }],
665 };
666 let v = validate(batch);
667 assert_eq!(v.entities, vec![entity]);
668 assert!(v.needs_review.is_empty());
669 }
670}