pr4xis_runtime/recursive_address.rs
1//! Recursive content-addressing — a concept's address that transitively fixes
2//! its definition (referents by ADDRESS, not name), cycle-safe.
3//!
4//! [`Definition::address`](crate::definition::Definition::address) is the LOCAL
5//! floor: a node's own fields, with edges referencing targets BY NAME. So a deep
6//! change to a referenced concept does not propagate to a referencing node's
7//! local address (`connection.rs` / `archive.rs` name this gap verbatim). The
8//! teach-a-peer North Star needs PER-CONCEPT transitive identity: send one
9//! concept (+ its functor) and a peer content-addresses it INCLUDING its
10//! reachable definition, then agrees on identity.
11//!
12//! The construction (design `~/.claude/plans/a2-recursive-content-address-design.md`):
13//! 1. **Tarjan SCC-condensation** over the LOCAL reference digraph (nodes by
14//! name; edges = `(kind, Local(name))`; a `Grounded` edge is already a
15//! `ContentAddress` — a LEAF). The condensation is acyclic by construction —
16//! that IS the cycle-safety theorem (Tarjan 1972).
17//! 2. **Reverse-topological Merkle fold** over the condensation (children
18//! addressed first — exactly Tarjan's emission order): a node's recursive
19//! address hashes its fields PLUS its referents' recursive addresses.
20//! 3. **Inside a cycle (SCC)**, members are put in a canonical order by their
21//! LOCAL address (a total order, since names are unique within an ontology,
22//! and `name` is part of identity); intra-cycle back-edges encode as the
23//! target's within-SCC INDEX (never a recursive address — that is what makes
24//! a cycle terminate). A TIE (two members with the same local address = a
25//! genuine automorphism with no distinguishing label) is REFUSED, not
26//! coin-flipped (the user's fail-closed policy, 2026-06-16): an unlabeled
27//! automorphic cycle is a modeling error.
28//!
29//! ADDITIVE: the local floor and `Archive::root` are untouched, so every
30//! committed `.prx` pin re-verifies byte-for-byte (#186 preserved). The
31//! recursive address is a SEPARATE claim ([`Archive::recursive_root`]).
32//!
33//! ## A3 — a morphism carries the kind's ADDRESS, not its name
34//!
35//! An edge's KIND (`Subsumption`, `Contains`, `denotes`, …) was a bare string —
36//! a label, not a referent. A3 resolves it, in the recursive encoding only,
37//! against a [`KindVocab`]: a kind present in the vocab becomes
38//! `ResolvedKind::Grounded` — the content address of its meta-concept (which
39//! folds in its `HasProperty → …` edges, so the kind's structural meaning is in
40//! the identity) — and a kind absent stays `ResolvedKind::Free`, carried by
41//! name (the open-world status a `Local` generator has before `rebind`, and an
42//! unmapped kind has in `apply`). Discrimination is **vocab-relative**: the
43//! `default_kind_vocab` is the meta-ontology's hand-authored
44//! kind floor; resolving against a vocab where `Subsumption` lacks `Transitive`
45//! yields a different address (that is what two peers comparing kind MEANING do).
46//!
47//! Scope fence: A3 is exactly this kind-resolution plus the meta kind floor. It
48//! does NOT touch the stored form ([`Definition::address`] keeps the kind NAME,
49//! byte-exact), the `pr4xis-derive` canonical-kind list (issue #152), or
50//! register an external corpus (the loaded Relations-ontology tier is a separate
51//! slice). Because the meta floor grows, `default_kind_vocab` changes — a
52//! recursive-layer semantic version event for cross-peer agreement (byte-additive
53//! to every committed `.prx`, but two peers on different floors resolve a kind to
54//! different addresses; the payload-carried vocab is the eventual mitigation).
55
56use std::collections::{BTreeMap, BTreeSet};
57use std::sync::OnceLock;
58
59use serde::Serialize;
60
61use crate::address::ContentAddress;
62use crate::archive::Archive;
63use crate::codec::{self, CodecError};
64use crate::definition::{Definition, EdgeTarget};
65use crate::meta;
66
67/// Why a recursive address could not be computed.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum RecursiveAddressError {
70 /// The canonical encoding failed.
71 Codec(CodecError),
72 /// A local edge names a target absent from the archive (referential closure
73 /// is violated — the same condition `materialize` rejects as `DanglingEdge`).
74 DanglingLocalEdge {
75 /// The node carrying the dangling edge.
76 from: String,
77 /// The local target name that resolves to no node.
78 to: String,
79 },
80 /// Two nodes in one cycle share a LOCAL identity (same definition incl. name)
81 /// — a genuine automorphism with no canonical order. Fail-closed: a cycle
82 /// whose members carry no distinguishing label is a modeling error to fix at
83 /// the source, not an identity to invent (the user's 2026-06-16 decision).
84 AmbiguousCyclicIdentity {
85 /// The colliding member names.
86 names: Vec<String>,
87 },
88 /// The concept to extract is not in the archive.
89 ConceptNotFound {
90 /// The requested concept name.
91 name: String,
92 },
93}
94
95impl From<CodecError> for RecursiveAddressError {
96 fn from(e: CodecError) -> Self {
97 RecursiveAddressError::Codec(e)
98 }
99}
100
101/// A target resolved for the recursive encoding — never a bare name.
102#[derive(Serialize)]
103enum ResolvedTarget {
104 /// A true Merkle child link: the target's OWN recursive address.
105 Local([u8; 32]),
106 /// An intra-cycle back-edge: the target's canonical within-SCC index. This is
107 /// the only non-address encoding, and it is what makes a cycle terminate.
108 SelfIndex(u64),
109 /// A cross-ontology atom — already content-addressed.
110 Grounded { ontology: String, atom: [u8; 32] },
111}
112
113/// A morphism's KIND, resolved for the recursive encoding — never a bare name.
114///
115/// Parallels [`ResolvedTarget`] on the kind side of the edge: a kind known to the
116/// [`KindVocab`] resolves to the content address of its MEANING (its meta-concept,
117/// whose own `HasProperty → …` edges are folded in), so two kinds that share a
118/// spelling but differ in their declared properties address differently. A kind
119/// the vocab does not know stays free, carried by name.
120#[derive(Serialize)]
121enum ResolvedKind {
122 /// The kind's meta-concept address in the vocab — its meaning, content-addressed.
123 Grounded([u8; 32]),
124 /// Open-world: the kind is absent from the vocab, carried verbatim by name
125 /// (injective on spelling). The same status a `Local` generator has before
126 /// `rebind`, and an unmapped kind has in `apply` (IDENTITY image) — not broken,
127 /// so not fail-closed.
128 Free(String),
129}
130
131/// A morphism-kind vocabulary: each kind name mapped to the content address of
132/// its meta-concept. Built from an archive of kind-concepts via
133/// [`from_archive`](KindVocab::from_archive); the `default_kind_vocab`
134/// is the meta-ontology's hand-authored kind floor.
135#[derive(Debug, Clone, Default)]
136pub struct KindVocab(BTreeMap<String, ContentAddress>);
137
138impl KindVocab {
139 /// The empty vocab — every kind resolves `ResolvedKind::Free`. This is
140 /// the FLOOR the vocab itself is addressed at: a kind-concept's vocab address
141 /// is its recursive address computed with kinds-as-`Free`, the well-founded
142 /// base of the kind tower (as [`address`](crate::address) is the hash floor),
143 /// so building a vocab never depends on a vocab.
144 pub fn empty() -> Self {
145 Self(BTreeMap::new())
146 }
147
148 /// Build a vocab from an archive of kind-concepts: each node's name → its
149 /// recursive address (resolved against the empty vocab — the floor). `Err` if
150 /// the archive is not referentially closed (the same fail-closed rule every
151 /// recursive address obeys).
152 pub fn from_archive(archive: &Archive) -> Result<Self, RecursiveAddressError> {
153 Ok(Self(recursive_addresses_grounded(archive, &Self::empty())?))
154 }
155
156 /// The content address of the kind named `name`, if this vocab knows it.
157 pub fn address_of(&self, name: &str) -> Option<ContentAddress> {
158 self.0.get(name).copied()
159 }
160
161 /// Fold `other` into this vocab; on a name collision `other` WINS. Used to
162 /// layer the LOADED, authoritative relation kinds over the hand-authored meta
163 /// floor: the Relations ontology is the cited authority for what a relation
164 /// kind MEANS, so its definition overrides the floor's bootstrap entry.
165 pub fn extend_overriding(&mut self, other: KindVocab) {
166 self.0.extend(other.0);
167 }
168}
169
170/// The default morphism-kind vocabulary — two tiers, built once:
171/// 1. the meta-ontology's hand-authored kind FLOOR (the self-describing kernel:
172/// `Subsumption`, `Contains`, `HasProperty`, …, the format-structural kinds);
173/// 2. the LOADED, authoritative domain relation kinds (`Parthood`, `Causation`,
174/// `Opposition`, …, each with its `HasProperty`/inter-kind edges) — the
175/// Relations ontology projected to [`morphism_kinds.prx`](load_relation_kinds),
176/// fail-closed against its baked root. The Relations tier WINS a name collision
177/// (`Subsumption`, `HasProperty`): it is the cited authority for relation-kind
178/// meaning; the floor's entry was the pre-Relations bootstrap.
179///
180/// Panics only if our own closed kernel data — the meta-ontology (guarded by
181/// `meta::is_referentially_closed`) or the committed projection (guarded by its
182/// pin + the domains drift test) — fails to address/load; that is a kernel bug,
183/// not a runtime input fault. [`the_default_kind_vocab_builds`](tests) proves it.
184fn default_kind_vocab() -> &'static KindVocab {
185 static VOCAB: OnceLock<KindVocab> = OnceLock::new();
186 VOCAB.get_or_init(|| {
187 let mut vocab = KindVocab::from_archive(&meta::ontology())
188 .expect("the meta-ontology kind floor must address (referentially closed kernel)");
189 vocab.extend_overriding(
190 KindVocab::from_archive(&load_relation_kinds())
191 .expect("the loaded relation-kind vocab must address (closed projection)"),
192 );
193 vocab
194 })
195}
196
197/// The authoritative relation-kind vocabulary, loaded from the committed
198/// `morphism_kinds.prx` — the `domains` Relations ontology emitted as an archive
199/// (every kind WITH its `HasProperty`/inter-kind edges). Embedded + fail-closed
200/// against its baked root, the same discipline as `relation_lexicon.prx` and the
201/// functor projections; the `domains` drift test regenerates + re-pins it.
202fn load_relation_kinds() -> Archive {
203 const MORPHISM_KINDS_PRX: &[u8] = include_bytes!("morphism_kinds.prx");
204 const MORPHISM_KINDS_ROOT_HEX: &str =
205 "6c83ec88e28cd19b13f7762747162a0136f23e468267b3214bc7b9b30d5665a8";
206 let root = ContentAddress::from_hex(MORPHISM_KINDS_ROOT_HEX)
207 .expect("MORPHISM_KINDS_ROOT_HEX is valid 64-hex");
208 crate::load::load(MORPHISM_KINDS_PRX, root)
209 .expect("committed morphism_kinds.prx must load against its baked root")
210}
211
212/// The canonical recursive form of one node (referents resolved to addresses).
213#[derive(Serialize)]
214struct NodeCanon<'a> {
215 kind: &'a str,
216 name: &'a str,
217 lexical: Option<&'a str>,
218 axioms: Vec<&'a str>,
219 edges: Vec<(ResolvedKind, ResolvedTarget)>,
220}
221
222impl Archive {
223 /// The recursive (transitive) content address of every node, keyed by name —
224 /// each address fixes the node's reachable definition, cycle-safe. See the
225 /// module doc. Edge kinds resolve against the `default_kind_vocab`
226 /// (the meta kind floor). `Err` on a dangling local edge or an unlabeled
227 /// automorphic cycle (fail-closed).
228 pub fn recursive_addresses(
229 &self,
230 ) -> Result<BTreeMap<String, ContentAddress>, RecursiveAddressError> {
231 recursive_addresses_grounded(self, default_kind_vocab())
232 }
233
234 /// As [`recursive_addresses`](Archive::recursive_addresses), but resolving edge
235 /// kinds against an EXPLICIT [`KindVocab`]. Two peers agree on a concept's
236 /// MEANING (not just spelling) by recomputing with the SAME vocab; a vocab in
237 /// which a kind's properties differ yields a different address (A3).
238 pub fn recursive_addresses_grounded(
239 &self,
240 vocab: &KindVocab,
241 ) -> Result<BTreeMap<String, ContentAddress>, RecursiveAddressError> {
242 recursive_addresses_grounded(self, vocab)
243 }
244
245 /// The recursive Merkle root — the content address over the sorted set of
246 /// every node's RECURSIVE address. The transitive-identity analogue of
247 /// [`root`](Archive::root); additive, leaves `root` untouched. Kinds resolve
248 /// against the `default_kind_vocab`.
249 pub fn recursive_root(&self) -> Result<ContentAddress, RecursiveAddressError> {
250 self.recursive_root_grounded(default_kind_vocab())
251 }
252
253 /// As [`recursive_root`](Archive::recursive_root), but resolving edge kinds
254 /// against an explicit [`KindVocab`].
255 pub fn recursive_root_grounded(
256 &self,
257 vocab: &KindVocab,
258 ) -> Result<ContentAddress, RecursiveAddressError> {
259 let by_name = self.recursive_addresses_grounded(vocab)?;
260 let mut addrs: Vec<[u8; 32]> = by_name.values().map(|a| *a.as_bytes()).collect();
261 // Connections (the functors) contribute their identity too. A connection's
262 // references — source/target ontologies, the action's source-generator
263 // names — are FOREIGN (in the connected ontologies the peer holds), so its
264 // recursive address IS its local content address; the deeper recursive form
265 // (depending on the connected-ontology roots) is the multi-ontology
266 // manifest layer, not an in-archive concern.
267 for c in &self.connections {
268 addrs.push(*c.address()?.as_bytes());
269 }
270 addrs.sort_unstable();
271 addrs.dedup();
272 Ok(codec::address_of(&addrs)?)
273 }
274
275 /// The minimal sub-archive carrying `name` and its transitive LOCAL closure
276 /// (every node reachable by `Local` edges) PLUS the ontology's connections —
277 /// the teach-a-peer payload. A peer that loads it (1) recomputes `name`'s
278 /// recursive address to the SAME value as in this archive — because a recursive
279 /// address depends only on this closure plus the `Grounded` leaves (whose
280 /// foreign roots the peer must already hold; a `Grounded` edge is kept verbatim
281 /// as a foreign-atom address) — and (2) holds the **functors** needed to
282 /// INTERPRET (rebind via `apply`) the concept, not merely identify it.
283 ///
284 /// The connections ride whole: a functor is ontology-level (it maps generic
285 /// KINDS, e.g. `Synset → Concept`), so it is the interpretation machinery for
286 /// every concept of the ontology, not one concept's. `Err` if `name` is absent
287 /// or a local edge dangles.
288 pub fn extract_concept(&self, name: &str) -> Result<Archive, RecursiveAddressError> {
289 let index: BTreeMap<&str, usize> = self
290 .nodes
291 .iter()
292 .enumerate()
293 .map(|(i, d)| (d.name.as_str(), i))
294 .collect();
295 let &start = index
296 .get(name)
297 .ok_or_else(|| RecursiveAddressError::ConceptNotFound {
298 name: name.to_string(),
299 })?;
300 // DFS the local closure from `name`.
301 let mut keep: BTreeSet<usize> = BTreeSet::new();
302 let mut stack = vec![start];
303 while let Some(i) = stack.pop() {
304 if !keep.insert(i) {
305 continue;
306 }
307 for (_kind, target) in &self.nodes[i].edges {
308 if let EdgeTarget::Local(n) = target {
309 match index.get(n.as_str()) {
310 Some(&j) => stack.push(j),
311 None => {
312 return Err(RecursiveAddressError::DanglingLocalEdge {
313 from: self.nodes[i].name.clone(),
314 to: n.clone(),
315 });
316 }
317 }
318 }
319 }
320 }
321 let nodes: Vec<Definition> = keep.iter().map(|&i| self.nodes[i].clone()).collect();
322 Ok(Archive {
323 nodes,
324 // The functors ride with the concept so the peer can INTERPRET it.
325 connections: self.connections.clone(),
326 })
327 }
328}
329
330fn recursive_addresses_grounded(
331 archive: &Archive,
332 vocab: &KindVocab,
333) -> Result<BTreeMap<String, ContentAddress>, RecursiveAddressError> {
334 let n = archive.nodes.len();
335 // name -> node index. A duplicate name shadows (last wins); referential
336 // resolution + the dedup'd root make duplicate names a no-op for identity.
337 let mut index: BTreeMap<&str, usize> = BTreeMap::new();
338 for (i, node) in archive.nodes.iter().enumerate() {
339 index.insert(node.name.as_str(), i);
340 }
341
342 // Local adjacency: i -> j for every (kind, Local(name)) edge whose name is in
343 // the archive. A Local edge to an absent name is a dangling reference.
344 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
345 for (i, node) in archive.nodes.iter().enumerate() {
346 for (_kind, target) in &node.edges {
347 if let EdgeTarget::Local(name) = target {
348 match index.get(name.as_str()) {
349 Some(&j) => adj[i].push(j),
350 None => {
351 return Err(RecursiveAddressError::DanglingLocalEdge {
352 from: node.name.clone(),
353 to: name.clone(),
354 });
355 }
356 }
357 }
358 }
359 }
360
361 // Tarjan SCC (iterative) → `scc_of[i]` + `order` = SCC ids in the order Tarjan
362 // emits them, which is REVERSE topological: when an SCC is emitted, every SCC
363 // reachable from it is already emitted (so its recursive addresses exist).
364 let (scc_of, members, order) = tarjan_scc(n, &adj);
365
366 let mut rec: BTreeMap<String, ContentAddress> = BTreeMap::new();
367 // index -> ContentAddress, filled as SCCs are processed (children first).
368 let mut addr_of: Vec<Option<ContentAddress>> = vec![None; n];
369
370 for &scc_id in &order {
371 let scc = &members[scc_id];
372 // A cycle iff the SCC has >1 member, or a single member with a self-loop.
373 let is_cycle = scc.len() > 1 || adj[scc[0]].contains(&scc[0]);
374
375 if !is_cycle {
376 // Acyclic singleton: a direct Merkle fold. All referents are in
377 // already-processed SCCs, so their addresses exist.
378 let i = scc[0];
379 let canon = node_canon(archive, i, &index, &scc_of, &addr_of, None, vocab)?;
380 let a = codec::address_of(&canon)?;
381 addr_of[i] = Some(a);
382 rec.insert(archive.nodes[i].name.clone(), a);
383 continue;
384 }
385
386 // A cycle. Canonical order = sort members by their LOCAL address (total,
387 // since names are unique + part of identity). A tie is an unlabeled
388 // automorphism → refuse.
389 let mut ordered: Vec<(usize, ContentAddress)> = Vec::with_capacity(scc.len());
390 for &i in scc {
391 ordered.push((i, archive.nodes[i].address()?));
392 }
393 ordered.sort_by(|a, b| a.1.as_bytes().cmp(b.1.as_bytes()));
394 for w in ordered.windows(2) {
395 if w[0].1 == w[1].1 {
396 return Err(RecursiveAddressError::AmbiguousCyclicIdentity {
397 names: scc.iter().map(|&i| archive.nodes[i].name.clone()).collect(),
398 });
399 }
400 }
401 // within-SCC canonical index per node index.
402 let mut within: BTreeMap<usize, u64> = BTreeMap::new();
403 for (pos, (i, _)) in ordered.iter().enumerate() {
404 within.insert(*i, pos as u64);
405 }
406
407 // Component digest x: over every member in canonical order, intra-cycle
408 // edges as SelfIndex (terminates), the rest already addressed.
409 let mut member_canons: Vec<NodeCanon> = Vec::with_capacity(ordered.len());
410 for (i, _) in &ordered {
411 member_canons.push(node_canon(
412 archive,
413 *i,
414 &index,
415 &scc_of,
416 &addr_of,
417 Some((scc_id, &within)),
418 vocab,
419 )?);
420 }
421 let x = codec::address_of(&member_canons)?;
422
423 // Each member's address = ("#cycle", x, its index) — one shared component
424 // digest, distinct per-member addresses.
425 for (i, _) in &ordered {
426 let idx = within[i];
427 let a = codec::address_of(&("#cycle", x.as_bytes(), idx))?;
428 addr_of[*i] = Some(a);
429 rec.insert(archive.nodes[*i].name.clone(), a);
430 }
431 }
432
433 Ok(rec)
434}
435
436/// Build the canonical recursive form of node `i`. When `cycle` is `Some((scc,
437/// within))`, an edge whose target is in the SAME scc encodes as `SelfIndex`;
438/// otherwise targets resolve to their (already-computed) recursive address.
439fn node_canon<'a>(
440 archive: &'a Archive,
441 i: usize,
442 index: &BTreeMap<&str, usize>,
443 scc_of: &[usize],
444 addr_of: &[Option<ContentAddress>],
445 cycle: Option<(usize, &BTreeMap<usize, u64>)>,
446 vocab: &KindVocab,
447) -> Result<NodeCanon<'a>, RecursiveAddressError> {
448 let node = &archive.nodes[i];
449 let mut edges: Vec<(ResolvedKind, ResolvedTarget)> = Vec::with_capacity(node.edges.len());
450 for (kind, target) in &node.edges {
451 // A3: the kind resolves to its meta-concept address (its MEANING) when the
452 // vocab knows it; otherwise it is a free leaf carried by name.
453 let resolved_kind = match vocab.address_of(kind) {
454 Some(addr) => ResolvedKind::Grounded(*addr.as_bytes()),
455 None => ResolvedKind::Free(kind.clone()),
456 };
457 let resolved = match target {
458 EdgeTarget::Grounded { ontology, atom } => ResolvedTarget::Grounded {
459 ontology: ontology.clone(),
460 atom: *atom.as_bytes(),
461 },
462 EdgeTarget::Local(name) => {
463 let j = index[name.as_str()];
464 match cycle {
465 Some((scc, within)) if scc_of[j] == scc => {
466 ResolvedTarget::SelfIndex(within[&j])
467 }
468 _ => ResolvedTarget::Local(
469 *addr_of[j].expect("child addressed first").as_bytes(),
470 ),
471 }
472 }
473 };
474 edges.push((resolved_kind, resolved));
475 }
476 // Canonical: sort + dedup edges and axioms (assembly-order-independent, like
477 // Definition::address). Neither ResolvedKind nor ResolvedTarget is Ord, so
478 // sort by total keys over both halves of the edge.
479 edges.sort_by(|a, b| {
480 (kind_key(&a.0), target_key(&a.1)).cmp(&(kind_key(&b.0), target_key(&b.1)))
481 });
482 // Dedup by the same total key the sort used (injective over both halves, so
483 // key-equality is edge-equality): a duplicate edge must not change the address,
484 // exactly as `Definition::address` dedups its local edges. `ResolvedKind` /
485 // `ResolvedTarget` are not `Eq`, so dedup through the key, not the value.
486 edges.dedup_by_key(|e| (kind_key(&e.0), target_key(&e.1)));
487 let mut axioms: Vec<&str> = node.axioms.iter().map(|s| s.as_str()).collect();
488 axioms.sort_unstable();
489 axioms.dedup();
490 Ok(NodeCanon {
491 kind: node.kind.as_str(),
492 name: node.name.as_str(),
493 lexical: node.lexical.as_deref(),
494 axioms,
495 edges,
496 })
497}
498
499/// A total sort key over a resolved kind (canonical edge ordering).
500fn kind_key(k: &ResolvedKind) -> (u8, Vec<u8>) {
501 match k {
502 ResolvedKind::Grounded(a) => (0, a.to_vec()),
503 ResolvedKind::Free(name) => (1, name.as_bytes().to_vec()),
504 }
505}
506
507/// A total sort key over a resolved target (canonical edge ordering).
508fn target_key(t: &ResolvedTarget) -> (u8, Vec<u8>) {
509 match t {
510 ResolvedTarget::Local(a) => (0, a.to_vec()),
511 ResolvedTarget::SelfIndex(n) => (1, n.to_le_bytes().to_vec()),
512 ResolvedTarget::Grounded { ontology, atom } => {
513 let mut k = ontology.as_bytes().to_vec();
514 k.extend_from_slice(atom);
515 (2, k)
516 }
517 }
518}
519
520/// Iterative Tarjan SCC. Returns `(scc_of, members, order)` where `order` lists
521/// SCC ids in Tarjan's emission order = reverse topological (a reachable SCC is
522/// emitted first).
523fn tarjan_scc(n: usize, adj: &[Vec<usize>]) -> (Vec<usize>, Vec<Vec<usize>>, Vec<usize>) {
524 const UNVISITED: usize = usize::MAX;
525 let mut idx = vec![UNVISITED; n];
526 let mut low = vec![0usize; n];
527 let mut on_stack = vec![false; n];
528 let mut stack: Vec<usize> = Vec::new();
529 let mut scc_of = vec![UNVISITED; n];
530 let mut members: Vec<Vec<usize>> = Vec::new();
531 let mut order: Vec<usize> = Vec::new();
532 let mut next_idx = 0usize;
533
534 // Explicit DFS stack of (node, next-child-cursor).
535 for s in 0..n {
536 if idx[s] != UNVISITED {
537 continue;
538 }
539 let mut work: Vec<(usize, usize)> = Vec::new();
540 work.push((s, 0));
541 idx[s] = next_idx;
542 low[s] = next_idx;
543 next_idx += 1;
544 stack.push(s);
545 on_stack[s] = true;
546
547 while let Some(&(v, ci)) = work.last() {
548 if ci < adj[v].len() {
549 work.last_mut().unwrap().1 += 1;
550 let w = adj[v][ci];
551 if idx[w] == UNVISITED {
552 idx[w] = next_idx;
553 low[w] = next_idx;
554 next_idx += 1;
555 stack.push(w);
556 on_stack[w] = true;
557 work.push((w, 0));
558 } else if on_stack[w] {
559 low[v] = low[v].min(idx[w]);
560 }
561 } else {
562 // Done with v: propagate low to parent, maybe close an SCC.
563 if low[v] == idx[v] {
564 let scc_id = members.len();
565 let mut comp = Vec::new();
566 loop {
567 let w = stack.pop().unwrap();
568 on_stack[w] = false;
569 scc_of[w] = scc_id;
570 comp.push(w);
571 if w == v {
572 break;
573 }
574 }
575 members.push(comp);
576 order.push(scc_id);
577 }
578 work.pop();
579 if let Some(&(p, _)) = work.last() {
580 low[p] = low[p].min(low[v]);
581 }
582 }
583 }
584 }
585 (scc_of, members, order)
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591 use crate::definition::Definition;
592
593 fn node(name: &str, lexical: Option<&str>, edges: &[(&str, &str)]) -> Definition {
594 Definition {
595 kind: "Concept".into(),
596 name: name.into(),
597 edges: edges
598 .iter()
599 .map(|(k, t)| ((*k).to_string(), EdgeTarget::Local((*t).to_string())))
600 .collect(),
601 axioms: vec![],
602 lexical: lexical.map(|s| s.to_string()),
603 }
604 }
605
606 fn rec(a: &Archive, name: &str) -> ContentAddress {
607 a.recursive_addresses().unwrap()[name]
608 }
609
610 /// Test 1 — a concept's recursive address transitively fixes a DEEP referent
611 /// (the property the local floor lacked).
612 #[test]
613 fn recursive_address_fixes_a_deep_referent() {
614 let mk = |c_lex: &str| Archive {
615 nodes: vec![
616 node("A", Some("a"), &[("Subsumption", "B")]),
617 node("B", Some("b"), &[("Subsumption", "C")]),
618 node("C", Some(c_lex), &[]),
619 ],
620 connections: vec![],
621 };
622 let a1 = mk("c");
623 let a2 = mk("c-changed"); // only C's lexical differs
624 assert_ne!(rec(&a1, "A"), rec(&a2, "A"), "deep change propagates to A");
625 assert_eq!(
626 a1.nodes[0].address().unwrap(),
627 a2.nodes[0].address().unwrap(),
628 "A's LOCAL address is unchanged — only the recursive one moves"
629 );
630 }
631
632 /// Test 2 — assembly-order independence, deep-difference sensitivity.
633 #[test]
634 fn recursive_root_is_order_independent_but_deep_sensitive() {
635 let a = Archive {
636 nodes: vec![
637 node("A", None, &[("Subsumption", "B")]),
638 node("B", Some("b"), &[]),
639 ],
640 connections: vec![],
641 };
642 let shuffled = Archive {
643 nodes: vec![a.nodes[1].clone(), a.nodes[0].clone()],
644 connections: vec![],
645 };
646 assert_eq!(
647 a.recursive_root().unwrap(),
648 shuffled.recursive_root().unwrap(),
649 "assembly order must not change the recursive root"
650 );
651 let deep = Archive {
652 nodes: vec![
653 node("A", None, &[("Subsumption", "B")]),
654 node("B", Some("b-DIFFERENT"), &[]),
655 ],
656 connections: vec![],
657 };
658 assert_ne!(a.recursive_root().unwrap(), deep.recursive_root().unwrap());
659 assert_ne!(rec(&a, "A"), rec(&deep, "A"));
660 }
661
662 /// Test 2b — a DUPLICATE edge must not change the recursive address: the
663 /// canonical form dedups edges exactly as [`Definition::address`] does, so the
664 /// recursive layer stays a faithful refinement of the local floor (regression:
665 /// `node_canon` once sorted edges but did not dedup them, so a dup-edge node
666 /// addressed differently from its equal, deduped sibling).
667 #[test]
668 fn a_duplicate_edge_does_not_change_the_recursive_address() {
669 let plain = Archive {
670 nodes: vec![
671 node("A", None, &[("Subsumption", "B")]),
672 node("B", Some("b"), &[]),
673 ],
674 connections: vec![],
675 };
676 let with_dup = Archive {
677 nodes: vec![
678 // A names the SAME edge twice — a no-op under canonical dedup.
679 node("A", None, &[("Subsumption", "B"), ("Subsumption", "B")]),
680 node("B", Some("b"), &[]),
681 ],
682 connections: vec![],
683 };
684 // The LOCAL floor is already dup-insensitive — this is the contract the
685 // recursive layer must match, not diverge from.
686 assert_eq!(
687 plain.nodes[0].address().unwrap(),
688 with_dup.nodes[0].address().unwrap(),
689 "the local address is dup-insensitive (the contract)"
690 );
691 assert_eq!(
692 rec(&plain, "A"),
693 rec(&with_dup, "A"),
694 "a duplicate edge must not change A's recursive address"
695 );
696 assert_eq!(
697 plain.recursive_root().unwrap(),
698 with_dup.recursive_root().unwrap(),
699 "the recursive root is dup-insensitive too"
700 );
701 }
702
703 /// Test 3 — a labeled symmetric cycle terminates, addresses each member
704 /// distinctly, and is deterministic under node reordering.
705 #[test]
706 fn a_labeled_symmetric_cycle_addresses_deterministically() {
707 let hot = node("Hot", Some("hot"), &[("Equivalence", "Cold")]);
708 let cold = node("Cold", Some("cold"), &[("Equivalence", "Hot")]);
709 let a = Archive {
710 nodes: vec![hot.clone(), cold.clone()],
711 connections: vec![],
712 };
713 let r = a.recursive_addresses().unwrap(); // terminates (no stack overflow)
714 assert_ne!(
715 r["Hot"], r["Cold"],
716 "distinct members get distinct addresses"
717 );
718 let reordered = Archive {
719 nodes: vec![cold, hot],
720 connections: vec![],
721 };
722 let r2 = reordered.recursive_addresses().unwrap();
723 assert_eq!(
724 r["Hot"], r2["Hot"],
725 "cycle addressing is node-order independent"
726 );
727 assert_eq!(r["Cold"], r2["Cold"]);
728 }
729
730 /// A dangling local edge fails closed (referential closure).
731 #[test]
732 fn a_dangling_local_edge_is_refused() {
733 let a = Archive {
734 nodes: vec![node("A", None, &[("Subsumption", "Ghost")])],
735 connections: vec![],
736 };
737 assert!(matches!(
738 a.recursive_addresses(),
739 Err(RecursiveAddressError::DanglingLocalEdge { .. })
740 ));
741 }
742
743 /// Test 5 (the headline) — TEACH-A-PEER round trip. A sender extracts a
744 /// concept's minimal payload; a receiver recomputes its recursive address and
745 /// AGREES, including the transitive (and grounded) dependencies — from a
746 /// payload that excludes everything unrelated.
747 #[test]
748 fn teach_a_peer_round_trip() {
749 // G grounds into a foreign ontology by atom address (a leaf).
750 let mut g = node("G", Some("g"), &[]);
751 g.edges.push((
752 "denotes".to_string(),
753 EdgeTarget::Grounded {
754 ontology: "english".to_string(),
755 atom: ContentAddress::of(b"the-foreign-atom"),
756 },
757 ));
758 let full = Archive {
759 nodes: vec![
760 node("A", Some("a"), &[("Subsumption", "B")]),
761 node("B", Some("b"), &[("Subsumption", "C"), ("Denotes", "G")]),
762 node("C", Some("c"), &[]),
763 node("Unrelated", Some("u"), &[]), // not in A's closure
764 g,
765 ],
766 connections: vec![],
767 };
768 let sender_addr = rec(&full, "A");
769
770 // Sender extracts the minimal payload for A.
771 let payload = full.extract_concept("A").unwrap();
772 let have = |n: &str| payload.nodes.iter().any(|d| d.name == n);
773 assert!(
774 have("A") && have("B") && have("C") && have("G"),
775 "the closure travels"
776 );
777 assert!(
778 !have("Unrelated"),
779 "unrelated nodes are excluded — minimal payload"
780 );
781
782 // Receiver recomputes A's recursive address from the payload alone.
783 let receiver_addr = rec(&payload, "A");
784 assert_eq!(
785 sender_addr, receiver_addr,
786 "the peer agrees on A's identity, transitive + grounded deps included, from the minimal payload"
787 );
788 }
789
790 /// Slice (c) — the FUNCTOR rides with the concept, so the peer can INTERPRET
791 /// it (rebind via `apply`), not merely identify it, and the functor's identity
792 /// is fixed in the recursive root.
793 #[test]
794 fn the_functor_rides_with_the_concept() {
795 use crate::connection::{Connection, GeneratorAction};
796 let functor = Connection {
797 kind: "FullyFaithful".to_string(),
798 source: "Wordnet".to_string(),
799 target: "Praxis".to_string(),
800 action: GeneratorAction::Functor {
801 map_object: vec![("Synset".to_string(), "Concept".to_string())],
802 map_morphism: vec![("hypernym".to_string(), "Subsumption".to_string())],
803 },
804 laws: vec![],
805 };
806 let full = Archive {
807 nodes: vec![
808 node("Dog", Some("dog"), &[("Subsumption", "Animal")]),
809 node("Animal", Some("animal"), &[]),
810 ],
811 connections: vec![functor.clone()],
812 };
813 // The payload carries the functor (interpretation machinery), not just nodes.
814 let payload = full.extract_concept("Dog").unwrap();
815 assert_eq!(
816 payload.connections,
817 vec![functor],
818 "the functor rides with the concept so the peer can interpret it"
819 );
820 // Node identity still agrees from the payload.
821 assert_eq!(rec(&full, "Dog"), rec(&payload, "Dog"));
822 // And the functor's identity is part of the recursive root.
823 let without = Archive {
824 nodes: full.nodes.clone(),
825 connections: vec![],
826 };
827 assert_ne!(
828 full.recursive_root().unwrap(),
829 without.recursive_root().unwrap(),
830 "the functor contributes to the recursive root"
831 );
832 }
833
834 // --- A3: a morphism carries the kind's ADDRESS, not its name ---
835
836 /// RC1 (load-bearing) — `recursive_addresses()` routes through
837 /// `default_kind_vocab()`, which addresses the meta-ontology kind floor. If
838 /// the floor were not referentially closed it would fail-closed and EVERY
839 /// recursive call (incl. the A2 suite) would panic. Prove it builds and grounds
840 /// the format's own kinds.
841 #[test]
842 fn the_default_kind_vocab_builds() {
843 let vocab =
844 KindVocab::from_archive(&meta::ontology()).expect("the meta kind floor must address");
845 for kind in [
846 "Subsumption",
847 "Contains",
848 "HasProperty",
849 "Roots",
850 "Constrains",
851 ] {
852 assert!(
853 vocab.address_of(kind).is_some(),
854 "the meta floor must ground the format kind {kind:?}"
855 );
856 }
857 // The no-arg path (default vocab) works on a real archive.
858 let a = Archive {
859 nodes: vec![
860 node("A", None, &[("Subsumption", "B")]),
861 node("B", None, &[]),
862 ],
863 connections: vec![],
864 };
865 assert!(a.recursive_addresses().is_ok());
866 }
867
868 /// RC4 (the headline) — a kind resolves to the content-address of its MEANING
869 /// in a CHOSEN vocab: the SAME archive addresses differently under a vocab
870 /// where the kind is `Transitive` vs one where it is not. Discrimination is
871 /// vocab-relative — exactly what two peers comparing kind meaning exercise.
872 #[test]
873 fn a_kind_resolves_to_its_meaning_in_the_vocab() {
874 let vocab_with = KindVocab::from_archive(&Archive {
875 nodes: vec![
876 node("Transitive", None, &[]),
877 node("Rel", None, &[("HasProperty", "Transitive")]),
878 ],
879 connections: vec![],
880 })
881 .unwrap();
882 let vocab_without = KindVocab::from_archive(&Archive {
883 nodes: vec![node("Rel", None, &[])],
884 connections: vec![],
885 })
886 .unwrap();
887 assert_ne!(
888 vocab_with.address_of("Rel"),
889 vocab_without.address_of("Rel"),
890 "a kind's vocab address folds in its declared properties"
891 );
892
893 let archive = Archive {
894 nodes: vec![node("A", None, &[("Rel", "B")]), node("B", None, &[])],
895 connections: vec![],
896 };
897 let with = archive.recursive_addresses_grounded(&vocab_with).unwrap();
898 let without = archive
899 .recursive_addresses_grounded(&vocab_without)
900 .unwrap();
901 assert_ne!(
902 with["A"], without["A"],
903 "A's recursive address depends on what the kind Rel MEANS in the vocab"
904 );
905 }
906
907 /// A kind the vocab does not know stays a `Free` leaf, carried by name —
908 /// injective on spelling, and identical across any vocab that omits it (the
909 /// open-world status, like a `Local` generator before `rebind`).
910 #[test]
911 fn an_ungrounded_kind_is_a_free_leaf() {
912 let mk = |k: &str| Archive {
913 nodes: vec![node("A", None, &[(k, "B")]), node("B", None, &[])],
914 connections: vec![],
915 };
916 let empty = KindVocab::empty();
917 assert_ne!(
918 mk("Foo").recursive_addresses_grounded(&empty).unwrap()["A"],
919 mk("Bar").recursive_addresses_grounded(&empty).unwrap()["A"],
920 "distinct ungrounded kinds address distinctly"
921 );
922 let other = KindVocab::from_archive(&Archive {
923 nodes: vec![node("Unrelated", None, &[])],
924 connections: vec![],
925 })
926 .unwrap();
927 assert_eq!(
928 mk("Foo").recursive_addresses_grounded(&empty).unwrap()["A"],
929 mk("Foo").recursive_addresses_grounded(&other).unwrap()["A"],
930 "a kind absent from the vocab is Free regardless of which vocab"
931 );
932 }
933
934 // --- A3 slice (b): the LOADED relation kinds (Relations ontology projection) ---
935
936 /// Slice (b) RC1 — the loaded relation vocab BUILDS: the committed projection
937 /// is referentially closed under the strict recursive rules (incl. the labeled
938 /// Subsumption⟷Specialisation cycle). If it were not, `default_kind_vocab()` —
939 /// hence every `recursive_addresses()` call — would panic.
940 #[test]
941 fn the_loaded_relation_vocab_builds() {
942 let relations = load_relation_kinds();
943 assert!(
944 relations
945 .recursive_addresses_grounded(&KindVocab::empty())
946 .is_ok(),
947 "the committed Relations projection must address (closed; labeled cycles)"
948 );
949 }
950
951 /// Slice (b) — the default vocab grounds the DOMAIN relation kinds
952 /// (`Parthood`, `Causation`, …) the meta FLOOR does not define: they come from
953 /// the committed Relations projection (loaded, not hardcoded).
954 #[test]
955 fn the_default_vocab_grounds_the_loaded_relation_kinds() {
956 let floor = KindVocab::from_archive(&meta::ontology()).unwrap();
957 let relations = KindVocab::from_archive(&load_relation_kinds()).unwrap();
958 for kind in [
959 "Parthood",
960 "Causation",
961 "Opposition",
962 "Equivalence",
963 "Specialisation",
964 ] {
965 assert!(
966 floor.address_of(kind).is_none(),
967 "{kind} is a domain relation kind, not a format-floor kind"
968 );
969 assert!(
970 relations.address_of(kind).is_some(),
971 "the loaded Relations vocab must ground {kind}"
972 );
973 assert!(
974 default_kind_vocab().address_of(kind).is_some(),
975 "the default vocab must ground the loaded {kind}"
976 );
977 }
978 // A real archive using a loaded relation kind resolves it to its meaning,
979 // not a bare name (addresses differently with the vocab vs the empty floor).
980 let archive = Archive {
981 nodes: vec![
982 node("Whole", None, &[("Parthood", "Part")]),
983 node("Part", None, &[]),
984 ],
985 connections: vec![],
986 };
987 assert_ne!(
988 archive.recursive_addresses_grounded(&relations).unwrap()["Whole"],
989 archive
990 .recursive_addresses_grounded(&KindVocab::empty())
991 .unwrap()["Whole"],
992 "Parthood resolves to its loaded meaning, not its spelling"
993 );
994 }
995
996 /// Slice (b) — the authoritative Relations definition WINS a name collision
997 /// over the bootstrap floor: `Subsumption` resolves to its richer Relations
998 /// meaning (which adds Antisymmetric/Reflexive + InverseOf Specialisation).
999 #[test]
1000 fn the_loaded_authority_overrides_the_bootstrap_floor() {
1001 let floor = KindVocab::from_archive(&meta::ontology()).unwrap();
1002 let relations = KindVocab::from_archive(&load_relation_kinds()).unwrap();
1003 let f = floor
1004 .address_of("Subsumption")
1005 .expect("floor defines Subsumption");
1006 let r = relations
1007 .address_of("Subsumption")
1008 .expect("Relations defines Subsumption");
1009 assert_ne!(f, r, "the two tiers define Subsumption differently");
1010 assert_eq!(
1011 default_kind_vocab().address_of("Subsumption"),
1012 Some(r),
1013 "the loaded authority wins the collision"
1014 );
1015 }
1016
1017 // --- A3 slice (c): teach-a-peer agrees on a kind's MEANING ---
1018
1019 /// Slice (c) (the headline) — two peers that share the default vocab recompute
1020 /// the SAME recursive address for a concept whose edge uses a loaded relation
1021 /// kind, INCLUDING the kind's meaning — yet the kind's definition is NOT shipped
1022 /// in the payload (B.3.i: both peers bootstrap the same vocab). A peer whose
1023 /// vocab gives that kind a DIFFERENT meaning computes a DIFFERENT address — so
1024 /// agreement is on what the kind IS, never just its spelling.
1025 #[test]
1026 fn a_peer_agrees_on_kind_meaning_via_default_vocab() {
1027 let full = Archive {
1028 nodes: vec![
1029 node("Engine", Some("engine"), &[("Parthood", "Car")]),
1030 node("Car", Some("car"), &[]),
1031 ],
1032 connections: vec![],
1033 };
1034 // Sender addresses via the default vocab — Parthood is a LOADED relation kind.
1035 let sender = rec(&full, "Engine");
1036
1037 // The peer receives the minimal payload and recomputes with the SAME default
1038 // vocab → agrees, kind meaning included.
1039 let payload = full.extract_concept("Engine").unwrap();
1040 assert!(
1041 !payload.nodes.iter().any(|n| n.name == "Parthood"),
1042 "the kind's meaning is NOT shipped — both peers share the default vocab (B.3.i)"
1043 );
1044 assert_eq!(
1045 rec(&payload, "Engine"),
1046 sender,
1047 "the peer agrees on Engine's identity, the loaded kind's meaning included"
1048 );
1049
1050 // Control: a peer whose vocab MEANS a different Parthood disagrees —
1051 // agreement was on the kind's MEANING, not its name.
1052 let divergent = KindVocab::from_archive(&Archive {
1053 nodes: vec![node("Parthood", Some("a different parthood"), &[])],
1054 connections: vec![],
1055 })
1056 .unwrap();
1057 assert_ne!(
1058 payload.recursive_addresses_grounded(&divergent).unwrap()["Engine"],
1059 sender,
1060 "a peer that means a different Parthood does NOT agree — meaning, not spelling"
1061 );
1062 }
1063}