Skip to main content

anno/backends/inference/
relation_extraction.rs

1//! Relation extraction: configuration, trigger detection, and the main heuristic pipeline.
2//!
3//! Used by TPLinker and other relation-capable backends.
4
5use super::registry::{LabelDefinition, SemanticRegistry};
6use super::RelationTriple;
7use crate::{Entity, EntityType};
8use anno_core::Relation;
9
10/// Configuration for relation extraction.
11#[derive(Debug, Clone)]
12pub struct RelationExtractionConfig {
13    /// Maximum token distance between head and tail
14    pub max_span_distance: usize,
15    /// Minimum confidence for relation
16    pub threshold: f32,
17    /// Whether to extract relation triggers
18    pub extract_triggers: bool,
19}
20
21impl Default for RelationExtractionConfig {
22    fn default() -> Self {
23        Self {
24            max_span_distance: 50,
25            threshold: 0.5,
26            extract_triggers: true,
27        }
28    }
29}
30
31/// Extract relations between entities.
32///
33/// # Algorithm (Two-Pass)
34///
35/// 1. Run entity NER to find all entity mentions
36/// 2. For each entity pair within distance threshold:
37///    - Encode the span between them
38///    - Match against relation type embeddings
39///    - Optionally identify trigger span
40///
41/// # Returns
42///
43/// Relations with head/tail entities and optional trigger spans.
44pub fn extract_relations(
45    entities: &[Entity],
46    text: &str,
47    registry: &SemanticRegistry,
48    config: &RelationExtractionConfig,
49) -> Vec<Relation> {
50    let mut relations = Vec::new();
51    // `Entity` spans in anno are character offsets, but slicing a Rust `&str` requires byte
52    // offsets. Build a converter once so we can safely slice and map trigger spans back.
53    let span_converter = crate::offset::SpanConverter::new(text);
54
55    // Get relation labels
56    let relation_labels: Vec<_> = registry.relation_labels().collect();
57    if relation_labels.is_empty() {
58        return relations;
59    }
60
61    // Check all entity pairs
62    for (i, head) in entities.iter().enumerate() {
63        for (j, tail) in entities.iter().enumerate() {
64            if i == j {
65                continue;
66            }
67
68            // Check distance
69            let distance = if head.end <= tail.start {
70                tail.start - head.end
71            } else {
72                head.start.saturating_sub(tail.end)
73            };
74
75            if distance > config.max_span_distance {
76                continue;
77            }
78
79            // Look for relation triggers in the text between entities
80            let (span_start, span_end) = if head.end <= tail.start {
81                (head.end, tail.start)
82            } else {
83                (tail.end, head.start)
84            };
85
86            let between_span = span_converter.from_chars(span_start, span_end);
87            let between_text = text
88                .get(between_span.byte_start..between_span.byte_end)
89                .unwrap_or("");
90
91            // Simple heuristic: check for common relation indicators
92            let relation_type = detect_relation_type(head, tail, between_text, &relation_labels);
93
94            if let Some((rel_type, mut confidence, trigger)) = relation_type {
95                // Apply distance penalty: closer entities are more likely to be related
96                // Confidence decays linearly from 1.0 at distance 0 to 0.5 at max_span_distance
97                let distance_penalty = if distance < config.max_span_distance {
98                    let penalty_factor =
99                        1.0 - (distance as f64 / config.max_span_distance as f64) * 0.5;
100                    penalty_factor.max(0.5) // Minimum 0.5 confidence even at max distance
101                } else {
102                    0.5 // At or beyond max distance, apply minimum confidence
103                };
104                confidence *= distance_penalty;
105
106                if confidence < config.threshold as f64 {
107                    continue;
108                }
109
110                // `detect_relation_type` returns byte offsets into `between_text`.
111                let trigger_span = if config.extract_triggers {
112                    trigger.map(|(s, e)| {
113                        let trigger_start_byte = between_span.byte_start.saturating_add(s);
114                        let trigger_end_byte = between_span.byte_start.saturating_add(e);
115                        (
116                            span_converter.byte_to_char(trigger_start_byte),
117                            span_converter.byte_to_char(trigger_end_byte),
118                        )
119                    })
120                } else {
121                    None
122                };
123
124                relations.push(Relation {
125                    head: head.clone(),
126                    tail: tail.clone(),
127                    relation_type: rel_type.to_string(),
128                    trigger_span,
129                    confidence: confidence.clamp(0.0, 1.0), // Clamp to [0, 1]
130                });
131            }
132        }
133    }
134
135    relations
136}
137
138/// Extract relations as index-based triples (for joint extraction backends).
139///
140/// This is the same heuristic logic as [`extract_relations`], but returns
141/// [`RelationTriple`] with indices into the provided `entities` slice.
142///
143/// Notes:
144/// - Entity spans are **character offsets**.
145/// - Trigger spans are not currently exposed in `RelationTriple`.
146#[must_use]
147pub fn extract_relation_triples(
148    entities: &[Entity],
149    text: &str,
150    registry: &SemanticRegistry,
151    config: &RelationExtractionConfig,
152) -> Vec<RelationTriple> {
153    let mut triples = Vec::new();
154    if entities.len() < 2 {
155        return triples;
156    }
157
158    // `Entity` spans are character offsets; slicing needs byte offsets.
159    let span_converter = crate::offset::SpanConverter::new(text);
160
161    let relation_labels: Vec<_> = registry.relation_labels().collect();
162    if relation_labels.is_empty() {
163        return triples;
164    }
165
166    for (i, head) in entities.iter().enumerate() {
167        for (j, tail) in entities.iter().enumerate() {
168            if i == j {
169                continue;
170            }
171
172            // Skip overlapping spans (avoids self-nesting artifacts like "New York" vs "York").
173            if head.start < tail.end && tail.start < head.end {
174                continue;
175            }
176
177            // Check distance (character offsets)
178            let distance = if head.end <= tail.start {
179                tail.start - head.end
180            } else {
181                head.start.saturating_sub(tail.end)
182            };
183            if distance > config.max_span_distance {
184                continue;
185            }
186
187            let (span_start, span_end) = if head.end <= tail.start {
188                (head.end, tail.start)
189            } else {
190                (tail.end, head.start)
191            };
192
193            let between_span = span_converter.from_chars(span_start, span_end);
194            let between_text = text
195                .get(between_span.byte_start..between_span.byte_end)
196                .unwrap_or("");
197
198            if let Some((rel_type, mut confidence, _trigger)) =
199                detect_relation_type(head, tail, between_text, &relation_labels)
200            {
201                // Apply distance penalty (same logic as extract_relations)
202                let distance_penalty = if distance < config.max_span_distance {
203                    let penalty_factor =
204                        1.0 - (distance as f64 / config.max_span_distance as f64) * 0.5;
205                    penalty_factor.max(0.5)
206                } else {
207                    0.5
208                };
209                confidence *= distance_penalty;
210
211                if confidence < config.threshold as f64 {
212                    continue;
213                }
214
215                triples.push(RelationTriple {
216                    head_idx: i,
217                    tail_idx: j,
218                    relation_type: rel_type.to_string(),
219                    confidence: confidence as f32,
220                });
221            }
222        }
223    }
224
225    triples
226}
227
228/// Result of relation detection: (label, confidence, optional span).
229type RelationMatch<'a> = (&'a str, f64, Option<(usize, usize)>);
230
231/// Detect relation type from context (heuristic fallback).
232fn detect_relation_type<'a>(
233    head: &Entity,
234    tail: &Entity,
235    between_text: &str,
236    relation_labels: &[&'a LabelDefinition],
237) -> Option<RelationMatch<'a>> {
238    // Use Unicode-aware lowercasing for multilingual support
239    // Note: For CJK languages, case doesn't apply, but this is safe
240    let between_lower = between_text.to_lowercase();
241
242    // Normalize relation slugs so datasets that use kebab-case / colon-separated schemas
243    // (e.g. DocRED: "part-of", "general-affiliation") can match our canonical patterns
244    // (e.g. "PART_OF", "GENERAL_AFFILIATION").
245    fn norm_rel_slug(s: &str) -> String {
246        // Uppercase + map non-alphanumerics to '_' so we can compare across naming schemes.
247        let mut out = String::with_capacity(s.len());
248        let mut prev_underscore = false;
249        for ch in s.chars() {
250            if ch.is_alphanumeric() {
251                // Keep Unicode letters/digits; uppercase ASCII for stable matching.
252                if ch.is_ascii_alphabetic() {
253                    out.push(ch.to_ascii_uppercase());
254                } else {
255                    out.push(ch);
256                }
257                prev_underscore = false;
258            } else if !prev_underscore {
259                out.push('_');
260                prev_underscore = true;
261            }
262        }
263        while out.starts_with('_') {
264            out.remove(0);
265        }
266        while out.ends_with('_') {
267            out.pop();
268        }
269        out
270    }
271
272    // Common patterns: (relation_slug, triggers, confidence)
273    struct RelPattern {
274        slug: &'static str,
275        triggers: &'static [&'static str],
276        confidence: f64,
277    }
278
279    let patterns: &[RelPattern] = &[
280        // Employment relations
281        RelPattern {
282            slug: "CEO_OF",
283            triggers: &[
284                "ceo of",
285                "chief executive",
286                "chief executive officer",
287                "leads",
288                "founded",
289                "founder of",
290            ],
291            confidence: 0.8,
292        },
293        RelPattern {
294            slug: "WORKS_FOR",
295            triggers: &[
296                "works for",
297                "works at",
298                "employed by",
299                "employee of",
300                "works with",
301                "staff at",
302                "member of",
303            ],
304            confidence: 0.7,
305        },
306        RelPattern {
307            slug: "FOUNDED",
308            triggers: &[
309                "founded",
310                "co-founded",
311                "cofounder",
312                "started",
313                "established",
314                "created",
315                "launched",
316            ],
317            confidence: 0.8,
318        },
319        RelPattern {
320            slug: "MANAGES",
321            triggers: &[
322                "manages",
323                "managing",
324                "oversees",
325                "directs",
326                "supervises",
327                "runs",
328            ],
329            confidence: 0.75,
330        },
331        RelPattern {
332            slug: "REPORTS_TO",
333            triggers: &["reports to", "reported to", "under", "reports directly to"],
334            confidence: 0.7,
335        },
336        // Location relations
337        RelPattern {
338            slug: "LOCATED_IN",
339            triggers: &[
340                "in",
341                "at",
342                "based in",
343                "located in",
344                "headquartered in",
345                "situated in",
346                "found in",
347            ],
348            confidence: 0.6,
349        },
350        RelPattern {
351            slug: "BORN_IN",
352            triggers: &[
353                "born in",
354                "native of",
355                "from",
356                "hails from",
357                "originated in",
358            ],
359            confidence: 0.7,
360        },
361        RelPattern {
362            slug: "LIVES_IN",
363            triggers: &["lives in", "resides in", "living in", "based in"],
364            confidence: 0.65,
365        },
366        RelPattern {
367            slug: "DIED_IN",
368            triggers: &["died in", "passed away in", "deceased in"],
369            confidence: 0.8,
370        },
371        // Temporal relations
372        RelPattern {
373            slug: "OCCURRED_ON",
374            triggers: &["on", "occurred on", "happened on", "took place on", "dated"],
375            confidence: 0.6,
376        },
377        RelPattern {
378            slug: "STARTED_ON",
379            triggers: &["started on", "began on", "commenced on", "initiated on"],
380            confidence: 0.7,
381        },
382        RelPattern {
383            slug: "ENDED_ON",
384            triggers: &["ended on", "concluded on", "finished on", "completed on"],
385            confidence: 0.7,
386        },
387        // Organizational relations
388        RelPattern {
389            slug: "PART_OF",
390            triggers: &[
391                "part of",
392                "member of",
393                "belongs to",
394                "subsidiary of",
395                "division of",
396                "branch of",
397            ],
398            confidence: 0.7,
399        },
400        RelPattern {
401            slug: "ACQUIRED",
402            triggers: &[
403                "acquired",
404                "bought",
405                "purchased",
406                "took over",
407                "merged with",
408            ],
409            confidence: 0.75,
410        },
411        RelPattern {
412            slug: "MERGED_WITH",
413            triggers: &["merged with", "merged into", "combined with", "joined with"],
414            confidence: 0.8,
415        },
416        RelPattern {
417            slug: "PARENT_OF",
418            triggers: &["parent of", "parent company of", "owns", "owner of"],
419            confidence: 0.75,
420        },
421        // Social relations
422        RelPattern {
423            slug: "MARRIED_TO",
424            triggers: &["married to", "wed to", "spouse of", "husband of", "wife of"],
425            confidence: 0.85,
426        },
427        RelPattern {
428            slug: "CHILD_OF",
429            triggers: &["son of", "daughter of", "child of", "offspring of"],
430            confidence: 0.8,
431        },
432        RelPattern {
433            slug: "SIBLING_OF",
434            triggers: &["brother of", "sister of", "sibling of"],
435            confidence: 0.8,
436        },
437        // Academic/Professional
438        RelPattern {
439            slug: "STUDIED_AT",
440            triggers: &[
441                "studied at",
442                "attended",
443                "graduated from",
444                "alumni of",
445                "educated at",
446            ],
447            confidence: 0.75,
448        },
449        RelPattern {
450            slug: "TEACHES_AT",
451            triggers: &["teaches at", "professor at", "instructor at", "faculty at"],
452            confidence: 0.8,
453        },
454        // Product/Service relations
455        RelPattern {
456            slug: "DEVELOPS",
457            triggers: &[
458                "develops",
459                "created",
460                "built",
461                "designed",
462                "produces",
463                "manufactures",
464            ],
465            confidence: 0.7,
466        },
467        RelPattern {
468            slug: "USES",
469            triggers: &["uses", "utilizes", "employs", "adopts", "implements"],
470            confidence: 0.6,
471        },
472        // Dataset-style relation labels (DocRED/CHisIEC-like)
473        //
474        // These are the *coarse* label names we actually see in the CrossRE/DocRED-style
475        // exports used by this repo (e.g. `docred_dev.json`), which differ from the
476        // “canonical” IE labels above.
477        RelPattern {
478            slug: "NAMED",
479            triggers: &[
480                "called",
481                "known as",
482                "also known as",
483                "named",
484                "referred to as",
485                "nickname",
486            ],
487            confidence: 0.6,
488        },
489        RelPattern {
490            slug: "TYPE_OF",
491            triggers: &[
492                "type of",
493                "kind of",
494                "form of",
495                "a type of",
496                "is a",
497                "are a",
498            ],
499            confidence: 0.6,
500        },
501        RelPattern {
502            slug: "RELATED_TO",
503            triggers: &["related to", "associated with", "connected to", "linked to"],
504            confidence: 0.55,
505        },
506        RelPattern {
507            slug: "ORIGIN",
508            triggers: &[
509                "from",
510                "born",
511                "originated",
512                "created by",
513                "invented by",
514                "derived from",
515                "spinoff",
516                "spin-off",
517            ],
518            confidence: 0.55,
519        },
520        RelPattern {
521            slug: "ROLE",
522            triggers: &[
523                "president",
524                "ceo",
525                "chair",
526                "director",
527                "editor",
528                "producer",
529                "actor",
530                "professor",
531                "fellow",
532                "member",
533            ],
534            confidence: 0.55,
535        },
536        RelPattern {
537            slug: "TEMPORAL",
538            triggers: &[
539                "in 19", "in 20", "during", "before", "after", "between", "until", "since",
540            ],
541            confidence: 0.5,
542        },
543        RelPattern {
544            slug: "PHYSICAL",
545            triggers: &["located in", "based in", "headquartered in", "at "],
546            confidence: 0.55,
547        },
548        RelPattern {
549            slug: "TOPIC",
550            triggers: &["topic", "about", "on", "regarding", "focused on"],
551            confidence: 0.5,
552        },
553        RelPattern {
554            slug: "OPPOSITE",
555            triggers: &["opposite", "contrasts with", "as opposed to"],
556            confidence: 0.6,
557        },
558        RelPattern {
559            slug: "WIN_DEFEAT",
560            triggers: &["defeated", "beat", "won", "win", "lose", "lost to"],
561            confidence: 0.6,
562        },
563        RelPattern {
564            slug: "CAUSE_EFFECT",
565            triggers: &["caused", "causes", "leads to", "results in", "because"],
566            confidence: 0.55,
567        },
568        RelPattern {
569            slug: "USAGE",
570            triggers: &["use", "uses", "used", "using", "utilize", "employ", "adopt"],
571            confidence: 0.55,
572        },
573        RelPattern {
574            slug: "ARTIFACT",
575            triggers: &[
576                "tool",
577                "library",
578                "framework",
579                "system",
580                "artifact",
581                "implementation",
582            ],
583            confidence: 0.55,
584        },
585        RelPattern {
586            slug: "COMPARE",
587            triggers: &[
588                "compare",
589                "compared to",
590                "versus",
591                "vs",
592                "better than",
593                "worse than",
594            ],
595            confidence: 0.55,
596        },
597        RelPattern {
598            slug: "GENERAL_AFFILIATION",
599            triggers: &[
600                "affiliation",
601                "affiliated with",
602                "member of",
603                "part of",
604                "associated with",
605            ],
606            confidence: 0.55,
607        },
608        // CHisIEC (classical Chinese) relations (match either simplified or traditional labels)
609        RelPattern {
610            slug: "父母",
611            triggers: &["父", "母", "父母"],
612            confidence: 0.7,
613        },
614        RelPattern {
615            slug: "兄弟",
616            triggers: &["兄", "弟", "兄弟"],
617            confidence: 0.7,
618        },
619        RelPattern {
620            slug: "別名",
621            triggers: &["別名", "别名"],
622            confidence: 0.75,
623        },
624        RelPattern {
625            slug: "到達",
626            triggers: &["到", "至", "達", "到達", "到达"],
627            confidence: 0.6,
628        },
629        RelPattern {
630            slug: "出生於某地",
631            triggers: &["生於", "生于", "出生於", "出生于"],
632            confidence: 0.65,
633        },
634        RelPattern {
635            slug: "任職",
636            triggers: &["任", "拜", "任職", "任职"],
637            confidence: 0.6,
638        },
639        RelPattern {
640            slug: "管理",
641            triggers: &["管", "治", "守", "管理"],
642            confidence: 0.55,
643        },
644        RelPattern {
645            slug: "駐守",
646            triggers: &["駐", "驻", "守", "駐守", "驻守"],
647            confidence: 0.55,
648        },
649        RelPattern {
650            slug: "敵對攻伐",
651            triggers: &["敵", "敌", "攻", "伐", "戰", "战"],
652            confidence: 0.55,
653        },
654        RelPattern {
655            slug: "同僚",
656            triggers: &["同僚"],
657            confidence: 0.55,
658        },
659        RelPattern {
660            slug: "政治奧援",
661            triggers: &["奧援", "奥援"],
662            confidence: 0.55,
663        },
664        // Communication/Interaction
665        RelPattern {
666            slug: "MET_WITH",
667            triggers: &["met with", "met", "met up with", "encountered", "saw"],
668            confidence: 0.65,
669        },
670        RelPattern {
671            slug: "SPOKE_WITH",
672            triggers: &[
673                "spoke with",
674                "talked with",
675                "discussed with",
676                "conversed with",
677            ],
678            confidence: 0.7,
679        },
680        // Ownership
681        RelPattern {
682            slug: "OWNS",
683            triggers: &["owns", "owner of", "possesses", "holds"],
684            confidence: 0.75,
685        },
686        // =========================================================================
687        // Multilingual relation triggers
688        // =========================================================================
689        // Spanish (es)
690        RelPattern {
691            slug: "WORKS_FOR",
692            triggers: &["trabaja en", "trabaja para", "empleado de", "trabaja con"],
693            confidence: 0.7,
694        },
695        RelPattern {
696            slug: "FOUNDED",
697            triggers: &["fundó", "fundada", "creó", "creada", "estableció", "inició"],
698            confidence: 0.8,
699        },
700        RelPattern {
701            slug: "LOCATED_IN",
702            triggers: &[
703                "en",
704                "ubicado en",
705                "situado en",
706                "basado en",
707                "localizado en",
708            ],
709            confidence: 0.6,
710        },
711        RelPattern {
712            slug: "BORN_IN",
713            triggers: &["nació en", "nacido en", "originario de", "de"],
714            confidence: 0.7,
715        },
716        RelPattern {
717            slug: "LIVES_IN",
718            triggers: &["cerno en", "reside en", "viviendo en"],
719            confidence: 0.65,
720        },
721        RelPattern {
722            slug: "MARRIED_TO",
723            triggers: &["casado con", "casada con", "esposo de", "esposa de"],
724            confidence: 0.85,
725        },
726        // French (fr)
727        RelPattern {
728            slug: "WORKS_FOR",
729            triggers: &[
730                "travaille pour",
731                "travaille à",
732                "employé de",
733                "travaille avec",
734            ],
735            confidence: 0.7,
736        },
737        RelPattern {
738            slug: "FOUNDED",
739            triggers: &["fondé", "fondée", "créé", "créée", "établi", "établie"],
740            confidence: 0.8,
741        },
742        RelPattern {
743            slug: "LOCATED_IN",
744            triggers: &["dans", "à", "situé en", "basé en", "localisé en"],
745            confidence: 0.6,
746        },
747        RelPattern {
748            slug: "BORN_IN",
749            triggers: &["né en", "née en", "originaire de", "de"],
750            confidence: 0.7,
751        },
752        RelPattern {
753            slug: "LIVES_IN",
754            triggers: &["vit en", "réside en", "vivant en"],
755            confidence: 0.65,
756        },
757        RelPattern {
758            slug: "MARRIED_TO",
759            triggers: &["marié avec", "mariée avec", "époux de", "épouse de"],
760            confidence: 0.85,
761        },
762        // German (de)
763        RelPattern {
764            slug: "WORKS_FOR",
765            triggers: &[
766                "arbeitet für",
767                "arbeitet bei",
768                "angestellt bei",
769                "arbeitet mit",
770            ],
771            confidence: 0.7,
772        },
773        RelPattern {
774            slug: "FOUNDED",
775            triggers: &[
776                "gegründet",
777                "gründete",
778                "erstellt",
779                "errichtet",
780                "etabliert",
781            ],
782            confidence: 0.8,
783        },
784        RelPattern {
785            slug: "LOCATED_IN",
786            triggers: &["in", "bei", "situiert in", "basiert in", "befindet sich in"],
787            confidence: 0.6,
788        },
789        RelPattern {
790            slug: "BORN_IN",
791            triggers: &["geboren in", "geboren am", "stammt aus", "aus"],
792            confidence: 0.7,
793        },
794        RelPattern {
795            slug: "LIVES_IN",
796            triggers: &["lebt in", "wohnt in", "lebend in"],
797            confidence: 0.65,
798        },
799        RelPattern {
800            slug: "MARRIED_TO",
801            triggers: &["verheiratet mit", "ehemann von", "ehefrau von"],
802            confidence: 0.85,
803        },
804        // Chinese (zh) - Simplified
805        RelPattern {
806            slug: "WORKS_FOR",
807            triggers: &["为", "在", "工作于", "就职于", "任职于"],
808            confidence: 0.7,
809        },
810        RelPattern {
811            slug: "FOUNDED",
812            triggers: &["创立", "创建", "建立", "成立", "创办"],
813            confidence: 0.8,
814        },
815        RelPattern {
816            slug: "LOCATED_IN",
817            triggers: &["在", "位于", "坐落于", "地处"],
818            confidence: 0.6,
819        },
820        RelPattern {
821            slug: "BORN_IN",
822            triggers: &["出生于", "生于", "来自", "出生于"],
823            confidence: 0.7,
824        },
825        RelPattern {
826            slug: "LIVES_IN",
827            triggers: &["居住于", "住在", "生活在"],
828            confidence: 0.65,
829        },
830        RelPattern {
831            slug: "MARRIED_TO",
832            triggers: &["与...结婚", "嫁给", "娶了"],
833            confidence: 0.85,
834        },
835        // Japanese (ja)
836        RelPattern {
837            slug: "WORKS_FOR",
838            triggers: &["で働く", "に勤務", "に所属", "で就職"],
839            confidence: 0.7,
840        },
841        RelPattern {
842            slug: "FOUNDED",
843            triggers: &["設立", "創立", "設立した", "創設"],
844            confidence: 0.8,
845        },
846        RelPattern {
847            slug: "LOCATED_IN",
848            triggers: &["に", "で", "に位置", "に所在"],
849            confidence: 0.6,
850        },
851        RelPattern {
852            slug: "BORN_IN",
853            triggers: &["に生まれた", "の出身", "で生まれた"],
854            confidence: 0.7,
855        },
856        RelPattern {
857            slug: "LIVES_IN",
858            triggers: &["に住む", "に居住", "に在住"],
859            confidence: 0.65,
860        },
861        RelPattern {
862            slug: "MARRIED_TO",
863            triggers: &["と結婚", "と結婚した", "の配偶者"],
864            confidence: 0.85,
865        },
866        // Arabic (ar) - RTL
867        RelPattern {
868            slug: "WORKS_FOR",
869            triggers: &["يعمل في", "يعمل لصالح", "موظف في", "يعمل مع"],
870            confidence: 0.7,
871        },
872        RelPattern {
873            slug: "FOUNDED",
874            triggers: &["أسس", "أنشأ", "تأسست", "أنشأت"],
875            confidence: 0.8,
876        },
877        RelPattern {
878            slug: "LOCATED_IN",
879            triggers: &["في", "ب", "يقع في", "موجود في"],
880            confidence: 0.6,
881        },
882        RelPattern {
883            slug: "BORN_IN",
884            triggers: &["ولد في", "من مواليد", "من"],
885            confidence: 0.7,
886        },
887        RelPattern {
888            slug: "LIVES_IN",
889            triggers: &["يعيش في", "يسكن في", "مقيم في"],
890            confidence: 0.65,
891        },
892        RelPattern {
893            slug: "MARRIED_TO",
894            triggers: &["متزوج من", "زوج", "زوجة"],
895            confidence: 0.85,
896        },
897        // Russian (ru)
898        RelPattern {
899            slug: "WORKS_FOR",
900            triggers: &["работает в", "работает на", "работает для", "сотрудник"],
901            confidence: 0.7,
902        },
903        RelPattern {
904            slug: "FOUNDED",
905            triggers: &["основал", "основала", "создал", "создала", "учредил"],
906            confidence: 0.8,
907        },
908        RelPattern {
909            slug: "LOCATED_IN",
910            triggers: &["в", "на", "расположен в", "находится в"],
911            confidence: 0.6,
912        },
913        RelPattern {
914            slug: "BORN_IN",
915            triggers: &["родился в", "родилась в", "родом из", "из"],
916            confidence: 0.7,
917        },
918        RelPattern {
919            slug: "LIVES_IN",
920            triggers: &["живет в", "проживает в", "живущий в"],
921            confidence: 0.65,
922        },
923        RelPattern {
924            slug: "MARRIED_TO",
925            triggers: &["женат на", "замужем за", "супруг", "супруга"],
926            confidence: 0.85,
927        },
928    ];
929
930    for pattern in patterns {
931        // Find the canonical label in the registry (case-insensitive).
932        // We return the label's *original* slug so callers preserve user-provided casing.
933        let label = match relation_labels.iter().find(|l| {
934            // Match both:
935            // - exact canonical names (e.g. "PART_OF")
936            // - normalized dataset slugs (e.g. "part-of" -> "PART_OF")
937            norm_rel_slug(&l.slug) == pattern.slug || l.slug.eq_ignore_ascii_case(pattern.slug)
938        }) {
939            Some(l) => *l,
940            None => continue,
941        };
942
943        for trigger in pattern.triggers {
944            if let Some(pos) = between_lower.find(trigger) {
945                // Validate entity types make sense for the relation
946                let valid = match pattern.slug {
947                    // Person-Organization relations
948                    "CEO_OF" | "WORKS_FOR" | "FOUNDED" | "MANAGES" | "REPORTS_TO" => {
949                        // If either side is unknown/misc, don't reject on type alone (relation datasets
950                        // often use a richer schema than `EntityType`).
951                        matches!(
952                            head.entity_type,
953                            EntityType::Other(_) | EntityType::Custom { .. }
954                        ) || matches!(
955                            tail.entity_type,
956                            EntityType::Other(_) | EntityType::Custom { .. }
957                        ) || (matches!(head.entity_type, EntityType::Person)
958                            && matches!(tail.entity_type, EntityType::Organization))
959                    }
960                    // Location relations (any entity can be located in/born in a location)
961                    "LOCATED_IN" | "BORN_IN" | "LIVES_IN" | "DIED_IN" => {
962                        matches!(
963                            tail.entity_type,
964                            EntityType::Other(_) | EntityType::Custom { .. }
965                        ) || matches!(tail.entity_type, EntityType::Location)
966                    }
967                    // Temporal relations (any entity can have temporal attributes)
968                    "OCCURRED_ON" | "STARTED_ON" | "ENDED_ON" => {
969                        matches!(
970                            tail.entity_type,
971                            EntityType::Other(_) | EntityType::Custom { .. }
972                        ) || matches!(tail.entity_type, EntityType::Date | EntityType::Time)
973                    }
974                    // Organizational relations
975                    "PART_OF" | "ACQUIRED" | "MERGED_WITH" | "PARENT_OF" => {
976                        matches!(
977                            head.entity_type,
978                            EntityType::Other(_) | EntityType::Custom { .. }
979                        ) || matches!(
980                            tail.entity_type,
981                            EntityType::Other(_) | EntityType::Custom { .. }
982                        ) || (matches!(head.entity_type, EntityType::Organization)
983                            && matches!(tail.entity_type, EntityType::Organization))
984                    }
985                    // Social relations
986                    "MARRIED_TO" | "CHILD_OF" | "SIBLING_OF" => {
987                        matches!(
988                            head.entity_type,
989                            EntityType::Other(_) | EntityType::Custom { .. }
990                        ) || matches!(
991                            tail.entity_type,
992                            EntityType::Other(_) | EntityType::Custom { .. }
993                        ) || (matches!(head.entity_type, EntityType::Person)
994                            && matches!(tail.entity_type, EntityType::Person))
995                    }
996                    // Academic relations
997                    "STUDIED_AT" | "TEACHES_AT" => {
998                        matches!(
999                            head.entity_type,
1000                            EntityType::Other(_) | EntityType::Custom { .. }
1001                        ) || matches!(
1002                            tail.entity_type,
1003                            EntityType::Other(_) | EntityType::Custom { .. }
1004                        ) || (matches!(head.entity_type, EntityType::Person)
1005                            && matches!(
1006                                tail.entity_type,
1007                                EntityType::Organization | EntityType::Location
1008                            ))
1009                    }
1010                    // Product relations
1011                    "DEVELOPS" | "USES" => {
1012                        matches!(
1013                            head.entity_type,
1014                            EntityType::Other(_) | EntityType::Custom { .. }
1015                        ) || matches!(
1016                            head.entity_type,
1017                            EntityType::Organization | EntityType::Person
1018                        )
1019                    }
1020                    // Interaction relations
1021                    "MET_WITH" | "SPOKE_WITH" => {
1022                        matches!(
1023                            head.entity_type,
1024                            EntityType::Other(_) | EntityType::Custom { .. }
1025                        ) || matches!(
1026                            tail.entity_type,
1027                            EntityType::Other(_) | EntityType::Custom { .. }
1028                        ) || (matches!(head.entity_type, EntityType::Person)
1029                            && matches!(
1030                                tail.entity_type,
1031                                EntityType::Person | EntityType::Organization
1032                            ))
1033                    }
1034                    // Ownership
1035                    "OWNS" => {
1036                        matches!(
1037                            head.entity_type,
1038                            EntityType::Other(_) | EntityType::Custom { .. }
1039                        ) || matches!(
1040                            head.entity_type,
1041                            EntityType::Person | EntityType::Organization
1042                        )
1043                    }
1044                    _ => true, // Default: allow any combination
1045                };
1046
1047                if valid {
1048                    return Some((
1049                        label.slug.as_str(),
1050                        pattern.confidence,
1051                        Some((pos, pos + trigger.len())),
1052                    ));
1053                }
1054            }
1055        }
1056    }
1057
1058    None
1059}