1use crate::knowledge::Polarity;
14use crate::registry::{LiteralType, ObjectKind, PredicateDef};
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ExtractorConfig {
24 pub model_id: String,
25 pub prompt_version: u32,
26 pub registry_major: u32,
27 pub mechanism: ExtractMechanism,
28 pub max_tokens: u32,
29 #[serde(default)]
33 pub model_digest: Option<String>,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum ExtractMechanism {
40 JsonSchema,
42 ToolCall,
44 Grammar,
47 JsonMode,
49}
50
51impl ExtractorConfig {
52 pub fn id(&self) -> String {
56 let mut hasher = blake3::Hasher::new();
57 hasher.update(self.model_id.as_bytes());
58 hasher.update(&self.prompt_version.to_le_bytes());
59 hasher.update(&self.registry_major.to_le_bytes());
60 hasher.update(&[self.mechanism as u8]);
61 if let Some(digest) = &self.model_digest {
62 hasher.update(digest.as_bytes());
63 }
64 hex::encode(hasher.finalize().as_bytes())
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct MentionRef {
74 pub surface: String,
75 pub entity_type: String,
76 pub span: (u32, u32),
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(tag = "kind", rename_all = "snake_case")]
82pub enum ClaimObject {
83 Entity {
84 mention: MentionRef,
85 },
86 Literal {
87 literal_type: String,
88 value: String,
89 span: (u32, u32),
90 },
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct Claim {
96 pub predicate: String,
97 pub subject: MentionRef,
98 pub object: ClaimObject,
99 pub polarity: Polarity,
100 #[serde(default)]
102 pub valid_from: Option<i64>,
103 #[serde(default)]
105 pub valid_to: Option<i64>,
106 pub confidence: f32,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ExtractionResponse {
112 pub claims: Vec<Claim>,
113}
114
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
117pub struct ExtractSummary {
118 pub extracted: usize,
119 pub quarantined: usize,
120 pub episodes_done: usize,
121 pub episodes_failed: usize,
122 #[serde(default)]
126 pub failures: Vec<(String, String)>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct ExtractionBudget {
132 pub max_concurrent: usize,
133 pub max_episodes_per_batch: usize,
134 pub max_tokens_per_episode: u32,
135 pub max_repair_attempts: u32,
136 pub lease_timeout_secs: u64,
137}
138
139impl Default for ExtractionBudget {
140 fn default() -> Self {
141 Self {
142 max_concurrent: 4,
143 max_episodes_per_batch: 50,
144 max_tokens_per_episode: 8192,
145 max_repair_attempts: 1,
146 lease_timeout_secs: 300,
147 }
148 }
149}
150
151#[derive(Debug, Clone)]
155pub struct ValidationResult {
156 pub valid: Vec<Claim>,
157 pub invalid: Vec<(Claim, Vec<ValidationError>)>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162#[serde(tag = "kind", rename_all = "snake_case")]
163pub enum ValidationError {
164 UnknownPredicate {
165 predicate: String,
166 },
167 SubjectTypeMismatch {
168 predicate: String,
169 expected: Vec<String>,
170 got: String,
171 },
172 ObjectTypeMismatch {
173 predicate: String,
174 expected: String,
175 got: String,
176 },
177 MalformedLiteral {
178 literal_type: String,
179 value: String,
180 reason: String,
181 },
182 SpanOutOfBounds {
183 span: (u32, u32),
184 content_len: usize,
185 },
186 SurfaceNotVerbatim {
187 surface: String,
188 span: (u32, u32),
189 found: String,
190 },
191 ConfidenceOutOfRange {
192 confidence: f32,
193 },
194}
195
196pub fn validate_claims(
199 claims: &[Claim],
200 content: &str,
201 predicates: &[PredicateDef],
202) -> ValidationResult {
203 let mut valid = Vec::new();
204 let mut invalid = Vec::new();
205
206 for claim in claims {
207 let mut errors = Vec::new();
208
209 if !(0.0..=1.0).contains(&claim.confidence) {
211 errors.push(ValidationError::ConfidenceOutOfRange {
212 confidence: claim.confidence,
213 });
214 }
215
216 let Some(pred_def) = predicates.iter().find(|p| p.name == claim.predicate) else {
218 errors.push(ValidationError::UnknownPredicate {
219 predicate: claim.predicate.clone(),
220 });
221 invalid.push((claim.clone(), errors));
222 continue;
223 };
224
225 if !pred_def
227 .subject_types
228 .iter()
229 .any(|t| t == &claim.subject.entity_type)
230 {
231 errors.push(ValidationError::SubjectTypeMismatch {
232 predicate: claim.predicate.clone(),
233 expected: pred_def
234 .subject_types
235 .iter()
236 .map(|t| t.to_string())
237 .collect(),
238 got: claim.subject.entity_type.clone(),
239 });
240 }
241
242 match (&claim.object, &pred_def.object_kind) {
244 (ClaimObject::Entity { mention }, ObjectKind::Entity(expected)) => {
245 if !expected.0.iter().any(|t| t == &mention.entity_type) {
246 errors.push(ValidationError::ObjectTypeMismatch {
247 predicate: claim.predicate.clone(),
248 expected: expected.0.join("|"),
249 got: mention.entity_type.clone(),
250 });
251 }
252 }
253 (ClaimObject::Literal { literal_type, .. }, ObjectKind::Literal(expected_lt)) => {
254 if !literal_type_matches(literal_type, expected_lt) {
255 errors.push(ValidationError::ObjectTypeMismatch {
256 predicate: claim.predicate.clone(),
257 expected: format!("{expected_lt:?}"),
258 got: literal_type.clone(),
259 });
260 }
261 }
262 (
263 ClaimObject::Literal {
264 literal_type,
265 value,
266 ..
267 },
268 ObjectKind::Enum { variants },
269 ) => {
270 if !variants.iter().any(|v| v == value) {
271 errors.push(ValidationError::ObjectTypeMismatch {
272 predicate: claim.predicate.clone(),
273 expected: format!("enum: {}", variants.join("|")),
274 got: value.clone(),
275 });
276 }
277 let _ = literal_type; }
279 (ClaimObject::Entity { .. }, ObjectKind::Literal(_))
280 | (ClaimObject::Entity { .. }, ObjectKind::Enum { .. })
281 | (ClaimObject::Literal { .. }, ObjectKind::Entity(_)) => {
282 errors.push(ValidationError::ObjectTypeMismatch {
283 predicate: claim.predicate.clone(),
284 expected: format!("{:?}", pred_def.object_kind),
285 got: match &claim.object {
286 ClaimObject::Entity { .. } => "entity".into(),
287 ClaimObject::Literal { .. } => "literal".into(),
288 },
289 });
290 }
291 }
292
293 let mut repaired = claim.clone();
298 if !resolve_mention(&mut repaired.subject, content) {
299 errors.push(ValidationError::SurfaceNotVerbatim {
300 surface: claim.subject.surface.clone(),
301 span: claim.subject.span,
302 found: String::new(),
303 });
304 }
305 if let ClaimObject::Entity { mention } = &mut repaired.object {
306 if !resolve_mention(mention, content) {
307 errors.push(ValidationError::SurfaceNotVerbatim {
308 surface: mention.surface.clone(),
309 span: mention.span,
310 found: String::new(),
311 });
312 }
313 }
314 if let ClaimObject::Literal { span, .. } = &repaired.object {
315 check_span(span, content, &mut errors);
316 }
317
318 if errors.is_empty() {
319 valid.push(repaired);
320 } else {
321 invalid.push((claim.clone(), errors));
322 }
323 }
324
325 ValidationResult { valid, invalid }
326}
327
328fn check_span(span: &(u32, u32), content: &str, errors: &mut Vec<ValidationError>) {
329 let len = content.len();
330 if span.0 as usize >= len || span.1 as usize > len || span.0 >= span.1 {
331 errors.push(ValidationError::SpanOutOfBounds {
332 span: *span,
333 content_len: len,
334 });
335 }
336}
337
338fn resolve_mention(m: &mut MentionRef, content: &str) -> bool {
350 let (a, b) = (m.span.0 as usize, m.span.1 as usize);
351 if content.get(a..b) == Some(m.surface.as_str()) {
353 return true;
354 }
355 if let Some(range) = char_span_to_bytes(content, a, b) {
357 if content.get(range.clone()) == Some(m.surface.as_str()) {
358 m.span = (range.start as u32, range.end as u32);
359 return true;
360 }
361 }
362 if let Some(found) = content.get(a..b) {
364 if found.eq_ignore_ascii_case(m.surface.as_str()) {
365 m.surface = found.to_string();
366 return true;
367 }
368 }
369 false
370}
371
372fn char_span_to_bytes(content: &str, a: usize, b: usize) -> Option<std::ops::Range<usize>> {
375 if b < a {
376 return None;
377 }
378 let mut start = None;
379 let mut end = None;
380 let mut idx = 0usize;
381 for (bi, _) in content.char_indices() {
382 if idx == a {
383 start = Some(bi);
384 }
385 if idx == b {
386 end = Some(bi);
387 break;
388 }
389 idx += 1;
390 }
391 let end = end.or((idx == b).then_some(content.len()))?;
393 Some(start?..end)
394}
395
396fn literal_type_matches(given: &str, expected: &LiteralType) -> bool {
397 match expected {
398 LiteralType::Text => given == "text",
399 LiteralType::Date => given == "date",
400 LiteralType::DateTime => given == "datetime",
401 LiteralType::Number => given == "number",
402 LiteralType::Bool => given == "bool",
403 LiteralType::Quantity { .. } => given == "quantity",
404 }
405}
406
407pub fn schema_from_registry(predicates: &[PredicateDef]) -> serde_json::Value {
413 let pred_names: Vec<&str> = predicates.iter().map(|p| p.name.as_str()).collect();
414 let entity_types: Vec<&str> = predicates
415 .iter()
416 .flat_map(|p| {
417 let subjects = p.subject_types.iter().map(|t| t.as_str());
418 let objects = match &p.object_kind {
419 ObjectKind::Entity(types) => types.0.iter().map(|t| t.as_str()).collect(),
420 _ => vec![],
421 };
422 subjects.chain(objects)
423 })
424 .collect::<std::collections::BTreeSet<_>>()
425 .into_iter()
426 .collect();
427
428 let mention_schema = serde_json::json!({
429 "type": "object",
430 "properties": {
431 "surface": { "type": "string", "description": "Verbatim text from the episode" },
432 "entity_type": { "type": "string", "enum": entity_types },
433 "span": {
434 "type": "array",
435 "items": { "type": "integer" },
436 "minItems": 2,
437 "maxItems": 2,
438 "description": "Byte offset [start, end) into the episode text"
439 }
440 },
441 "required": ["surface", "entity_type", "span"]
442 });
443
444 let object_schema = serde_json::json!({
445 "type": "object",
446 "properties": {
447 "kind": { "type": "string", "enum": ["entity", "literal"] }
448 },
449 "required": ["kind"],
450 "oneOf": [
451 {
452 "properties": {
453 "kind": { "const": "entity" },
454 "mention": mention_schema.clone()
455 },
456 "required": ["mention"]
457 },
458 {
459 "properties": {
460 "kind": { "const": "literal" },
461 "literal_type": { "type": "string", "enum": ["text", "date", "datetime", "number", "bool", "quantity"] },
462 "value": { "type": "string" },
463 "span": {
464 "type": "array",
465 "items": { "type": "integer" },
466 "minItems": 2,
467 "maxItems": 2
468 }
469 },
470 "required": ["literal_type", "value", "span"]
471 }
472 ]
473 });
474
475 serde_json::json!({
476 "type": "object",
477 "properties": {
478 "claims": {
479 "type": "array",
480 "items": {
481 "type": "object",
482 "properties": {
483 "predicate": {
484 "type": "string",
485 "enum": pred_names
486 },
487 "subject": mention_schema,
488 "object": object_schema,
489 "polarity": {
490 "type": "string",
491 "enum": ["affirm", "deny"]
492 },
493 "valid_from": {
494 "type": ["integer", "null"],
495 "description": "Epoch millis, or null for 'always'"
496 },
497 "valid_to": {
498 "type": ["integer", "null"],
499 "description": "Epoch millis, or null for 'still true'"
500 },
501 "confidence": {
502 "type": "number",
503 "minimum": 0.0,
504 "maximum": 1.0
505 }
506 },
507 "required": ["predicate", "subject", "object", "polarity", "confidence"]
508 }
509 }
510 },
511 "required": ["claims"]
512 })
513}
514
515fn enum_alternation(values: &[&str]) -> String {
519 values
520 .iter()
521 .map(|v| format!("\"\\\"{v}\\\"\""))
522 .collect::<Vec<_>>()
523 .join(" | ")
524}
525
526pub fn grammar_from_registry(predicates: &[PredicateDef]) -> String {
538 let pred_names: Vec<&str> = predicates.iter().map(|p| p.name.as_str()).collect();
540 let entity_types: Vec<&str> = predicates
541 .iter()
542 .flat_map(|p| {
543 let subjects = p.subject_types.iter().map(|t| t.as_str());
544 let objects = match &p.object_kind {
545 ObjectKind::Entity(types) => types.0.iter().map(|t| t.as_str()).collect(),
546 _ => vec![],
547 };
548 subjects.chain(objects)
549 })
550 .collect::<std::collections::BTreeSet<_>>()
551 .into_iter()
552 .collect();
553
554 let pred_alts = enum_alternation(&pred_names);
555 let etype_alts = enum_alternation(&entity_types);
556
557 format!(
562 r#"root ::= ws "{{" ws "\"claims\"" ws ":" ws "[" ws claims ws "]" ws "}}"
563claims ::= (claim (ws "," ws claim)*)?
564claim ::= "{{" ws "\"predicate\"" ws ":" ws predicate ws "," ws "\"subject\"" ws ":" ws mention ws "," ws "\"object\"" ws ":" ws object-union ws "," ws "\"polarity\"" ws ":" ws polarity ws "," ws valid-from-opt valid-to-opt "\"confidence\"" ws ":" ws number ws "}}"
565valid-from-opt ::= ("\"valid_from\"" ws ":" ws temporal-val ws "," ws)?
566valid-to-opt ::= ("\"valid_to\"" ws ":" ws temporal-val ws "," ws)?
567temporal-val ::= "null" | integer
568mention ::= "{{" ws "\"surface\"" ws ":" ws string ws "," ws "\"entity_type\"" ws ":" ws entity-type ws "," ws "\"span\"" ws ":" ws "[" ws integer ws "," ws integer ws "]" ws "}}"
569object-union ::= entity-object | literal-object
570entity-object ::= "{{" ws "\"kind\"" ws ":" ws "\"entity\"" ws "," ws "\"mention\"" ws ":" ws mention ws "}}"
571literal-object ::= "{{" ws "\"kind\"" ws ":" ws "\"literal\"" ws "," ws "\"literal_type\"" ws ":" ws literal-type ws "," ws "\"value\"" ws ":" ws string ws "," ws "\"span\"" ws ":" ws "[" ws integer ws "," ws integer ws "]" ws "}}"
572entity-type ::= {etype_alts}
573literal-type ::= "\"text\"" | "\"date\"" | "\"datetime\"" | "\"number\"" | "\"bool\"" | "\"quantity\""
574predicate ::= {pred_alts}
575polarity ::= "\"affirm\"" | "\"deny\""
576string ::= "\"" ([^"\\] | "\\" (["\\/bfnrt] | "u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F]))* "\"" ws
577number ::= ("-"? ([0-9] | [1-9] [0-9]*)) ("." [0-9]+)? ([eE] [-+]? [0-9]+)? ws
578integer ::= "-"? ([0-9] | [1-9] [0-9]*) ws
579ws ::= [ \t\n]*
580"#,
581 )
582}
583
584pub fn build_extraction_prompt(predicates: &[PredicateDef]) -> String {
587 let mut s = String::new();
588 s.push_str(
589 "You are a knowledge extraction engine. Extract structured claims from the given text. \
590 Each claim references entities by their VERBATIM surface form and byte span in the text.\n\n\
591 Available predicates:\n",
592 );
593 for p in predicates {
594 let obj_desc = match &p.object_kind {
595 ObjectKind::Entity(types) => format!("entity: {}", types.0.join("|")),
596 ObjectKind::Literal(lt) => format!("literal: {lt:?}"),
597 ObjectKind::Enum { variants } => format!("enum: {}", variants.join("|")),
598 };
599 s.push_str(&format!("- {} ({}): {}\n", p.name, obj_desc, p.description));
600 if !p.examples.is_empty() {
601 s.push_str(&format!(" Examples: {}\n", p.examples.join("; ")));
602 }
603 }
604 s.push_str(
605 "\nReturn JSON matching the provided schema. For each entity mention, provide the surface \
606 text exactly as it appears in the episode, its type, and the byte offset range \
607 [start, end) where it appears in the text. Byte offsets are relative to the start of \
608 the episode text (offset 0 = first byte).\n\n\
609 Rules:\n\
610 - Entity surfaces MUST appear verbatim in the text at the given byte span.\n\
611 - Only use predicates from the list above.\n\
612 - Subject and object types must match the predicate's definition.\n\
613 - Set confidence to your confidence in the claim (0.0 to 1.0).\n\
614 - Use valid_from/valid_to for time-bounded claims. Use null for 'always true' or 'still true'.\n",
615 );
616 s
617}
618
619#[derive(Debug, Clone)]
625pub struct FewShotExample {
626 pub text: String,
627 pub claims_json: String,
628}
629
630pub fn few_shot_examples<'a>(
636 target_text: &str,
637 corpus: &'a [FewShotExample],
638 k: usize,
639) -> Vec<&'a FewShotExample> {
640 if corpus.is_empty() || k == 0 {
641 return Vec::new();
642 }
643 let target_shingles = oxibrain_index::shingles(target_text.to_lowercase().trim(), 3);
644 let mut scored: Vec<(f64, &FewShotExample)> = corpus
645 .iter()
646 .map(|ex| {
647 let ex_shingles = oxibrain_index::shingles(ex.text.to_lowercase().trim(), 3);
648 let sim = oxibrain_index::jaccard(&target_shingles, &ex_shingles);
649 (sim, ex)
650 })
651 .collect();
652 scored.sort_by(|a, b| {
654 b.0.partial_cmp(&a.0)
655 .unwrap_or(std::cmp::Ordering::Equal)
656 .then_with(|| a.1.text.cmp(&b.1.text))
657 });
658 scored.iter().take(k).map(|(_, ex)| *ex).collect()
659}
660
661pub fn format_few_shot(examples: &[&FewShotExample]) -> String {
664 if examples.is_empty() {
665 return String::new();
666 }
667 let mut out = String::from("\nHere are some examples of correct extraction:\n\n");
668 for (i, ex) in examples.iter().enumerate() {
669 out.push_str(&format!("Example {}:\n", i + 1));
670 out.push_str(&format!("Input: {}\n", ex.text));
671 out.push_str(&format!("Output: {}\n\n", ex.claims_json));
672 }
673 out
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679
680 #[test]
681 fn extractor_id_deterministic() {
682 let c = ExtractorConfig {
683 model_id: "claude-sonnet-4-5".into(),
684 prompt_version: 1,
685 registry_major: 1,
686 mechanism: ExtractMechanism::ToolCall,
687 max_tokens: 8192,
688 model_digest: None,
689 };
690 assert_eq!(c.id(), c.id());
691 }
692
693 #[test]
694 fn grammar_mechanism_changes_extractor_id() {
695 let base = ExtractorConfig {
698 model_id: "qwen2.5-1.5b-instruct".into(),
699 prompt_version: 1,
700 registry_major: 1,
701 mechanism: ExtractMechanism::Grammar,
702 max_tokens: 8192,
703 model_digest: None,
704 };
705 assert_eq!(base.id(), base.id());
706 let json_mode = ExtractorConfig {
707 mechanism: ExtractMechanism::JsonSchema,
708 ..base.clone()
709 };
710 assert_ne!(base.id(), json_mode.id());
711 }
712
713 #[test]
714 fn resolve_mention_repairs_casing_but_not_location() {
715 let content = "The user prefers Rust.";
717 let mut m = MentionRef {
718 surface: "the user".into(),
719 entity_type: "person".into(),
720 span: (0, 8),
721 };
722 assert!(resolve_mention(&mut m, content));
723 assert_eq!(m.surface, "The user");
724
725 let mut m = MentionRef {
728 surface: "Rust".into(),
729 entity_type: "technology".into(),
730 span: (0, 4),
731 };
732 assert!(!resolve_mention(&mut m, content));
733 }
734
735 #[test]
736 fn resolve_mention_rejects_fabricated_surface() {
737 let content = "The user prefers Rust.";
738 let mut m = MentionRef {
739 surface: "Python".into(),
740 entity_type: "technology".into(),
741 span: (0, 6),
742 };
743 assert!(!resolve_mention(&mut m, content));
744 }
745
746 #[test]
747 fn extractor_id_changes_with_model() {
748 let base = ExtractorConfig {
749 model_id: "a".into(),
750 prompt_version: 1,
751 registry_major: 1,
752 mechanism: ExtractMechanism::JsonSchema,
753 max_tokens: 4096,
754 model_digest: None,
755 };
756 let diff = ExtractorConfig {
757 model_id: "b".into(),
758 ..base.clone()
759 };
760 assert_ne!(base.id(), diff.id());
761 }
762
763 #[test]
764 fn extractor_id_changes_with_mechanism() {
765 let base = ExtractorConfig {
766 model_id: "a".into(),
767 prompt_version: 1,
768 registry_major: 1,
769 mechanism: ExtractMechanism::JsonSchema,
770 max_tokens: 4096,
771 model_digest: None,
772 };
773 let diff = ExtractorConfig {
774 mechanism: ExtractMechanism::ToolCall,
775 ..base.clone()
776 };
777 assert_ne!(base.id(), diff.id());
778 }
779
780 #[test]
781 fn extractor_id_changes_with_registry_major() {
782 let base = ExtractorConfig {
783 model_id: "a".into(),
784 prompt_version: 1,
785 registry_major: 1,
786 mechanism: ExtractMechanism::JsonSchema,
787 max_tokens: 4096,
788 model_digest: None,
789 };
790 let diff = ExtractorConfig {
791 registry_major: 2,
792 ..base.clone()
793 };
794 assert_ne!(base.id(), diff.id());
795 }
796
797 #[test]
798 fn extractor_id_changes_with_digest() {
799 let base = ExtractorConfig {
802 model_id: "qwen2.5-1.5b".into(),
803 prompt_version: 1,
804 registry_major: 1,
805 mechanism: ExtractMechanism::JsonSchema,
806 max_tokens: 8192,
807 model_digest: Some("abc123".into()),
808 };
809 let diff = ExtractorConfig {
810 model_digest: Some("def456".into()),
811 ..base.clone()
812 };
813 assert_ne!(
814 base.id(),
815 diff.id(),
816 "weight change must invalidate ExtractorId"
817 );
818
819 let nodigest = ExtractorConfig {
821 model_digest: None,
822 ..base.clone()
823 };
824 assert_ne!(base.id(), nodigest.id());
825 }
826
827 #[test]
828 fn schema_contains_all_predicates() {
829 let schema = schema_from_registry(crate::registry::core_v1());
830 let claims_items =
831 &schema["properties"]["claims"]["items"]["properties"]["predicate"]["enum"];
832 let names: Vec<String> = claims_items
833 .as_array()
834 .unwrap()
835 .iter()
836 .map(|v| v.as_str().unwrap().to_string())
837 .collect();
838 assert!(names.contains(&"works_on".to_string()));
839 assert!(names.contains(&"employed_by".to_string()));
840 assert!(names.contains(&"born_in".to_string()));
841 }
842
843 #[test]
844 fn prompt_contains_predicate_descriptions() {
845 let prompt = build_extraction_prompt(crate::registry::core_v1());
846 assert!(prompt.contains("works_on"));
847 assert!(prompt.contains("project"));
848 assert!(prompt.contains("VERBATIM"));
849 }
850
851 fn make_claim(
852 predicate: &str,
853 subj_surface: &str,
854 subj_type: &str,
855 subj_span: (u32, u32),
856 obj_surface: &str,
857 obj_type: &str,
858 obj_span: (u32, u32),
859 ) -> Claim {
860 Claim {
861 predicate: predicate.into(),
862 subject: MentionRef {
863 surface: subj_surface.into(),
864 entity_type: subj_type.into(),
865 span: subj_span,
866 },
867 object: ClaimObject::Entity {
868 mention: MentionRef {
869 surface: obj_surface.into(),
870 entity_type: obj_type.into(),
871 span: obj_span,
872 },
873 },
874 polarity: Polarity::Affirm,
875 valid_from: None,
876 valid_to: None,
877 confidence: 0.9,
878 }
879 }
880
881 #[test]
882 fn validate_valid_claim() {
883 let content = "Alice works on ProjectX at Acme Corp.";
884 let claim = make_claim(
885 "works_on",
886 "Alice",
887 "Person",
888 (0, 5),
889 "ProjectX",
890 "Project",
891 (15, 23),
892 );
893 let result = validate_claims(&[claim], content, crate::registry::core_v1());
894 assert_eq!(result.valid.len(), 1);
895 assert!(result.invalid.is_empty());
896 }
897
898 #[test]
899 fn validate_part_of_accepts_project_object() {
900 let content = "The parser module belongs to ProjectX.";
904 let claim = make_claim(
905 "part_of",
906 "parser module",
907 "Artifact",
908 (4, 17),
909 "ProjectX",
910 "Project",
911 (29, 37),
912 );
913 let result = validate_claims(&[claim], content, crate::registry::core_v1());
914 assert_eq!(result.valid.len(), 1, "errors: {:?}", result.invalid);
915 assert!(result.invalid.is_empty());
916 }
917
918 #[test]
919 fn validate_object_type_mismatch_lists_allowed_types() {
920 let content = "Alice works on ProjectX.";
921 let claim = make_claim(
922 "works_on",
923 "Alice",
924 "Person",
925 (0, 5),
926 "ProjectX",
927 "Place",
928 (15, 23),
929 );
930 let result = validate_claims(&[claim], content, crate::registry::core_v1());
931 assert!(result.valid.is_empty());
932 assert!(result.invalid[0].1.iter().any(|e| matches!(
933 e,
934 ValidationError::ObjectTypeMismatch { expected, got, .. }
935 if expected == "Project" && got == "Place"
936 )));
937 }
938
939 #[test]
940 fn validate_unknown_predicate() {
941 let content = "Alice works on ProjectX.";
942 let claim = make_claim(
943 "unknown_pred",
944 "Alice",
945 "Person",
946 (0, 5),
947 "ProjectX",
948 "Project",
949 (15, 23),
950 );
951 let result = validate_claims(&[claim], content, crate::registry::core_v1());
952 assert!(result.valid.is_empty());
953 assert_eq!(result.invalid.len(), 1);
954 }
955
956 #[test]
957 fn validate_fabricated_entity_rejected() {
958 let content = "Alice works on ProjectX.";
959 let claim = make_claim(
961 "works_on",
962 "Bob",
963 "Person",
964 (0, 5),
965 "ProjectX",
966 "Project",
967 (15, 23),
968 );
969 let result = validate_claims(&[claim], content, crate::registry::core_v1());
970 assert!(result.valid.is_empty());
971 assert_eq!(result.invalid.len(), 1);
972 assert!(matches!(
973 result.invalid[0].1[0],
974 ValidationError::SurfaceNotVerbatim { .. }
975 ));
976 }
977
978 #[test]
979 fn validate_span_out_of_bounds() {
980 let content = "Alice works on ProjectX.";
984 let claim = make_claim(
985 "works_on",
986 "Alice",
987 "Person",
988 (0, 5),
989 "Zanzibar",
990 "Project",
991 (999, 1000),
992 );
993 let result = validate_claims(&[claim], content, crate::registry::core_v1());
994 assert!(result.valid.is_empty());
995 }
996
997 #[test]
998 fn validate_subject_type_mismatch() {
999 let content = "Acme Corp employs Alice (0-5).";
1000 let claim = make_claim(
1001 "employed_by",
1002 "Acme Corp",
1003 "Organization",
1004 (0, 9),
1005 "Somewhere",
1006 "Organization",
1007 (17, 26),
1008 );
1009 let result = validate_claims(&[claim], content, crate::registry::core_v1());
1010 assert!(result.valid.is_empty());
1011 assert!(matches!(
1012 result.invalid[0].1[0],
1013 ValidationError::SubjectTypeMismatch { .. }
1014 ));
1015 }
1016
1017 #[test]
1020 fn grammar_smoke_has_rules() {
1021 let g = grammar_from_registry(crate::registry::core_v1());
1022 let norm: String = g.split_whitespace().collect::<Vec<_>>().join(" ");
1024 for rule in [
1025 "root",
1026 "claims",
1027 "claim",
1028 "mention",
1029 "object-union",
1030 "predicate",
1031 "entity-type",
1032 "polarity",
1033 "literal-type",
1034 "string",
1035 "number",
1036 "integer",
1037 "ws",
1038 ] {
1039 let needle = format!("{rule} ::=");
1040 assert!(
1041 norm.contains(&needle),
1042 "grammar missing rule definition for `{rule}`"
1043 );
1044 }
1045 }
1046
1047 #[test]
1048 fn grammar_and_schema_agree_on_predicates() {
1049 let preds = crate::registry::core_v1();
1050 let grammar = grammar_from_registry(preds);
1051 let schema = schema_from_registry(preds);
1052
1053 let schema_preds: std::collections::BTreeSet<String> =
1054 schema["properties"]["claims"]["items"]["properties"]["predicate"]["enum"]
1055 .as_array()
1056 .unwrap()
1057 .iter()
1058 .map(|v| v.as_str().unwrap().to_string())
1059 .collect();
1060
1061 for name in &schema_preds {
1062 let needle = format!("\\\"{name}\\\"");
1064 assert!(
1065 grammar.contains(&needle),
1066 "grammar missing predicate `{name}` present in schema"
1067 );
1068 }
1069 }
1070
1071 #[test]
1072 fn grammar_and_schema_agree_on_entity_types() {
1073 let preds = crate::registry::core_v1();
1074 let grammar = grammar_from_registry(preds);
1075 let schema = schema_from_registry(preds);
1076
1077 let schema_types: std::collections::BTreeSet<String> = schema["properties"]["claims"]["items"]
1078 ["properties"]["subject"]["properties"]["entity_type"]["enum"]
1079 .as_array()
1080 .unwrap()
1081 .iter()
1082 .map(|v| v.as_str().unwrap().to_string())
1083 .collect();
1084
1085 for name in &schema_types {
1086 let needle = format!("\\\"{name}\\\"");
1087 assert!(
1088 grammar.contains(&needle),
1089 "grammar missing entity type `{name}` present in schema"
1090 );
1091 }
1092 }
1093
1094 #[test]
1095 fn grammar_has_polarity_and_literal_type_enums() {
1096 let g = grammar_from_registry(crate::registry::core_v1());
1097 assert!(g.contains("\\\"affirm\\\""));
1098 assert!(g.contains("\\\"deny\\\""));
1099 for lt in ["text", "date", "datetime", "number", "bool", "quantity"] {
1100 assert!(
1101 g.contains(&format!("\\\"{lt}\\\"")),
1102 "grammar missing literal type `{lt}`"
1103 );
1104 }
1105 }
1106
1107 #[test]
1108 fn grammar_valid_response_roundtrips_serde() {
1109 let claim = make_claim(
1113 "works_on",
1114 "Alice",
1115 "Person",
1116 (0, 5),
1117 "ProjectX",
1118 "Project",
1119 (15, 23),
1120 );
1121 let resp = ExtractionResponse {
1122 claims: vec![claim],
1123 };
1124 let json = serde_json::to_string(&resp).unwrap();
1125 let back: ExtractionResponse = serde_json::from_str(&json).unwrap();
1126 assert_eq!(back.claims.len(), 1);
1127 assert_eq!(back.claims[0].predicate, "works_on");
1128 }
1129
1130 #[test]
1131 fn grammar_has_optional_temporal_fields() {
1132 let g = grammar_from_registry(crate::registry::core_v1());
1133 assert!(g.contains("valid-from-opt"));
1134 assert!(g.contains("valid-to-opt"));
1135 assert!(g.contains("\\\"valid_from\\\""));
1137 }
1138 #[test]
1139 fn grammar_supports_empty_claims() {
1140 let g = grammar_from_registry(crate::registry::core_v1());
1142 assert!(g.contains("(claim (ws \",\" ws claim)*)?"));
1144 }
1145
1146 #[test]
1149 fn few_shot_selects_most_similar() {
1150 let corpus = vec![
1151 FewShotExample {
1152 text: "Alice works at Acme.".into(),
1153 claims_json: r#"{"claims":[]}"#.into(),
1154 },
1155 FewShotExample {
1156 text: "Bob likes pizza.".into(),
1157 claims_json: r#"{"claims":[]}"#.into(),
1158 },
1159 ];
1160 let target = "Alice works at Globex.";
1161 let selected = few_shot_examples(target, &corpus, 1);
1162 assert_eq!(selected.len(), 1);
1163 assert!(
1164 selected[0].text.contains("Alice"),
1165 "should pick the most similar example, got: {}",
1166 selected[0].text
1167 );
1168 }
1169
1170 #[test]
1171 fn few_shot_empty_corpus_returns_empty() {
1172 let corpus: Vec<FewShotExample> = vec![];
1173 let selected = few_shot_examples("any text", &corpus, 3);
1174 assert!(selected.is_empty());
1175 }
1176
1177 #[test]
1178 fn few_shot_k_caps_results() {
1179 let corpus: Vec<FewShotExample> = (0..10)
1180 .map(|i| FewShotExample {
1181 text: format!("Sample text {i}."),
1182 claims_json: r#"{"claims":[]}"#.into(),
1183 })
1184 .collect();
1185 let selected = few_shot_examples("Sample text", &corpus, 3);
1186 assert_eq!(selected.len(), 3);
1187 }
1188
1189 #[test]
1190 fn few_shot_format_includes_input_output() {
1191 let ex = FewShotExample {
1192 text: "Alice works at Acme.".into(),
1193 claims_json: r#"{"claims":[]}"#.into(),
1194 };
1195 let formatted = format_few_shot(&[&ex]);
1196 assert!(formatted.contains("Alice works at Acme"));
1197 assert!(formatted.contains(r#"{"claims":[]}"#));
1198 }
1199
1200 #[test]
1201 fn few_shot_format_empty_returns_empty_string() {
1202 let formatted = format_few_shot(&[]);
1203 assert_eq!(formatted, "");
1204 }
1205}