Skip to main content

anno_core/core/
entity.rs

1//! Entity types and structures for NER.
2//!
3//! # Design Philosophy (Research-Aligned)
4//!
5//! This module implements entity types informed by NER research (GLiNER, TPLinker):
6//!
7//! - **GLiNER/Bi-Encoder**: Entity types are *labels to match against*, not fixed classes.
8//!   Relations ("CEO of") are entities too - they're just labels in the same latent space.
9//!
10//! - **TPLinker/Joint Extraction**: Entities and relations can be extracted in a single pass.
11//!   The type system supports relation triggers as first-class mentions.
12//!
13//! - **Knowledge Graphs**: Entities can link to external knowledge bases (`kb_id`) for
14//!   coreference resolution and GraphRAG applications.
15//!
16//! # Type Hierarchy
17//!
18//! ```text
19//! Mention
20//! ├── Entity (single span)
21//! │   ├── Named (ML): Person, Organization, Location
22//! │   ├── Temporal (Pattern): Date, Time
23//! │   ├── Numeric (Pattern): Money, Percent, Quantity, Cardinal, Ordinal
24//! │   └── Contact (Pattern): Email, Url, Phone
25//! │
26//! └── Relation (connects entities)
27//!     └── Trigger text: "CEO of", "located in", "born on"
28//! ```
29//!
30//! # Design Principles
31//!
32//! 1. **Bi-encoder compatible**: Types are semantic labels, not fixed enums
33//! 2. **Joint extraction**: Relations are mentions with trigger spans
34//! 3. **Knowledge linking**: `kb_id` for connecting to external KBs
35//! 4. **Hierarchical confidence**: Coarse (linkage) + fine (type) scores
36//! 5. **Multi-modal ready**: Spans can be text offsets or visual bboxes
37
38use super::confidence::Confidence;
39use super::types::MentionType;
40use serde::{Deserialize, Serialize};
41use std::borrow::Cow;
42
43// ============================================================================
44// Entity Category (OntoNotes-inspired)
45// ============================================================================
46
47/// Category of entity based on detection characteristics and semantics.
48///
49/// Based on OntoNotes 5.0 categories with extensions for:
50/// - Structured data (Contact, patterns)
51/// - Knowledge graphs (Relation, for TPLinker/GLiNER joint extraction)
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
53#[non_exhaustive]
54pub enum EntityCategory {
55    /// Named entities for people/groups (ML-required).
56    /// Types: Person, NORP (nationalities/religious/political groups)
57    Agent,
58    /// Named entities for organizations/facilities (ML-required).
59    /// Types: Organization, Facility
60    Organization,
61    /// Named entities for places (ML-required).
62    /// Types: GPE (geo-political), Location (geographic)
63    Place,
64    /// Named entities for creative/conceptual (ML-required).
65    /// Types: Event, Product, WorkOfArt, Law, Language
66    Creative,
67    /// Temporal entities (pattern-detectable).
68    /// Types: Date, Time
69    Temporal,
70    /// Numeric entities (pattern-detectable).
71    /// Types: Money, Percent, Quantity, Cardinal, Ordinal
72    Numeric,
73    /// Contact/identifier entities (pattern-detectable).
74    /// Types: Email, Url, Phone
75    Contact,
76    /// Relation triggers for knowledge graph construction (ML-required).
77    /// Examples: "CEO of", "located in", "founded by"
78    /// In GLiNER bi-encoder, relations are just another label to match.
79    Relation,
80    /// Miscellaneous/unknown category
81    Misc,
82}
83
84impl EntityCategory {
85    /// Returns true if this category requires ML for detection.
86    #[must_use]
87    pub const fn requires_ml(&self) -> bool {
88        matches!(
89            self,
90            EntityCategory::Agent
91                | EntityCategory::Organization
92                | EntityCategory::Place
93                | EntityCategory::Creative
94                | EntityCategory::Relation
95        )
96    }
97
98    /// Returns true if this category can be detected via patterns.
99    #[must_use]
100    pub const fn pattern_detectable(&self) -> bool {
101        matches!(
102            self,
103            EntityCategory::Temporal | EntityCategory::Numeric | EntityCategory::Contact
104        )
105    }
106
107    /// Returns true if this is a relation (for knowledge graph construction).
108    #[must_use]
109    pub const fn is_relation(&self) -> bool {
110        matches!(self, EntityCategory::Relation)
111    }
112
113    /// Returns OntoNotes-compatible category name.
114    #[must_use]
115    pub const fn as_str(&self) -> &'static str {
116        match self {
117            EntityCategory::Agent => "agent",
118            EntityCategory::Organization => "organization",
119            EntityCategory::Place => "place",
120            EntityCategory::Creative => "creative",
121            EntityCategory::Temporal => "temporal",
122            EntityCategory::Numeric => "numeric",
123            EntityCategory::Contact => "contact",
124            EntityCategory::Relation => "relation",
125            EntityCategory::Misc => "misc",
126        }
127    }
128}
129
130impl std::fmt::Display for EntityCategory {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        write!(f, "{}", self.as_str())
133    }
134}
135
136// ============================================================================
137// Entity Type
138// ============================================================================
139
140/// Entity type classification.
141///
142/// Organized into categories:
143/// - **Named** (ML-required): Person, Organization, Location
144/// - **Temporal** (pattern): Date, Time
145/// - **Numeric** (pattern): Money, Percent, Quantity, Cardinal, Ordinal
146/// - **Contact** (pattern): Email, Url, Phone
147///
148/// # Examples
149///
150/// ```
151/// use anno_core::EntityType;
152///
153/// let ty = EntityType::Email;
154/// assert!(ty.category().pattern_detectable());
155/// assert!(!ty.category().requires_ml());
156///
157/// let ty = EntityType::Person;
158/// assert!(ty.category().requires_ml());
159/// ```
160#[derive(Debug, Clone, PartialEq, Eq, Hash)]
161#[non_exhaustive]
162pub enum EntityType {
163    // === Named Entities (ML-required) ===
164    /// Person name (PER) - requires ML/context
165    Person,
166    /// Organization name (ORG) - requires ML/context
167    Organization,
168    /// Location/Place (LOC/GPE) - requires ML/context
169    Location,
170
171    // === Temporal Entities (Pattern-detectable) ===
172    /// Date expression (DATE) - pattern-detectable
173    Date,
174    /// Time expression (TIME) - pattern-detectable
175    Time,
176
177    // === Numeric Entities (Pattern-detectable) ===
178    /// Monetary value (MONEY) - pattern-detectable
179    Money,
180    /// Percentage (PERCENT) - pattern-detectable
181    Percent,
182    /// Quantity with unit (QUANTITY) - pattern-detectable
183    Quantity,
184    /// Cardinal number (CARDINAL) - pattern-detectable
185    Cardinal,
186    /// Ordinal number (ORDINAL) - pattern-detectable
187    Ordinal,
188
189    // === Contact Entities (Pattern-detectable) ===
190    /// Email address - pattern-detectable
191    Email,
192    /// URL/URI - pattern-detectable
193    Url,
194    /// Phone number - pattern-detectable
195    Phone,
196
197    // === Extensibility ===
198    /// Domain-specific custom type with explicit category
199    Custom {
200        /// Type name (e.g., "DISEASE", "PRODUCT", "EVENT")
201        name: String,
202        /// Category for this custom type
203        category: EntityCategory,
204    },
205}
206
207impl EntityType {
208    /// Get the category of this entity type.
209    #[must_use]
210    pub fn category(&self) -> EntityCategory {
211        match self {
212            // Agent entities (people/groups)
213            EntityType::Person => EntityCategory::Agent,
214            // Organization entities
215            EntityType::Organization => EntityCategory::Organization,
216            // Place entities (locations)
217            EntityType::Location => EntityCategory::Place,
218            // Temporal entities
219            EntityType::Date | EntityType::Time => EntityCategory::Temporal,
220            // Numeric entities
221            EntityType::Money
222            | EntityType::Percent
223            | EntityType::Quantity
224            | EntityType::Cardinal
225            | EntityType::Ordinal => EntityCategory::Numeric,
226            // Contact entities
227            EntityType::Email | EntityType::Url | EntityType::Phone => EntityCategory::Contact,
228            // Custom with explicit category
229            EntityType::Custom { category, .. } => *category,
230        }
231    }
232
233    /// Returns true if this entity type requires ML for detection.
234    #[must_use]
235    pub fn requires_ml(&self) -> bool {
236        self.category().requires_ml()
237    }
238
239    /// Returns true if this entity type can be detected via patterns.
240    #[must_use]
241    pub fn pattern_detectable(&self) -> bool {
242        self.category().pattern_detectable()
243    }
244
245    /// Convert to standard label string (CoNLL/OntoNotes format).
246    ///
247    /// ```
248    /// use anno_core::EntityType;
249    ///
250    /// assert_eq!(EntityType::Person.as_label(), "PER");
251    /// assert_eq!(EntityType::Location.as_label(), "LOC");
252    /// ```
253    #[must_use]
254    pub fn as_label(&self) -> &str {
255        match self {
256            EntityType::Person => "PER",
257            EntityType::Organization => "ORG",
258            EntityType::Location => "LOC",
259            EntityType::Date => "DATE",
260            EntityType::Time => "TIME",
261            EntityType::Money => "MONEY",
262            EntityType::Percent => "PERCENT",
263            EntityType::Quantity => "QUANTITY",
264            EntityType::Cardinal => "CARDINAL",
265            EntityType::Ordinal => "ORDINAL",
266            EntityType::Email => "EMAIL",
267            EntityType::Url => "URL",
268            EntityType::Phone => "PHONE",
269            EntityType::Custom { name, .. } => name.as_str(),
270        }
271    }
272
273    /// Parse from standard label string.
274    ///
275    /// Handles various formats: CoNLL (PER), OntoNotes (PERSON), BIO (B-PER).
276    ///
277    /// ```
278    /// use anno_core::EntityType;
279    ///
280    /// assert_eq!(EntityType::from_label("PER"), EntityType::Person);
281    /// assert_eq!(EntityType::from_label("B-ORG"), EntityType::Organization);
282    /// assert_eq!(EntityType::from_label("PERSON"), EntityType::Person);
283    /// ```
284    #[must_use]
285    pub fn from_label(label: &str) -> Self {
286        // Strip BIO prefix if present
287        let label = label
288            .strip_prefix("B-")
289            .or_else(|| label.strip_prefix("I-"))
290            .or_else(|| label.strip_prefix("E-"))
291            .or_else(|| label.strip_prefix("S-"))
292            .unwrap_or(label);
293
294        match label.to_uppercase().as_str() {
295            // Named entities (multiple variations)
296            "PER" | "PERSON" => EntityType::Person,
297            "ORG" | "ORGANIZATION" | "COMPANY" | "CORPORATION" => EntityType::Organization,
298            "LOC" | "LOCATION" | "GPE" | "GEO-LOC" => EntityType::Location,
299            // WNUT / FewNERD specific types (common in social media / Wikipedia)
300            "FACILITY" | "FAC" | "BUILDING" => {
301                EntityType::custom("BUILDING", EntityCategory::Place)
302            }
303            "PRODUCT" | "PROD" => EntityType::custom("PRODUCT", EntityCategory::Misc),
304            "EVENT" => EntityType::custom("EVENT", EntityCategory::Creative),
305            "CREATIVE-WORK" | "WORK_OF_ART" | "ART" => {
306                EntityType::custom("CREATIVE_WORK", EntityCategory::Creative)
307            }
308            "GROUP" | "NORP" => EntityType::custom("GROUP", EntityCategory::Agent),
309            // Temporal
310            "DATE" => EntityType::Date,
311            "TIME" => EntityType::Time,
312            // Numeric
313            "MONEY" | "CURRENCY" => EntityType::Money,
314            "PERCENT" | "PERCENTAGE" => EntityType::Percent,
315            "QUANTITY" => EntityType::Quantity,
316            "CARDINAL" => EntityType::Cardinal,
317            "ORDINAL" => EntityType::Ordinal,
318            // Contact
319            "EMAIL" => EntityType::Email,
320            "URL" | "URI" => EntityType::Url,
321            "PHONE" | "TELEPHONE" => EntityType::Phone,
322            // MISC variations
323            "MISC" | "MISCELLANEOUS" | "OTHER" => EntityType::custom("MISC", EntityCategory::Misc),
324            // Biomedical types
325            "DISEASE" | "DISORDER" => EntityType::custom("DISEASE", EntityCategory::Misc),
326            "CHEMICAL" | "DRUG" => EntityType::custom("CHEMICAL", EntityCategory::Misc),
327            "GENE" => EntityType::custom("GENE", EntityCategory::Misc),
328            "PROTEIN" => EntityType::custom("PROTEIN", EntityCategory::Misc),
329            // Unknown -> Custom with Misc category
330            other => EntityType::custom(other, EntityCategory::Misc),
331        }
332    }
333
334    /// Create a custom domain-specific entity type.
335    ///
336    /// # Examples
337    ///
338    /// ```
339    /// use anno_core::{EntityType, EntityCategory};
340    ///
341    /// let disease = EntityType::custom("DISEASE", EntityCategory::Agent);
342    /// assert!(disease.requires_ml());
343    ///
344    /// let product_id = EntityType::custom("PRODUCT_ID", EntityCategory::Misc);
345    /// assert!(!product_id.requires_ml());
346    /// ```
347    #[must_use]
348    pub fn custom(name: impl Into<String>, category: EntityCategory) -> Self {
349        EntityType::Custom {
350            name: name.into(),
351            category,
352        }
353    }
354}
355
356impl std::fmt::Display for EntityType {
357    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
358        write!(f, "{}", self.as_label())
359    }
360}
361
362impl std::str::FromStr for EntityType {
363    type Err = std::convert::Infallible;
364
365    /// Parse from standard label string. Never fails -- unknown labels become `Custom`.
366    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
367        Ok(Self::from_label(s))
368    }
369}
370
371// Flatten EntityType to its label string for JSON serialization.
372// `Custom { name: "MISC", .. }` -> `"MISC"`, `Person` -> `"PER"`, etc.
373// Deserialization accepts both the flat string (new format) and the
374// tagged-enum object (backward compat with existing serialized data).
375impl Serialize for EntityType {
376    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
377        serializer.serialize_str(self.as_label())
378    }
379}
380
381impl<'de> Deserialize<'de> for EntityType {
382    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
383        struct EntityTypeVisitor;
384
385        impl<'de> serde::de::Visitor<'de> for EntityTypeVisitor {
386            type Value = EntityType;
387
388            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389                f.write_str("a string label or a tagged enum object")
390            }
391
392            // New flat format: "PER", "ORG", "MISC", etc.
393            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<EntityType, E> {
394                Ok(EntityType::from_label(v))
395            }
396
397            // Backward-compat: {"Custom":{"name":"MISC","category":"Misc"}}
398            // or {"Other":"foo"} or "Person" (unit variant as map key)
399            fn visit_map<A: serde::de::MapAccess<'de>>(
400                self,
401                mut map: A,
402            ) -> Result<EntityType, A::Error> {
403                let key: String = map
404                    .next_key()?
405                    .ok_or_else(|| serde::de::Error::custom("empty object"))?;
406                match key.as_str() {
407                    "Custom" => {
408                        #[derive(Deserialize)]
409                        struct CustomFields {
410                            name: String,
411                            category: EntityCategory,
412                        }
413                        let fields: CustomFields = map.next_value()?;
414                        Ok(EntityType::Custom {
415                            name: fields.name,
416                            category: fields.category,
417                        })
418                    }
419                    "Other" => {
420                        // Route legacy Other to Custom with Misc category
421                        let val: String = map.next_value()?;
422                        Ok(EntityType::custom(val, EntityCategory::Misc))
423                    }
424                    // Unit variants serialized as {"Person":null} etc.
425                    variant => {
426                        // Consume the value (null or unit)
427                        let _: serde::de::IgnoredAny = map.next_value()?;
428                        Ok(EntityType::from_label(variant))
429                    }
430                }
431            }
432        }
433
434        deserializer.deserialize_any(EntityTypeVisitor)
435    }
436}
437
438// =============================================================================
439// Type Mapping for Domain-Specific Datasets
440// =============================================================================
441
442/// Maps domain-specific entity types to standard NER types.
443///
444/// # Research Context (Familiarity paper, arXiv:2412.10121)
445///
446/// Type mapping creates "label overlap" between training and evaluation:
447/// - Mapping ACTOR → Person increases overlap
448/// - This can inflate zero-shot F1 scores
449///
450/// Use `LabelShift::from_type_sets()` to quantify how much overlap exists.
451/// High overlap (>80%) means the evaluation is NOT truly zero-shot.
452///
453/// # When to Use TypeMapper
454///
455/// - Cross-dataset comparison (normalize schemas for fair eval)
456/// - Domain adaptation (map new labels to known types)
457///
458/// # When NOT to Use TypeMapper
459///
460/// - True zero-shot evaluation (keep labels distinct)
461/// - Measuring generalization (overlap hides generalization failures)
462///
463/// # Example
464///
465/// ```rust
466/// use anno_core::{TypeMapper, EntityType, EntityCategory};
467///
468/// // MIT Movie dataset mapping
469/// let mut mapper = TypeMapper::new();
470/// mapper.add("ACTOR", EntityType::Person);
471/// mapper.add("DIRECTOR", EntityType::Person);
472/// mapper.add("TITLE", EntityType::custom("WORK_OF_ART", EntityCategory::Creative));
473///
474/// assert_eq!(mapper.map("ACTOR"), Some(&EntityType::Person));
475/// assert_eq!(mapper.normalize("DIRECTOR"), EntityType::Person);
476/// ```
477#[derive(Debug, Clone, Default)]
478pub struct TypeMapper {
479    mappings: std::collections::HashMap<String, EntityType>,
480}
481
482impl TypeMapper {
483    /// Create empty mapper.
484    #[must_use]
485    pub fn new() -> Self {
486        Self::default()
487    }
488
489    /// Create mapper for MIT Movie dataset.
490    #[must_use]
491    pub fn mit_movie() -> Self {
492        let mut mapper = Self::new();
493        // Map to standard types where possible
494        mapper.add("ACTOR", EntityType::Person);
495        mapper.add("DIRECTOR", EntityType::Person);
496        mapper.add("CHARACTER", EntityType::Person);
497        mapper.add(
498            "TITLE",
499            EntityType::custom("WORK_OF_ART", EntityCategory::Creative),
500        );
501        mapper.add("GENRE", EntityType::custom("GENRE", EntityCategory::Misc));
502        mapper.add("YEAR", EntityType::Date);
503        mapper.add("RATING", EntityType::custom("RATING", EntityCategory::Misc));
504        mapper.add("PLOT", EntityType::custom("PLOT", EntityCategory::Misc));
505        mapper
506    }
507
508    /// Create mapper for MIT Restaurant dataset.
509    #[must_use]
510    pub fn mit_restaurant() -> Self {
511        let mut mapper = Self::new();
512        mapper.add("RESTAURANT_NAME", EntityType::Organization);
513        mapper.add("LOCATION", EntityType::Location);
514        mapper.add(
515            "CUISINE",
516            EntityType::custom("CUISINE", EntityCategory::Misc),
517        );
518        mapper.add("DISH", EntityType::custom("DISH", EntityCategory::Misc));
519        mapper.add("PRICE", EntityType::Money);
520        mapper.add(
521            "AMENITY",
522            EntityType::custom("AMENITY", EntityCategory::Misc),
523        );
524        mapper.add("HOURS", EntityType::Time);
525        mapper
526    }
527
528    /// Create mapper for biomedical datasets (BC5CDR, NCBI).
529    #[must_use]
530    pub fn biomedical() -> Self {
531        let mut mapper = Self::new();
532        mapper.add(
533            "DISEASE",
534            EntityType::custom("DISEASE", EntityCategory::Agent),
535        );
536        mapper.add(
537            "CHEMICAL",
538            EntityType::custom("CHEMICAL", EntityCategory::Misc),
539        );
540        mapper.add("DRUG", EntityType::custom("DRUG", EntityCategory::Misc));
541        mapper.add("GENE", EntityType::custom("GENE", EntityCategory::Misc));
542        mapper.add(
543            "PROTEIN",
544            EntityType::custom("PROTEIN", EntityCategory::Misc),
545        );
546        // GENIA types
547        mapper.add("DNA", EntityType::custom("DNA", EntityCategory::Misc));
548        mapper.add("RNA", EntityType::custom("RNA", EntityCategory::Misc));
549        mapper.add(
550            "cell_line",
551            EntityType::custom("CELL_LINE", EntityCategory::Misc),
552        );
553        mapper.add(
554            "cell_type",
555            EntityType::custom("CELL_TYPE", EntityCategory::Misc),
556        );
557        mapper
558    }
559
560    /// Create mapper for social media NER datasets (TweetNER7, etc.).
561    #[must_use]
562    pub fn social_media() -> Self {
563        let mut mapper = Self::new();
564        // TweetNER7 types
565        mapper.add("person", EntityType::Person);
566        mapper.add("corporation", EntityType::Organization);
567        mapper.add("location", EntityType::Location);
568        mapper.add("group", EntityType::Organization);
569        mapper.add(
570            "product",
571            EntityType::custom("PRODUCT", EntityCategory::Misc),
572        );
573        mapper.add(
574            "creative_work",
575            EntityType::custom("WORK_OF_ART", EntityCategory::Creative),
576        );
577        mapper.add("event", EntityType::custom("EVENT", EntityCategory::Misc));
578        mapper
579    }
580
581    /// Create mapper for manufacturing domain datasets (FabNER, etc.).
582    #[must_use]
583    pub fn manufacturing() -> Self {
584        let mut mapper = Self::new();
585        // FabNER entity types
586        mapper.add("MATE", EntityType::custom("MATERIAL", EntityCategory::Misc));
587        mapper.add("MANP", EntityType::custom("PROCESS", EntityCategory::Misc));
588        mapper.add("MACEQ", EntityType::custom("MACHINE", EntityCategory::Misc));
589        mapper.add(
590            "APPL",
591            EntityType::custom("APPLICATION", EntityCategory::Misc),
592        );
593        mapper.add("FEAT", EntityType::custom("FEATURE", EntityCategory::Misc));
594        mapper.add(
595            "PARA",
596            EntityType::custom("PARAMETER", EntityCategory::Misc),
597        );
598        mapper.add("PRO", EntityType::custom("PROPERTY", EntityCategory::Misc));
599        mapper.add(
600            "CHAR",
601            EntityType::custom("CHARACTERISTIC", EntityCategory::Misc),
602        );
603        mapper.add(
604            "ENAT",
605            EntityType::custom("ENABLING_TECHNOLOGY", EntityCategory::Misc),
606        );
607        mapper.add(
608            "CONPRI",
609            EntityType::custom("CONCEPT_PRINCIPLE", EntityCategory::Misc),
610        );
611        mapper.add(
612            "BIOP",
613            EntityType::custom("BIO_PROCESS", EntityCategory::Misc),
614        );
615        mapper.add(
616            "MANS",
617            EntityType::custom("MAN_STANDARD", EntityCategory::Misc),
618        );
619        mapper
620    }
621
622    /// Add a mapping from source label to target type.
623    pub fn add(&mut self, source: impl Into<String>, target: EntityType) {
624        self.mappings.insert(source.into().to_uppercase(), target);
625    }
626
627    /// Get mapped type for a label (returns None if not mapped).
628    #[must_use]
629    pub fn map(&self, label: &str) -> Option<&EntityType> {
630        self.mappings.get(&label.to_uppercase())
631    }
632
633    /// Normalize a label to EntityType, using mapping if available.
634    ///
635    /// Falls back to `EntityType::from_label()` if no mapping exists.
636    #[must_use]
637    pub fn normalize(&self, label: &str) -> EntityType {
638        self.map(label)
639            .cloned()
640            .unwrap_or_else(|| EntityType::from_label(label))
641    }
642
643    /// Check if a label is mapped.
644    #[must_use]
645    pub fn contains(&self, label: &str) -> bool {
646        self.mappings.contains_key(&label.to_uppercase())
647    }
648
649    /// Get all source labels.
650    pub fn labels(&self) -> impl Iterator<Item = &String> {
651        self.mappings.keys()
652    }
653}
654
655/// Extraction method used to identify an entity.
656///
657/// # Research Context
658///
659/// Different extraction methods have different strengths:
660///
661/// | Method | Precision | Recall | Generalization | Use Case |
662/// |--------|-----------|--------|----------------|----------|
663/// | Pattern | Very High | Low | N/A (format-based) | Dates, emails, money |
664/// | Neural | High | High | Good | General NER |
665///
666/// See `docs/` for repo-local notes and entry points.
667#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
668#[non_exhaustive]
669pub enum ExtractionMethod {
670    /// Regex pattern matching (high precision for structured data like dates, money).
671    /// Does not generalize - only detects format-based entities.
672    Pattern,
673
674    /// Neural model inference (BERT, GLiNER, etc.).
675    /// The recommended default for general NER. Generalizes to unseen entities.
676    #[default]
677    Neural,
678
679    /// Multiple methods agreed on this entity (high confidence).
680    Consensus,
681
682    /// Heuristic-based extraction (capitalization, word shape, context).
683    /// Used by heuristic backends that don't use neural models.
684    Heuristic,
685
686    /// Unknown or unspecified extraction method.
687    Unknown,
688}
689
690impl ExtractionMethod {
691    /// Returns true if this extraction method produces probabilistically calibrated
692    /// confidence scores suitable for calibration analysis (ECE, Brier score, etc.).
693    ///
694    /// # Calibrated Methods
695    ///
696    /// - **Neural**: Softmax outputs are intended to be probabilistic (though may need
697    ///   temperature scaling for true calibration)
698    ///
699    /// # Uncalibrated Methods
700    ///
701    /// - **Pattern**: Binary (match/no-match); confidence is typically hardcoded
702    /// - **Heuristic**: Arbitrary scores from hand-crafted rules
703    /// - **Consensus**: Agreement count, not a probability
704    ///
705    /// # Example
706    ///
707    /// ```rust
708    /// use anno_core::ExtractionMethod;
709    ///
710    /// assert!(ExtractionMethod::Neural.is_calibrated());
711    /// assert!(!ExtractionMethod::Pattern.is_calibrated());
712    /// assert!(!ExtractionMethod::Heuristic.is_calibrated());
713    /// ```
714    #[must_use]
715    pub const fn is_calibrated(&self) -> bool {
716        match self {
717            ExtractionMethod::Neural => true,
718            // Everything else is not calibrated
719            ExtractionMethod::Pattern => false,
720            ExtractionMethod::Consensus => false,
721            ExtractionMethod::Heuristic => false,
722            ExtractionMethod::Unknown => false,
723        }
724    }
725
726    /// Returns the confidence interpretation for this extraction method.
727    ///
728    /// This helps users understand what the confidence score means:
729    /// - `"probability"`: Score approximates P(correct)
730    /// - `"heuristic_score"`: Score is a non-probabilistic quality measure
731    /// - `"binary"`: Score is 0 or 1 (or a fixed value for matches)
732    #[must_use]
733    pub const fn confidence_interpretation(&self) -> &'static str {
734        match self {
735            ExtractionMethod::Neural => "probability",
736            ExtractionMethod::Pattern => "binary",
737            ExtractionMethod::Heuristic => "heuristic_score",
738            ExtractionMethod::Consensus => "agreement_ratio",
739            ExtractionMethod::Unknown => "unknown",
740        }
741    }
742}
743
744impl std::fmt::Display for ExtractionMethod {
745    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
746        match self {
747            ExtractionMethod::Pattern => write!(f, "pattern"),
748            ExtractionMethod::Neural => write!(f, "neural"),
749            ExtractionMethod::Consensus => write!(f, "consensus"),
750            ExtractionMethod::Heuristic => write!(f, "heuristic"),
751            ExtractionMethod::Unknown => write!(f, "unknown"),
752        }
753    }
754}
755
756// =============================================================================
757// Lexicon Traits
758// =============================================================================
759
760/// Exact-match lexicon/gazetteer for entity lookup.
761///
762/// # Research Context
763///
764/// Gazetteers (lists of known entities) are a classic NER technique. Recent research
765/// suggests they are most valuable when:
766///
767/// 1. **Domain is closed**: Stock tickers, medical codes, known product catalogs
768/// 2. **Text is short**: where context is insufficient
769/// 3. **Used as features**: Input to neural model, not final output (Song et al. 2020)
770///
771/// They're harmful when:
772/// 1. **Domain is open**: Novel entities not in the list get missed
773/// 2. **Used as authority**: Hardcoded lookups inflate test scores but fail in production
774///
775/// # When to Use
776///
777/// ```text
778/// Decision: Should I use a Lexicon?
779///
780/// Is entity type CLOSED (fixed, known list)?
781/// ├─ Yes: Lexicon is appropriate
782/// │       Examples: stock tickers, ICD-10 codes, country names
783/// └─ No:  Use Neural extraction instead
784///         Examples: person names, organization names, products
785/// ```
786///
787/// # Example
788///
789/// ```rust
790/// use anno_core::{Lexicon, EntityType, HashMapLexicon};
791///
792/// // Create a domain-specific lexicon
793/// let mut lexicon = HashMapLexicon::new("stock_tickers");
794/// lexicon.insert("AAPL", EntityType::Organization, 0.99);
795/// lexicon.insert("GOOGL", EntityType::Organization, 0.99);
796///
797/// // Lookup
798/// if let Some((entity_type, confidence)) = lexicon.lookup("AAPL") {
799///     assert_eq!(entity_type, EntityType::Organization);
800///     assert!(confidence > 0.9);
801/// }
802/// ```
803///
804/// # References
805///
806/// - Song et al. (2020). "Improving Neural NER with Gazetteers"
807/// - Nie et al. (2021). "GEMNET: Effective Gated Gazetteer Representations"
808/// - Rijhwani et al. (2020). "Soft Gazetteers for Low-Resource NER"
809pub trait Lexicon: Send + Sync {
810    /// Lookup an exact string, returning entity type and confidence if found.
811    ///
812    /// Returns `None` if the text is not in the lexicon.
813    fn lookup(&self, text: &str) -> Option<(EntityType, Confidence)>;
814
815    /// Check if the lexicon contains this exact string.
816    fn contains(&self, text: &str) -> bool {
817        self.lookup(text).is_some()
818    }
819
820    /// Get the lexicon source identifier (for provenance tracking).
821    fn source(&self) -> &str;
822
823    /// Get approximate number of entries (for debugging/metrics).
824    fn len(&self) -> usize;
825
826    /// Check if lexicon is empty.
827    fn is_empty(&self) -> bool {
828        self.len() == 0
829    }
830}
831
832/// Simple HashMap-based lexicon implementation.
833///
834/// Suitable for small to medium lexicons (<100k entries).
835/// For larger lexicons, consider a trie-based or FST implementation.
836#[derive(Debug, Clone)]
837pub struct HashMapLexicon {
838    entries: std::collections::HashMap<String, (EntityType, Confidence)>,
839    source: String,
840}
841
842impl HashMapLexicon {
843    /// Create a new empty lexicon with the given source identifier.
844    #[must_use]
845    pub fn new(source: impl Into<String>) -> Self {
846        Self {
847            entries: std::collections::HashMap::new(),
848            source: source.into(),
849        }
850    }
851
852    /// Insert an entry into the lexicon.
853    pub fn insert(
854        &mut self,
855        text: impl Into<String>,
856        entity_type: EntityType,
857        confidence: impl Into<Confidence>,
858    ) {
859        self.entries
860            .insert(text.into(), (entity_type, confidence.into()));
861    }
862
863    /// Create from an iterator of (text, type, confidence) tuples.
864    pub fn from_iter<I, S, C>(source: impl Into<String>, entries: I) -> Self
865    where
866        I: IntoIterator<Item = (S, EntityType, C)>,
867        S: Into<String>,
868        C: Into<Confidence>,
869    {
870        let mut lexicon = Self::new(source);
871        for (text, entity_type, conf) in entries {
872            lexicon.insert(text, entity_type, conf);
873        }
874        lexicon
875    }
876
877    /// Get all entries as an iterator (for debugging).
878    pub fn entries(&self) -> impl Iterator<Item = (&str, &EntityType, Confidence)> {
879        self.entries.iter().map(|(k, (t, c))| (k.as_str(), t, *c))
880    }
881}
882
883impl Lexicon for HashMapLexicon {
884    fn lookup(&self, text: &str) -> Option<(EntityType, Confidence)> {
885        self.entries.get(text).cloned()
886    }
887
888    fn source(&self) -> &str {
889        &self.source
890    }
891
892    fn len(&self) -> usize {
893        self.entries.len()
894    }
895}
896
897/// Provenance information for an extracted entity.
898///
899/// Tracks where an entity came from for debugging, explainability,
900/// and confidence calibration in hybrid/ensemble systems.
901#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
902pub struct Provenance {
903    /// Name of the backend that produced this entity (e.g., "pattern", "bert-onnx")
904    pub source: Cow<'static, str>,
905    /// Extraction method used
906    pub method: ExtractionMethod,
907    /// Specific pattern/rule name (for pattern/rule-based extraction)
908    pub pattern: Option<Cow<'static, str>>,
909    /// Raw confidence from the source model (before any calibration)
910    pub raw_confidence: Option<Confidence>,
911    /// Model version for reproducibility (e.g., "gliner-v2.1", "bert-base-uncased-2024-01")
912    #[serde(default, skip_serializing_if = "Option::is_none")]
913    pub model_version: Option<Cow<'static, str>>,
914    /// Timestamp when extraction occurred (ISO 8601)
915    #[serde(default, skip_serializing_if = "Option::is_none")]
916    pub timestamp: Option<String>,
917}
918
919impl Provenance {
920    /// Create provenance for regex-based extraction.
921    #[must_use]
922    pub fn pattern(pattern_name: &'static str) -> Self {
923        Self {
924            source: Cow::Borrowed("pattern"),
925            method: ExtractionMethod::Pattern,
926            pattern: Some(Cow::Borrowed(pattern_name)),
927            raw_confidence: Some(Confidence::ONE), // Patterns are deterministic
928            model_version: None,
929            timestamp: None,
930        }
931    }
932
933    /// Create provenance for ML-based extraction.
934    ///
935    /// Accepts both static strings and owned strings:
936    /// ```rust
937    /// use anno_core::Provenance;
938    ///
939    /// // Static string (zero allocation)
940    /// let p1 = Provenance::ml("gliner", 0.95);
941    ///
942    /// // Owned string (dynamic model name)
943    /// let model_name = "bert-base";
944    /// let p2 = Provenance::ml(model_name.to_string(), 0.95);
945    /// ```
946    #[must_use]
947    pub fn ml(model_name: impl Into<Cow<'static, str>>, confidence: impl Into<Confidence>) -> Self {
948        Self {
949            source: model_name.into(),
950            method: ExtractionMethod::Neural,
951            pattern: None,
952            raw_confidence: Some(confidence.into()),
953            model_version: None,
954            timestamp: None,
955        }
956    }
957
958    /// Create provenance for ensemble/hybrid extraction.
959    #[must_use]
960    pub fn ensemble(sources: &'static str) -> Self {
961        Self {
962            source: Cow::Borrowed(sources),
963            method: ExtractionMethod::Consensus,
964            pattern: None,
965            raw_confidence: None,
966            model_version: None,
967            timestamp: None,
968        }
969    }
970
971    /// Create provenance with model version for reproducibility.
972    #[must_use]
973    pub fn with_version(mut self, version: &'static str) -> Self {
974        self.model_version = Some(Cow::Borrowed(version));
975        self
976    }
977
978    /// Create provenance with timestamp.
979    #[must_use]
980    pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
981        self.timestamp = Some(timestamp.into());
982        self
983    }
984}
985
986// ============================================================================
987// Span Types (Multi-Modal Support)
988// ============================================================================
989
990/// A span locator for text and visual modalities.
991///
992/// `Span` is a **simplified subset** of [`grounded::Location`] designed for
993/// the detection layer (`Entity`). It covers the most common cases:
994///
995/// - Text offsets (traditional NER)
996/// - Bounding boxes (visual document understanding)
997/// - Hybrid (OCR with both text and visual location)
998///
999/// # Relationship to `Location`
1000///
1001/// | `Span` variant | `Location` equivalent |
1002/// |----------------|-----------------------|
1003/// | `Text` | `Location::Text` |
1004/// | `BoundingBox` | `Location::BoundingBox` |
1005/// | `Hybrid` | `Location::TextWithBbox` |
1006///
1007/// For modalities not covered by `Span` (temporal, cuboid, genomic, discontinuous),
1008/// use `Location` directly via the canonical `Signal` → `Track` → `Identity` pipeline.
1009///
1010/// # Conversion
1011///
1012/// - `Span → Location`: Always succeeds via `Location::from(&span)`
1013/// - `Location → Span`: Use `location.to_span()`, returns `None` for unsupported variants
1014///
1015/// [`grounded::Location`]: super::grounded::Location
1016#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1017pub enum Span {
1018    /// Text span with **character offsets** (start, end).
1019    ///
1020    /// Offsets are Unicode scalar value indices (what `text.chars()` counts),
1021    /// consistent with `Entity.start/end` and `grounded::Location::Text`.
1022    Text {
1023        /// Start character offset (inclusive)
1024        start: usize,
1025        /// End character offset (exclusive)
1026        end: usize,
1027    },
1028    /// Visual bounding box (normalized 0.0-1.0 coordinates)
1029    /// For ColPali: image patch locations
1030    BoundingBox {
1031        /// X coordinate (normalized 0.0-1.0)
1032        x: f32,
1033        /// Y coordinate (normalized 0.0-1.0)
1034        y: f32,
1035        /// Width (normalized 0.0-1.0)
1036        width: f32,
1037        /// Height (normalized 0.0-1.0)
1038        height: f32,
1039        /// Optional page number (for multi-page documents)
1040        page: Option<u32>,
1041    },
1042    /// Hybrid: both text and visual location (for OCR-verified extraction)
1043    Hybrid {
1044        /// Start character offset (inclusive)
1045        start: usize,
1046        /// End character offset (exclusive)
1047        end: usize,
1048        /// Bounding box for visual location
1049        bbox: Box<Span>,
1050    },
1051}
1052
1053impl Span {
1054    /// Create a text span.
1055    #[must_use]
1056    pub const fn text(start: usize, end: usize) -> Self {
1057        Self::Text { start, end }
1058    }
1059
1060    /// Create a bounding box span with normalized coordinates.
1061    #[must_use]
1062    pub fn bbox(x: f32, y: f32, width: f32, height: f32) -> Self {
1063        Self::BoundingBox {
1064            x,
1065            y,
1066            width,
1067            height,
1068            page: None,
1069        }
1070    }
1071
1072    /// Create a bounding box with page number.
1073    #[must_use]
1074    pub fn bbox_on_page(x: f32, y: f32, width: f32, height: f32, page: u32) -> Self {
1075        Self::BoundingBox {
1076            x,
1077            y,
1078            width,
1079            height,
1080            page: Some(page),
1081        }
1082    }
1083
1084    /// Check if this is a text span.
1085    #[must_use]
1086    pub const fn is_text(&self) -> bool {
1087        matches!(self, Self::Text { .. } | Self::Hybrid { .. })
1088    }
1089
1090    /// Check if this has visual location.
1091    #[must_use]
1092    pub const fn is_visual(&self) -> bool {
1093        matches!(self, Self::BoundingBox { .. } | Self::Hybrid { .. })
1094    }
1095
1096    /// Get text offsets if available.
1097    #[must_use]
1098    pub const fn text_offsets(&self) -> Option<(usize, usize)> {
1099        match self {
1100            Self::Text { start, end } => Some((*start, *end)),
1101            Self::Hybrid { start, end, .. } => Some((*start, *end)),
1102            Self::BoundingBox { .. } => None,
1103        }
1104    }
1105
1106    /// Calculate span length for text spans.
1107    #[must_use]
1108    pub fn len(&self) -> usize {
1109        match self {
1110            Self::Text { start, end } => end.saturating_sub(*start),
1111            Self::Hybrid { start, end, .. } => end.saturating_sub(*start),
1112            Self::BoundingBox { .. } => 0,
1113        }
1114    }
1115
1116    /// Check if span is empty.
1117    #[must_use]
1118    pub fn is_empty(&self) -> bool {
1119        self.len() == 0
1120    }
1121}
1122
1123// ============================================================================
1124// Discontinuous Spans (W2NER/ACE-style)
1125// ============================================================================
1126
1127/// A discontinuous span representing non-contiguous entity mentions.
1128///
1129/// Some entities span multiple non-adjacent text regions:
1130/// - "severe \[pain\] in the \[abdomen\]" → "severe abdominal pain"
1131/// - "the \[president\] ... \[Obama\]" → coreference
1132///
1133/// This is required for:
1134/// - **Medical NER**: Anatomical modifiers separated from findings
1135/// - **Legal NER**: Parties referenced across clauses
1136/// - **W2NER**: Word-word relation grids that detect discontinuous entities
1137///
1138/// # Offset Unit (CRITICAL)
1139///
1140/// `DiscontinuousSpan` uses **character offsets** (Unicode scalar value indices),
1141/// consistent with [`Entity::start`](super::entity::Entity::start) /
1142/// [`Entity::end`](super::entity::Entity::end) and `anno::core::grounded::Location`.
1143///
1144/// This is intentionally *not* byte offsets. If you have byte offsets (from regex,
1145/// `str::find`, tokenizers, etc.), convert them to character offsets first (see
1146/// `anno::offset::SpanConverter` in the `anno` crate).
1147///
1148/// # Example
1149///
1150/// ```rust
1151/// use anno_core::DiscontinuousSpan;
1152///
1153/// // "severe pain in the abdomen" where "severe" modifies "pain"
1154/// // but they're separated by other words
1155/// let span = DiscontinuousSpan::new(vec![
1156///     0..6,   // "severe"
1157///     12..16, // "pain"
1158/// ]);
1159///
1160/// assert_eq!(span.num_segments(), 2);
1161/// assert!(span.is_discontinuous());
1162/// ```
1163#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1164pub struct DiscontinuousSpan {
1165    /// Non-overlapping segments, sorted by start position.
1166    /// Each `Range<usize>` represents (start_char, end_char).
1167    segments: Vec<std::ops::Range<usize>>,
1168}
1169
1170impl<'de> serde::Deserialize<'de> for DiscontinuousSpan {
1171    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1172    where
1173        D: serde::Deserializer<'de>,
1174    {
1175        /// Helper to drive the default Vec<Range<usize>> deserialization.
1176        #[derive(serde::Deserialize)]
1177        struct Raw {
1178            segments: Vec<std::ops::Range<usize>>,
1179        }
1180        let raw = Raw::deserialize(deserializer)?;
1181        // Route through `new()` so segments are sorted, merged, and deduplicated.
1182        Ok(Self::new(raw.segments))
1183    }
1184}
1185
1186impl DiscontinuousSpan {
1187    /// Create a new discontinuous span from segments.
1188    ///
1189    /// Segments are sorted by start position and overlapping segments are
1190    /// merged. Empty segments (where `start >= end`) are discarded.
1191    #[must_use]
1192    pub fn new(mut segments: Vec<std::ops::Range<usize>>) -> Self {
1193        // Discard empty segments
1194        segments.retain(|r| r.start < r.end);
1195        // Sort by start position
1196        segments.sort_by_key(|r| r.start);
1197        // Merge overlapping or adjacent segments
1198        let mut merged: Vec<std::ops::Range<usize>> = Vec::with_capacity(segments.len());
1199        for seg in segments {
1200            if let Some(last) = merged.last_mut() {
1201                if seg.start <= last.end {
1202                    // Overlapping or adjacent -- extend
1203                    last.end = last.end.max(seg.end);
1204                    continue;
1205                }
1206            }
1207            merged.push(seg);
1208        }
1209        Self { segments: merged }
1210    }
1211
1212    /// Create from a single contiguous span.
1213    ///
1214    /// If `start > end`, the values are swapped.  Empty spans (`start == end`) produce
1215    /// a zero-segment span.
1216    #[must_use]
1217    #[allow(clippy::single_range_in_vec_init)] // Intentional: contiguous is special case of discontinuous
1218    pub fn contiguous(start: usize, end: usize) -> Self {
1219        let (lo, hi) = if start <= end {
1220            (start, end)
1221        } else {
1222            (end, start)
1223        };
1224        if lo == hi {
1225            Self {
1226                segments: Vec::new(),
1227            }
1228        } else {
1229            Self {
1230                segments: vec![lo..hi],
1231            }
1232        }
1233    }
1234
1235    /// Number of segments.
1236    #[must_use]
1237    pub fn num_segments(&self) -> usize {
1238        self.segments.len()
1239    }
1240
1241    /// True if this spans multiple non-adjacent regions.
1242    #[must_use]
1243    pub fn is_discontinuous(&self) -> bool {
1244        self.segments.len() > 1
1245    }
1246
1247    /// True if this is a single contiguous span.
1248    #[must_use]
1249    pub fn is_contiguous(&self) -> bool {
1250        self.segments.len() <= 1
1251    }
1252
1253    /// Get the segments.
1254    #[must_use]
1255    pub fn segments(&self) -> &[std::ops::Range<usize>] {
1256        &self.segments
1257    }
1258
1259    /// Get the overall bounding range (start of first to end of last).
1260    #[must_use]
1261    pub fn bounding_range(&self) -> Option<std::ops::Range<usize>> {
1262        if self.segments.is_empty() {
1263            return None;
1264        }
1265        let start = self.segments.first()?.start;
1266        let end = self.segments.last()?.end;
1267        Some(start..end)
1268    }
1269
1270    /// Total character length (sum of all segments).
1271    ///
1272    #[must_use]
1273    pub fn total_len(&self) -> usize {
1274        self.segments.iter().map(|r| r.end - r.start).sum()
1275    }
1276
1277    /// Extract text from each segment and join with separator.
1278    #[must_use]
1279    pub fn extract_text(&self, text: &str, separator: &str) -> String {
1280        self.segments
1281            .iter()
1282            .map(|r| {
1283                let start = r.start;
1284                let len = r.end.saturating_sub(r.start);
1285                text.chars().skip(start).take(len).collect::<String>()
1286            })
1287            .collect::<Vec<_>>()
1288            .join(separator)
1289    }
1290
1291    /// Check if a character position falls within any segment.
1292    ///
1293    /// # Arguments
1294    ///
1295    /// * `pos` - Character offset to check (Unicode scalar value index)
1296    ///
1297    /// # Returns
1298    ///
1299    /// `true` if the character position falls within any segment of this span.
1300    #[must_use]
1301    pub fn contains(&self, pos: usize) -> bool {
1302        self.segments.iter().any(|r| r.contains(&pos))
1303    }
1304
1305    /// Convert to a regular Span (uses bounding range, loses discontinuity info).
1306    #[must_use]
1307    pub fn to_span(&self) -> Option<Span> {
1308        self.bounding_range().map(|r| Span::Text {
1309            start: r.start,
1310            end: r.end,
1311        })
1312    }
1313}
1314
1315impl From<std::ops::Range<usize>> for DiscontinuousSpan {
1316    fn from(range: std::ops::Range<usize>) -> Self {
1317        Self::contiguous(range.start, range.end)
1318    }
1319}
1320
1321impl Default for Span {
1322    fn default() -> Self {
1323        Self::Text { start: 0, end: 0 }
1324    }
1325}
1326
1327// ============================================================================
1328// Hierarchical Confidence (Coarse-to-Fine)
1329// ============================================================================
1330
1331/// Hierarchical confidence scores for coarse-to-fine extraction.
1332///
1333/// Research (HiNet, InfoHier) shows that extraction benefits from
1334/// decomposed confidence:
1335/// - **Linkage**: "Is there ANY entity here?" (binary, fast filter)
1336/// - **Type**: "What type is it?" (fine-grained classification)
1337/// - **Boundary**: "Where exactly does it start/end?" (span refinement)
1338#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1339pub struct HierarchicalConfidence {
1340    /// Coarse: probability that this span contains ANY entity (0.0-1.0)
1341    /// Used for early filtering in the TPLinker "handshaking" matrix.
1342    pub linkage: Confidence,
1343    /// Fine: probability that the type classification is correct (0.0-1.0)
1344    pub type_score: Confidence,
1345    /// Boundary: confidence in the exact span boundaries (0.0-1.0)
1346    /// Low for entities with fuzzy boundaries (e.g., "the CEO" vs "CEO")
1347    pub boundary: Confidence,
1348}
1349
1350impl HierarchicalConfidence {
1351    /// Create hierarchical confidence with all scores.
1352    ///
1353    /// Accepts any type convertible to `Confidence` (f32, f64, Confidence).
1354    /// Out-of-range values are clamped to [0.0, 1.0].
1355    #[must_use]
1356    pub fn new(
1357        linkage: impl Into<Confidence>,
1358        type_score: impl Into<Confidence>,
1359        boundary: impl Into<Confidence>,
1360    ) -> Self {
1361        Self {
1362            linkage: linkage.into(),
1363            type_score: type_score.into(),
1364            boundary: boundary.into(),
1365        }
1366    }
1367
1368    /// Create from a single confidence score (legacy compatibility).
1369    /// Assigns same score to all levels.
1370    #[must_use]
1371    pub fn from_single(confidence: impl Into<Confidence>) -> Self {
1372        let c = confidence.into();
1373        Self {
1374            linkage: c,
1375            type_score: c,
1376            boundary: c,
1377        }
1378    }
1379
1380    /// Calculate combined confidence (geometric mean).
1381    /// Geometric mean penalizes low scores more than arithmetic mean.
1382    #[must_use]
1383    pub fn combined(&self) -> Confidence {
1384        let product = self.linkage.value() * self.type_score.value() * self.boundary.value();
1385        Confidence::new(product.powf(1.0 / 3.0))
1386    }
1387
1388    /// Calculate combined confidence as f64 for legacy compatibility.
1389    #[must_use]
1390    pub fn as_f64(&self) -> f64 {
1391        self.combined().value()
1392    }
1393
1394    /// Check if passes minimum threshold at all levels.
1395    #[must_use]
1396    pub fn passes_threshold(&self, linkage_min: f64, type_min: f64, boundary_min: f64) -> bool {
1397        self.linkage >= linkage_min && self.type_score >= type_min && self.boundary >= boundary_min
1398    }
1399}
1400
1401impl Default for HierarchicalConfidence {
1402    fn default() -> Self {
1403        Self {
1404            linkage: Confidence::ONE,
1405            type_score: Confidence::ONE,
1406            boundary: Confidence::ONE,
1407        }
1408    }
1409}
1410
1411impl From<f64> for HierarchicalConfidence {
1412    fn from(confidence: f64) -> Self {
1413        Self::from_single(confidence)
1414    }
1415}
1416
1417impl From<f32> for HierarchicalConfidence {
1418    fn from(confidence: f32) -> Self {
1419        Self::from_single(confidence)
1420    }
1421}
1422
1423impl From<Confidence> for HierarchicalConfidence {
1424    fn from(confidence: Confidence) -> Self {
1425        Self::from_single(confidence)
1426    }
1427}
1428
1429// ============================================================================
1430// Ragged Batch (ModernBERT Unpadding)
1431// ============================================================================
1432
1433/// A ragged (unpadded) batch for efficient ModernBERT inference.
1434///
1435/// ModernBERT achieves its speed advantage by avoiding padding tokens entirely.
1436/// Instead of `[batch, max_seq_len]`, it uses a single contiguous 1D sequence
1437/// with offset indices to track document boundaries.
1438///
1439/// # Memory Layout
1440///
1441/// ```text
1442/// Traditional (padded):
1443/// [doc1_tok1, doc1_tok2, PAD, PAD, PAD]  <- wasted compute
1444/// [doc2_tok1, doc2_tok2, doc2_tok3, PAD, PAD]
1445///
1446/// Ragged (unpadded):
1447/// [doc1_tok1, doc1_tok2, doc2_tok1, doc2_tok2, doc2_tok3]
1448/// cumulative_offsets: [0, 2, 5]  <- doc1 is [0..2], doc2 is [2..5]
1449/// ```
1450#[derive(Debug, Clone)]
1451pub struct RaggedBatch {
1452    /// Token IDs flattened into a single contiguous array.
1453    /// Shape: `[total_tokens]` (1D, no padding)
1454    pub token_ids: Vec<u32>,
1455    /// Cumulative sequence lengths.
1456    /// Length: batch_size + 1
1457    /// Document i spans tokens \[offsets\[i\]..offsets\[i+1\])
1458    pub cumulative_offsets: Vec<u32>,
1459    /// Maximum sequence length in this batch (for kernel bounds).
1460    pub max_seq_len: usize,
1461}
1462
1463impl RaggedBatch {
1464    /// Create a new ragged batch from sequences.
1465    pub fn from_sequences(sequences: &[Vec<u32>]) -> Self {
1466        let total_tokens: usize = sequences.iter().map(|s| s.len()).sum();
1467        let mut token_ids = Vec::with_capacity(total_tokens);
1468        let mut cumulative_offsets = Vec::with_capacity(sequences.len() + 1);
1469        let mut max_seq_len = 0;
1470
1471        cumulative_offsets.push(0);
1472        for seq in sequences {
1473            token_ids.extend_from_slice(seq);
1474            // Check for overflow: u32::MAX is 4,294,967,295
1475            // If token_ids.len() exceeds this, we'll truncate (which is a bug)
1476            // but in practice, this is unlikely for reasonable batch sizes
1477            let len = token_ids.len();
1478            if len > u32::MAX as usize {
1479                // This would overflow - use saturating cast to prevent panic
1480                // but log a warning as this indicates a problem
1481                log::warn!(
1482                    "Token count {} exceeds u32::MAX, truncating to {}",
1483                    len,
1484                    u32::MAX
1485                );
1486                cumulative_offsets.push(u32::MAX);
1487            } else {
1488                cumulative_offsets.push(len as u32);
1489            }
1490            max_seq_len = max_seq_len.max(seq.len());
1491        }
1492
1493        Self {
1494            token_ids,
1495            cumulative_offsets,
1496            max_seq_len,
1497        }
1498    }
1499
1500    /// Get the number of documents in this batch.
1501    #[must_use]
1502    pub fn batch_size(&self) -> usize {
1503        self.cumulative_offsets.len().saturating_sub(1)
1504    }
1505
1506    /// Get the total number of tokens (no padding).
1507    #[must_use]
1508    pub fn total_tokens(&self) -> usize {
1509        self.token_ids.len()
1510    }
1511
1512    /// Get token range for a specific document.
1513    #[must_use]
1514    pub fn doc_range(&self, doc_idx: usize) -> Option<std::ops::Range<usize>> {
1515        if doc_idx + 1 < self.cumulative_offsets.len() {
1516            let start = self.cumulative_offsets[doc_idx] as usize;
1517            let end = self.cumulative_offsets[doc_idx + 1] as usize;
1518            Some(start..end)
1519        } else {
1520            None
1521        }
1522    }
1523
1524    /// Get tokens for a specific document.
1525    #[must_use]
1526    pub fn doc_tokens(&self, doc_idx: usize) -> Option<&[u32]> {
1527        self.doc_range(doc_idx).map(|r| &self.token_ids[r])
1528    }
1529
1530    /// Calculate memory saved vs padded batch.
1531    #[must_use]
1532    pub fn padding_savings(&self) -> f64 {
1533        let padded_size = self.batch_size() * self.max_seq_len;
1534        if padded_size == 0 {
1535            return 0.0;
1536        }
1537        1.0 - (self.total_tokens() as f64 / padded_size as f64)
1538    }
1539}
1540
1541// ============================================================================
1542// Span Candidate Generation
1543// ============================================================================
1544
1545/// A candidate span for entity extraction.
1546///
1547/// In GLiNER/bi-encoder systems, we generate all possible spans up to a
1548/// maximum width and score them against entity type embeddings.
1549#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1550pub struct SpanCandidate {
1551    /// Document index in the batch
1552    pub doc_idx: u32,
1553    /// Start token index (within the document)
1554    pub start: u32,
1555    /// End token index (exclusive)
1556    pub end: u32,
1557}
1558
1559impl SpanCandidate {
1560    /// Create a new span candidate.
1561    #[must_use]
1562    pub const fn new(doc_idx: u32, start: u32, end: u32) -> Self {
1563        Self {
1564            doc_idx,
1565            start,
1566            end,
1567        }
1568    }
1569
1570    /// Get span width (number of tokens).
1571    #[must_use]
1572    pub const fn width(&self) -> u32 {
1573        self.end.saturating_sub(self.start)
1574    }
1575}
1576
1577/// Generate all valid span candidates for a ragged batch.
1578///
1579/// This is the "gnarly" operation in GLiNER - efficiently enumerating
1580/// all valid spans without O(N^2) memory allocation.
1581pub fn generate_span_candidates(batch: &RaggedBatch, max_width: usize) -> Vec<SpanCandidate> {
1582    let mut candidates = Vec::new();
1583
1584    for doc_idx in 0..batch.batch_size() {
1585        if let Some(range) = batch.doc_range(doc_idx) {
1586            let doc_len = range.len();
1587            // Generate all spans [i, j) where j - i <= max_width
1588            for start in 0..doc_len {
1589                let max_end = (start + max_width).min(doc_len);
1590                for end in (start + 1)..=max_end {
1591                    candidates.push(SpanCandidate::new(doc_idx as u32, start as u32, end as u32));
1592                }
1593            }
1594        }
1595    }
1596
1597    candidates
1598}
1599
1600/// Generate span candidates with early filtering.
1601///
1602/// Uses a linkage mask to skip low-probability spans (TPLinker optimization).
1603pub fn generate_filtered_candidates(
1604    batch: &RaggedBatch,
1605    max_width: usize,
1606    linkage_mask: &[f32],
1607    threshold: f32,
1608) -> Vec<SpanCandidate> {
1609    let mut candidates = Vec::new();
1610    let mut mask_idx = 0;
1611
1612    for doc_idx in 0..batch.batch_size() {
1613        if let Some(range) = batch.doc_range(doc_idx) {
1614            let doc_len = range.len();
1615            for start in 0..doc_len {
1616                let max_end = (start + max_width).min(doc_len);
1617                for end in (start + 1)..=max_end {
1618                    // Only include if linkage probability exceeds threshold
1619                    if mask_idx < linkage_mask.len() && linkage_mask[mask_idx] >= threshold {
1620                        candidates.push(SpanCandidate::new(
1621                            doc_idx as u32,
1622                            start as u32,
1623                            end as u32,
1624                        ));
1625                    }
1626                    mask_idx += 1;
1627                }
1628            }
1629        }
1630    }
1631
1632    candidates
1633}
1634
1635// ============================================================================
1636// Entity (Extended)
1637// ============================================================================
1638
1639/// A recognized named entity or relation trigger.
1640///
1641/// # Entity Structure
1642///
1643/// ```text
1644/// "Contact John at john@example.com on Jan 15"
1645///          ^^^^    ^^^^^^^^^^^^^^^^    ^^^^^^
1646///          PER     EMAIL               DATE
1647///          |       |                   |
1648///          Named   Contact             Temporal
1649///          (ML)    (Pattern)           (Pattern)
1650/// ```
1651///
1652/// # Core Fields (Stable API)
1653///
1654/// - `text`, `entity_type`, `start`, `end`, `confidence` — always present
1655/// - `normalized`, `provenance` — commonly used optional fields
1656/// - `kb_id`, `canonical_id` — knowledge graph and coreference support
1657///
1658/// # Extended Fields (Research/Experimental)
1659///
1660/// The following fields support advanced research applications but may evolve:
1661///
1662/// | Field | Purpose | Status |
1663/// |-------|---------|--------|
1664/// | `visual_span` | Multi-modal (ColPali) extraction | Experimental |
1665/// | `discontinuous_span` | W2NER non-contiguous entities | Experimental |
1666/// | `hierarchical_confidence` | Coarse-to-fine NER | Experimental |
1667///
1668/// These fields are `#[serde(skip_serializing_if = "Option::is_none")]` so they
1669/// have no overhead when unused.
1670///
1671/// # Knowledge Graph Support
1672///
1673/// For GraphRAG and coreference resolution, entities support:
1674/// - `kb_id`: External knowledge base identifier (e.g., Wikidata Q-ID)
1675/// - `canonical_id`: Local coreference cluster ID (links "John" and "he")
1676///
1677/// # Normalization
1678///
1679/// Entities can have a normalized form for downstream processing:
1680/// - Dates: "Jan 15" → "2024-01-15" (ISO 8601)
1681/// - Money: "$1.5M" → "1500000 USD"
1682/// - Locations: "NYC" → "New York City"
1683#[derive(Debug, Clone, Serialize)]
1684pub struct Entity {
1685    /// Entity text (surface form as it appears in source)
1686    pub text: String,
1687    /// Entity type classification
1688    pub entity_type: EntityType,
1689    /// Start position (character offset, NOT byte offset).
1690    ///
1691    /// For Unicode text, character offsets differ from byte offsets.
1692    /// Use `anno::offset::bytes_to_chars` to convert if needed.
1693    ///
1694    /// Access via [`Entity::start()`] / [`Entity::set_start()`].
1695    start: usize,
1696    /// End position (character offset, exclusive).
1697    ///
1698    /// For Unicode text, character offsets differ from byte offsets.
1699    /// Use `anno::offset::bytes_to_chars` to convert if needed.
1700    ///
1701    /// Access via [`Entity::end()`] / [`Entity::set_end()`].
1702    end: usize,
1703    /// Confidence score (0.0-1.0, calibrated).
1704    ///
1705    /// Construction via [`Confidence::new`] clamps to `[0.0, 1.0]`.
1706    /// Use `.value()` or `Into<f64>` to extract the raw score.
1707    pub confidence: Confidence,
1708    /// Normalized/canonical form (e.g., "Jan 15" → "2024-01-15")
1709    #[serde(default, skip_serializing_if = "Option::is_none")]
1710    pub normalized: Option<String>,
1711    /// Provenance: which backend/method produced this entity
1712    #[serde(default, skip_serializing_if = "Option::is_none")]
1713    pub provenance: Option<Provenance>,
1714    /// External knowledge base ID (e.g., "Q7186" for Marie Curie in Wikidata).
1715    /// Used for entity linking and GraphRAG applications.
1716    #[serde(default, skip_serializing_if = "Option::is_none")]
1717    pub kb_id: Option<String>,
1718    /// Local coreference cluster ID.
1719    /// Multiple mentions with the same `canonical_id` refer to the same entity.
1720    /// Example: "Marie Curie" and "she" might share `canonical_id = CanonicalId(42)`.
1721    #[serde(default, skip_serializing_if = "Option::is_none")]
1722    pub canonical_id: Option<super::types::CanonicalId>,
1723    /// Hierarchical confidence (coarse-to-fine).
1724    /// Provides linkage, type, and boundary scores separately.
1725    #[serde(default, skip_serializing_if = "Option::is_none")]
1726    pub hierarchical_confidence: Option<HierarchicalConfidence>,
1727    /// Visual span for multi-modal (ColPali) extraction.
1728    /// When set, provides bounding box location in addition to text offsets.
1729    #[serde(default, skip_serializing_if = "Option::is_none")]
1730    pub visual_span: Option<Span>,
1731    /// Discontinuous span for non-contiguous entity mentions (W2NER support).
1732    /// When set, overrides `start`/`end` for length calculations.
1733    /// Example: "New York and LA \[airports\]" where "airports" modifies both.
1734    #[serde(default, skip_serializing_if = "Option::is_none")]
1735    pub discontinuous_span: Option<DiscontinuousSpan>,
1736    /// Mention type classification (Proper, Nominal, Pronominal, Zero).
1737    ///
1738    /// Classifies the referring expression type for coreference resolution.
1739    /// Follows the Accessibility Hierarchy (Ariel 1990):
1740    /// Proper > Nominal > Pronominal > Zero.
1741    #[serde(default, skip_serializing_if = "Option::is_none")]
1742    pub mention_type: Option<MentionType>,
1743}
1744
1745impl<'de> Deserialize<'de> for Entity {
1746    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1747        /// Helper that mirrors Entity's fields so serde can derive the parsing,
1748        /// then we route through `Entity::new()` to enforce invariants (e.g.
1749        /// inverted span normalization).
1750        #[derive(Deserialize)]
1751        struct EntityHelper {
1752            text: String,
1753            entity_type: EntityType,
1754            start: usize,
1755            end: usize,
1756            confidence: Confidence,
1757            #[serde(default)]
1758            normalized: Option<String>,
1759            #[serde(default)]
1760            provenance: Option<Provenance>,
1761            #[serde(default)]
1762            kb_id: Option<String>,
1763            #[serde(default)]
1764            canonical_id: Option<super::types::CanonicalId>,
1765            #[serde(default)]
1766            hierarchical_confidence: Option<HierarchicalConfidence>,
1767            #[serde(default)]
1768            visual_span: Option<Span>,
1769            #[serde(default)]
1770            discontinuous_span: Option<DiscontinuousSpan>,
1771            #[serde(default)]
1772            mention_type: Option<MentionType>,
1773        }
1774
1775        let h = EntityHelper::deserialize(deserializer)?;
1776        let mut entity = Entity::new(h.text, h.entity_type, h.start, h.end, h.confidence);
1777        entity.normalized = h.normalized;
1778        entity.provenance = h.provenance;
1779        entity.kb_id = h.kb_id;
1780        entity.canonical_id = h.canonical_id;
1781        entity.hierarchical_confidence = h.hierarchical_confidence;
1782        entity.visual_span = h.visual_span;
1783        entity.discontinuous_span = h.discontinuous_span;
1784        entity.mention_type = h.mention_type;
1785        Ok(entity)
1786    }
1787}
1788
1789impl Entity {
1790    /// Create a new entity.
1791    ///
1792    /// ```
1793    /// use anno_core::{Entity, EntityType};
1794    ///
1795    /// let e = Entity::new("Berlin", EntityType::Location, 10, 16, 0.95);
1796    /// assert_eq!(e.text, "Berlin");
1797    /// assert_eq!(e.entity_type, EntityType::Location);
1798    /// assert_eq!((e.start(), e.end()), (10, 16));
1799    /// ```
1800    #[must_use]
1801    pub fn new(
1802        text: impl Into<String>,
1803        entity_type: EntityType,
1804        start: usize,
1805        end: usize,
1806        confidence: impl Into<Confidence>,
1807    ) -> Self {
1808        // Normalize inverted spans (same as CharSpan::new)
1809        let (start, end) = if start > end {
1810            (end, start)
1811        } else {
1812            (start, end)
1813        };
1814        Self {
1815            text: text.into(),
1816            entity_type,
1817            start,
1818            end,
1819            confidence: confidence.into(),
1820            normalized: None,
1821            provenance: None,
1822            kb_id: None,
1823            canonical_id: None,
1824            hierarchical_confidence: None,
1825            visual_span: None,
1826            discontinuous_span: None,
1827            mention_type: None,
1828        }
1829    }
1830
1831    /// Start character offset (inclusive, 0-indexed).
1832    #[inline]
1833    #[must_use]
1834    pub fn start(&self) -> usize {
1835        self.start
1836    }
1837
1838    /// End character offset (exclusive).
1839    #[inline]
1840    #[must_use]
1841    pub fn end(&self) -> usize {
1842        self.end
1843    }
1844
1845    /// Set the start offset. For use in post-processing pipelines.
1846    ///
1847    /// # Panics (debug only)
1848    ///
1849    /// Debug-asserts that `start <= self.end` to prevent inverted spans.
1850    #[inline]
1851    pub fn set_start(&mut self, start: usize) {
1852        debug_assert!(
1853            start <= self.end,
1854            "set_start({start}) would invert span (end={})",
1855            self.end
1856        );
1857        self.start = start;
1858    }
1859
1860    /// Set the end offset. For use in post-processing pipelines.
1861    ///
1862    /// # Panics (debug only)
1863    ///
1864    /// Debug-asserts that `end >= self.start` to prevent inverted spans.
1865    #[inline]
1866    pub fn set_end(&mut self, end: usize) {
1867        debug_assert!(
1868            end >= self.start,
1869            "set_end({end}) would invert span (start={})",
1870            self.start
1871        );
1872        self.end = end;
1873    }
1874
1875    /// Create a new entity with provenance information.
1876    #[must_use]
1877    pub fn with_provenance(
1878        text: impl Into<String>,
1879        entity_type: EntityType,
1880        start: usize,
1881        end: usize,
1882        confidence: impl Into<Confidence>,
1883        provenance: Provenance,
1884    ) -> Self {
1885        let (start, end) = if start > end {
1886            (end, start)
1887        } else {
1888            (start, end)
1889        };
1890        Self {
1891            text: text.into(),
1892            entity_type,
1893            start,
1894            end,
1895            confidence: confidence.into(),
1896            normalized: None,
1897            provenance: Some(provenance),
1898            kb_id: None,
1899            canonical_id: None,
1900            hierarchical_confidence: None,
1901            visual_span: None,
1902            discontinuous_span: None,
1903            mention_type: None,
1904        }
1905    }
1906
1907    /// Create an entity with hierarchical confidence scores.
1908    #[must_use]
1909    pub fn with_hierarchical_confidence(
1910        text: impl Into<String>,
1911        entity_type: EntityType,
1912        start: usize,
1913        end: usize,
1914        confidence: HierarchicalConfidence,
1915    ) -> Self {
1916        let (start, end) = if start > end {
1917            (end, start)
1918        } else {
1919            (start, end)
1920        };
1921        Self {
1922            text: text.into(),
1923            entity_type,
1924            start,
1925            end,
1926            confidence: Confidence::new(confidence.as_f64()),
1927            normalized: None,
1928            provenance: None,
1929            kb_id: None,
1930            canonical_id: None,
1931            hierarchical_confidence: Some(confidence),
1932            visual_span: None,
1933            discontinuous_span: None,
1934            mention_type: None,
1935        }
1936    }
1937
1938    /// Create an entity from a visual bounding box (ColPali multi-modal).
1939    #[must_use]
1940    pub fn from_visual(
1941        text: impl Into<String>,
1942        entity_type: EntityType,
1943        bbox: Span,
1944        confidence: impl Into<Confidence>,
1945    ) -> Self {
1946        Self {
1947            text: text.into(),
1948            entity_type,
1949            start: 0,
1950            end: 0,
1951            confidence: confidence.into(),
1952            normalized: None,
1953            provenance: None,
1954            kb_id: None,
1955            canonical_id: None,
1956            hierarchical_confidence: None,
1957            visual_span: Some(bbox),
1958            discontinuous_span: None,
1959            mention_type: None,
1960        }
1961    }
1962
1963    /// Create an entity with default confidence (1.0).
1964    #[must_use]
1965    pub fn with_type(
1966        text: impl Into<String>,
1967        entity_type: EntityType,
1968        start: usize,
1969        end: usize,
1970    ) -> Self {
1971        Self::new(text, entity_type, start, end, 1.0)
1972    }
1973
1974    /// Link this entity to an external knowledge base.
1975    ///
1976    /// # Examples
1977    /// ```
1978    /// use anno_core::{Entity, EntityType};
1979    /// let mut e = Entity::new("Marie Curie", EntityType::Person, 0, 11, 0.95);
1980    /// e.link_to_kb("Q7186");
1981    /// assert_eq!(e.kb_id.as_deref(), Some("Q7186"));
1982    /// ```
1983    pub fn link_to_kb(&mut self, kb_id: impl Into<String>) {
1984        self.kb_id = Some(kb_id.into());
1985    }
1986
1987    /// Assign this entity to a coreference cluster.
1988    ///
1989    /// Entities with the same `canonical_id` refer to the same real-world entity.
1990    pub fn set_canonical(&mut self, canonical_id: impl Into<super::types::CanonicalId>) {
1991        self.canonical_id = Some(canonical_id.into());
1992    }
1993
1994    /// Builder-style method to set canonical ID.
1995    ///
1996    /// # Example
1997    /// ```
1998    /// use anno_core::{CanonicalId, Entity, EntityType};
1999    /// let entity = Entity::new("John", EntityType::Person, 0, 4, 0.9)
2000    ///     .with_canonical_id(42);
2001    /// assert_eq!(entity.canonical_id, Some(CanonicalId::new(42)));
2002    /// ```
2003    #[must_use]
2004    pub fn with_canonical_id(mut self, canonical_id: impl Into<super::types::CanonicalId>) -> Self {
2005        self.canonical_id = Some(canonical_id.into());
2006        self
2007    }
2008
2009    /// Check if this entity is linked to a knowledge base.
2010    #[must_use]
2011    pub fn is_linked(&self) -> bool {
2012        self.kb_id.is_some()
2013    }
2014
2015    /// Check if this entity has coreference information.
2016    #[must_use]
2017    pub fn has_coreference(&self) -> bool {
2018        self.canonical_id.is_some()
2019    }
2020
2021    /// Check if this entity has a discontinuous span.
2022    ///
2023    /// Discontinuous entities span non-contiguous text regions.
2024    /// Example: "New York and LA airports" contains "New York airports"
2025    /// as a discontinuous entity.
2026    #[must_use]
2027    pub fn is_discontinuous(&self) -> bool {
2028        self.discontinuous_span
2029            .as_ref()
2030            .map(|s| s.is_discontinuous())
2031            .unwrap_or(false)
2032    }
2033
2034    /// Get the discontinuous segments if present.
2035    ///
2036    /// Returns `None` if this is a contiguous entity.
2037    #[must_use]
2038    pub fn discontinuous_segments(&self) -> Option<Vec<std::ops::Range<usize>>> {
2039        self.discontinuous_span
2040            .as_ref()
2041            .filter(|s| s.is_discontinuous())
2042            .map(|s| s.segments().to_vec())
2043    }
2044
2045    /// Set a discontinuous span for this entity.
2046    ///
2047    /// This is used by W2NER and similar models that detect non-contiguous mentions.
2048    pub fn set_discontinuous_span(&mut self, span: DiscontinuousSpan) {
2049        // Update start/end to match the bounding range
2050        if let Some(bounding) = span.bounding_range() {
2051            self.start = bounding.start;
2052            self.end = bounding.end;
2053        }
2054        self.discontinuous_span = Some(span);
2055    }
2056
2057    /// Get the total length covered by this entity, in **characters**.
2058    ///
2059    /// - **Contiguous**: `end - start`
2060    /// - **Discontinuous**: sum of segment lengths
2061    ///
2062    /// This is intentionally consistent: all offsets in `anno::core` entity spans
2063    /// are **character offsets** (Unicode scalar values), not byte offsets.
2064    #[must_use]
2065    pub fn total_len(&self) -> usize {
2066        if let Some(ref span) = self.discontinuous_span {
2067            span.segments().iter().map(|r| r.end - r.start).sum()
2068        } else {
2069            self.end.saturating_sub(self.start)
2070        }
2071    }
2072
2073    /// Set the normalized form for this entity.
2074    ///
2075    /// # Examples
2076    ///
2077    /// ```rust
2078    /// use anno_core::{Entity, EntityType};
2079    ///
2080    /// let mut entity = Entity::new("Jan 15", EntityType::Date, 0, 6, 0.95);
2081    /// entity.set_normalized("2024-01-15");
2082    /// assert_eq!(entity.normalized.as_deref(), Some("2024-01-15"));
2083    /// ```
2084    pub fn set_normalized(&mut self, normalized: impl Into<String>) {
2085        self.normalized = Some(normalized.into());
2086    }
2087
2088    /// Get the normalized form, or the original text if not normalized.
2089    #[must_use]
2090    pub fn normalized_or_text(&self) -> &str {
2091        self.normalized.as_deref().unwrap_or(&self.text)
2092    }
2093
2094    /// Get the extraction method, if known.
2095    #[must_use]
2096    pub fn method(&self) -> ExtractionMethod {
2097        self.provenance
2098            .as_ref()
2099            .map_or(ExtractionMethod::Unknown, |p| p.method)
2100    }
2101
2102    /// Get the source backend name, if known.
2103    #[must_use]
2104    pub fn source(&self) -> Option<&str> {
2105        self.provenance.as_ref().map(|p| p.source.as_ref())
2106    }
2107
2108    /// Get the entity category.
2109    #[must_use]
2110    pub fn category(&self) -> EntityCategory {
2111        self.entity_type.category()
2112    }
2113
2114    /// Returns true if this entity was detected via patterns (not ML).
2115    #[must_use]
2116    pub fn is_structured(&self) -> bool {
2117        self.entity_type.pattern_detectable()
2118    }
2119
2120    /// Returns true if this entity required ML for detection.
2121    #[must_use]
2122    pub fn is_named(&self) -> bool {
2123        self.entity_type.requires_ml()
2124    }
2125
2126    /// Check if this entity overlaps with another.
2127    #[must_use]
2128    pub fn overlaps(&self, other: &Entity) -> bool {
2129        !(self.end <= other.start || other.end <= self.start)
2130    }
2131
2132    /// Calculate overlap ratio (IoU) with another entity.
2133    #[must_use]
2134    pub fn overlap_ratio(&self, other: &Entity) -> f64 {
2135        let intersection_start = self.start.max(other.start);
2136        let intersection_end = self.end.min(other.end);
2137
2138        if intersection_start >= intersection_end {
2139            return 0.0;
2140        }
2141
2142        let intersection = (intersection_end - intersection_start) as f64;
2143        let union = ((self.end - self.start) + (other.end - other.start)
2144            - (intersection_end - intersection_start)) as f64;
2145
2146        if union == 0.0 {
2147            return 1.0;
2148        }
2149
2150        intersection / union
2151    }
2152
2153    /// Set hierarchical confidence scores.
2154    pub fn set_hierarchical_confidence(&mut self, confidence: HierarchicalConfidence) {
2155        self.confidence = Confidence::new(confidence.as_f64());
2156        self.hierarchical_confidence = Some(confidence);
2157    }
2158
2159    /// Get the linkage confidence (coarse filter score).
2160    #[must_use]
2161    pub fn linkage_confidence(&self) -> Confidence {
2162        self.hierarchical_confidence
2163            .map_or(self.confidence, |h| h.linkage)
2164    }
2165
2166    /// Get the type classification confidence.
2167    #[must_use]
2168    pub fn type_confidence(&self) -> Confidence {
2169        self.hierarchical_confidence
2170            .map_or(self.confidence, |h| h.type_score)
2171    }
2172
2173    /// Get the boundary confidence.
2174    #[must_use]
2175    pub fn boundary_confidence(&self) -> Confidence {
2176        self.hierarchical_confidence
2177            .map_or(self.confidence, |h| h.boundary)
2178    }
2179
2180    /// Check if this entity has visual location (multi-modal).
2181    #[must_use]
2182    pub fn is_visual(&self) -> bool {
2183        self.visual_span.is_some()
2184    }
2185
2186    /// Get the text span (start, end).
2187    #[must_use]
2188    pub const fn text_span(&self) -> (usize, usize) {
2189        (self.start, self.end)
2190    }
2191
2192    /// Get the span length.
2193    #[must_use]
2194    pub const fn span_len(&self) -> usize {
2195        self.end.saturating_sub(self.start)
2196    }
2197
2198    /// Create a unified TextSpan with both byte and char offsets.
2199    ///
2200    /// This is useful when you need to work with both offset systems.
2201    /// The `text` parameter must be the original source text from which
2202    /// this entity was extracted.
2203    ///
2204    /// # Arguments
2205    /// * `source_text` - The original text (needed to compute byte offsets)
2206    ///
2207    /// # Returns
2208    /// A TextSpan with both byte and char offsets.
2209    ///
2210    /// # Note
2211    ///
2212    /// This method requires the offset conversion utilities from the `anno` crate.
2213    /// Use `anno::offset::char_to_byte_offsets()` directly for now.
2214    ///
2215    /// # Example
2216    /// ```rust,ignore
2217    /// use anno_core::{Entity, EntityType};
2218    ///
2219    /// let (byte_start, byte_end) = char_to_byte_offsets(text, entity.start(), entity.end());
2220    /// ```
2221    /// Set visual span for multi-modal extraction.
2222    pub fn set_visual_span(&mut self, span: Span) {
2223        self.visual_span = Some(span);
2224    }
2225
2226    /// Safely extract text from source using character offsets.
2227    ///
2228    /// Entity stores character offsets, not byte offsets. This method
2229    /// correctly extracts text by iterating over characters.
2230    ///
2231    /// # Arguments
2232    /// * `source_text` - The original text from which this entity was extracted
2233    ///
2234    /// # Returns
2235    /// The extracted text, or empty string if offsets are invalid
2236    ///
2237    /// # Example
2238    /// ```rust
2239    /// use anno_core::{Entity, EntityType};
2240    ///
2241    /// let text = "Hello, 日本!";
2242    /// let entity = Entity::new("日本", EntityType::Location, 7, 9, 0.95);
2243    /// assert_eq!(entity.extract_text(text), "日本");
2244    /// ```
2245    #[must_use]
2246    pub fn extract_text(&self, source_text: &str) -> String {
2247        // Performance: Use cached length if available, but fallback to counting
2248        // For single entity extraction, this is fine. For batch operations,
2249        // use extract_text_with_len with pre-computed length.
2250        let char_count = source_text.chars().count();
2251        self.extract_text_with_len(source_text, char_count)
2252    }
2253
2254    /// Extract text with pre-computed text length (performance optimization).
2255    ///
2256    /// Use this when validating/clamping multiple entities from the same text
2257    /// to avoid recalculating `text.chars().count()` for each entity.
2258    ///
2259    /// # Arguments
2260    /// * `source_text` - The original text
2261    /// * `text_char_count` - Pre-computed character count (from `text.chars().count()`)
2262    ///
2263    /// # Returns
2264    /// The extracted text, or empty string if offsets are invalid
2265    #[must_use]
2266    pub fn extract_text_with_len(&self, source_text: &str, text_char_count: usize) -> String {
2267        if self.start >= text_char_count || self.end > text_char_count || self.start >= self.end {
2268            return String::new();
2269        }
2270        source_text
2271            .chars()
2272            .skip(self.start)
2273            .take(self.end - self.start)
2274            .collect()
2275    }
2276
2277    /// Create a builder for fluent entity construction.
2278    #[must_use]
2279    pub fn builder(text: impl Into<String>, entity_type: EntityType) -> EntityBuilder {
2280        EntityBuilder::new(text, entity_type)
2281    }
2282
2283    // =========================================================================
2284    // Validation Methods (Production Quality)
2285    // =========================================================================
2286
2287    /// Validate this entity against the source text.
2288    ///
2289    /// Returns a list of validation issues. Empty list means the entity is valid.
2290    ///
2291    /// # Checks Performed
2292    ///
2293    /// 1. **Span bounds**: `start < end`, both within text length
2294    /// 2. **Text match**: `text` matches the span in source
2295    /// 3. **Confidence range**: `confidence` in [0.0, 1.0]
2296    /// 4. **Type consistency**: Custom types have non-empty names
2297    /// 5. **Discontinuous consistency**: If present, segments are valid
2298    ///
2299    /// # Example
2300    ///
2301    /// ```rust
2302    /// use anno_core::{Entity, EntityType};
2303    ///
2304    /// let text = "John works at Apple";
2305    /// let entity = Entity::new("John", EntityType::Person, 0, 4, 0.95);
2306    ///
2307    /// let issues = entity.validate(text);
2308    /// assert!(issues.is_empty(), "Entity should be valid");
2309    ///
2310    /// // Invalid entity: span doesn't match text
2311    /// let bad = Entity::new("Jane", EntityType::Person, 0, 4, 0.95);
2312    /// let issues = bad.validate(text);
2313    /// assert!(!issues.is_empty(), "Entity text doesn't match span");
2314    /// ```
2315    #[must_use]
2316    pub fn validate(&self, source_text: &str) -> Vec<ValidationIssue> {
2317        // Performance: Calculate length once, delegate to optimized version
2318        let char_count = source_text.chars().count();
2319        self.validate_with_len(source_text, char_count)
2320    }
2321
2322    /// Validate entity with pre-computed text length (performance optimization).
2323    ///
2324    /// Use this when validating multiple entities from the same text to avoid
2325    /// recalculating `text.chars().count()` for each entity.
2326    ///
2327    /// # Arguments
2328    /// * `source_text` - The original text
2329    /// * `text_char_count` - Pre-computed character count (from `text.chars().count()`)
2330    ///
2331    /// # Returns
2332    /// Vector of validation issues (empty if valid)
2333    #[must_use]
2334    pub fn validate_with_len(
2335        &self,
2336        source_text: &str,
2337        text_char_count: usize,
2338    ) -> Vec<ValidationIssue> {
2339        let mut issues = Vec::new();
2340
2341        // 1. Span bounds
2342        if self.start >= self.end {
2343            issues.push(ValidationIssue::InvalidSpan {
2344                start: self.start,
2345                end: self.end,
2346                reason: "start must be less than end".to_string(),
2347            });
2348        }
2349
2350        if self.end > text_char_count {
2351            issues.push(ValidationIssue::SpanOutOfBounds {
2352                end: self.end,
2353                text_len: text_char_count,
2354            });
2355        }
2356
2357        // 2. Text match (only if span is valid)
2358        if self.start < self.end && self.end <= text_char_count {
2359            let actual = self.extract_text_with_len(source_text, text_char_count);
2360            if actual != self.text {
2361                issues.push(ValidationIssue::TextMismatch {
2362                    expected: self.text.clone(),
2363                    actual,
2364                    start: self.start,
2365                    end: self.end,
2366                });
2367            }
2368        }
2369
2370        // 3. Confidence range (now enforced by the Confidence type, so this is a no-op)
2371
2372        // 4. Type consistency
2373        if let EntityType::Custom { ref name, .. } = self.entity_type {
2374            if name.is_empty() {
2375                issues.push(ValidationIssue::InvalidType {
2376                    reason: "Custom entity type has empty name".to_string(),
2377                });
2378            }
2379        }
2380
2381        // 5. Discontinuous span consistency
2382        if let Some(ref disc_span) = self.discontinuous_span {
2383            for (i, seg) in disc_span.segments().iter().enumerate() {
2384                if seg.start >= seg.end {
2385                    issues.push(ValidationIssue::InvalidSpan {
2386                        start: seg.start,
2387                        end: seg.end,
2388                        reason: format!("discontinuous segment {} is invalid", i),
2389                    });
2390                }
2391                if seg.end > text_char_count {
2392                    issues.push(ValidationIssue::SpanOutOfBounds {
2393                        end: seg.end,
2394                        text_len: text_char_count,
2395                    });
2396                }
2397            }
2398        }
2399
2400        issues
2401    }
2402
2403    /// Check if this entity is valid against the source text.
2404    ///
2405    /// Convenience method that returns `true` if `validate()` returns empty.
2406    #[must_use]
2407    pub fn is_valid(&self, source_text: &str) -> bool {
2408        self.validate(source_text).is_empty()
2409    }
2410
2411    /// Validate a batch of entities efficiently.
2412    ///
2413    /// Returns a map of entity index -> validation issues.
2414    /// Only entities with issues are included.
2415    ///
2416    /// # Example
2417    ///
2418    /// ```rust
2419    /// use anno_core::{Entity, EntityType};
2420    ///
2421    /// let text = "John and Jane work at Apple";
2422    /// let entities = vec![
2423    ///     Entity::new("John", EntityType::Person, 0, 4, 0.95),
2424    ///     Entity::new("Wrong", EntityType::Person, 9, 13, 0.8),
2425    /// ];
2426    ///
2427    /// let issues = Entity::validate_batch(&entities, text);
2428    /// assert!(issues.is_empty() || issues.contains_key(&1)); // Second entity might fail
2429    /// ```
2430    #[must_use]
2431    pub fn validate_batch(
2432        entities: &[Entity],
2433        source_text: &str,
2434    ) -> std::collections::HashMap<usize, Vec<ValidationIssue>> {
2435        entities
2436            .iter()
2437            .enumerate()
2438            .filter_map(|(idx, entity)| {
2439                let issues = entity.validate(source_text);
2440                if issues.is_empty() {
2441                    None
2442                } else {
2443                    Some((idx, issues))
2444                }
2445            })
2446            .collect()
2447    }
2448}
2449
2450/// Validation issue found during entity validation.
2451#[derive(Debug, Clone, PartialEq)]
2452pub enum ValidationIssue {
2453    /// Span bounds are invalid (start >= end).
2454    InvalidSpan {
2455        /// Start position of the invalid span.
2456        start: usize,
2457        /// End position of the invalid span.
2458        end: usize,
2459        /// Description of why the span is invalid.
2460        reason: String,
2461    },
2462    /// Span extends beyond text length.
2463    SpanOutOfBounds {
2464        /// End position that exceeds the text.
2465        end: usize,
2466        /// Actual length of the text.
2467        text_len: usize,
2468    },
2469    /// Entity text doesn't match the span in source.
2470    TextMismatch {
2471        /// Text stored in the entity.
2472        expected: String,
2473        /// Text found at the span in source.
2474        actual: String,
2475        /// Start position of the span.
2476        start: usize,
2477        /// End position of the span.
2478        end: usize,
2479    },
2480    /// Confidence is outside [0.0, 1.0].
2481    InvalidConfidence {
2482        /// The invalid confidence value.
2483        value: f64,
2484    },
2485    /// Entity type is invalid.
2486    InvalidType {
2487        /// Description of why the type is invalid.
2488        reason: String,
2489    },
2490}
2491
2492impl std::fmt::Display for ValidationIssue {
2493    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2494        match self {
2495            ValidationIssue::InvalidSpan { start, end, reason } => {
2496                write!(f, "Invalid span [{}, {}): {}", start, end, reason)
2497            }
2498            ValidationIssue::SpanOutOfBounds { end, text_len } => {
2499                write!(f, "Span end {} exceeds text length {}", end, text_len)
2500            }
2501            ValidationIssue::TextMismatch {
2502                expected,
2503                actual,
2504                start,
2505                end,
2506            } => {
2507                write!(
2508                    f,
2509                    "Text mismatch at [{}, {}): expected '{}', got '{}'",
2510                    start, end, expected, actual
2511                )
2512            }
2513            ValidationIssue::InvalidConfidence { value } => {
2514                write!(f, "Confidence {} outside [0.0, 1.0]", value)
2515            }
2516            ValidationIssue::InvalidType { reason } => {
2517                write!(f, "Invalid entity type: {}", reason)
2518            }
2519        }
2520    }
2521}
2522
2523/// Fluent builder for constructing entities with optional fields.
2524///
2525/// # Example
2526///
2527/// ```rust
2528/// use anno_core::{Entity, EntityType, Provenance};
2529///
2530/// let entity = Entity::builder("Marie Curie", EntityType::Person)
2531///     .span(0, 11)
2532///     .confidence(0.95)
2533///     .kb_id("Q7186")
2534///     .provenance(Provenance::ml("bert", 0.95))
2535///     .build();
2536/// ```
2537#[derive(Debug, Clone)]
2538pub struct EntityBuilder {
2539    text: String,
2540    entity_type: EntityType,
2541    start: usize,
2542    end: usize,
2543    confidence: Confidence,
2544    normalized: Option<String>,
2545    provenance: Option<Provenance>,
2546    kb_id: Option<String>,
2547    canonical_id: Option<super::types::CanonicalId>,
2548    hierarchical_confidence: Option<HierarchicalConfidence>,
2549    visual_span: Option<Span>,
2550    discontinuous_span: Option<DiscontinuousSpan>,
2551    mention_type: Option<MentionType>,
2552}
2553
2554impl EntityBuilder {
2555    /// Create a new builder.
2556    #[must_use]
2557    pub fn new(text: impl Into<String>, entity_type: EntityType) -> Self {
2558        let text = text.into();
2559        let end = text.chars().count();
2560        Self {
2561            text,
2562            entity_type,
2563            start: 0,
2564            end,
2565            confidence: Confidence::ONE,
2566            normalized: None,
2567            provenance: None,
2568            kb_id: None,
2569            canonical_id: None,
2570            hierarchical_confidence: None,
2571            visual_span: None,
2572            discontinuous_span: None,
2573            mention_type: None,
2574        }
2575    }
2576
2577    /// Set span offsets.
2578    #[must_use]
2579    pub const fn span(mut self, start: usize, end: usize) -> Self {
2580        self.start = start;
2581        self.end = end;
2582        self
2583    }
2584
2585    /// Set confidence score.
2586    #[must_use]
2587    pub fn confidence(mut self, confidence: impl Into<Confidence>) -> Self {
2588        self.confidence = confidence.into();
2589        self
2590    }
2591
2592    /// Set hierarchical confidence.
2593    #[must_use]
2594    pub fn hierarchical_confidence(mut self, confidence: HierarchicalConfidence) -> Self {
2595        self.confidence = Confidence::new(confidence.as_f64());
2596        self.hierarchical_confidence = Some(confidence);
2597        self
2598    }
2599
2600    /// Set normalized form.
2601    #[must_use]
2602    pub fn normalized(mut self, normalized: impl Into<String>) -> Self {
2603        self.normalized = Some(normalized.into());
2604        self
2605    }
2606
2607    /// Set provenance.
2608    #[must_use]
2609    pub fn provenance(mut self, provenance: Provenance) -> Self {
2610        self.provenance = Some(provenance);
2611        self
2612    }
2613
2614    /// Set knowledge base ID.
2615    #[must_use]
2616    pub fn kb_id(mut self, kb_id: impl Into<String>) -> Self {
2617        self.kb_id = Some(kb_id.into());
2618        self
2619    }
2620
2621    /// Set canonical (coreference) ID.
2622    #[must_use]
2623    pub const fn canonical_id(mut self, canonical_id: u64) -> Self {
2624        self.canonical_id = Some(super::types::CanonicalId::new(canonical_id));
2625        self
2626    }
2627
2628    /// Set visual span.
2629    #[must_use]
2630    pub fn visual_span(mut self, span: Span) -> Self {
2631        self.visual_span = Some(span);
2632        self
2633    }
2634
2635    /// Set discontinuous span for non-contiguous entities.
2636    ///
2637    /// This automatically updates `start` and `end` to the bounding range.
2638    #[must_use]
2639    pub fn discontinuous_span(mut self, span: DiscontinuousSpan) -> Self {
2640        // Update start/end to bounding range
2641        if let Some(bounding) = span.bounding_range() {
2642            self.start = bounding.start;
2643            self.end = bounding.end;
2644        }
2645        self.discontinuous_span = Some(span);
2646        self
2647    }
2648
2649    /// Set mention type classification.
2650    #[must_use]
2651    pub fn mention_type(mut self, mention_type: MentionType) -> Self {
2652        self.mention_type = Some(mention_type);
2653        self
2654    }
2655
2656    /// Build the entity.
2657    ///
2658    /// If `start > end`, the values are swapped (matching [`Entity::new`] behaviour).
2659    #[must_use]
2660    pub fn build(self) -> Entity {
2661        let (start, end) = if self.start <= self.end {
2662            (self.start, self.end)
2663        } else {
2664            (self.end, self.start)
2665        };
2666        Entity {
2667            text: self.text,
2668            entity_type: self.entity_type,
2669            start,
2670            end,
2671            confidence: self.confidence,
2672            normalized: self.normalized,
2673            provenance: self.provenance,
2674            kb_id: self.kb_id,
2675            canonical_id: self.canonical_id,
2676            hierarchical_confidence: self.hierarchical_confidence,
2677            visual_span: self.visual_span,
2678            discontinuous_span: self.discontinuous_span,
2679            mention_type: self.mention_type,
2680        }
2681    }
2682}
2683
2684// ============================================================================
2685// Relation (for Knowledge Graph Construction)
2686// ============================================================================
2687
2688/// A relation between two entities, forming a knowledge graph triple.
2689///
2690/// In the GLiNER bi-encoder paradigm, relations are detected just like entities:
2691/// the relation trigger text ("CEO of", "located in") is matched against
2692/// relation type labels in the same latent space.
2693///
2694/// # Structure
2695///
2696/// ```text
2697/// Triple: (Head, Relation, Tail)
2698///
2699/// "Marie Curie worked at the Sorbonne"
2700///  ^^^^^^^^^^^ ~~~~~~~~~ ^^^^^^^^
2701///  Head        Rel       Tail
2702///  (Person)  (Employment)  (Organization)
2703/// ```
2704///
2705/// # TPLinker/Joint Extraction
2706///
2707/// For joint extraction, relations are extracted in a single pass with entities.
2708/// The `trigger_span` captures the text that indicates the relation.
2709#[derive(Debug, Clone, Serialize, Deserialize)]
2710pub struct Relation {
2711    /// The source entity (head of the triple)
2712    pub head: Entity,
2713    /// The target entity (tail of the triple)
2714    pub tail: Entity,
2715    /// Relation type label (e.g., "EMPLOYMENT", "LOCATED_IN", "FOUNDED_BY")
2716    pub relation_type: String,
2717    /// Optional trigger span: the text that indicates this relation
2718    /// For "CEO of", this would be the span covering "CEO of"
2719    pub trigger_span: Option<(usize, usize)>,
2720    /// Confidence score for this relation (0.0-1.0).
2721    pub confidence: Confidence,
2722}
2723
2724impl Relation {
2725    /// Create a new relation between two entities.
2726    #[must_use]
2727    pub fn new(
2728        head: Entity,
2729        tail: Entity,
2730        relation_type: impl Into<String>,
2731        confidence: impl Into<Confidence>,
2732    ) -> Self {
2733        Self {
2734            head,
2735            tail,
2736            relation_type: relation_type.into(),
2737            trigger_span: None,
2738            confidence: confidence.into(),
2739        }
2740    }
2741
2742    /// Create a relation with an explicit trigger span.
2743    #[must_use]
2744    pub fn with_trigger(
2745        head: Entity,
2746        tail: Entity,
2747        relation_type: impl Into<String>,
2748        trigger_start: usize,
2749        trigger_end: usize,
2750        confidence: impl Into<Confidence>,
2751    ) -> Self {
2752        Self {
2753            head,
2754            tail,
2755            relation_type: relation_type.into(),
2756            trigger_span: Some((trigger_start, trigger_end)),
2757            confidence: confidence.into(),
2758        }
2759    }
2760
2761    /// Convert to a triple string representation (for debugging/display).
2762    #[must_use]
2763    pub fn as_triple(&self) -> String {
2764        format!(
2765            "({}, {}, {})",
2766            self.head.text, self.relation_type, self.tail.text
2767        )
2768    }
2769
2770    /// Check if the head and tail entities are adjacent (within n tokens).
2771    /// Useful for filtering spurious long-distance relations.
2772    #[must_use]
2773    pub fn span_distance(&self) -> usize {
2774        if self.head.end <= self.tail.start {
2775            self.tail.start.saturating_sub(self.head.end)
2776        } else if self.tail.end <= self.head.start {
2777            self.head.start.saturating_sub(self.tail.end)
2778        } else {
2779            0 // Overlapping spans
2780        }
2781    }
2782}
2783
2784#[cfg(test)]
2785mod tests {
2786    #![allow(clippy::unwrap_used)] // unwrap() is acceptable in test code
2787    use super::*;
2788
2789    #[test]
2790    fn entity_new_swaps_inverted_span() {
2791        let e = Entity::new("test", EntityType::Person, 10, 5, 0.9);
2792        assert_eq!(e.start(), 5);
2793        assert_eq!(e.end(), 10);
2794    }
2795
2796    #[test]
2797    fn entity_deserialize_swaps_inverted_span() {
2798        let json = r#"{"text":"test","entity_type":"PER","start":10,"end":5,"confidence":0.9}"#;
2799        let e: Entity = serde_json::from_str(json).unwrap();
2800        assert_eq!(e.start(), 5);
2801        assert_eq!(e.end(), 10);
2802    }
2803
2804    #[test]
2805    fn entity_serde_round_trip() {
2806        let original = Entity::new("Berlin", EntityType::Location, 10, 16, 0.95);
2807        let json = serde_json::to_string(&original).unwrap();
2808        let restored: Entity = serde_json::from_str(&json).unwrap();
2809        assert_eq!(restored.text, original.text);
2810        assert_eq!(restored.entity_type, original.entity_type);
2811        assert_eq!(restored.start(), original.start());
2812        assert_eq!(restored.end(), original.end());
2813        assert!((restored.confidence.value() - original.confidence.value()).abs() < f64::EPSILON);
2814    }
2815
2816    #[test]
2817    fn test_entity_type_roundtrip() {
2818        let types = [
2819            EntityType::Person,
2820            EntityType::Organization,
2821            EntityType::Location,
2822            EntityType::Date,
2823            EntityType::Money,
2824            EntityType::Percent,
2825        ];
2826
2827        for t in types {
2828            let label = t.as_label();
2829            let parsed = EntityType::from_label(label);
2830            assert_eq!(t, parsed);
2831        }
2832    }
2833
2834    #[test]
2835    fn test_entity_overlap() {
2836        let e1 = Entity::new("John", EntityType::Person, 0, 4, 0.9);
2837        let e2 = Entity::new("Smith", EntityType::Person, 5, 10, 0.9);
2838        let e3 = Entity::new("John Smith", EntityType::Person, 0, 10, 0.9);
2839
2840        assert!(!e1.overlaps(&e2)); // No overlap
2841        assert!(e1.overlaps(&e3)); // e1 is contained in e3
2842        assert!(e3.overlaps(&e2)); // e3 contains e2
2843    }
2844
2845    #[test]
2846    fn test_confidence_clamping() {
2847        let e1 = Entity::new("test", EntityType::Person, 0, 4, 1.5);
2848        assert!((e1.confidence - 1.0).abs() < f64::EPSILON);
2849
2850        let e2 = Entity::new("test", EntityType::Person, 0, 4, -0.5);
2851        assert!(e2.confidence.abs() < f64::EPSILON);
2852    }
2853
2854    #[test]
2855    fn test_entity_categories() {
2856        // Agent/Org/Place entities require ML
2857        assert_eq!(EntityType::Person.category(), EntityCategory::Agent);
2858        assert_eq!(
2859            EntityType::Organization.category(),
2860            EntityCategory::Organization
2861        );
2862        assert_eq!(EntityType::Location.category(), EntityCategory::Place);
2863        assert!(EntityType::Person.requires_ml());
2864        assert!(!EntityType::Person.pattern_detectable());
2865
2866        // Temporal entities are pattern-detectable
2867        assert_eq!(EntityType::Date.category(), EntityCategory::Temporal);
2868        assert_eq!(EntityType::Time.category(), EntityCategory::Temporal);
2869        assert!(EntityType::Date.pattern_detectable());
2870        assert!(!EntityType::Date.requires_ml());
2871
2872        // Numeric entities are pattern-detectable
2873        assert_eq!(EntityType::Money.category(), EntityCategory::Numeric);
2874        assert_eq!(EntityType::Percent.category(), EntityCategory::Numeric);
2875        assert!(EntityType::Money.pattern_detectable());
2876
2877        // Contact entities are pattern-detectable
2878        assert_eq!(EntityType::Email.category(), EntityCategory::Contact);
2879        assert_eq!(EntityType::Url.category(), EntityCategory::Contact);
2880        assert_eq!(EntityType::Phone.category(), EntityCategory::Contact);
2881        assert!(EntityType::Email.pattern_detectable());
2882    }
2883
2884    #[test]
2885    fn test_new_types_roundtrip() {
2886        let types = [
2887            EntityType::Time,
2888            EntityType::Email,
2889            EntityType::Url,
2890            EntityType::Phone,
2891            EntityType::Quantity,
2892            EntityType::Cardinal,
2893            EntityType::Ordinal,
2894        ];
2895
2896        for t in types {
2897            let label = t.as_label();
2898            let parsed = EntityType::from_label(label);
2899            assert_eq!(t, parsed, "Roundtrip failed for {}", label);
2900        }
2901    }
2902
2903    #[test]
2904    fn test_custom_entity_type() {
2905        let disease = EntityType::custom("DISEASE", EntityCategory::Agent);
2906        assert_eq!(disease.as_label(), "DISEASE");
2907        assert!(disease.requires_ml());
2908
2909        let product_id = EntityType::custom("PRODUCT_ID", EntityCategory::Misc);
2910        assert_eq!(product_id.as_label(), "PRODUCT_ID");
2911        assert!(!product_id.requires_ml());
2912        assert!(!product_id.pattern_detectable());
2913    }
2914
2915    #[test]
2916    fn test_entity_normalization() {
2917        let mut e = Entity::new("Jan 15", EntityType::Date, 0, 6, 0.95);
2918        assert!(e.normalized.is_none());
2919        assert_eq!(e.normalized_or_text(), "Jan 15");
2920
2921        e.set_normalized("2024-01-15");
2922        assert_eq!(e.normalized.as_deref(), Some("2024-01-15"));
2923        assert_eq!(e.normalized_or_text(), "2024-01-15");
2924    }
2925
2926    #[test]
2927    fn test_entity_helpers() {
2928        let named = Entity::new("John", EntityType::Person, 0, 4, 0.9);
2929        assert!(named.is_named());
2930        assert!(!named.is_structured());
2931        assert_eq!(named.category(), EntityCategory::Agent);
2932
2933        let structured = Entity::new("$100", EntityType::Money, 0, 4, 0.95);
2934        assert!(!structured.is_named());
2935        assert!(structured.is_structured());
2936        assert_eq!(structured.category(), EntityCategory::Numeric);
2937    }
2938
2939    #[test]
2940    fn test_knowledge_linking() {
2941        let mut entity = Entity::new("Marie Curie", EntityType::Person, 0, 11, 0.95);
2942        assert!(!entity.is_linked());
2943        assert!(!entity.has_coreference());
2944
2945        entity.link_to_kb("Q7186"); // Wikidata ID
2946        assert!(entity.is_linked());
2947        assert_eq!(entity.kb_id.as_deref(), Some("Q7186"));
2948
2949        entity.set_canonical(42);
2950        assert!(entity.has_coreference());
2951        assert_eq!(
2952            entity.canonical_id,
2953            Some(crate::core::types::CanonicalId::new(42))
2954        );
2955    }
2956
2957    #[test]
2958    fn test_relation_creation() {
2959        let head = Entity::new("Marie Curie", EntityType::Person, 0, 11, 0.95);
2960        let tail = Entity::new("Sorbonne", EntityType::Organization, 24, 32, 0.90);
2961
2962        let relation = Relation::new(head.clone(), tail.clone(), "WORKED_AT", 0.85);
2963        assert_eq!(relation.relation_type, "WORKED_AT");
2964        assert_eq!(relation.as_triple(), "(Marie Curie, WORKED_AT, Sorbonne)");
2965        assert!(relation.trigger_span.is_none());
2966
2967        // With trigger span
2968        let relation2 = Relation::with_trigger(head, tail, "EMPLOYMENT", 13, 19, 0.85);
2969        assert_eq!(relation2.trigger_span, Some((13, 19)));
2970    }
2971
2972    #[test]
2973    fn test_relation_span_distance() {
2974        // Head at 0-11, tail at 24-32 -> distance is 24-11 = 13
2975        let head = Entity::new("Marie Curie", EntityType::Person, 0, 11, 0.95);
2976        let tail = Entity::new("Sorbonne", EntityType::Organization, 24, 32, 0.90);
2977        let relation = Relation::new(head, tail, "WORKED_AT", 0.85);
2978        assert_eq!(relation.span_distance(), 13);
2979    }
2980
2981    #[test]
2982    fn test_relation_category() {
2983        // Relation types should be categorized as Relation
2984        let rel_type = EntityType::custom("CEO_OF", EntityCategory::Relation);
2985        assert_eq!(rel_type.category(), EntityCategory::Relation);
2986        assert!(rel_type.category().is_relation());
2987        assert!(rel_type.requires_ml()); // Relations require ML
2988    }
2989
2990    // ========================================================================
2991    // Span Tests
2992    // ========================================================================
2993
2994    #[test]
2995    fn test_span_text() {
2996        let span = Span::text(10, 20);
2997        assert!(span.is_text());
2998        assert!(!span.is_visual());
2999        assert_eq!(span.text_offsets(), Some((10, 20)));
3000        assert_eq!(span.len(), 10);
3001        assert!(!span.is_empty());
3002    }
3003
3004    #[test]
3005    fn test_span_bbox() {
3006        let span = Span::bbox(0.1, 0.2, 0.3, 0.4);
3007        assert!(!span.is_text());
3008        assert!(span.is_visual());
3009        assert_eq!(span.text_offsets(), None);
3010        assert_eq!(span.len(), 0); // No text length
3011    }
3012
3013    #[test]
3014    fn test_span_bbox_with_page() {
3015        let span = Span::bbox_on_page(0.1, 0.2, 0.3, 0.4, 5);
3016        if let Span::BoundingBox { page, .. } = span {
3017            assert_eq!(page, Some(5));
3018        } else {
3019            panic!("Expected BoundingBox");
3020        }
3021    }
3022
3023    #[test]
3024    fn test_span_hybrid() {
3025        let bbox = Span::bbox(0.1, 0.2, 0.3, 0.4);
3026        let hybrid = Span::Hybrid {
3027            start: 10,
3028            end: 20,
3029            bbox: Box::new(bbox),
3030        };
3031        assert!(hybrid.is_text());
3032        assert!(hybrid.is_visual());
3033        assert_eq!(hybrid.text_offsets(), Some((10, 20)));
3034        assert_eq!(hybrid.len(), 10);
3035    }
3036
3037    // ========================================================================
3038    // Hierarchical Confidence Tests
3039    // ========================================================================
3040
3041    #[test]
3042    fn test_hierarchical_confidence_new() {
3043        let hc = HierarchicalConfidence::new(0.9, 0.8, 0.7);
3044        assert!((hc.linkage - 0.9).abs() < f64::EPSILON);
3045        assert!((hc.type_score - 0.8).abs() < f64::EPSILON);
3046        assert!((hc.boundary - 0.7).abs() < f64::EPSILON);
3047    }
3048
3049    #[test]
3050    fn test_hierarchical_confidence_clamping() {
3051        let hc = HierarchicalConfidence::new(1.5, -0.5, 0.5);
3052        assert_eq!(hc.linkage, 1.0);
3053        assert_eq!(hc.type_score, 0.0);
3054        assert_eq!(hc.boundary, 0.5);
3055    }
3056
3057    #[test]
3058    fn test_hierarchical_confidence_from_single() {
3059        let hc = HierarchicalConfidence::from_single(0.8);
3060        assert!((hc.linkage - 0.8).abs() < f64::EPSILON);
3061        assert!((hc.type_score - 0.8).abs() < f64::EPSILON);
3062        assert!((hc.boundary - 0.8).abs() < f64::EPSILON);
3063    }
3064
3065    #[test]
3066    fn test_hierarchical_confidence_combined() {
3067        let hc = HierarchicalConfidence::new(1.0, 1.0, 1.0);
3068        assert!((hc.combined() - 1.0).abs() < f64::EPSILON);
3069
3070        let hc2 = HierarchicalConfidence::new(0.8, 0.8, 0.8);
3071        assert!((hc2.combined() - 0.8).abs() < 0.001);
3072
3073        // Geometric mean: (0.5 * 0.5 * 0.5)^(1/3) = 0.5
3074        let hc3 = HierarchicalConfidence::new(0.5, 0.5, 0.5);
3075        assert!((hc3.combined() - 0.5).abs() < 0.001);
3076    }
3077
3078    #[test]
3079    fn test_hierarchical_confidence_threshold() {
3080        let hc = HierarchicalConfidence::new(0.9, 0.8, 0.7);
3081        assert!(hc.passes_threshold(0.5, 0.5, 0.5));
3082        assert!(hc.passes_threshold(0.9, 0.8, 0.7));
3083        assert!(!hc.passes_threshold(0.95, 0.8, 0.7)); // linkage too high
3084        assert!(!hc.passes_threshold(0.9, 0.85, 0.7)); // type too high
3085    }
3086
3087    #[test]
3088    fn test_hierarchical_confidence_from_f64() {
3089        let hc: HierarchicalConfidence = 0.85_f64.into();
3090        assert!((hc.linkage - 0.85).abs() < 0.001);
3091    }
3092
3093    // ========================================================================
3094    // RaggedBatch Tests
3095    // ========================================================================
3096
3097    #[test]
3098    fn test_ragged_batch_from_sequences() {
3099        let seqs = vec![vec![1, 2, 3], vec![4, 5], vec![6, 7, 8, 9]];
3100        let batch = RaggedBatch::from_sequences(&seqs);
3101
3102        assert_eq!(batch.batch_size(), 3);
3103        assert_eq!(batch.total_tokens(), 9);
3104        assert_eq!(batch.max_seq_len, 4);
3105        assert_eq!(batch.cumulative_offsets, vec![0, 3, 5, 9]);
3106    }
3107
3108    #[test]
3109    fn test_ragged_batch_doc_range() {
3110        let seqs = vec![vec![1, 2, 3], vec![4, 5]];
3111        let batch = RaggedBatch::from_sequences(&seqs);
3112
3113        assert_eq!(batch.doc_range(0), Some(0..3));
3114        assert_eq!(batch.doc_range(1), Some(3..5));
3115        assert_eq!(batch.doc_range(2), None);
3116    }
3117
3118    #[test]
3119    fn test_ragged_batch_doc_tokens() {
3120        let seqs = vec![vec![1, 2, 3], vec![4, 5]];
3121        let batch = RaggedBatch::from_sequences(&seqs);
3122
3123        assert_eq!(batch.doc_tokens(0), Some(&[1, 2, 3][..]));
3124        assert_eq!(batch.doc_tokens(1), Some(&[4, 5][..]));
3125    }
3126
3127    #[test]
3128    fn test_ragged_batch_padding_savings() {
3129        // 3 docs: [3, 2, 4] tokens, max = 4
3130        // Padded: 3 * 4 = 12, actual: 9
3131        // Savings: 1 - 9/12 = 0.25
3132        let seqs = vec![vec![1, 2, 3], vec![4, 5], vec![6, 7, 8, 9]];
3133        let batch = RaggedBatch::from_sequences(&seqs);
3134        let savings = batch.padding_savings();
3135        assert!((savings - 0.25).abs() < 0.001);
3136    }
3137
3138    // ========================================================================
3139    // SpanCandidate Tests
3140    // ========================================================================
3141
3142    #[test]
3143    fn test_span_candidate() {
3144        let sc = SpanCandidate::new(0, 5, 10);
3145        assert_eq!(sc.doc_idx, 0);
3146        assert_eq!(sc.start, 5);
3147        assert_eq!(sc.end, 10);
3148        assert_eq!(sc.width(), 5);
3149    }
3150
3151    #[test]
3152    fn test_generate_span_candidates() {
3153        let seqs = vec![vec![1, 2, 3]]; // doc with 3 tokens
3154        let batch = RaggedBatch::from_sequences(&seqs);
3155        let candidates = generate_span_candidates(&batch, 2);
3156
3157        // With max_width=2: [0,1], [1,2], [2,3], [0,2], [1,3]
3158        // = spans: (0,1), (0,2), (1,2), (1,3), (2,3)
3159        assert_eq!(candidates.len(), 5);
3160
3161        // Verify all candidates are valid
3162        for c in &candidates {
3163            assert_eq!(c.doc_idx, 0);
3164            assert!(c.end as usize <= 3);
3165            assert!(c.width() as usize <= 2);
3166        }
3167    }
3168
3169    #[test]
3170    fn test_generate_filtered_candidates() {
3171        let seqs = vec![vec![1, 2, 3]];
3172        let batch = RaggedBatch::from_sequences(&seqs);
3173
3174        // With max_width=2, we have 5 candidates
3175        // Set mask: only first 2 pass threshold
3176        let mask = vec![0.9, 0.9, 0.1, 0.1, 0.1];
3177        let candidates = generate_filtered_candidates(&batch, 2, &mask, 0.5);
3178
3179        assert_eq!(candidates.len(), 2);
3180    }
3181
3182    // ========================================================================
3183    // EntityBuilder Tests
3184    // ========================================================================
3185
3186    #[test]
3187    fn test_entity_builder_basic() {
3188        let entity = Entity::builder("John", EntityType::Person)
3189            .span(0, 4)
3190            .confidence(0.95)
3191            .build();
3192
3193        assert_eq!(entity.text, "John");
3194        assert_eq!(entity.entity_type, EntityType::Person);
3195        assert_eq!(entity.start(), 0);
3196        assert_eq!(entity.end(), 4);
3197        assert!((entity.confidence - 0.95).abs() < f64::EPSILON);
3198    }
3199
3200    #[test]
3201    fn test_entity_builder_full() {
3202        let entity = Entity::builder("Marie Curie", EntityType::Person)
3203            .span(0, 11)
3204            .confidence(0.95)
3205            .kb_id("Q7186")
3206            .canonical_id(42)
3207            .normalized("Marie Salomea Skłodowska Curie")
3208            .provenance(Provenance::ml("bert", 0.95))
3209            .build();
3210
3211        assert_eq!(entity.text, "Marie Curie");
3212        assert_eq!(entity.kb_id.as_deref(), Some("Q7186"));
3213        assert_eq!(
3214            entity.canonical_id,
3215            Some(crate::core::types::CanonicalId::new(42))
3216        );
3217        assert_eq!(
3218            entity.normalized.as_deref(),
3219            Some("Marie Salomea Skłodowska Curie")
3220        );
3221        assert!(entity.provenance.is_some());
3222    }
3223
3224    #[test]
3225    fn test_entity_builder_hierarchical() {
3226        let hc = HierarchicalConfidence::new(0.9, 0.8, 0.7);
3227        let entity = Entity::builder("test", EntityType::Person)
3228            .span(0, 4)
3229            .hierarchical_confidence(hc)
3230            .build();
3231
3232        assert!(entity.hierarchical_confidence.is_some());
3233        assert!((entity.linkage_confidence() - 0.9).abs() < 0.001);
3234        assert!((entity.type_confidence() - 0.8).abs() < 0.001);
3235        assert!((entity.boundary_confidence() - 0.7).abs() < 0.001);
3236    }
3237
3238    #[test]
3239    fn test_entity_builder_visual() {
3240        let bbox = Span::bbox(0.1, 0.2, 0.3, 0.4);
3241        let entity = Entity::builder("receipt item", EntityType::Money)
3242            .visual_span(bbox)
3243            .confidence(0.9)
3244            .build();
3245
3246        assert!(entity.is_visual());
3247        assert!(entity.visual_span.is_some());
3248    }
3249
3250    // ========================================================================
3251    // Entity Helper Method Tests
3252    // ========================================================================
3253
3254    #[test]
3255    fn test_entity_hierarchical_confidence_helpers() {
3256        let mut entity = Entity::new("test", EntityType::Person, 0, 4, 0.8);
3257
3258        // Without hierarchical confidence, falls back to main confidence
3259        assert!((entity.linkage_confidence() - 0.8).abs() < 0.001);
3260        assert!((entity.type_confidence() - 0.8).abs() < 0.001);
3261        assert!((entity.boundary_confidence() - 0.8).abs() < 0.001);
3262
3263        // Set hierarchical confidence
3264        entity.set_hierarchical_confidence(HierarchicalConfidence::new(0.95, 0.85, 0.75));
3265        assert!((entity.linkage_confidence() - 0.95).abs() < 0.001);
3266        assert!((entity.type_confidence() - 0.85).abs() < 0.001);
3267        assert!((entity.boundary_confidence() - 0.75).abs() < 0.001);
3268    }
3269
3270    #[test]
3271    fn test_entity_from_visual() {
3272        let entity = Entity::from_visual(
3273            "receipt total",
3274            EntityType::Money,
3275            Span::bbox(0.5, 0.8, 0.2, 0.05),
3276            0.92,
3277        );
3278
3279        assert!(entity.is_visual());
3280        assert_eq!(entity.start(), 0);
3281        assert_eq!(entity.end(), 0);
3282        assert!((entity.confidence - 0.92).abs() < f64::EPSILON);
3283    }
3284
3285    #[test]
3286    fn test_entity_span_helpers() {
3287        let entity = Entity::new("test", EntityType::Person, 10, 20, 0.9);
3288        assert_eq!(entity.text_span(), (10, 20));
3289        assert_eq!(entity.span_len(), 10);
3290    }
3291
3292    // ========================================================================
3293    // Provenance Tests
3294    // ========================================================================
3295
3296    #[test]
3297    fn test_provenance_pattern() {
3298        let prov = Provenance::pattern("EMAIL");
3299        assert_eq!(prov.method, ExtractionMethod::Pattern);
3300        assert_eq!(prov.pattern.as_deref(), Some("EMAIL"));
3301        assert_eq!(prov.raw_confidence, Some(Confidence::new(1.0))); // Patterns are deterministic
3302    }
3303
3304    #[test]
3305    fn test_provenance_ml() {
3306        let prov = Provenance::ml("bert-ner", 0.87);
3307        assert_eq!(prov.method, ExtractionMethod::Neural);
3308        assert_eq!(prov.source.as_ref(), "bert-ner");
3309        assert_eq!(prov.raw_confidence, Some(Confidence::new(0.87)));
3310    }
3311
3312    #[test]
3313    fn test_provenance_with_version() {
3314        let prov = Provenance::ml("gliner", 0.92).with_version("v2.1.0");
3315
3316        assert_eq!(prov.model_version.as_deref(), Some("v2.1.0"));
3317        assert_eq!(prov.source.as_ref(), "gliner");
3318    }
3319
3320    #[test]
3321    fn test_provenance_with_timestamp() {
3322        let prov = Provenance::pattern("DATE").with_timestamp("2024-01-15T10:30:00Z");
3323
3324        assert_eq!(prov.timestamp.as_deref(), Some("2024-01-15T10:30:00Z"));
3325    }
3326
3327    #[test]
3328    fn test_provenance_builder_chain() {
3329        let prov = Provenance::ml("modernbert-ner", 0.95)
3330            .with_version("v1.0.0")
3331            .with_timestamp("2024-11-27T12:00:00Z");
3332
3333        assert_eq!(prov.method, ExtractionMethod::Neural);
3334        assert_eq!(prov.source.as_ref(), "modernbert-ner");
3335        assert_eq!(prov.raw_confidence, Some(Confidence::new(0.95)));
3336        assert_eq!(prov.model_version.as_deref(), Some("v1.0.0"));
3337        assert_eq!(prov.timestamp.as_deref(), Some("2024-11-27T12:00:00Z"));
3338    }
3339
3340    #[test]
3341    fn test_provenance_serialization() {
3342        let prov = Provenance::ml("test", 0.9)
3343            .with_version("v1.0")
3344            .with_timestamp("2024-01-01");
3345
3346        let json = serde_json::to_string(&prov).unwrap();
3347        assert!(json.contains("model_version"));
3348        assert!(json.contains("v1.0"));
3349
3350        let restored: Provenance = serde_json::from_str(&json).unwrap();
3351        assert_eq!(restored.model_version.as_deref(), Some("v1.0"));
3352        assert_eq!(restored.timestamp.as_deref(), Some("2024-01-01"));
3353    }
3354
3355    #[test]
3356    fn entity_serde_roundtrip_no_temporal_fields() {
3357        let entity = Entity::new("Berlin", EntityType::Location, 0, 6, 0.95);
3358        let json = serde_json::to_string(&entity).unwrap();
3359        // Verify removed fields don't appear
3360        assert!(!json.contains("valid_from"));
3361        assert!(!json.contains("valid_until"));
3362        assert!(!json.contains("phi_features"));
3363        // Roundtrip works
3364        let recovered: Entity = serde_json::from_str(&json).unwrap();
3365        assert_eq!(recovered.text, "Berlin");
3366        assert_eq!(recovered.start(), 0);
3367        assert_eq!(recovered.end(), 6);
3368    }
3369
3370    #[test]
3371    fn entity_deserialize_ignores_unknown_fields() {
3372        let json = r#"{"text":"Berlin","entity_type":"LOC","start":0,"end":6,"confidence":0.95,"valid_from":null,"phi_features":null}"#;
3373        let entity: Entity = serde_json::from_str(json).unwrap();
3374        assert_eq!(entity.text, "Berlin");
3375    }
3376}
3377
3378#[cfg(test)]
3379mod proptests {
3380    #![allow(clippy::unwrap_used)] // unwrap() is acceptable in property tests
3381    use super::*;
3382    use proptest::prelude::*;
3383
3384    proptest! {
3385        #[test]
3386        fn confidence_always_clamped(conf in -10.0f64..10.0) {
3387            let e = Entity::new("test", EntityType::Person, 0, 4, conf);
3388            prop_assert!(e.confidence >= 0.0);
3389            prop_assert!(e.confidence <= 1.0);
3390        }
3391
3392        #[test]
3393        fn entity_type_roundtrip(label in "[A-Z]{3,10}") {
3394            let et = EntityType::from_label(&label);
3395            let back = EntityType::from_label(et.as_label());
3396            // Custom types may round-trip to themselves or normalize
3397            let is_custom = matches!(back, EntityType::Custom { .. });
3398            prop_assert!(is_custom || back == et);
3399        }
3400
3401        #[test]
3402        fn overlap_is_symmetric(
3403            s1 in 0usize..100,
3404            len1 in 1usize..50,
3405            s2 in 0usize..100,
3406            len2 in 1usize..50,
3407        ) {
3408            let e1 = Entity::new("a", EntityType::Person, s1, s1 + len1, 1.0);
3409            let e2 = Entity::new("b", EntityType::Person, s2, s2 + len2, 1.0);
3410            prop_assert_eq!(e1.overlaps(&e2), e2.overlaps(&e1));
3411        }
3412
3413        #[test]
3414        fn overlap_ratio_bounded(
3415            s1 in 0usize..100,
3416            len1 in 1usize..50,
3417            s2 in 0usize..100,
3418            len2 in 1usize..50,
3419        ) {
3420            let e1 = Entity::new("a", EntityType::Person, s1, s1 + len1, 1.0);
3421            let e2 = Entity::new("b", EntityType::Person, s2, s2 + len2, 1.0);
3422            let ratio = e1.overlap_ratio(&e2);
3423            prop_assert!(ratio >= 0.0);
3424            prop_assert!(ratio <= 1.0);
3425        }
3426
3427        #[test]
3428        fn self_overlap_ratio_is_one(s in 0usize..100, len in 1usize..50) {
3429            let e = Entity::new("test", EntityType::Person, s, s + len, 1.0);
3430            let ratio = e.overlap_ratio(&e);
3431            prop_assert!((ratio - 1.0).abs() < 1e-10);
3432        }
3433
3434        #[test]
3435        fn hierarchical_confidence_always_clamped(
3436            linkage in -2.0f32..2.0,
3437            type_score in -2.0f32..2.0,
3438            boundary in -2.0f32..2.0,
3439        ) {
3440            let hc = HierarchicalConfidence::new(linkage, type_score, boundary);
3441            prop_assert!(hc.linkage >= 0.0 && hc.linkage <= 1.0);
3442            prop_assert!(hc.type_score >= 0.0 && hc.type_score <= 1.0);
3443            prop_assert!(hc.boundary >= 0.0 && hc.boundary <= 1.0);
3444            prop_assert!(hc.combined() >= 0.0 && hc.combined() <= 1.0);
3445        }
3446
3447        #[test]
3448        fn span_candidate_width_consistent(
3449            doc in 0u32..10,
3450            start in 0u32..100,
3451            end in 1u32..100,
3452        ) {
3453            let actual_end = start.max(end);
3454            let sc = SpanCandidate::new(doc, start, actual_end);
3455            prop_assert_eq!(sc.width(), actual_end.saturating_sub(start));
3456        }
3457
3458        #[test]
3459        fn ragged_batch_preserves_tokens(
3460            seq_lens in proptest::collection::vec(1usize..10, 1..5),
3461        ) {
3462            // Create sequences with sequential token IDs
3463            let mut counter = 0u32;
3464            let seqs: Vec<Vec<u32>> = seq_lens.iter().map(|&len| {
3465                let seq: Vec<u32> = (counter..counter + len as u32).collect();
3466                counter += len as u32;
3467                seq
3468            }).collect();
3469
3470            let batch = RaggedBatch::from_sequences(&seqs);
3471
3472            // Verify batch properties
3473            prop_assert_eq!(batch.batch_size(), seqs.len());
3474            prop_assert_eq!(batch.total_tokens(), seq_lens.iter().sum::<usize>());
3475
3476            // Verify each doc can be retrieved correctly
3477            for (i, seq) in seqs.iter().enumerate() {
3478                let doc_tokens = batch.doc_tokens(i).unwrap();
3479                prop_assert_eq!(doc_tokens, seq.as_slice());
3480            }
3481        }
3482
3483        #[test]
3484        fn span_text_offsets_consistent(start in 0usize..100, len in 0usize..50) {
3485            let end = start + len;
3486            let span = Span::text(start, end);
3487            let (s, e) = span.text_offsets().unwrap();
3488            prop_assert_eq!(s, start);
3489            prop_assert_eq!(e, end);
3490            prop_assert_eq!(span.len(), len);
3491        }
3492
3493        // =================================================================
3494        // Property tests for core type invariants
3495        // =================================================================
3496
3497        /// Entity with start < end always passes the span validity check in validate().
3498        #[test]
3499        fn entity_span_validity(
3500            start in 0usize..10000,
3501            len in 1usize..500,
3502            conf in 0.0f64..=1.0,
3503        ) {
3504            let end = start + len;
3505            // Build a source text long enough to cover the span
3506            let text_content: String = "x".repeat(end);
3507            let entity_text: String = text_content.chars().skip(start).take(len).collect();
3508            let e = Entity::new(&entity_text, EntityType::Person, start, end, conf);
3509            let issues = e.validate(&text_content);
3510            // No InvalidSpan or SpanOutOfBounds issues
3511            for issue in &issues {
3512                match issue {
3513                    ValidationIssue::InvalidSpan { .. } => {
3514                        prop_assert!(false, "start < end should never produce InvalidSpan");
3515                    }
3516                    ValidationIssue::SpanOutOfBounds { .. } => {
3517                        prop_assert!(false, "span within text should never produce SpanOutOfBounds");
3518                    }
3519                    _ => {} // TextMismatch or others are fine to check separately
3520                }
3521            }
3522        }
3523
3524        /// EntityType::from_label(et.as_label()) == et for all standard (non-Custom) types.
3525        #[test]
3526        fn entity_type_label_roundtrip_standard(
3527            idx in 0usize..13,
3528        ) {
3529            let standard_types = [
3530                EntityType::Person,
3531                EntityType::Organization,
3532                EntityType::Location,
3533                EntityType::Date,
3534                EntityType::Time,
3535                EntityType::Money,
3536                EntityType::Percent,
3537                EntityType::Quantity,
3538                EntityType::Cardinal,
3539                EntityType::Ordinal,
3540                EntityType::Email,
3541                EntityType::Url,
3542                EntityType::Phone,
3543            ];
3544            let et = &standard_types[idx];
3545            let label = et.as_label();
3546            let roundtripped = EntityType::from_label(label);
3547            prop_assert_eq!(&roundtripped, et,
3548                "from_label(as_label()) must roundtrip for {:?} (label={:?})", et, label);
3549        }
3550
3551        /// Span containment: if span A contains span B, then A.start <= B.start && A.end >= B.end.
3552        #[test]
3553        fn span_containment_property(
3554            a_start in 0usize..5000,
3555            a_len in 1usize..5000,
3556            b_offset in 0usize..5000,
3557            b_len in 1usize..5000,
3558        ) {
3559            let a_end = a_start + a_len;
3560            let b_start = a_start + (b_offset % a_len); // B starts within A
3561            let b_end_candidate = b_start + b_len;
3562
3563            // Only test the containment invariant when B is actually inside A
3564            if b_start >= a_start && b_end_candidate <= a_end {
3565                // B is contained in A
3566                prop_assert!(a_start <= b_start);
3567                prop_assert!(a_end >= b_end_candidate);
3568
3569                // Also verify via Entity overlap: A must overlap B if A contains B
3570                let ea = Entity::new("a", EntityType::Person, a_start, a_end, 1.0);
3571                let eb = Entity::new("b", EntityType::Person, b_start, b_end_candidate, 1.0);
3572                prop_assert!(ea.overlaps(&eb),
3573                    "containing span must overlap contained span");
3574            }
3575        }
3576
3577        /// Serde roundtrip preserves all fields of Entity.
3578        #[test]
3579        fn entity_serde_roundtrip(
3580            start in 0usize..10000,
3581            len in 1usize..500,
3582            conf in 0.0f64..=1.0,
3583            type_idx in 0usize..5,
3584        ) {
3585            let end = start + len;
3586            let types = [
3587                EntityType::Person,
3588                EntityType::Organization,
3589                EntityType::Location,
3590                EntityType::Date,
3591                EntityType::Email,
3592            ];
3593            let et = types[type_idx].clone();
3594            let text = format!("entity_{}", start);
3595            let e = Entity::new(&text, et, start, end, conf);
3596
3597            let json = serde_json::to_string(&e).unwrap();
3598            let e2: Entity = serde_json::from_str(&json).unwrap();
3599
3600            prop_assert_eq!(&e.text, &e2.text);
3601            prop_assert_eq!(&e.entity_type, &e2.entity_type);
3602            prop_assert_eq!(e.start(), e2.start());
3603            prop_assert_eq!(e.end(), e2.end());
3604            // f64 roundtrip through JSON: compare with tolerance
3605            prop_assert!((e.confidence - e2.confidence).abs() < 1e-10,
3606                "confidence roundtrip: {} vs {}", e.confidence, e2.confidence);
3607            prop_assert_eq!(&e.normalized, &e2.normalized);
3608            prop_assert_eq!(&e.kb_id, &e2.kb_id);
3609        }
3610
3611        /// DiscontinuousSpan: total_len() == sum of merged segment lengths,
3612        /// and merged segments are non-overlapping and sorted.
3613        #[test]
3614        fn discontinuous_span_total_length(
3615            segments in proptest::collection::vec(
3616                (0usize..5000, 1usize..500),
3617                1..6
3618            ),
3619        ) {
3620            let ranges: Vec<std::ops::Range<usize>> = segments.iter()
3621                .map(|&(start, len)| start..start + len)
3622                .collect();
3623            let span = DiscontinuousSpan::new(ranges);
3624            // After merging, total_len must equal sum of the stored segments.
3625            let expected_sum: usize = span.segments().iter().map(|r| r.end - r.start).sum();
3626            prop_assert_eq!(span.total_len(), expected_sum,
3627                "total_len must equal sum of merged segment lengths");
3628            // Verify no overlaps in stored segments.
3629            for w in span.segments().windows(2) {
3630                prop_assert!(w[0].end <= w[1].start,
3631                    "segments must not overlap: {:?} vs {:?}", w[0], w[1]);
3632            }
3633        }
3634    }
3635
3636    // ========================================================================
3637    // EntityCategory Tests
3638    // ========================================================================
3639
3640    #[test]
3641    fn test_entity_category_requires_ml() {
3642        assert!(EntityCategory::Agent.requires_ml());
3643        assert!(EntityCategory::Organization.requires_ml());
3644        assert!(EntityCategory::Place.requires_ml());
3645        assert!(EntityCategory::Creative.requires_ml());
3646        assert!(EntityCategory::Relation.requires_ml());
3647
3648        assert!(!EntityCategory::Temporal.requires_ml());
3649        assert!(!EntityCategory::Numeric.requires_ml());
3650        assert!(!EntityCategory::Contact.requires_ml());
3651        assert!(!EntityCategory::Misc.requires_ml());
3652    }
3653
3654    #[test]
3655    fn test_entity_category_pattern_detectable() {
3656        assert!(EntityCategory::Temporal.pattern_detectable());
3657        assert!(EntityCategory::Numeric.pattern_detectable());
3658        assert!(EntityCategory::Contact.pattern_detectable());
3659
3660        assert!(!EntityCategory::Agent.pattern_detectable());
3661        assert!(!EntityCategory::Organization.pattern_detectable());
3662        assert!(!EntityCategory::Place.pattern_detectable());
3663        assert!(!EntityCategory::Creative.pattern_detectable());
3664        assert!(!EntityCategory::Relation.pattern_detectable());
3665        assert!(!EntityCategory::Misc.pattern_detectable());
3666    }
3667
3668    #[test]
3669    fn test_entity_category_is_relation() {
3670        assert!(EntityCategory::Relation.is_relation());
3671
3672        assert!(!EntityCategory::Agent.is_relation());
3673        assert!(!EntityCategory::Organization.is_relation());
3674        assert!(!EntityCategory::Place.is_relation());
3675        assert!(!EntityCategory::Temporal.is_relation());
3676        assert!(!EntityCategory::Numeric.is_relation());
3677        assert!(!EntityCategory::Contact.is_relation());
3678        assert!(!EntityCategory::Creative.is_relation());
3679        assert!(!EntityCategory::Misc.is_relation());
3680    }
3681
3682    #[test]
3683    fn test_entity_category_as_str() {
3684        assert_eq!(EntityCategory::Agent.as_str(), "agent");
3685        assert_eq!(EntityCategory::Organization.as_str(), "organization");
3686        assert_eq!(EntityCategory::Place.as_str(), "place");
3687        assert_eq!(EntityCategory::Creative.as_str(), "creative");
3688        assert_eq!(EntityCategory::Temporal.as_str(), "temporal");
3689        assert_eq!(EntityCategory::Numeric.as_str(), "numeric");
3690        assert_eq!(EntityCategory::Contact.as_str(), "contact");
3691        assert_eq!(EntityCategory::Relation.as_str(), "relation");
3692        assert_eq!(EntityCategory::Misc.as_str(), "misc");
3693    }
3694
3695    #[test]
3696    fn test_entity_category_display() {
3697        assert_eq!(format!("{}", EntityCategory::Agent), "agent");
3698        assert_eq!(format!("{}", EntityCategory::Temporal), "temporal");
3699        assert_eq!(format!("{}", EntityCategory::Relation), "relation");
3700    }
3701
3702    // ========================================================================
3703    // EntityType serde tests (N20: flat string serialization)
3704    // ========================================================================
3705
3706    #[test]
3707    fn test_entity_type_serializes_to_flat_string() {
3708        assert_eq!(
3709            serde_json::to_string(&EntityType::Person).unwrap(),
3710            r#""PER""#
3711        );
3712        assert_eq!(
3713            serde_json::to_string(&EntityType::Organization).unwrap(),
3714            r#""ORG""#
3715        );
3716        assert_eq!(
3717            serde_json::to_string(&EntityType::Location).unwrap(),
3718            r#""LOC""#
3719        );
3720        assert_eq!(
3721            serde_json::to_string(&EntityType::Date).unwrap(),
3722            r#""DATE""#
3723        );
3724        assert_eq!(
3725            serde_json::to_string(&EntityType::Money).unwrap(),
3726            r#""MONEY""#
3727        );
3728    }
3729
3730    #[test]
3731    fn test_custom_entity_type_serializes_flat() {
3732        let misc = EntityType::custom("MISC", EntityCategory::Misc);
3733        assert_eq!(serde_json::to_string(&misc).unwrap(), r#""MISC""#);
3734
3735        let disease = EntityType::custom("DISEASE", EntityCategory::Agent);
3736        assert_eq!(serde_json::to_string(&disease).unwrap(), r#""DISEASE""#);
3737    }
3738
3739    #[test]
3740    fn test_entity_type_deserializes_from_flat_string() {
3741        let per: EntityType = serde_json::from_str(r#""PER""#).unwrap();
3742        assert_eq!(per, EntityType::Person);
3743
3744        let org: EntityType = serde_json::from_str(r#""ORG""#).unwrap();
3745        assert_eq!(org, EntityType::Organization);
3746
3747        let misc: EntityType = serde_json::from_str(r#""MISC""#).unwrap();
3748        assert_eq!(misc, EntityType::custom("MISC", EntityCategory::Misc));
3749    }
3750
3751    #[test]
3752    fn test_entity_type_deserializes_backward_compat_custom() {
3753        // Old format: {"Custom":{"name":"MISC","category":"Misc"}}
3754        let json = r#"{"Custom":{"name":"MISC","category":"Misc"}}"#;
3755        let et: EntityType = serde_json::from_str(json).unwrap();
3756        assert_eq!(et, EntityType::custom("MISC", EntityCategory::Misc));
3757    }
3758
3759    #[test]
3760    fn test_entity_type_deserializes_backward_compat_other() {
3761        // Old format: {"Other":"foo"} -- now routes to Custom with Misc category
3762        let json = r#"{"Other":"foo"}"#;
3763        let et: EntityType = serde_json::from_str(json).unwrap();
3764        assert_eq!(et, EntityType::custom("foo", EntityCategory::Misc));
3765    }
3766
3767    #[test]
3768    fn test_entity_type_serde_roundtrip() {
3769        let types = vec![
3770            EntityType::Person,
3771            EntityType::Organization,
3772            EntityType::Location,
3773            EntityType::Date,
3774            EntityType::Time,
3775            EntityType::Money,
3776            EntityType::Percent,
3777            EntityType::Quantity,
3778            EntityType::Cardinal,
3779            EntityType::Ordinal,
3780            EntityType::Email,
3781            EntityType::Url,
3782            EntityType::Phone,
3783            EntityType::custom("MISC", EntityCategory::Misc),
3784            EntityType::custom("DISEASE", EntityCategory::Agent),
3785        ];
3786
3787        for t in &types {
3788            let json = serde_json::to_string(t).unwrap();
3789            let back: EntityType = serde_json::from_str(&json).unwrap();
3790            // All variants roundtrip through from_label, so Custom types
3791            // survive as Custom (not as a built-in variant).
3792            assert_eq!(
3793                t.as_label(),
3794                back.as_label(),
3795                "roundtrip failed for {:?}",
3796                t
3797            );
3798        }
3799    }
3800}