Skip to main content

graphforge_value/
ids.rs

1//! Checked owner of the existing tagged type-ID integer encoding.
2
3use graphforge_core::{PropId, TypeId};
4use serde::{Deserialize, Serialize};
5
6/// Exclusive upper bound of ontology/semantic and runtime-local type IDs.
7pub const TYPE_LOCAL_ID_LIMIT: u32 = 1 << 30;
8const ENTITY_TAG: u32 = 1 << 30;
9const RELATION_TAG: u32 = 1 << 31;
10const TAG_MASK: u32 = ENTITY_TAG | RELATION_TAG;
11const LOCAL_MASK: u32 = TYPE_LOCAL_ID_LIMIT - 1;
12
13/// Identity domain encoded by a persisted type ID.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum TypeIdKind {
16    /// Untagged declared identity, interpreted against its ontology/semantic authority.
17    Ontology,
18    /// Entity label observed by the runtime catalog.
19    RuntimeEntity,
20    /// Relation type observed by the runtime catalog.
21    RuntimeRelation,
22    /// Owner-scoped runtime property identity (not stored in tagged type columns).
23    RuntimeProperty,
24    /// Declared ontology or composition property identity.
25    OntologyProperty,
26}
27
28/// A malformed identity or an attempted use in the wrong identity domain.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
30pub enum CatalogIdError {
31    /// A local identity does not fit its existing serialized domain.
32    #[error("{kind:?} local ID {value} exceeds its supported range")]
33    OutOfRange {
34        /// Requested identity domain.
35        kind: TypeIdKind,
36        /// Rejected raw identity.
37        value: u32,
38    },
39    /// Both reserved type-domain bits are set.
40    #[error("type ID {encoded} has conflicting runtime entity and relation tags")]
41    ConflictingTags {
42        /// Rejected persisted integer.
43        encoded: u32,
44    },
45    /// An entity/relation-specific field received the opposite runtime domain.
46    #[error("expected {expected:?} type domain, found {actual:?}")]
47    WrongDomain {
48        /// Runtime domain allowed by the destination field.
49        expected: TypeIdKind,
50        /// Domain supplied by the input.
51        actual: TypeIdKind,
52    },
53}
54
55macro_rules! runtime_id {
56    ($name:ident, $kind:ident, $limit:expr, $doc:literal) => {
57        #[doc = $doc]
58        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
59        #[serde(try_from = "u32", into = "u32")]
60        pub struct $name(u32);
61
62        impl $name {
63            /// Check a catalog-local integer without interpreting any type tag.
64            pub const fn new(value: u32) -> Result<Self, CatalogIdError> {
65                if value < $limit {
66                    Ok(Self(value))
67                } else {
68                    Err(CatalogIdError::OutOfRange {
69                        kind: TypeIdKind::$kind,
70                        value,
71                    })
72                }
73            }
74
75            /// Return the unchanged catalog-local integer.
76            #[must_use]
77            pub const fn get(self) -> u32 {
78                self.0
79            }
80        }
81
82        impl TryFrom<u32> for $name {
83            type Error = CatalogIdError;
84
85            fn try_from(value: u32) -> Result<Self, Self::Error> {
86                Self::new(value)
87            }
88        }
89
90        impl From<$name> for u32 {
91            fn from(value: $name) -> Self {
92                value.get()
93            }
94        }
95    };
96}
97
98runtime_id!(
99    RuntimeEntityId,
100    RuntimeEntity,
101    TYPE_LOCAL_ID_LIMIT,
102    "Checked runtime-catalog entity identity; distinct from relation and ontology IDs."
103);
104runtime_id!(
105    RuntimeRelationId,
106    RuntimeRelation,
107    TYPE_LOCAL_ID_LIMIT,
108    "Checked runtime-catalog relation identity; distinct from entity and ontology IDs."
109);
110// Existing catalog decoding requires a representable next-property counter.
111runtime_id!(
112    RuntimePropId,
113    RuntimeProperty,
114    u32::MAX,
115    "Checked owner-scoped runtime property identity, retaining its untagged integer encoding."
116);
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
119enum TypeIdentity {
120    Ontology(TypeId),
121    RuntimeEntity(RuntimeEntityId),
122    RuntimeRelation(RuntimeRelationId),
123}
124
125/// Checked carrier for the persisted ontology/runtime type-ID integer space.
126///
127/// Construction and serde decoding reject invalid identities. Entity and relation
128/// consumers use [`EntityTypeId`] and [`RelationTypeId`] to enforce their context.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
130#[serde(try_from = "u32", into = "u32")]
131pub struct TaggedTypeId(TypeIdentity);
132
133impl TaggedTypeId {
134    /// Check an untagged ontology or generation-bound semantic identity.
135    pub const fn ontology(id: TypeId) -> Result<Self, CatalogIdError> {
136        if id.0 < TYPE_LOCAL_ID_LIMIT {
137            Ok(Self(TypeIdentity::Ontology(id)))
138        } else {
139            Err(CatalogIdError::OutOfRange {
140                kind: TypeIdKind::Ontology,
141                value: id.0,
142            })
143        }
144    }
145
146    /// Decode the existing UInt32 type-column representation.
147    pub const fn decode(encoded: u32) -> Result<Self, CatalogIdError> {
148        let local = encoded & LOCAL_MASK;
149        match encoded & TAG_MASK {
150            0 => Ok(Self(TypeIdentity::Ontology(TypeId(local)))),
151            ENTITY_TAG => Ok(Self(TypeIdentity::RuntimeEntity(RuntimeEntityId(local)))),
152            RELATION_TAG => Ok(Self(TypeIdentity::RuntimeRelation(RuntimeRelationId(
153                local,
154            )))),
155            _ => Err(CatalogIdError::ConflictingTags { encoded }),
156        }
157    }
158
159    /// Encode without changing the existing persisted integer.
160    #[must_use]
161    pub const fn encode(self) -> u32 {
162        match self.0 {
163            TypeIdentity::Ontology(id) => id.0,
164            TypeIdentity::RuntimeEntity(id) => ENTITY_TAG | id.get(),
165            TypeIdentity::RuntimeRelation(id) => RELATION_TAG | id.get(),
166        }
167    }
168
169    /// Inspect the checked identity domain without interpreting tag bits again.
170    #[must_use]
171    pub const fn kind(self) -> TypeIdKind {
172        match self.0 {
173            TypeIdentity::Ontology(_) => TypeIdKind::Ontology,
174            TypeIdentity::RuntimeEntity(_) => TypeIdKind::RuntimeEntity,
175            TypeIdentity::RuntimeRelation(_) => TypeIdKind::RuntimeRelation,
176        }
177    }
178
179    /// Get an ontology/semantic ID only when the input belongs to that domain.
180    #[must_use]
181    pub const fn ontology_id(self) -> Option<TypeId> {
182        match self.0 {
183            TypeIdentity::Ontology(id) => Some(id),
184            _ => None,
185        }
186    }
187
188    /// Get a runtime entity ID only when the input belongs to that domain.
189    #[must_use]
190    pub const fn runtime_entity_id(self) -> Option<RuntimeEntityId> {
191        match self.0 {
192            TypeIdentity::RuntimeEntity(id) => Some(id),
193            _ => None,
194        }
195    }
196
197    /// Get a runtime relation ID only when the input belongs to that domain.
198    #[must_use]
199    pub const fn runtime_relation_id(self) -> Option<RuntimeRelationId> {
200        match self.0 {
201            TypeIdentity::RuntimeRelation(id) => Some(id),
202            _ => None,
203        }
204    }
205}
206
207impl TryFrom<u32> for TaggedTypeId {
208    type Error = CatalogIdError;
209    fn try_from(value: u32) -> Result<Self, Self::Error> {
210        Self::decode(value)
211    }
212}
213
214impl From<TaggedTypeId> for u32 {
215    fn from(value: TaggedTypeId) -> Self {
216        value.encode()
217    }
218}
219
220macro_rules! contextual_id {
221    ($name:ident, $runtime:ident, $variant:ident, $kind:ident, $other:ident, $doc:literal) => {
222        #[doc = $doc]
223        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
224        #[serde(try_from = "u32", into = "u32")]
225        pub struct $name(TaggedTypeId);
226
227        impl $name {
228            /// Check an ontology or generation-bound semantic identity.
229            pub const fn ontology(id: TypeId) -> Result<Self, CatalogIdError> {
230                match TaggedTypeId::ontology(id) {
231                    Ok(id) => Ok(Self(id)),
232                    Err(error) => Err(error),
233                }
234            }
235
236            /// Carry an already checked runtime identity of the correct kind.
237            #[must_use]
238            pub const fn runtime(id: $runtime) -> Self {
239                Self(TaggedTypeId(TypeIdentity::$variant(id)))
240            }
241
242            /// Validate persisted encoding and its entity/relation context.
243            pub const fn decode(encoded: u32) -> Result<Self, CatalogIdError> {
244                let decoded = match TaggedTypeId::decode(encoded) {
245                    Ok(id) => id,
246                    Err(error) => return Err(error),
247                };
248                match decoded.kind() {
249                    TypeIdKind::$other => Err(CatalogIdError::WrongDomain {
250                        expected: TypeIdKind::$kind,
251                        actual: TypeIdKind::$other,
252                    }),
253                    _ => Ok(Self(decoded)),
254                }
255            }
256
257            /// Return the unchanged integer for a typed serialization boundary.
258            #[must_use]
259            pub const fn encode(self) -> u32 {
260                self.0.encode()
261            }
262
263            /// Inspect identity domain through the shared checked carrier.
264            #[must_use]
265            pub const fn tagged(self) -> TaggedTypeId {
266                self.0
267            }
268        }
269
270        impl TryFrom<u32> for $name {
271            type Error = CatalogIdError;
272            fn try_from(value: u32) -> Result<Self, Self::Error> {
273                Self::decode(value)
274            }
275        }
276        impl From<$name> for u32 {
277            fn from(value: $name) -> Self {
278                value.encode()
279            }
280        }
281    };
282}
283
284contextual_id!(
285    EntityTypeId,
286    RuntimeEntityId,
287    RuntimeEntity,
288    RuntimeEntity,
289    RuntimeRelation,
290    "Checked entity-label carrier, which cannot contain a runtime relation ID."
291);
292contextual_id!(
293    RelationTypeId,
294    RuntimeRelationId,
295    RuntimeRelation,
296    RuntimeRelation,
297    RuntimeEntity,
298    "Checked relation-type carrier, which cannot contain a runtime entity ID."
299);
300
301/// Entity-label selection without conflating an unknown label with all labels.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
303pub enum EntityTypeSelection {
304    /// No label restriction.
305    All,
306    /// Select one checked declared or runtime entity label.
307    Known(EntityTypeId),
308    /// A requested label is absent; preserve the empty-result behavior.
309    Missing,
310}
311
312impl EntityTypeSelection {
313    /// Inspect a selected identity without treating missing as unrestricted.
314    #[must_use]
315    pub const fn known_id(self) -> Option<EntityTypeId> {
316        match self {
317            Self::Known(id) => Some(id),
318            Self::All | Self::Missing => None,
319        }
320    }
321}
322
323/// Relation selection with distinct wildcard and unresolved-name states.
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
325pub enum RelationTypeSelection {
326    /// Expand every relation type.
327    All,
328    /// Expand the checked declared or runtime relation type.
329    Known(RelationTypeId),
330    /// An explicitly requested type is absent.
331    Missing,
332}
333
334impl RelationTypeSelection {
335    /// Inspect a selected identity without treating missing as a wildcard.
336    #[must_use]
337    pub const fn known_id(self) -> Option<RelationTypeId> {
338        match self {
339            Self::Known(id) => Some(id),
340            Self::All | Self::Missing => None,
341        }
342    }
343}
344
345/// Immutable primary property-routing label, independent of current membership.
346///
347/// The existing topology scalar uses `u32::MAX` for an originally unlabelled
348/// node. Label changes can leave that absence beside a nonempty membership list.
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
350#[serde(try_from = "u32", into = "u32")]
351pub struct PrimaryEntityTypeId(Option<EntityTypeId>);
352
353impl PrimaryEntityTypeId {
354    /// Preserve the original absence of a primary routing label.
355    #[must_use]
356    pub const fn absent() -> Self {
357        Self(None)
358    }
359
360    /// Preserve a checked original primary routing label.
361    #[must_use]
362    pub const fn known(id: EntityTypeId) -> Self {
363        Self(Some(id))
364    }
365
366    /// Decode the existing primary-routing scalar, including its absence value.
367    pub const fn decode(encoded: u32) -> Result<Self, CatalogIdError> {
368        if encoded == u32::MAX {
369            Ok(Self::absent())
370        } else {
371            match EntityTypeId::decode(encoded) {
372                Ok(id) => Ok(Self::known(id)),
373                Err(error) => Err(error),
374            }
375        }
376    }
377
378    /// Return the original scalar without inferring it from current membership.
379    #[must_use]
380    pub const fn encode(self) -> u32 {
381        match self.0 {
382            Some(id) => id.encode(),
383            None => u32::MAX,
384        }
385    }
386
387    /// Inspect the original routing label independently of current membership.
388    #[must_use]
389    pub const fn label(self) -> Option<EntityTypeId> {
390        self.0
391    }
392}
393
394impl TryFrom<u32> for PrimaryEntityTypeId {
395    type Error = CatalogIdError;
396
397    fn try_from(value: u32) -> Result<Self, Self::Error> {
398        Self::decode(value)
399    }
400}
401
402impl From<PrimaryEntityTypeId> for u32 {
403    fn from(value: PrimaryEntityTypeId) -> Self {
404        value.encode()
405    }
406}
407
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
409enum PropertyIdentity {
410    Ontology(PropId),
411    Runtime(RuntimePropId),
412}
413
414/// Compiler property reference with explicit ontology/runtime ownership.
415///
416/// Property catalog integers have no tag bits. Their domain must stay attached
417/// in compiler carriers and be resolved against the matching authority; it
418/// cannot be reconstructed from a bare numeric property ID.
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
420#[serde(try_from = "PropertyWire", into = "PropertyWire")]
421pub struct PropertyId(PropertyIdentity);
422
423impl PropertyId {
424    /// Carry a declared property identity without substituting a runtime ID.
425    pub const fn ontology(id: PropId) -> Result<Self, CatalogIdError> {
426        if id.0 == u32::MAX {
427            Err(CatalogIdError::OutOfRange {
428                kind: TypeIdKind::OntologyProperty,
429                value: id.0,
430            })
431        } else {
432            Ok(Self(PropertyIdentity::Ontology(id)))
433        }
434    }
435
436    /// Carry an already checked owner-scoped runtime property identity.
437    #[must_use]
438    pub const fn runtime(id: RuntimePropId) -> Self {
439        Self(PropertyIdentity::Runtime(id))
440    }
441
442    /// Resolve only declared property references through an ontology map.
443    #[must_use]
444    pub const fn ontology_id(self) -> Option<PropId> {
445        match self.0 {
446            PropertyIdentity::Ontology(id) => Some(id),
447            PropertyIdentity::Runtime(_) => None,
448        }
449    }
450
451    /// Resolve only runtime property references through the runtime catalog.
452    #[must_use]
453    pub const fn runtime_id(self) -> Option<RuntimePropId> {
454        match self.0 {
455            PropertyIdentity::Runtime(id) => Some(id),
456            PropertyIdentity::Ontology(_) => None,
457        }
458    }
459}
460
461impl std::fmt::Display for PropertyId {
462    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463        match self.0 {
464            PropertyIdentity::Ontology(id) => write!(f, "ontology_{}", id.0),
465            PropertyIdentity::Runtime(id) => write!(f, "runtime_{}", id.get()),
466        }
467    }
468}
469
470#[derive(Serialize, Deserialize)]
471enum PropertyWire {
472    Ontology(u32),
473    Runtime(u32),
474}
475
476impl TryFrom<PropertyWire> for PropertyId {
477    type Error = CatalogIdError;
478
479    fn try_from(value: PropertyWire) -> Result<Self, Self::Error> {
480        match value {
481            PropertyWire::Ontology(id) => Self::ontology(PropId(id)),
482            PropertyWire::Runtime(id) => RuntimePropId::new(id).map(Self::runtime),
483        }
484    }
485}
486
487impl From<PropertyId> for PropertyWire {
488    fn from(value: PropertyId) -> Self {
489        match value.0 {
490            PropertyIdentity::Ontology(id) => Self::Ontology(id.0),
491            PropertyIdentity::Runtime(id) => Self::Runtime(id.get()),
492        }
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499    use std::collections::HashSet;
500
501    #[test]
502    fn original_integer_and_serde_golden_vectors() {
503        let fixture: serde_json::Value =
504            serde_json::from_str(include_str!("../tests/fixtures/catalog_ids_v1.json")).unwrap();
505        let mut unique = HashSet::new();
506        for vector in fixture["valid"].as_array().unwrap() {
507            let raw = u32::try_from(vector["encoded"].as_u64().unwrap()).unwrap();
508            let local = u32::try_from(vector["local"].as_u64().unwrap()).unwrap();
509            let decoded = TaggedTypeId::decode(raw).unwrap();
510            let expected = match vector["kind"].as_str().unwrap() {
511                "ontology" => TaggedTypeId::ontology(TypeId(local)).unwrap(),
512                "runtime_entity" => {
513                    EntityTypeId::runtime(RuntimeEntityId::new(local).unwrap()).tagged()
514                }
515                "runtime_relation" => {
516                    RelationTypeId::runtime(RuntimeRelationId::new(local).unwrap()).tagged()
517                }
518                other => panic!("unknown frozen fixture domain {other}"),
519            };
520            assert_eq!(decoded, expected);
521            assert_eq!(decoded.encode(), raw);
522            assert_eq!(serde_json::to_value(decoded).unwrap(), vector["encoded"]);
523            assert_eq!(
524                serde_json::from_value::<TaggedTypeId>(vector["encoded"].clone()).unwrap(),
525                decoded
526            );
527            assert_eq!(
528                serde_json::to_value(raw.to_le_bytes()).unwrap(),
529                vector["little_endian"]
530            );
531            assert!(unique.insert(decoded.encode()));
532        }
533        for raw in fixture["invalid_tagged"].as_array().unwrap() {
534            let encoded = u32::try_from(raw.as_u64().unwrap()).unwrap();
535            assert!(TaggedTypeId::decode(encoded).is_err());
536            assert!(EntityTypeId::decode(encoded).is_err());
537            assert!(RelationTypeId::decode(encoded).is_err());
538            assert!(serde_json::from_value::<TaggedTypeId>(raw.clone()).is_err());
539        }
540        for raw in fixture["invalid_type_locals"].as_array().unwrap() {
541            let local = u32::try_from(raw.as_u64().unwrap()).unwrap();
542            assert!(TaggedTypeId::ontology(TypeId(local)).is_err());
543            assert!(RuntimeEntityId::new(local).is_err());
544            assert!(RuntimeRelationId::new(local).is_err());
545        }
546    }
547
548    #[test]
549    fn contextual_types_reject_the_opposite_runtime_domain() {
550        let entity = EntityTypeId::runtime(RuntimeEntityId::new(0).unwrap());
551        let relation = RelationTypeId::runtime(RuntimeRelationId::new(0).unwrap());
552        assert!(EntityTypeId::decode(relation.encode()).is_err());
553        assert!(RelationTypeId::decode(entity.encode()).is_err());
554        assert!(
555            serde_json::from_value::<EntityTypeId>(serde_json::json!(relation.encode())).is_err()
556        );
557        assert!(
558            serde_json::from_value::<RelationTypeId>(serde_json::json!(entity.encode())).is_err()
559        );
560        assert_eq!(entity.tagged().runtime_relation_id(), None);
561        assert_eq!(relation.tagged().runtime_entity_id(), None);
562        assert_eq!(entity.tagged().ontology_id(), None);
563        assert_eq!(relation.tagged().ontology_id(), None);
564    }
565
566    #[test]
567    fn primary_absence_does_not_admit_a_membership_or_relation_sentinel() {
568        let primary = PrimaryEntityTypeId::decode(u32::MAX).unwrap();
569        assert_eq!(primary, PrimaryEntityTypeId::absent());
570        assert_eq!(primary.label(), None);
571        assert_eq!(
572            serde_json::to_value(primary).unwrap(),
573            serde_json::json!(4294967295_u32)
574        );
575        assert!(EntityTypeId::decode(u32::MAX).is_err());
576        assert!(RelationTypeId::decode(u32::MAX).is_err());
577        let membership = EntityTypeId::runtime(RuntimeEntityId::new(7).unwrap());
578        assert_eq!(PrimaryEntityTypeId::known(membership).encode(), 1073741831);
579        assert!(PrimaryEntityTypeId::decode(2147483648).is_err());
580        assert!(PrimaryEntityTypeId::decode(3221225472).is_err());
581    }
582
583    #[test]
584    fn property_ir_domains_do_not_collide_or_accept_ambiguous_numeric_input() {
585        let declared = PropertyId::ontology(PropId(7)).unwrap();
586        let runtime = PropertyId::runtime(RuntimePropId::new(7).unwrap());
587        assert_ne!(declared, runtime);
588        assert_eq!(HashSet::from([declared, runtime]).len(), 2);
589        assert_eq!(declared.runtime_id(), None);
590        assert_eq!(runtime.ontology_id(), None);
591        for (id, wire) in [
592            (declared, serde_json::json!({"Ontology": 7})),
593            (runtime, serde_json::json!({"Runtime": 7})),
594        ] {
595            assert_eq!(serde_json::to_value(id).unwrap(), wire);
596            assert_eq!(serde_json::from_value::<PropertyId>(wire).unwrap(), id);
597        }
598        for invalid in [
599            serde_json::json!(7),
600            serde_json::json!({"Runtime": u32::MAX}),
601            serde_json::json!({"Ontology": u32::MAX}),
602        ] {
603            assert!(serde_json::from_value::<PropertyId>(invalid).is_err());
604        }
605    }
606
607    #[test]
608    fn missing_and_all_are_distinct_wire_selections() {
609        assert_ne!(EntityTypeSelection::All, EntityTypeSelection::Missing);
610        assert_ne!(RelationTypeSelection::All, RelationTypeSelection::Missing);
611        assert_eq!(
612            serde_json::to_value(EntityTypeSelection::All).unwrap(),
613            serde_json::json!("All")
614        );
615        assert_eq!(
616            serde_json::to_value(EntityTypeSelection::Missing).unwrap(),
617            serde_json::json!("Missing")
618        );
619        assert_eq!(
620            serde_json::from_value::<EntityTypeSelection>(serde_json::json!("Missing")).unwrap(),
621            EntityTypeSelection::Missing
622        );
623        assert!(
624            serde_json::from_value::<EntityTypeSelection>(serde_json::json!({"Known": u32::MAX}))
625                .is_err()
626        );
627    }
628
629    #[test]
630    fn property_ids_remain_untagged_and_counter_range_checked() {
631        for raw in [0, TYPE_LOCAL_ID_LIMIT, u32::MAX - 1] {
632            let id = RuntimePropId::new(raw).unwrap();
633            assert_eq!(id.get(), raw);
634            assert_eq!(serde_json::to_value(id).unwrap(), serde_json::json!(raw));
635            assert_eq!(
636                serde_json::from_value::<RuntimePropId>(serde_json::json!(raw)).unwrap(),
637                id
638            );
639        }
640        assert!(RuntimePropId::new(u32::MAX).is_err());
641    }
642}