Skip to main content

pr4xis_runtime/
definition.rs

1//! Definition-bearing addressing — a node's identity is the content address of
2//! its DEFINITION, not its name.
3//!
4//! This is the head of the build spine: it closes the **G5 wire gap**. Under
5//! name-only addressing (`hash(kind, name)`) two nodes that share a name but
6//! differ in structure collide to the same address, so peers could "agree" on a
7//! node while meaning different things. Addressing the *definition* — kind +
8//! name + outgoing edges + governing axioms + lexical grounding — makes
9//! agreement mean agreement on what the node actually IS.
10//!
11//! References to other nodes (edge targets, axioms) are by NAME at this layer,
12//! resolved within the node's own ontology and bound by the ontology's Merkle
13//! root. The stronger *recursive* form — where this address depends on the
14//! targets' own addresses — is the Merkle-DAG layer, which must additionally
15//! handle cyclic graphs (e.g. symmetric `opposition`) via strongly-connected-
16//! component hashing. This module is the cycle-safe floor that layer builds on.
17
18use core::fmt;
19
20use serde::de::{self, MapAccess, Visitor};
21use serde::ser::SerializeMap;
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23
24use crate::address::ContentAddress;
25use crate::codec::{self, CodecError};
26
27/// The endpoint of a typed edge.
28///
29/// Most edges are LOCAL: a name resolved within the node's OWN ontology, bound
30/// by that ontology's Merkle root (the form every `.prx` has carried so far). A
31/// GROUNDED edge is the foreign-atom slot — it crosses INTO another ontology,
32/// naming an atom by its content [`ContentAddress`] in a declared connected
33/// ontology. It is the typed cross-ontology morphism the grounding vocabulary
34/// rides (lexical `denotes` and the other kinds): a span endpoint resolved by
35/// content-address agreement across archives, not by name within one.
36///
37/// # Byte-exactness
38///
39/// `Local` serializes byte-IDENTICALLY to the bare string target it replaced, so
40/// every node minted before grounding existed keeps its exact content address —
41/// the codec migration is invisible to all-local archives (the committed pins
42/// re-verify unchanged). `Grounded` serializes as a CBOR map, which the decoder
43/// tells apart from a string by major type (`deserialize_any`); the common local
44/// case spends no discriminator byte.
45#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub enum EdgeTarget {
47    /// A same-ontology target, by name — resolved within the node's ontology.
48    Local(String),
49    /// A cross-ontology target: an atom of `ontology` named by its content
50    /// `atom` address (the foreign-atom slot; resolved by address agreement).
51    Grounded {
52        /// The connected ontology the atom lives in.
53        ontology: String,
54        /// The content address of the foreign atom.
55        atom: ContentAddress,
56    },
57}
58
59impl EdgeTarget {
60    /// The local target name, or `None` if this edge grounds into another
61    /// ontology. Traversers of the LOCAL graph use this; it forces the foreign
62    /// case to be handled explicitly, never silently read as a local name.
63    pub fn local_name(&self) -> Option<&str> {
64        match self {
65            EdgeTarget::Local(name) => Some(name),
66            EdgeTarget::Grounded { .. } => None,
67        }
68    }
69}
70
71impl From<String> for EdgeTarget {
72    fn from(name: String) -> Self {
73        EdgeTarget::Local(name)
74    }
75}
76
77impl From<&str> for EdgeTarget {
78    fn from(name: &str) -> Self {
79        EdgeTarget::Local(name.to_string())
80    }
81}
82
83impl Serialize for EdgeTarget {
84    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
85        match self {
86            // Byte-identical to the pre-grounding bare-string target.
87            EdgeTarget::Local(name) => serializer.serialize_str(name),
88            EdgeTarget::Grounded { ontology, atom } => {
89                let mut map = serializer.serialize_map(Some(2))?;
90                map.serialize_entry("atom", &atom.to_hex())?;
91                map.serialize_entry("ontology", ontology)?;
92                map.end()
93            }
94        }
95    }
96}
97
98impl<'de> Deserialize<'de> for EdgeTarget {
99    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
100        struct EdgeTargetVisitor;
101
102        impl<'de> Visitor<'de> for EdgeTargetVisitor {
103            type Value = EdgeTarget;
104
105            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
106                f.write_str("a local target name (string) or a grounded {ontology, atom} map")
107            }
108
109            fn visit_str<E: de::Error>(self, v: &str) -> Result<EdgeTarget, E> {
110                Ok(EdgeTarget::Local(v.to_string()))
111            }
112
113            fn visit_string<E: de::Error>(self, v: String) -> Result<EdgeTarget, E> {
114                Ok(EdgeTarget::Local(v))
115            }
116
117            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<EdgeTarget, A::Error> {
118                let mut ontology: Option<String> = None;
119                let mut atom_hex: Option<String> = None;
120                while let Some(key) = map.next_key::<String>()? {
121                    match key.as_str() {
122                        "ontology" => ontology = Some(map.next_value()?),
123                        "atom" => atom_hex = Some(map.next_value()?),
124                        _ => {
125                            let _: de::IgnoredAny = map.next_value()?;
126                        }
127                    }
128                }
129                let ontology = ontology.ok_or_else(|| de::Error::missing_field("ontology"))?;
130                let atom_hex = atom_hex.ok_or_else(|| de::Error::missing_field("atom"))?;
131                let atom = ContentAddress::from_hex(&atom_hex).ok_or_else(|| {
132                    de::Error::custom("grounded edge atom is not a valid content address")
133                })?;
134                Ok(EdgeTarget::Grounded { ontology, atom })
135            }
136        }
137
138        deserializer.deserialize_any(EdgeTargetVisitor)
139    }
140}
141
142/// The canonical definition of a node — the structure its content-address is
143/// taken over. Field order is the serialized order; the `edges`/`axioms` rows
144/// are sorted + deduplicated by [`Definition::address`] before encoding, so the
145/// address does not depend on assembly order.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct Definition {
148    /// The node's kind — a name resolved against the meta-ontology
149    /// (e.g. `"Concept"`, `"Functor"`, `"Lens"`).
150    pub kind: String,
151    /// The node's name within its ontology.
152    pub name: String,
153    /// Outgoing typed edges, each `(relation-kind name, target)`. The target is
154    /// usually [`EdgeTarget::Local`] (a name in this ontology); a
155    /// [`EdgeTarget::Grounded`] target crosses into a connected ontology by
156    /// content address. An all-local node's encoding — hence its address — is
157    /// unchanged from when targets were bare strings.
158    pub edges: Vec<(String, EdgeTarget)>,
159    /// Names of the axioms that constrain this node.
160    pub axioms: Vec<String>,
161    /// The node's lexical grounding (canonical English form / Lemon entry), if
162    /// any — part of the definition because in praxis "everything is Lemon".
163    pub lexical: Option<String>,
164}
165
166impl Definition {
167    /// The content address of this node — its definition-bearing identity.
168    ///
169    /// Canonical: the `edges` and `axioms` rows are sorted + deduplicated before
170    /// the DAG-CBOR encoding, so two definitions with the same content assembled
171    /// in different orders share the same address.
172    pub fn address(&self) -> Result<ContentAddress, CodecError> {
173        let mut canon = self.clone();
174        canon.edges.sort();
175        canon.edges.dedup();
176        canon.axioms.sort();
177        canon.axioms.dedup();
178        codec::address_of(&canon)
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    fn base() -> Definition {
187        Definition {
188            kind: "Concept".into(),
189            name: "Employer".into(),
190            edges: vec![("Subsumption".into(), "Agent".into())],
191            axioms: vec!["EmployerIsAgent".into()],
192            lexical: Some("employer".into()),
193        }
194    }
195
196    #[test]
197    fn identical_definitions_share_an_address() {
198        assert_eq!(base().address().unwrap(), base().address().unwrap());
199    }
200
201    #[test]
202    fn changing_an_edge_changes_the_address() {
203        let mut b = base();
204        b.edges = vec![("Subsumption".into(), "Person".into())]; // different target
205        assert_ne!(base().address().unwrap(), b.address().unwrap());
206    }
207
208    #[test]
209    fn changing_an_axiom_changes_the_address() {
210        let mut b = base();
211        b.axioms = vec!["EmployerHiresEmployee".into()];
212        assert_ne!(base().address().unwrap(), b.address().unwrap());
213    }
214
215    #[test]
216    fn changing_the_lexical_changes_the_address() {
217        let mut b = base();
218        b.lexical = Some("boss".into());
219        assert_ne!(base().address().unwrap(), b.address().unwrap());
220    }
221
222    #[test]
223    fn same_name_different_definition_does_not_collide() {
224        // The G5 fix: same name, different structure → different address.
225        let mut b = base();
226        b.edges.push(("Opposition".into(), "Employee".into()));
227        assert_ne!(base().address().unwrap(), b.address().unwrap());
228    }
229
230    #[test]
231    fn address_is_order_independent() {
232        let mut a = base();
233        a.edges = vec![
234            ("Subsumption".into(), "Agent".into()),
235            ("Opposition".into(), "Employee".into()),
236        ];
237        a.axioms = vec!["B".into(), "A".into()];
238        let mut b = base();
239        b.edges = vec![
240            ("Opposition".into(), "Employee".into()),
241            ("Subsumption".into(), "Agent".into()),
242        ];
243        b.axioms = vec!["A".into(), "B".into()];
244        assert_eq!(a.address().unwrap(), b.address().unwrap());
245    }
246
247    // --- EdgeTarget: the byte-exact migration guarantee ---
248
249    #[test]
250    fn a_local_target_encodes_byte_identically_to_a_bare_string() {
251        // The crux of the codec migration: a Local target serializes to the SAME
252        // canonical DAG-CBOR bytes as the bare string it replaced. So every node
253        // minted before grounding existed keeps its exact content address.
254        let local = EdgeTarget::Local("Agent".to_string());
255        assert_eq!(
256            codec::canonical_encode(&local).unwrap(),
257            codec::canonical_encode(&"Agent".to_string()).unwrap(),
258            "EdgeTarget::Local must encode as a bare CBOR string"
259        );
260    }
261
262    #[test]
263    fn an_all_local_definition_address_is_unchanged_by_the_edge_target_type() {
264        // Concretely against the LEGACY shape: a node whose edges are Local
265        // targets has the exact address it had when edges were (String, String).
266        // This is the regression gate every committed pin relies on, proven at
267        // the unit level (the corpus pin tests confirm it at scale).
268        #[derive(serde::Serialize)]
269        struct LegacyDefinition {
270            kind: String,
271            name: String,
272            edges: Vec<(String, String)>,
273            axioms: Vec<String>,
274            lexical: Option<String>,
275        }
276        let legacy = LegacyDefinition {
277            kind: "Concept".into(),
278            name: "Employer".into(),
279            edges: vec![("Subsumption".into(), "Agent".into())],
280            axioms: vec!["EmployerIsAgent".into()],
281            lexical: Some("employer".into()),
282        };
283        assert_eq!(
284            base().address().unwrap(),
285            codec::address_of(&legacy).unwrap(),
286            "an all-Local Definition must address identically to the pre-migration shape"
287        );
288    }
289
290    #[test]
291    fn a_grounded_target_round_trips_and_is_distinct_from_a_local_one() {
292        // A foreign-atom target encodes as a CBOR map (not a string), so it is
293        // unambiguous on decode and never collides with a local name.
294        let atom = ContentAddress::of(b"a connected ontology's atom definition");
295        let grounded = EdgeTarget::Grounded {
296            ontology: "english_wordnet".to_string(),
297            atom,
298        };
299        let bytes = codec::canonical_encode(&grounded).unwrap();
300        let back: EdgeTarget = codec::canonical_decode(&bytes).unwrap();
301        assert_eq!(back, grounded, "a grounded target must round-trip");
302        assert_ne!(
303            bytes,
304            codec::canonical_encode(&EdgeTarget::Local("english_wordnet".to_string())).unwrap(),
305            "a grounded target must not encode like a local string of the same text"
306        );
307    }
308
309    #[test]
310    fn a_grounded_edge_changes_a_nodes_address() {
311        // Grounding is identity-bearing: adding a foreign-atom edge changes the
312        // node's content address (it asserts a new cross-ontology connection).
313        let atom = ContentAddress::of(b"some english form");
314        let mut b = base();
315        b.edges.push((
316            "denotes".to_string(),
317            EdgeTarget::Grounded {
318                ontology: "english_wordnet".to_string(),
319                atom,
320            },
321        ));
322        assert_ne!(base().address().unwrap(), b.address().unwrap());
323    }
324}