Skip to main content

pr4xis_runtime/
grounding.rs

1//! Grounding resolution — turning a [`Grounded`](crate::definition::EdgeTarget::Grounded)
2//! edge's foreign atom back into the [`Definition`] it names, by content-address
3//! agreement across the connected ontologies an archive declares.
4//!
5//! This is the address-keyed DUAL of the name-keyed [`rebind`](crate::rebind):
6//! rebind asks "does the running system know a concept by this NAME at an
7//! agreeing address?"; resolution asks "does this connected ontology hold an
8//! atom at this ADDRESS?". A foreign atom has no name in our archive — only a
9//! content address — so name-keyed rebind cannot reach it. [`AtomResolver`] is
10//! the one new primitive that can (inward gap #3).
11//!
12//! # Fail-closed, the lock decides
13//!
14//! An archive declares its external connections in a [`ConnectedOntologies`]
15//! manifest: for each connected ontology, the `root` its lock pins. The resolver
16//! is built ONCE and gated — every declared ontology's supplied archive must
17//! match its pinned root before a single atom resolves, so a version/content
18//! skew is refused up front (the G5 fail-closed spirit, now across archives).
19//! A grounded edge into an undeclared ontology, or naming an atom the connected
20//! ontology does not hold, returns a typed [`LinkError`] — never a silent miss
21//! and never a wrong bind.
22
23use std::collections::BTreeMap;
24
25use crate::address::ContentAddress;
26use crate::archive::Archive;
27use crate::codec::CodecError;
28use crate::definition::{Definition, EdgeTarget};
29
30/// Add typed cross-ontology grounding edges to an [`Archive`]'s nodes — the
31/// PRODUCE side of grounding, GENERAL over the lens (the [`resolve`](AtomResolver::resolve)
32/// counterpart).
33///
34/// `lens(node)` maps a node to the `(kind, `[`EdgeTarget::Grounded`]`)` edges its
35/// content points along — a node's lexical prose grounding into a connected
36/// ontology's atoms, say. The lexical `denotes` floor is ONE lens; `cites` /
37/// `defines` are others, the same shape. The lens is the only place a specific
38/// ontology (English, a cited title, …) enters; `ground` itself is
39/// source-agnostic, so any content archive (USC, English, …) grounds the same way
40/// — the returned archive's grounded edges resolve through [`AtomResolver`].
41pub fn ground(
42    archive: &Archive,
43    lens: impl Fn(&Definition) -> Vec<(String, EdgeTarget)>,
44) -> Archive {
45    let nodes = archive
46        .nodes
47        .iter()
48        .map(|node| {
49            let mut grounded = node.clone();
50            grounded.edges.extend(lens(node));
51            grounded
52        })
53        .collect();
54    Archive {
55        nodes,
56        connections: archive.connections.clone(),
57    }
58}
59
60/// One declared connection: a connected ontology, the `root` its lock pins, and
61/// the `role` the grounding edges into it carry (the kind — `denotes` for the
62/// lexical floor; carried here so the floor spends no per-edge kind tag).
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct ConnectedOntology {
65    /// The connected ontology's name (how a grounded edge addresses it).
66    pub name: String,
67    /// The content address the lock pins for that ontology — resolution refuses
68    /// a supplied archive whose root disagrees.
69    pub root: ContentAddress,
70    /// The grounding kind edges into this ontology assert (e.g. `denotes`).
71    pub role: String,
72}
73
74/// The `[connected_ontologies]` manifest — which ontologies this archive's
75/// grounded edges point into, each pinned to a root the lock must satisfy.
76#[derive(Debug, Clone, Default, PartialEq, Eq)]
77pub struct ConnectedOntologies(pub Vec<ConnectedOntology>);
78
79impl ConnectedOntologies {
80    /// The declaration for `name`, if this manifest names it.
81    pub fn get(&self, name: &str) -> Option<&ConnectedOntology> {
82        self.0.iter().find(|c| c.name == name)
83    }
84}
85
86/// Why a grounded edge could not be resolved — fail-closed, never a silent bind.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum LinkError {
89    /// The edge grounds into an ontology the manifest does not declare (a
90    /// resolve-time fault).
91    UnknownOntology { ontology: String },
92    /// A manifest-DECLARED ontology has no supplied peer archive, so the resolver
93    /// cannot be built (a build-time fault — distinct from [`UnknownOntology`](Self::UnknownOntology),
94    /// which is an edge into an ontology the manifest never declared).
95    MissingPeerArchive { ontology: String },
96    /// A declared ontology was supplied, but its archive's actual root disagrees
97    /// with the pinned root — a version/content skew. Refused.
98    RootMismatch {
99        ontology: String,
100        pinned: ContentAddress,
101        actual: ContentAddress,
102    },
103    /// The connected ontology holds no atom at the named address.
104    AtomAbsent {
105        ontology: String,
106        atom: ContentAddress,
107    },
108    /// The target is a [`Local`](EdgeTarget::Local) edge — not a grounded edge
109    /// to resolve. (Callers traverse local edges by name, not through here.)
110    NotGrounded,
111    /// A node or archive address could not be derived (codec failure).
112    Codec(CodecError),
113}
114
115impl core::fmt::Display for LinkError {
116    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
117        match self {
118            LinkError::UnknownOntology { ontology } => {
119                write!(f, "grounding: edge into undeclared ontology {ontology:?}")
120            }
121            LinkError::MissingPeerArchive { ontology } => write!(
122                f,
123                "grounding: declared ontology {ontology:?} has no supplied peer archive"
124            ),
125            LinkError::RootMismatch {
126                ontology,
127                pinned,
128                actual,
129            } => write!(
130                f,
131                "grounding: {ontology:?} root skew — pinned {}, supplied {}",
132                pinned.to_hex(),
133                actual.to_hex()
134            ),
135            LinkError::AtomAbsent { ontology, atom } => write!(
136                f,
137                "grounding: {ontology:?} holds no atom at {}",
138                atom.to_hex()
139            ),
140            LinkError::NotGrounded => write!(f, "grounding: target is local, not a grounded edge"),
141            LinkError::Codec(e) => write!(f, "grounding: {e}"),
142        }
143    }
144}
145
146impl std::error::Error for LinkError {}
147
148/// Resolves a [`Grounded`](EdgeTarget::Grounded) edge target to the foreign atom
149/// it names, by content-address agreement across the loaded connected archives.
150///
151/// Built once from a manifest + the supplied peer archives, gated so every
152/// declared ontology's archive matches its pinned root. Each connected ontology
153/// is indexed by its nodes' definition-bearing addresses, so resolution is an
154/// O(log n) lookup, never a scan.
155#[derive(Debug)]
156pub struct AtomResolver<'a> {
157    /// ontology name → (atom address → its node).
158    atoms: BTreeMap<String, BTreeMap<ContentAddress, &'a Definition>>,
159}
160
161impl<'a> AtomResolver<'a> {
162    /// Build the resolver from the `manifest` and the loaded `peers` (by name).
163    ///
164    /// Fail-closed: a declared ontology with no supplied archive is
165    /// [`MissingPeerArchive`](LinkError::MissingPeerArchive); a supplied archive
166    /// whose root disagrees with the pinned root is [`RootMismatch`](LinkError::RootMismatch).
167    /// Only after every pin agrees is any atom index built.
168    pub fn new(
169        manifest: &ConnectedOntologies,
170        peers: &'a BTreeMap<String, Archive>,
171    ) -> Result<Self, LinkError> {
172        let mut atoms: BTreeMap<String, BTreeMap<ContentAddress, &'a Definition>> = BTreeMap::new();
173        for decl in &manifest.0 {
174            let archive = peers
175                .get(&decl.name)
176                .ok_or_else(|| LinkError::MissingPeerArchive {
177                    ontology: decl.name.clone(),
178                })?;
179            let actual = archive.root().map_err(LinkError::Codec)?;
180            if actual != decl.root {
181                return Err(LinkError::RootMismatch {
182                    ontology: decl.name.clone(),
183                    pinned: decl.root,
184                    actual,
185                });
186            }
187            let mut index: BTreeMap<ContentAddress, &'a Definition> = BTreeMap::new();
188            for node in &archive.nodes {
189                index.insert(node.address().map_err(LinkError::Codec)?, node);
190            }
191            atoms.insert(decl.name.clone(), index);
192        }
193        Ok(Self { atoms })
194    }
195
196    /// Resolve a grounded edge `target` to its foreign atom. Fail-closed: an
197    /// undeclared ontology or an absent atom returns a typed [`LinkError`], never
198    /// a silent miss. A [`Local`](EdgeTarget::Local) target is
199    /// [`NotGrounded`](LinkError::NotGrounded) — there is nothing foreign to
200    /// resolve.
201    pub fn resolve(&self, target: &EdgeTarget) -> Result<&'a Definition, LinkError> {
202        let (ontology, atom) = match target {
203            EdgeTarget::Grounded { ontology, atom } => (ontology, atom),
204            EdgeTarget::Local(_) => return Err(LinkError::NotGrounded),
205        };
206        let index = self
207            .atoms
208            .get(ontology)
209            .ok_or_else(|| LinkError::UnknownOntology {
210                ontology: ontology.clone(),
211            })?;
212        index
213            .get(atom)
214            .copied()
215            .ok_or_else(|| LinkError::AtomAbsent {
216                ontology: ontology.clone(),
217                atom: *atom,
218            })
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn synset(name: &str, gloss: &str) -> Definition {
227        Definition {
228            kind: "Concept".into(),
229            name: name.into(),
230            edges: vec![],
231            axioms: vec![],
232            lexical: Some(gloss.into()),
233        }
234    }
235
236    /// A miniature connected ontology + a manifest pinning its real root, and a
237    /// grounded edge into one of its atoms.
238    fn fixture() -> (
239        BTreeMap<String, Archive>,
240        ConnectedOntologies,
241        ContentAddress,
242    ) {
243        let dog = synset("s-dog", "a domesticated canine");
244        let atom = dog.address().unwrap();
245        let english = Archive {
246            nodes: vec![dog, synset("s-animal", "a living organism")],
247            connections: vec![],
248        };
249        let root = english.root().unwrap();
250        let mut peers = BTreeMap::new();
251        peers.insert("english_wordnet".to_string(), english);
252        let manifest = ConnectedOntologies(vec![ConnectedOntology {
253            name: "english_wordnet".to_string(),
254            root,
255            role: "denotes".to_string(),
256        }]);
257        (peers, manifest, atom)
258    }
259
260    #[test]
261    fn resolves_a_grounded_atom_by_content_address() {
262        let (peers, manifest, atom) = fixture();
263        let resolver = AtomResolver::new(&manifest, &peers).unwrap();
264        let node = resolver
265            .resolve(&EdgeTarget::Grounded {
266                ontology: "english_wordnet".to_string(),
267                atom,
268            })
269            .expect("the atom resolves");
270        assert_eq!(node.name, "s-dog");
271        assert_eq!(node.lexical.as_deref(), Some("a domesticated canine"));
272    }
273
274    #[test]
275    fn ground_adds_lens_edges_that_then_resolve() {
276        // The produce side: a content archive grounds via a lens (here, a node
277        // named "provision" grounds into the english atom), and the added typed
278        // Grounded edge resolves through the resolver — produce ∘ resolve, all
279        // source-agnostic.
280        let (peers, manifest, atom) = fixture();
281        let content = Archive {
282            nodes: vec![Definition {
283                kind: "Provision".into(),
284                name: "title-1-§1".into(),
285                edges: vec![],
286                axioms: vec![],
287                lexical: Some("a domesticated canine occurs here".into()),
288            }],
289            connections: vec![],
290        };
291        // A lens that grounds any node into the fixture's atom (a stand-in for a
292        // real denotes producer).
293        let grounded = ground(&content, |_node| {
294            vec![(
295                "denotes".to_string(),
296                EdgeTarget::Grounded {
297                    ontology: "english_wordnet".to_string(),
298                    atom,
299                },
300            )]
301        });
302        let edge = &grounded.nodes[0].edges[0];
303        assert_eq!(edge.0, "denotes");
304        let resolver = AtomResolver::new(&manifest, &peers).unwrap();
305        let resolved = resolver
306            .resolve(&edge.1)
307            .expect("the grounded edge resolves");
308        assert_eq!(resolved.name, "s-dog");
309    }
310
311    #[test]
312    fn an_absent_atom_fails_closed() {
313        let (peers, manifest, _) = fixture();
314        let resolver = AtomResolver::new(&manifest, &peers).unwrap();
315        // An address of an atom the connected ontology does not hold.
316        let ghost = ContentAddress::of(b"a synset that was never declared");
317        assert_eq!(
318            resolver.resolve(&EdgeTarget::Grounded {
319                ontology: "english_wordnet".to_string(),
320                atom: ghost,
321            }),
322            Err(LinkError::AtomAbsent {
323                ontology: "english_wordnet".to_string(),
324                atom: ghost,
325            })
326        );
327    }
328
329    #[test]
330    fn an_undeclared_ontology_fails_closed() {
331        let (peers, manifest, atom) = fixture();
332        let resolver = AtomResolver::new(&manifest, &peers).unwrap();
333        assert_eq!(
334            resolver.resolve(&EdgeTarget::Grounded {
335                ontology: "klingon".to_string(),
336                atom,
337            }),
338            Err(LinkError::UnknownOntology {
339                ontology: "klingon".to_string(),
340            })
341        );
342    }
343
344    #[test]
345    fn a_root_skew_refuses_to_build() {
346        // The manifest pins a root that does NOT match the supplied archive — a
347        // version/content skew. The resolver refuses to build at all.
348        let (peers, _, _) = fixture();
349        let wrong = ConnectedOntologies(vec![ConnectedOntology {
350            name: "english_wordnet".to_string(),
351            root: ContentAddress::of(b"some other english version"),
352            role: "denotes".to_string(),
353        }]);
354        match AtomResolver::new(&wrong, &peers) {
355            Err(LinkError::RootMismatch { ontology, .. }) => {
356                assert_eq!(ontology, "english_wordnet");
357            }
358            other => panic!("expected a RootMismatch skew refusal; got {other:?}"),
359        }
360    }
361
362    #[test]
363    fn a_missing_peer_archive_fails_closed() {
364        let (_, manifest, _) = fixture();
365        let empty: BTreeMap<String, Archive> = BTreeMap::new();
366        assert_eq!(
367            AtomResolver::new(&manifest, &empty).map(|_| ()),
368            Err(LinkError::MissingPeerArchive {
369                ontology: "english_wordnet".to_string(),
370            })
371        );
372    }
373
374    #[test]
375    fn a_local_target_is_not_a_grounded_edge() {
376        let (peers, manifest, _) = fixture();
377        let resolver = AtomResolver::new(&manifest, &peers).unwrap();
378        assert_eq!(
379            resolver.resolve(&EdgeTarget::Local("s-dog".to_string())),
380            Err(LinkError::NotGrounded)
381        );
382    }
383}