Skip to main content

oxibrain_core/
registry.rs

1//! Predicate registry (DESIGN §5.5, P4). Predicate semantics — object type,
2//! cardinality, temporality, invalidation, symmetry — declared here, not in prompts.
3//! The registry drives the fold, the validator, and (in M3) the extraction schema.
4
5use crate::knowledge::{EntityTypeRef, PredicateRef};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum Cardinality {
11    Functional,
12    MultiValued,
13}
14
15impl Cardinality {
16    pub fn as_db(&self) -> &'static str {
17        match self {
18            Self::Functional => "functional",
19            Self::MultiValued => "multi_valued",
20        }
21    }
22    pub fn parse_db(s: &str) -> Option<Self> {
23        match s {
24            "functional" => Some(Self::Functional),
25            "multi_valued" => Some(Self::MultiValued),
26            _ => None,
27        }
28    }
29}
30
31#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum Temporality {
34    Static,
35    Interval,
36    Point,
37}
38
39impl Temporality {
40    pub fn as_db(&self) -> &'static str {
41        match self {
42            Self::Static => "static",
43            Self::Interval => "interval",
44            Self::Point => "point",
45        }
46    }
47    pub fn parse_db(s: &str) -> Option<Self> {
48        match s {
49            "static" => Some(Self::Static),
50            "interval" => Some(Self::Interval),
51            "point" => Some(Self::Point),
52            _ => None,
53        }
54    }
55}
56
57#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum Invalidation {
60    Supersede,
61    Coexist,
62    ExplicitOnly,
63}
64
65impl Invalidation {
66    pub fn as_db(&self) -> &'static str {
67        match self {
68            Self::Supersede => "supersede",
69            Self::Coexist => "coexist",
70            Self::ExplicitOnly => "explicit_only",
71        }
72    }
73    pub fn parse_db(s: &str) -> Option<Self> {
74        match s {
75            "supersede" => Some(Self::Supersede),
76            "coexist" => Some(Self::Coexist),
77            "explicit_only" => Some(Self::ExplicitOnly),
78            _ => None,
79        }
80    }
81}
82
83/// Entity types a predicate's object may take (registry minor 4+). Serializes
84/// as a JSON array; deserialization also accepts the pre-4 single-string form
85/// so `def_json` rows written by older registries keep loading.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct EntityTypes(pub Vec<EntityTypeRef>);
88
89impl<const N: usize> From<[&str; N]> for EntityTypes {
90    fn from(types: [&str; N]) -> Self {
91        Self(types.iter().map(|s| (*s).to_string()).collect())
92    }
93}
94
95impl From<&str> for EntityTypes {
96    fn from(t: &str) -> Self {
97        Self(vec![t.to_string()])
98    }
99}
100
101impl Serialize for EntityTypes {
102    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
103    where
104        S: serde::Serializer,
105    {
106        serializer.collect_seq(self.0.iter())
107    }
108}
109
110impl<'de> Deserialize<'de> for EntityTypes {
111    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
112    where
113        D: serde::Deserializer<'de>,
114    {
115        struct EntityTypesVisitor;
116
117        impl<'de> serde::de::Visitor<'de> for EntityTypesVisitor {
118            type Value = EntityTypes;
119
120            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121                f.write_str("an entity type string or an array of entity type strings")
122            }
123
124            fn visit_str<E>(self, v: &str) -> Result<EntityTypes, E>
125            where
126                E: serde::de::Error,
127            {
128                Ok(EntityTypes(vec![v.to_string()]))
129            }
130
131            fn visit_seq<A>(self, mut seq: A) -> Result<EntityTypes, A::Error>
132            where
133                A: serde::de::SeqAccess<'de>,
134            {
135                let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0));
136                while let Some(t) = seq.next_element::<String>()? {
137                    out.push(t);
138                }
139                Ok(EntityTypes(out))
140            }
141        }
142
143        deserializer.deserialize_any(EntityTypesVisitor)
144    }
145}
146
147/// What a predicate's object can be. Untagged; variant order is load-bearing:
148/// `Literal` is tried first so literal type names ("text", "date", …) are not
149/// swallowed by the string-accepting `Entity` visitor.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(untagged)]
152pub enum ObjectKind {
153    Literal(LiteralType),
154    Entity(EntityTypes),
155    Enum { variants: Vec<String> },
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum LiteralType {
161    Text,
162    Date,
163    DateTime,
164    Quantity { unit: String },
165    Number,
166    Bool,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct PredicateDef {
171    pub name: PredicateRef,
172    pub object_kind: ObjectKind,
173    pub subject_types: Vec<EntityTypeRef>,
174    pub cardinality: Cardinality,
175    pub temporality: Temporality,
176    pub invalidation: Invalidation,
177    pub symmetric: bool,
178    pub inverse_of: Option<PredicateRef>,
179    pub description: String,
180    pub examples: Vec<String>,
181    pub deprecated_by: Option<PredicateRef>,
182    /// §12.2 — predicate is "what the user wants in every answer". A predicate
183    /// marked here is queried on every Profile render. Defaults to false.
184    /// Bumping this on existing predicates is a minor-version event: existing
185    /// cached extractions are unaffected (D21).
186    #[serde(default)]
187    pub profile_relevant: bool,
188    /// §5.5 — confidence prior for this predicate. Defaults to 1.0 (normal
189    /// evidence). Hearsay/uncertain predicates (e.g. `allegedly_employed_by`)
190    /// carry a lower prior so the fold produces low-confidence beliefs
191    /// automatically — no special-casing in the pipeline (P4).
192    #[serde(default = "default_confidence_prior")]
193    pub confidence_prior: f32,
194}
195
196/// Default confidence prior for predicates (§5.5). Normal predicates do not
197/// penalise belief confidence; hearsay predicates override this to < 1.0.
198fn default_confidence_prior() -> f32 {
199    1.0
200}
201/// Registry version for this ontology.
202pub const CORE_V1_MAJOR: u32 = 1;
203pub const CORE_V1_MINOR: u32 = 4;
204
205static CORE_V1: std::sync::LazyLock<Vec<PredicateDef>> = std::sync::LazyLock::new(|| {
206    vec![
207        PredicateDef {
208            name: "employed_by".into(),
209            object_kind: ObjectKind::Entity("Organization".into()),
210            subject_types: vec!["Person".into()],
211            cardinality: Cardinality::Functional,
212            temporality: Temporality::Interval,
213            invalidation: Invalidation::Supersede,
214            symmetric: false,
215            inverse_of: None,
216            description: "The organization that employs this person.".into(),
217            examples: vec!["Alice is employed by Acme Corp".into()],
218            deprecated_by: None,
219            profile_relevant: true,
220            confidence_prior: 1.0,
221        },
222        PredicateDef {
223            name: "works_on".into(),
224            object_kind: ObjectKind::Entity("Project".into()),
225            subject_types: vec!["Person".into()],
226            cardinality: Cardinality::MultiValued,
227            temporality: Temporality::Interval,
228            invalidation: Invalidation::Coexist,
229            symmetric: false,
230            inverse_of: None,
231            description: "A project this person is currently working on.".into(),
232            examples: vec!["Bob works on ProjectX".into()],
233            deprecated_by: None,
234            profile_relevant: false,
235            confidence_prior: 1.0,
236        },
237        PredicateDef {
238            name: "born_in".into(),
239            object_kind: ObjectKind::Entity("Place".into()),
240            subject_types: vec!["Person".into()],
241            cardinality: Cardinality::Functional,
242            temporality: Temporality::Static,
243            invalidation: Invalidation::Supersede,
244            symmetric: false,
245            inverse_of: None,
246            description: "Where this person was born. A second value is a contradiction.".into(),
247            examples: vec!["Alice was born in Seoul".into()],
248            deprecated_by: None,
249            profile_relevant: true,
250            confidence_prior: 1.0,
251        },
252        PredicateDef {
253            name: "full_name".into(),
254            object_kind: ObjectKind::Literal(LiteralType::Text),
255            subject_types: vec!["Person".into()],
256            cardinality: Cardinality::Functional,
257            temporality: Temporality::Interval,
258            invalidation: Invalidation::Supersede,
259            symmetric: false,
260            inverse_of: None,
261            description: "The person's full legal name. A new value supersedes the old.".into(),
262            examples: vec!["Alice's full name is Alice Smith".into()],
263            deprecated_by: None,
264            profile_relevant: true,
265            confidence_prior: 1.0,
266        },
267        PredicateDef {
268            name: "died_at".into(),
269            object_kind: ObjectKind::Literal(LiteralType::DateTime),
270            subject_types: vec!["Person".into()],
271            cardinality: Cardinality::Functional,
272            temporality: Temporality::Static,
273            invalidation: Invalidation::ExplicitOnly,
274            symmetric: false,
275            inverse_of: None,
276            description: "When this person died. A second value is a contradiction.".into(),
277            examples: vec!["Alice died at 2024-03-01T00:00:00Z".into()],
278            deprecated_by: None,
279            profile_relevant: true,
280            confidence_prior: 1.0,
281        },
282        PredicateDef {
283            name: "knows".into(),
284            object_kind: ObjectKind::Entity("Person".into()),
285            subject_types: vec!["Person".into()],
286            cardinality: Cardinality::MultiValued,
287            temporality: Temporality::Interval,
288            invalidation: Invalidation::Coexist,
289            symmetric: true,
290            inverse_of: None,
291            description: "This person knows another person. Symmetric.".into(),
292            examples: vec!["Alice knows Bob".into()],
293            deprecated_by: None,
294            profile_relevant: false,
295            confidence_prior: 1.0,
296        },
297        PredicateDef {
298            name: "member_of".into(),
299            object_kind: ObjectKind::Entity("Organization".into()),
300            subject_types: vec!["Person".into()],
301            cardinality: Cardinality::MultiValued,
302            temporality: Temporality::Interval,
303            invalidation: Invalidation::Coexist,
304            symmetric: false,
305            inverse_of: None,
306            description: "Organizations this person is a member of.".into(),
307            examples: vec!["Alice is a member of the Engineering Guild".into()],
308            deprecated_by: None,
309            profile_relevant: true,
310            confidence_prior: 1.0,
311        },
312        PredicateDef {
313            name: "part_of".into(),
314            object_kind: ObjectKind::Entity(["Organization", "Project"].into()),
315            subject_types: vec!["Organization".into(), "Project".into(), "Artifact".into()],
316            cardinality: Cardinality::Functional,
317            temporality: Temporality::Static,
318            invalidation: Invalidation::Supersede,
319            symmetric: false,
320            inverse_of: None,
321            description:
322                "This organization/project/artifact is part of a parent organization or project."
323                    .into(),
324            examples: vec!["Acme subsidiary is part of Acme Corp".into()],
325            deprecated_by: None,
326            profile_relevant: false,
327            confidence_prior: 1.0,
328        },
329        PredicateDef {
330            name: "located_in".into(),
331            object_kind: ObjectKind::Entity("Place".into()),
332            subject_types: vec!["Place".into(), "Artifact".into()],
333            cardinality: Cardinality::Functional,
334            temporality: Temporality::Static,
335            invalidation: Invalidation::Supersede,
336            symmetric: false,
337            inverse_of: None,
338            description: "This place or artifact is located within a place.".into(),
339            examples: vec!["Seoul is located in South Korea".into()],
340            deprecated_by: None,
341            profile_relevant: false,
342            confidence_prior: 1.0,
343        },
344        PredicateDef {
345            name: "has_skill".into(),
346            object_kind: ObjectKind::Entity("Concept".into()),
347            subject_types: vec!["Person".into()],
348            cardinality: Cardinality::MultiValued,
349            temporality: Temporality::Interval,
350            invalidation: Invalidation::Coexist,
351            symmetric: false,
352            inverse_of: None,
353            description: "A skill or competency this person has.".into(),
354            examples: vec!["Alice has skill Rust programming".into()],
355            deprecated_by: None,
356            profile_relevant: true,
357            confidence_prior: 1.0,
358        },
359        PredicateDef {
360            name: "created_by".into(),
361            object_kind: ObjectKind::Entity("Person".into()),
362            subject_types: vec!["Artifact".into(), "Document".into()],
363            cardinality: Cardinality::Functional,
364            temporality: Temporality::Static,
365            invalidation: Invalidation::Supersede,
366            symmetric: false,
367            inverse_of: Some("author_of".into()),
368            description: "Who created this artifact or document.".into(),
369            examples: vec!["The report was created by Alice".into()],
370            deprecated_by: None,
371            profile_relevant: false,
372            confidence_prior: 1.0,
373        },
374        PredicateDef {
375            name: "aliases".into(),
376            object_kind: ObjectKind::Literal(LiteralType::Text),
377            subject_types: vec![
378                "Person".into(),
379                "Organization".into(),
380                "Project".into(),
381                "Artifact".into(),
382            ],
383            cardinality: Cardinality::MultiValued,
384            temporality: Temporality::Static,
385            invalidation: Invalidation::Coexist,
386            symmetric: false,
387            inverse_of: None,
388            description: "Alternative names for this entity. Multiple values coexist.".into(),
389            examples: vec!["Alice's alias is A. Smith".into()],
390            deprecated_by: None,
391            profile_relevant: true,
392            confidence_prior: 1.0,
393        },
394        // ── Hearsay / uncertain predicates (§5.5, M10 10.9) ──────────────
395        // These carry a low confidence_prior so beliefs derived from them
396        // are automatically down-weighted by the fold. No pipeline
397        // special-casing required (P4: semantics in the registry).
398        PredicateDef {
399            name: "allegedly_employed_by".into(),
400            object_kind: ObjectKind::Entity("Organization".into()),
401            subject_types: vec!["Person".into()],
402            cardinality: Cardinality::MultiValued,
403            temporality: Temporality::Interval,
404            invalidation: Invalidation::Coexist,
405            symmetric: false,
406            inverse_of: None,
407            description: "Unverified claim that an organization employs this person.".into(),
408            examples: vec!["Alice is allegedly employed by Acme Corp".into()],
409            deprecated_by: None,
410            profile_relevant: false,
411            confidence_prior: 0.3,
412        },
413        PredicateDef {
414            name: "rumored_knows".into(),
415            object_kind: ObjectKind::Entity("Person".into()),
416            subject_types: vec!["Person".into()],
417            cardinality: Cardinality::MultiValued,
418            temporality: Temporality::Interval,
419            invalidation: Invalidation::Coexist,
420            symmetric: false,
421            inverse_of: None,
422            description: "Unverified claim that this person knows another person.".into(),
423            examples: vec!["Alice is rumored to know Bob".into()],
424            deprecated_by: None,
425            profile_relevant: false,
426            confidence_prior: 0.3,
427        },
428    ]
429});
430
431/// The shipped core/v1 ontology (DESIGN §5.5).
432pub fn core_v1() -> &'static Vec<PredicateDef> {
433    &CORE_V1
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use std::collections::HashSet;
440
441    #[test]
442    fn core_v1_covers_all_invalidation_branches() {
443        let defs = core_v1();
444        // Must have at least one predicate for each branch we need to test:
445        let has_func_supersede_interval = defs.iter().any(|d| {
446            d.cardinality == Cardinality::Functional
447                && d.invalidation == Invalidation::Supersede
448                && d.temporality == Temporality::Interval
449        });
450        let has_func_supersede_static = defs.iter().any(|d| {
451            d.cardinality == Cardinality::Functional
452                && d.invalidation == Invalidation::Supersede
453                && d.temporality == Temporality::Static
454        });
455        let has_multi_coexist = defs.iter().any(|d| {
456            d.cardinality == Cardinality::MultiValued && d.invalidation == Invalidation::Coexist
457        });
458        let has_explicit_only = defs
459            .iter()
460            .any(|d| d.invalidation == Invalidation::ExplicitOnly);
461        let has_symmetric = defs.iter().any(|d| d.symmetric);
462        let has_inverse = defs.iter().any(|d| d.inverse_of.is_some());
463
464        assert!(
465            has_func_supersede_interval,
466            "missing Functional/Supersede/Interval"
467        );
468        assert!(
469            has_func_supersede_static,
470            "missing Functional/Supersede/Static"
471        );
472        assert!(has_multi_coexist, "missing MultiValued/Coexist");
473        assert!(has_explicit_only, "missing ExplicitOnly");
474        assert!(has_symmetric, "missing symmetric");
475        assert!(has_inverse, "missing inverse_of");
476    }
477
478    #[test]
479    fn predicate_names_are_unique() {
480        let names: HashSet<_> = core_v1().iter().map(|d| &d.name).collect();
481        assert_eq!(names.len(), core_v1().len(), "duplicate predicate names");
482    }
483
484    #[test]
485    fn predicate_defs_serialize_roundtrip() {
486        for def in core_v1() {
487            let json = serde_json::to_string(def).expect("serialize");
488            let back: PredicateDef = serde_json::from_str(&json).expect("deserialize");
489            assert_eq!(def.name, back.name);
490            assert_eq!(def.cardinality, back.cardinality);
491            assert_eq!(def.temporality, back.temporality);
492            assert_eq!(def.invalidation, back.invalidation);
493        }
494    }
495
496    #[test]
497    fn part_of_accepts_multiple_object_types() {
498        // The regression this redesign exists for: project-part-of-project
499        // knowledge must survive validation (minor 4).
500        let def = core_v1()
501            .iter()
502            .find(|d| d.name == "part_of")
503            .expect("part_of");
504        let ObjectKind::Entity(types) = &def.object_kind else {
505            panic!("part_of object must be Entity");
506        };
507        assert!(types.0.iter().any(|t| t == "Organization"));
508        assert!(types.0.iter().any(|t| t == "Project"));
509    }
510
511    #[test]
512    fn object_kind_entity_roundtrips_through_array() {
513        let def = core_v1()
514            .iter()
515            .find(|d| d.name == "part_of")
516            .expect("part_of");
517        let json = serde_json::to_string(&def.object_kind).expect("serialize");
518        assert_eq!(json, r#"["Organization","Project"]"#);
519        let back: ObjectKind = serde_json::from_str(&json).expect("deserialize");
520        assert_eq!(back, def.object_kind);
521    }
522
523    #[test]
524    fn object_kind_accepts_legacy_single_string() {
525        // def_json rows written by registry minor <= 3 stored Entity as a
526        // bare string; they must keep loading (no migration required).
527        let k: ObjectKind = serde_json::from_str(r#""Organization""#).expect("legacy string");
528        assert_eq!(k, ObjectKind::Entity(["Organization"].into()));
529    }
530
531    #[test]
532    fn object_kind_literal_strings_stay_literal() {
533        // Variant-order guard: Literal is tried before Entity so literal type
534        // names are not swallowed by the string-accepting Entity visitor.
535        let k: ObjectKind = serde_json::from_str(r#""text""#).expect("literal");
536        assert_eq!(k, ObjectKind::Literal(LiteralType::Text));
537    }
538
539    #[test]
540    fn object_kind_enum_uses_object_shape() {
541        // Enum moved from a bare array (ambiguous with multi-type Entity) to
542        // an object shape under untagged serde. No core/v1 predicate uses it.
543        let k: ObjectKind = serde_json::from_str(r#"{"variants":["yes","no"]}"#).expect("enum");
544        assert_eq!(
545            k,
546            ObjectKind::Enum {
547                variants: vec!["yes".into(), "no".into()]
548            }
549        );
550        let json = serde_json::to_string(&k).expect("serialize");
551        assert_eq!(json, r#"{"variants":["yes","no"]}"#);
552    }
553}