1use std::sync::Arc;
27use std::time::Duration;
28
29use async_trait::async_trait;
30use lunaris_core::LunarisError;
31use lunaris_llm::{GenOpts, LlmBackend, SchemaConstraint};
32use serde::Deserialize;
33use ulid::Ulid;
34
35use crate::Extractor;
36use crate::types::{
37 ChunkInput, Entity, EntityId, Fact, RawExtraction, RawExtractionBatch, Relation,
38};
39
40#[derive(Clone, Debug)]
44pub struct LlmExtractorOpts {
45 pub batch_timeout_ms: u64,
48 pub per_chunk_timeout_ms: u64,
51 pub max_tokens: u32,
53 pub temperature: f32,
55 pub gbnf: Option<&'static str>,
64}
65
66impl Default for LlmExtractorOpts {
67 fn default() -> Self {
68 Self {
69 batch_timeout_ms: 150,
70 per_chunk_timeout_ms: 450,
71 max_tokens: 512,
72 temperature: 0.0,
73 gbnf: None,
74 }
75 }
76}
77
78#[derive(Clone)]
81pub struct LlmExtractor {
82 backend: Arc<dyn LlmBackend>,
83 opts: LlmExtractorOpts,
84}
85
86impl std::fmt::Debug for LlmExtractor {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.debug_struct("LlmExtractor")
89 .field("model_id", &self.backend.model_id())
90 .field("opts", &self.opts)
91 .finish()
92 }
93}
94
95impl LlmExtractor {
96 pub fn new(backend: Arc<dyn LlmBackend>) -> Self {
97 Self { backend, opts: LlmExtractorOpts::default() }
98 }
99
100 pub fn with_opts(backend: Arc<dyn LlmBackend>, opts: LlmExtractorOpts) -> Self {
101 Self { backend, opts }
102 }
103
104 async fn extract_one(&self, chunk: &ChunkInput) -> RawExtraction {
109 let prompt = build_prompt(chunk);
110 let gen_opts = GenOpts {
111 max_tokens: self.opts.max_tokens,
112 temperature: self.opts.temperature,
113 timeout: Duration::from_millis(self.opts.per_chunk_timeout_ms),
114 };
115 let constraint = match &self.opts.gbnf {
122 Some(g) => SchemaConstraint::Gbnf(g),
123 None => SchemaConstraint::None,
124 };
125 match self.backend.generate(&prompt, constraint, gen_opts).await {
126 Ok(decoded) => parse_extraction_json(&decoded, chunk.chunk_id),
127 Err(e) => {
128 tracing::warn!(
129 err = %e,
130 chunk_id = %chunk.chunk_id,
131 model_id = self.backend.model_id(),
132 "LlmExtractor generate failed; emitting empty extraction"
133 );
134 RawExtraction { source_chunk_id: chunk.chunk_id, ..Default::default() }
135 }
136 }
137 }
138}
139
140#[async_trait]
141impl Extractor for LlmExtractor {
142 async fn extract(
143 &self,
144 _episode_id: Ulid,
145 chunks: &[ChunkInput],
146 ) -> Result<RawExtractionBatch, LunarisError> {
147 if chunks.is_empty() {
148 return Ok(RawExtractionBatch::default());
149 }
150 let batch_timeout = Duration::from_millis(self.opts.batch_timeout_ms);
153 let chunks_owned = chunks.to_vec();
154 let this = self.clone();
155 let batch_fut = async move {
156 let mut by_chunk = Vec::with_capacity(chunks_owned.len());
157 for c in &chunks_owned {
158 by_chunk.push(this.extract_one(c).await);
159 }
160 RawExtractionBatch { by_chunk }
161 };
162 match tokio::time::timeout(batch_timeout, batch_fut).await {
163 Ok(b) => Ok(b),
164 Err(_elapsed) => {
165 tracing::warn!(
166 batch_size = chunks.len(),
167 timeout_ms = self.opts.batch_timeout_ms,
168 model_id = self.backend.model_id(),
169 "LlmExtractor batch timeout; emitting empty per-chunk extractions"
170 );
171 let by_chunk = chunks
172 .iter()
173 .map(|c| RawExtraction { source_chunk_id: c.chunk_id, ..Default::default() })
174 .collect();
175 Ok(RawExtractionBatch { by_chunk })
176 }
177 }
178 }
179
180 fn applies(&self) -> bool {
181 self.backend.applies()
182 }
183}
184
185pub(crate) fn build_prompt(chunk: &ChunkInput) -> String {
190 let reference_block = match chunk.reference_time_iso.as_deref() {
216 Some(d) => format!(
217 "REFERENCE_TIME: {d} (the date the conversation/document in \
218 <chunk> is from)\n\
219 - Resolve relative time expressions against REFERENCE_TIME: \
220 \"yesterday\" = REFERENCE_TIME minus 1 day; \"last week\" = \
221 about 7 days before; \"two years ago\" = 2 years before; \
222 \"today\" / \"this morning\" / \"just now\" = REFERENCE_TIME.\n\
223 - A fact stated in the present tense with no other date (\"I \
224 work at Acme\") is known true as of this conversation: set \
225 valid_from_iso to REFERENCE_TIME.\n\
226 - Never output a date later than REFERENCE_TIME unless the \
227 text explicitly states a future plan.\n"
228 ),
229 None => String::new(),
230 };
231 format!(
232 "Extract entities and relations from the chunk below as JSON.\n\n\
233 Respond with a JSON object of EXACTLY this shape (all fields \
234 required except aliases and valid_to_iso):\n\
235 {{\"entities\":[{{\"name\":\"Alice\",\"entity_type\":\"Person\",\
236 \"aliases\":[],\"confidence\":0.9,\"valid_from_iso\":\"2023-05-14\",\
237 \"valid_to_iso\":null}}],\n\
238 \"relations\":[{{\"subject_name\":\"Alice\",\"subject_type\":\"Person\",\
239 \"predicate\":\"met\",\"object_name\":\"Bob\",\"object_type\":\"Person\",\
240 \"confidence\":0.9,\"valid_from_iso\":null,\"valid_to_iso\":null,\
241 \"fact_text\":\"Alice met Bob at the spring design conference\"}}]}}\n\
242 Use no other field names.\n\
243 Each relation's fact_text is ONE complete natural-language sentence \
244 restating the fact with ALL specific details preserved — proper \
245 nouns, brand and model names, quantities, prices, dates. Paraphrase \
246 the wording, never generalize.\n\
247 Extraction rules:\n\
248 - Chat transcripts are \"<speaker>: <text>\" lines. Attribute each \
249 fact to the correct speaker; the human speaker is the entity \
250 \"user\".\n\
251 - Resolve pronouns to the specific entity name when the chunk makes \
252 it clear; use the most specific form (\"road cycling\" not \
253 \"cycling\", \"the user's sister Anna\" not \"sister\").\n\
254 - NEVER extract pronouns, generic nouns, abstract concepts, or \
255 feelings as entities.\n\
256 {reference_block}\
257 Date rules for valid_from_iso / valid_to_iso (ISO 8601 dates, e.g. \
258 2023-05-14):\n\
259 - If the text states an explicit date (or one resolvable from the \
260 rules above) for when the fact became true, use it. Month and year \
261 only: use the 1st of that month. Year only: use January 1st.\n\
262 - If a fact's start date is genuinely unknown, set valid_from_iso \
263 to null. NEVER invent a date and NEVER infer temporal bounds from \
264 unrelated events.\n\
265 - Set valid_to_iso ONLY when the text says the fact ended, changed, \
266 or was replaced (\"no longer\", \"used to\", \"switched from X to \
267 Y\", \"sold my\"); otherwise null.\n\
268 If nothing is extractable, return \
269 {{\"entities\":[],\"relations\":[]}}.\n\n\
270 <chunk heading=\"{}\">\n{}\n</chunk>",
271 chunk.heading_path.join(" / "),
272 chunk.text
273 )
274}
275
276#[cfg(feature = "cloud-api")]
286pub(crate) fn parse_extraction_json_pub(decoded: &str, chunk_id: Ulid) -> RawExtraction {
287 parse_extraction_json(decoded, chunk_id)
288}
289
290fn parse_extraction_json(decoded: &str, chunk_id: Ulid) -> RawExtraction {
291 let Some(start) = decoded.find('{') else {
292 return RawExtraction { source_chunk_id: chunk_id, ..Default::default() };
293 };
294 let bytes = decoded.as_bytes();
295 let mut depth = 0_i32;
296 let mut end_excl = start;
297 let mut in_string = false;
298 let mut escaped = false;
299 for (i, &b) in bytes.iter().enumerate().skip(start) {
300 if in_string {
301 if escaped {
302 escaped = false;
303 } else if b == b'\\' {
304 escaped = true;
305 } else if b == b'"' {
306 in_string = false;
307 }
308 continue;
309 }
310 match b {
311 b'"' => in_string = true,
312 b'{' => depth += 1,
313 b'}' => {
314 depth -= 1;
315 if depth == 0 {
316 end_excl = i + 1;
317 break;
318 }
319 }
320 _ => {}
321 }
322 }
323 if end_excl == start {
324 return RawExtraction { source_chunk_id: chunk_id, ..Default::default() };
325 }
326 let json_slice = &decoded[start..end_excl];
327 match serde_json::from_str::<ExtractionJson>(json_slice) {
328 Ok(parsed) => parsed.into_raw(chunk_id),
329 Err(e) => {
330 tracing::warn!(
331 err = %e,
332 chunk_id = %chunk_id,
333 "LlmExtractor JSON parse failed; emitting empty extraction"
334 );
335 RawExtraction { source_chunk_id: chunk_id, ..Default::default() }
336 }
337 }
338}
339
340fn lenient_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
348where
349 D: serde::Deserializer<'de>,
350 T: serde::de::DeserializeOwned,
351{
352 let raw = Vec::<serde_json::Value>::deserialize(deserializer)?;
353 let total = raw.len();
354 let kept: Vec<T> = raw.into_iter().filter_map(|v| serde_json::from_value(v).ok()).collect();
355 if kept.len() < total {
356 tracing::debug!(
357 dropped = total - kept.len(),
358 kept = kept.len(),
359 "lenient_vec: skipped malformed extraction elements"
360 );
361 }
362 Ok(kept)
363}
364
365fn string_or_null<'de, D>(deserializer: D) -> Result<String, D::Error>
370where
371 D: serde::Deserializer<'de>,
372{
373 Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
374}
375
376fn mid_confidence() -> f32 {
379 0.5
380}
381
382#[derive(Debug, serde::Deserialize)]
383struct ExtractionJson {
384 #[serde(default, deserialize_with = "lenient_vec")]
385 entities: Vec<EntityJson>,
386 #[serde(default, deserialize_with = "lenient_vec")]
387 relations: Vec<RelationJson>,
388}
389
390#[derive(Debug, serde::Deserialize)]
391struct EntityJson {
392 name: String,
393 #[serde(default, deserialize_with = "string_or_null")]
394 entity_type: String,
395 #[serde(default)]
396 aliases: Vec<String>,
397 #[serde(default = "mid_confidence")]
398 confidence: f32,
399 #[serde(default, deserialize_with = "string_or_null")]
400 valid_from_iso: String,
401 #[serde(default)]
402 valid_to_iso: Option<String>,
403}
404
405#[derive(Debug, serde::Deserialize)]
406struct RelationJson {
407 subject_name: String,
408 #[serde(default, deserialize_with = "string_or_null")]
409 subject_type: String,
410 predicate: String,
411 object_name: String,
412 #[serde(default, deserialize_with = "string_or_null")]
413 object_type: String,
414 #[serde(default = "mid_confidence")]
415 confidence: f32,
416 #[serde(default, deserialize_with = "string_or_null")]
417 valid_from_iso: String,
418 #[serde(default)]
419 valid_to_iso: Option<String>,
420 #[serde(default, deserialize_with = "string_or_null")]
424 fact_text: String,
425}
426
427impl ExtractionJson {
428 fn into_raw(self, chunk_id: Ulid) -> RawExtraction {
429 let entities = self
430 .entities
431 .into_iter()
432 .map(|e| Entity {
433 id: EntityId::from_name_and_type(&e.name, &e.entity_type),
434 name: e.name,
435 aliases: e.aliases,
436 entity_type: e.entity_type,
437 confidence: e.confidence,
438 valid_from_iso: e.valid_from_iso,
439 valid_to_iso: e.valid_to_iso,
440 })
441 .collect();
442 let facts = self
454 .relations
455 .iter()
456 .filter(|r| !r.subject_name.trim().is_empty() && !r.object_name.trim().is_empty())
457 .map(|r| Fact {
458 id: Ulid::new(),
459 subject_id: EntityId::from_name_and_type(&r.subject_name, &r.subject_type),
460 predicate: r.predicate.clone(),
461 object_id: EntityId::from_name_and_type(&r.object_name, &r.object_type),
462 fact_text: if r.fact_text.trim().is_empty() {
466 synth_fact_text(&r.subject_name, &r.predicate, &r.object_name)
467 } else {
468 r.fact_text.trim().to_owned()
469 },
470 confidence: r.confidence,
471 valid_from_iso: r.valid_from_iso.clone(),
472 valid_to_iso: r.valid_to_iso.clone(),
473 })
474 .collect();
475 let relations = self
476 .relations
477 .into_iter()
478 .map(|r| Relation {
479 subject_id: EntityId::from_name_and_type(&r.subject_name, &r.subject_type),
480 predicate: r.predicate,
481 object_id: EntityId::from_name_and_type(&r.object_name, &r.object_type),
482 confidence: r.confidence,
483 valid_from_iso: r.valid_from_iso,
484 valid_to_iso: r.valid_to_iso,
485 })
486 .collect();
487 RawExtraction { source_chunk_id: chunk_id, entities, relations, facts }
488 }
489}
490
491fn synth_fact_text(subject: &str, predicate: &str, object: &str) -> String {
495 let pred = predicate.trim().replace('_', " ").to_lowercase();
496 format!("{} {} {}", subject.trim(), pred, object.trim())
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use lunaris_llm::FauxBackend;
503
504 fn chunk(text: &str) -> ChunkInput {
505 ChunkInput {
506 chunk_id: Ulid::new(),
507 heading_path: vec!["section".into()],
508 text: text.into(),
509 reference_time_iso: None,
510 }
511 }
512
513 #[test]
514 fn build_prompt_includes_every_required_json_field_name() {
515 let p = build_prompt(&chunk("Alice met Bob in Paris."));
529 for field in [
530 "entity_type",
531 "confidence",
532 "valid_from_iso",
533 "subject_name",
534 "subject_type",
535 "predicate",
536 "object_name",
537 "object_type",
538 ] {
539 assert!(p.contains(field), "prompt missing required field name: {field}");
540 }
541 }
542
543 #[tokio::test]
544 async fn empty_chunks_returns_empty_batch() {
545 let backend: Arc<dyn LlmBackend> = Arc::new(FauxBackend::new());
546 let extractor = LlmExtractor::new(backend);
547 let out = extractor.extract(Ulid::new(), &[]).await.unwrap();
548 assert!(out.by_chunk.is_empty());
549 }
550
551 #[test]
552 fn synthesizes_a_readable_fact_per_relation() {
553 let json = r#"{
561 "entities":[
562 {"name":"Alice","entity_type":"Person","confidence":0.9,"valid_from_iso":"2025-01-01"},
563 {"name":"Racket","entity_type":"Product","confidence":0.9,"valid_from_iso":"2025-01-01"}
564 ],
565 "relations":[
566 {"subject_name":"Alice","subject_type":"Person","predicate":"SHOPS_AT",
567 "object_name":"Store","object_type":"Org","confidence":0.8,
568 "valid_from_iso":"2025-01-01"}
569 ]
570 }"#;
571 let raw = parse_extraction_json(json, Ulid::new());
572 assert_eq!(raw.relations.len(), 1);
573 assert_eq!(raw.facts.len(), 1, "exactly one fact synthesized per relation");
574 let f = &raw.facts[0];
575 assert_eq!(f.fact_text, "Alice shops at Store", "readable S-P-O claim sentence");
576 assert_eq!(f.predicate, "SHOPS_AT", "structured predicate preserved verbatim");
577 assert_eq!(f.subject_id, EntityId::from_name_and_type("Alice", "Person"));
578 assert_eq!(f.object_id, EntityId::from_name_and_type("Store", "Org"));
579 assert_eq!(f.confidence, 0.8, "confidence inherited from the relation");
580 assert_eq!(f.valid_from_iso, "2025-01-01");
581 }
582
583 #[tokio::test]
584 async fn parses_valid_json_into_entities() {
585 let backend: Arc<dyn LlmBackend> = Arc::new(FauxBackend::new().with_response(
586 r#"{
587 "entities":[
588 {"name":"Alice","entity_type":"Person","confidence":0.9,
589 "valid_from_iso":"2025-01-01"}
590 ],
591 "relations":[]
592 }"#,
593 ));
594 let extractor = LlmExtractor::new(backend);
595 let c = chunk("Alice met Bob in Paris.");
596 let out = extractor.extract(Ulid::new(), &[c]).await.unwrap();
597 assert_eq!(out.by_chunk.len(), 1);
598 assert_eq!(out.by_chunk[0].entities.len(), 1);
599 assert_eq!(out.by_chunk[0].entities[0].name, "Alice");
600 }
601
602 #[tokio::test]
603 async fn malformed_json_emits_empty_extraction() {
604 let backend: Arc<dyn LlmBackend> =
605 Arc::new(FauxBackend::new().with_response("not json at all"));
606 let extractor = LlmExtractor::new(backend);
607 let c = chunk("hello");
608 let out = extractor.extract(Ulid::new(), &[c]).await.unwrap();
609 assert_eq!(out.by_chunk.len(), 1);
610 assert!(out.by_chunk[0].entities.is_empty());
611 assert!(out.by_chunk[0].relations.is_empty());
612 }
613
614 #[tokio::test]
615 async fn one_malformed_element_does_not_drop_the_whole_chunk() {
616 let backend: Arc<dyn LlmBackend> = Arc::new(FauxBackend::new().with_response(
625 r#"{
626 "entities":[
627 {"name":"Alice","entity_type":"Person","confidence":0.9,"valid_from_iso":"2025-01-01"},
628 {"name":"Bob","entity_type":null,"confidence":0.8,"valid_from_iso":null}
629 ],
630 "relations":[
631 {"subject_name":"Alice","predicate":"met","object_name":"Bob","object_type":"Person","confidence":0.9,"valid_from_iso":"2025-01-01"},
632 {"subject_name":"Alice","subject_type":"Person","predicate":"visited","object_name":"Paris","object_type":"City","confidence":0.7,"valid_from_iso":"2025-01-02"},
633 {"predicate":"orphan-no-subject-or-object"}
634 ]
635 }"#,
636 ));
637 let extractor = LlmExtractor::new(backend);
638 let c = chunk("Alice met Bob in Paris.");
639 let out = extractor.extract(Ulid::new(), &[c]).await.unwrap();
640 assert_eq!(out.by_chunk.len(), 1);
641 assert_eq!(out.by_chunk[0].entities.len(), 2, "both entities must survive");
643 assert_eq!(
647 out.by_chunk[0].relations.len(),
648 2,
649 "recoverable relations must survive; only the orphan drops"
650 );
651 }
652
653 #[tokio::test]
654 async fn batch_timeout_emits_empty_per_chunk_partials() {
655 let backend: Arc<dyn LlmBackend> = Arc::new(FauxBackend::new().with_delay_ms(500));
657 let extractor = LlmExtractor::with_opts(
658 backend,
659 LlmExtractorOpts {
660 batch_timeout_ms: 50,
661 per_chunk_timeout_ms: 500,
662 max_tokens: 64,
663 temperature: 0.0,
664 gbnf: None,
665 },
666 );
667 let chunks = vec![chunk("a"), chunk("b"), chunk("c")];
668 let out = extractor.extract(Ulid::new(), &chunks).await.unwrap();
669 assert_eq!(out.by_chunk.len(), 3);
671 for r in &out.by_chunk {
672 assert!(r.entities.is_empty());
673 }
674 }
675
676 #[tokio::test]
679 async fn gbnf_opt_threads_through_to_backend() {
680 let cap = Arc::new(FauxBackend::new().with_model_id("faux://capturing"));
681 let extractor = LlmExtractor::with_opts(
682 cap.clone() as Arc<dyn LlmBackend>,
683 LlmExtractorOpts { gbnf: Some("root ::= \"{}\""), ..LlmExtractorOpts::default() },
684 );
685 let _ = extractor.extract(Ulid::new(), &[chunk("hi")]).await.unwrap();
686 assert_eq!(cap.last_constraint_tag(), Some("gbnf"));
687 }
688
689 #[tokio::test]
690 async fn no_gbnf_opt_sends_constraint_none() {
691 let cap = Arc::new(FauxBackend::new());
692 let extractor = LlmExtractor::new(cap.clone() as Arc<dyn LlmBackend>);
693 let _ = extractor.extract(Ulid::new(), &[chunk("hi")]).await.unwrap();
694 assert_eq!(cap.last_constraint_tag(), Some("none"));
695 }
696
697 #[test]
698 fn parses_balanced_json_with_trailing_garbage() {
699 let chunk_id = Ulid::new();
700 let raw = parse_extraction_json(
701 r#"junk before {"entities":[],"relations":[]} junk after"#,
702 chunk_id,
703 );
704 assert_eq!(raw.source_chunk_id, chunk_id);
705 assert!(raw.entities.is_empty());
706 }
707
708 fn dated_chunk(text: &str, reference: &str) -> ChunkInput {
719 ChunkInput {
720 chunk_id: Ulid::new(),
721 heading_path: vec!["section".into()],
722 text: text.into(),
723 reference_time_iso: Some(reference.into()),
724 }
725 }
726
727 #[test]
728 fn build_prompt_renders_reference_time_and_temporal_rules() {
729 let p = build_prompt(&dated_chunk("I met Bob yesterday.", "2023-05-30"));
730 assert!(
731 p.contains("REFERENCE_TIME: 2023-05-30"),
732 "prompt must inject the session date as REFERENCE_TIME"
733 );
734 assert!(
735 p.contains("relative time expressions"),
736 "prompt must instruct resolving relative dates against REFERENCE_TIME"
737 );
738 assert!(p.contains("NEVER invent a date"), "prompt must carry the null-over-guess rule");
739 assert!(!p.contains("else today"), "the hallucination mandate must be gone");
740 assert!(
741 !p.contains("2025-01-01"),
742 "few-shot example dates must not anchor the model to 2025"
743 );
744 }
745
746 #[test]
760 fn prompt_asks_for_llm_authored_fact_text_and_hygiene() {
761 let p = build_prompt(&chunk("user: I bought a Wilson racket."));
762 assert!(p.contains("fact_text"), "relation schema must include fact_text");
763 assert!(
764 p.contains("never generalize"),
765 "detail-preservation rule (proper nouns/quantities/dates survive)"
766 );
767 assert!(
768 p.contains("NEVER extract pronouns"),
769 "negative constraint: no pronoun/generic/abstract entities"
770 );
771 assert!(p.contains("speaker"), "chat speaker-attribution rule");
772 }
773
774 #[test]
775 fn llm_authored_fact_text_preferred_with_synth_fallback() {
776 let chunk_id = Ulid::new();
777 let raw = parse_extraction_json(
778 r#"{"entities":[],"relations":[
779 {"subject_name":"Alice","subject_type":"Person","predicate":"BOUGHT",
780 "object_name":"Wilson Pro Staff","object_type":"Product","confidence":0.9,
781 "valid_from_iso":"2023-05-14","valid_to_iso":null,
782 "fact_text":"Alice bought a Wilson Pro Staff racket at Tennis Warehouse in May 2023"},
783 {"subject_name":"Alice","subject_type":"Person","predicate":"LIVES_IN",
784 "object_name":"Boston","object_type":"City","confidence":0.9,
785 "valid_from_iso":null,"valid_to_iso":null}
786 ]}"#,
787 chunk_id,
788 );
789 assert_eq!(raw.facts.len(), 2);
790 assert_eq!(
791 raw.facts[0].fact_text,
792 "Alice bought a Wilson Pro Staff racket at Tennis Warehouse in May 2023",
793 "model-authored fact_text must be used verbatim"
794 );
795 assert_eq!(
796 raw.facts[1].fact_text, "Alice lives in Boston",
797 "absent/empty fact_text falls back to the synthesized S-P-O sentence"
798 );
799 }
800
801 #[test]
802 fn build_prompt_without_reference_time_uses_null_policy() {
803 let p = build_prompt(&chunk("Alice met Bob in Paris."));
804 assert!(
805 !p.contains("REFERENCE_TIME:"),
806 "no reference time available -> no REFERENCE_TIME line"
807 );
808 assert!(
809 p.contains("NEVER invent a date"),
810 "null-over-guess must hold even without a reference time"
811 );
812 assert!(!p.contains("else today"));
813 assert!(!p.contains("2025-01-01"));
814 }
815}