Skip to main content

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}