Skip to main content

oxibrain_core/
extraction.rs

1//! Extraction types and pure functions (DESIGN §7). The LLM arrives in M3, but
2//! non-determinism is contained: schema generation, prompt building, and validation
3//! are all pure functions of the predicate registry. The LLM call itself is the
4//! only non-deterministic step, and its output is cached (§7.3).
5//!
6//! This module defines:
7//! - Types: `Claim`, `MentionRef`, `ClaimObject`, `ExtractionResponse`, `ExtractSummary`
8//! - Identity: `ExtractorConfig`, `ExtractMechanism`
9//! - Schema: `schema_from_registry` (pure fn of the registry)
10//! - Prompt: `build_extraction_prompt` (pure fn of the registry)
11//! - Validation: `validate_claims` (pure fn of claims + content + registry)
12
13use crate::knowledge::Polarity;
14use crate::registry::{LiteralType, ObjectKind, PredicateDef};
15use serde::{Deserialize, Serialize};
16
17// ─── Extractor identity (§7.5) ───────────────────────────────────────────────
18
19/// Configuration for an extractor: model, prompt version, mechanism.
20/// Hashes to an ExtractorId (§7.5). Only the registry MAJOR version invalidates
21/// the cache (D8); adding a predicate is a minor bump and does not force re-extraction.
22#[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    /// blake3 hex digest of the model weights. Changing weights must change
30    /// the extractor id (§9.5) — a silent quality change would poison the
31    /// extraction cache.
32    #[serde(default)]
33    pub model_digest: Option<String>,
34}
35
36/// How structured output is enforced (§7.4). Recorded in the ExtractorId hash.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum ExtractMechanism {
40    /// Provider-native JSON Schema structured output (OpenAI).
41    JsonSchema,
42    /// Forced tool call (Anthropic).
43    ToolCall,
44    /// GBNF grammar-constrained decoding (local GGUF path, §9.4 D28). The
45    /// grammar is generated from the predicate registry (P4).
46    Grammar,
47    /// JSON mode without schema enforcement (weakest; validator is the only gate).
48    JsonMode,
49}
50
51impl ExtractorConfig {
52    /// ExtractorId = blake3(model_id, prompt_version, registry_major, mechanism[, model_digest]).
53    /// Only the MAJOR registry version invalidates the cache (D8).
54    /// The model digest — when present — invalidates it on weight changes (§9.5).
55    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// ─── Extraction types ────────────────────────────────────────────────────────
69
70/// A reference to an entity mention in the episode text.
71/// `surface` must appear verbatim at `[span.0, span.1)` (the fabricated-entity gate).
72#[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/// The object of an extracted claim.
80#[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/// One extracted claim from the LLM. Maps to one assertion + mentions.
94#[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    /// Epoch millis. None = TIME_MIN (always true).
101    #[serde(default)]
102    pub valid_from: Option<i64>,
103    /// Epoch millis. None = TIME_MAX (still true).
104    #[serde(default)]
105    pub valid_to: Option<i64>,
106    pub confidence: f32,
107}
108
109/// The parsed LLM response.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ExtractionResponse {
112    pub claims: Vec<Claim>,
113}
114
115/// Summary of an extraction batch.
116#[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    /// (episode_id, error) for every failed episode. Batch loops used to
123    /// discard these, making deterministic per-episode failures (truncated
124    /// tool calls, unparseable responses) undiagnosable from the outside.
125    #[serde(default)]
126    pub failures: Vec<(String, String)>,
127}
128
129/// Extraction budget limits (§7.6). The queue holds on exhaustion; it never drops.
130#[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// ─── Validation ──────────────────────────────────────────────────────────────
152
153/// Result of validating claims against the registry + episode content.
154#[derive(Debug, Clone)]
155pub struct ValidationResult {
156    pub valid: Vec<Claim>,
157    pub invalid: Vec<(Claim, Vec<ValidationError>)>,
158}
159
160/// A validation error for a single claim (§7.4).
161#[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
196/// Validate parsed claims against the registry + episode content (§7.4).
197/// Pure function: same claims + content + registry → same result.
198pub 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        // 1. Confidence range.
210        if !(0.0..=1.0).contains(&claim.confidence) {
211            errors.push(ValidationError::ConfidenceOutOfRange {
212                confidence: claim.confidence,
213            });
214        }
215
216        // 2. Predicate exists in registry.
217        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        // 3. Subject type matches predicate.subject_types.
226        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        // 4. Object type matches predicate.object_kind.
243        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; // enum values are strings; type is not constrained further
278            }
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        // 5-7. Spans and surfaces. Model offsets drift on multibyte text and
294        // casing; repair before rejecting. The fabricated-entity gate still
295        // holds: a claim survives only when each surface is literally present
296        // in the content at the (possibly corrected) span.
297        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
338/// Resolve a mention against the content, repairing span-interpretation drift
339/// in place. Returns `true` when the surface is verbatim-present at the span.
340///
341/// Repair ladder — every step keeps the fabricated-entity gate intact: the
342/// bytes at the (final) span must spell the surface. Relocating a surface to
343/// a *different* span is deliberately NOT done: the injection suite
344/// (oxibrain-store/tests/injection_suite.rs) requires that a span citing the
345/// wrong bytes is rejected even when the surface occurs elsewhere.
346/// 1. Exact byte span.
347/// 2. Char-index span — models count chars on multibyte (Korean/CJK) text.
348/// 3. Casing drift at the same span — canonicalize surface to the source text.
349fn resolve_mention(m: &mut MentionRef, content: &str) -> bool {
350    let (a, b) = (m.span.0 as usize, m.span.1 as usize);
351    // 1. Exact byte span.
352    if content.get(a..b) == Some(m.surface.as_str()) {
353        return true;
354    }
355    // 2. Char-index span.
356    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    // 3. Casing drift at the same span: the content is the source of truth.
363    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
372/// Convert a (char_index_start, char_index_end) span to a byte range.
373/// Returns `None` when the indices don't address this content.
374fn 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    // `b` may equal the char count — the range then runs to the end.
392    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
407// ─── Schema generation (§6.1) ────────────────────────────────────────────────
408
409/// Generate the extraction JSON Schema from the predicate registry (P4).
410/// Pure function: same registry → same schema. The schema constrains structure;
411/// semantic rules (predicate↔type matching) are enforced by `validate_claims`.
412pub 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
515/// Build a GBNF alternation of JSON string literals for an enum.
516/// Produces: `"\"value1\"" | "\"value2\"" | ...`
517/// which in GBNF matches one of the JSON strings "value1", "value2", ...
518fn enum_alternation(values: &[&str]) -> String {
519    values
520        .iter()
521        .map(|v| format!("\"\\\"{v}\\\"\""))
522        .collect::<Vec<_>>()
523        .join(" | ")
524}
525
526/// Generate a GBNF grammar from the predicate registry (§9.4, D28, P4).
527///
528/// Sibling of [`schema_from_registry`] — one registry, two consumers.
529/// The grammar constrains structure and enum values; semantic rules
530/// (confidence range, span validity, type matching) are enforced by
531/// [`validate_claims`].
532///
533/// Any JSON matching this grammar parses into an [`ExtractionResponse`],
534/// and any valid serialized [`ExtractionResponse`] is accepted by the grammar.
535/// The grammar enforces a canonical key order; serde deserializes by field
536/// name, so the order is irrelevant on the consumer side.
537pub fn grammar_from_registry(predicates: &[PredicateDef]) -> String {
538    // Collect enum values — same logic as schema_from_registry.
539    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    // GBNF (GGML BNF) for llama.cpp. See llama.cpp grammars/README.md.
558    // {{ and }} are format! escapes for literal { and }.
559    // GBNF (GGML BNF) for llama.cpp. Each rule on a single line — the
560    // parser treats newlines as rule separators. See llama.cpp grammars/README.md.
561    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
584/// Build the extraction system prompt from the registry (P4: no hard-coded predicates).
585/// Pure function.
586pub 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// ─── Few-shot selection (§9.6, 10.8) ──────────────────────────────────────
620
621/// A golden-corpus example for few-shot extraction (§9.6, 10.8).
622/// The `text` is the episode content; `claims_json` is the expected
623/// extraction output as a JSON string.
624#[derive(Debug, Clone)]
625pub struct FewShotExample {
626    pub text: String,
627    pub claims_json: String,
628}
629
630/// Select the k most similar golden episodes to the target text, using
631/// character trigram Jaccard similarity (§9.6, 10.8). Language-independent
632/// by construction (P11).
633///
634/// Pure function: same inputs → same selection.
635pub 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    // Sort by similarity descending; tie-break on text for determinism.
653    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
661/// Format selected few-shot examples as prompt text (§9.6, 10.8).
662/// Injected into the system prompt before the target episode.
663pub 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        // GBNF-constrained decoding (local path, §9.4 D28) must be recorded
696        // in the ExtractorId — different output mechanism, different cache key.
697        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        // Casing drift at the exact span → surface canonicalized to content.
716        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        // Wrong span is rejected even when the surface occurs verbatim
726        // elsewhere — the injection suite relies on this (no relocation).
727        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        // §9.5: changing model weights must change the extractor id, or a
800        // silent quality change would poison the extraction cache.
801        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        // A missing digest must also differ (opt-in digest changes the id).
820        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        // Regression: part_of's object allowed only Organization before
901        // registry minor 4 — project-part-of-project and artifact-part-of-
902        // project knowledge was rejected as object_type_mismatch.
903        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        // Surface "Bob" doesn't appear at span [0, 5) — the content there is "Alice".
960        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        // Entity mentions with drifted spans are repaired (relocated) as long
981        // as the surface is verbatim in the content; fabricated surfaces are
982        // still rejected — the fabricated-entity gate.
983        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    // ─── grammar_from_registry tests ──────────────────────────────────────
1018
1019    #[test]
1020    fn grammar_smoke_has_rules() {
1021        let g = grammar_from_registry(crate::registry::core_v1());
1022        // Normalize whitespace so alignment in the template doesn't break matching.
1023        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            // The grammar must contain a GBNF literal for this predicate name.
1063            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        // A Claim serialized to JSON should round-trip through serde.
1110        // This is the structural half of the grammar/schema agreement: the
1111        // grammar generates the same structure that serde expects.
1112        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        // GBNF literals use backslash-escaped quotes.
1136        assert!(g.contains("\\\"valid_from\\\""));
1137    }
1138    #[test]
1139    fn grammar_supports_empty_claims() {
1140        // An empty claims array {"claims":[]} must be accepted.
1141        let g = grammar_from_registry(crate::registry::core_v1());
1142        // The claims rule uses ? to allow zero claims.
1143        assert!(g.contains("(claim (ws \",\" ws claim)*)?"));
1144    }
1145
1146    // ── Few-shot selection (§9.6, 10.8) ────────────────────────────────
1147
1148    #[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}