Skip to main content

pr4xis_runtime/
rebind.rs

1//! Rebind — the closing half of the free ⊣ forgetful load/store cycle.
2//!
3//! A loaded [`Archive`] is the OPEN, free-category form: nodes by name + data.
4//! Rebinding interprets it into the CLOSED world a running system actually
5//! knows. The rule is the keystone: a node binds to the running system's concept
6//! ONLY when the system knows its name AND its address AGREES with the node's
7//! definition-bearing address. Agreement is content-addressed, so a name
8//! collision with a *different* definition does NOT bind (the G5 fix at rebind
9//! time). A node the system doesn't know — or knows at a different address —
10//! stays FREE, carried as live data. That partial, faithful rebind is what lets
11//! one `.prx` span open and closed world at once.
12//!
13//! This module rebinds NODES by address. Rebinding a CONNECTION — replaying its
14//! `generator_action` as a `FreeExtension` into the closed-world `Category` — is
15//! the deeper form that depends on `pr4xis`; this address-agreement layer is its
16//! foundation (a connection can only be applied between nodes that bound).
17
18use crate::address::ContentAddress;
19use crate::archive::Archive;
20use crate::codec::CodecError;
21use crate::definition::Definition;
22
23/// The closed world a `.prx` rebinds INTO — whatever the running system knows,
24/// keyed by name, each at the content address the system holds for it.
25pub trait RebindTarget {
26    /// The content address the running system has for the concept named `name`,
27    /// or `None` if it does not know that concept.
28    fn address_of(&self, name: &str) -> Option<ContentAddress>;
29}
30
31/// The outcome of rebinding one node.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Rebound {
34    /// The target knows this concept at the SAME address — bound to the closed
35    /// world.
36    Bound(String),
37    /// The target does not know it, or knows it at a DIFFERENT address — left in
38    /// the free category as live data (graceful open-world).
39    Free(String),
40}
41
42impl Rebound {
43    /// The node's name, bound or free.
44    pub fn name(&self) -> &str {
45        match self {
46            Rebound::Bound(n) | Rebound::Free(n) => n,
47        }
48    }
49
50    /// Whether the node bound to the closed world.
51    pub fn is_bound(&self) -> bool {
52        matches!(self, Rebound::Bound(_))
53    }
54}
55
56/// Rebind one node against a target, by content-address agreement.
57pub fn rebind_node(node: &Definition, target: &impl RebindTarget) -> Result<Rebound, CodecError> {
58    let node_addr = node.address()?;
59    Ok(match target.address_of(&node.name) {
60        Some(target_addr) if target_addr == node_addr => Rebound::Bound(node.name.clone()),
61        _ => Rebound::Free(node.name.clone()),
62    })
63}
64
65/// Rebind every node of an archive against a target. The bound nodes are the
66/// archive's closed-world surface; the free nodes are its open-world remainder.
67pub fn rebind_nodes(
68    archive: &Archive,
69    target: &impl RebindTarget,
70) -> Result<Vec<Rebound>, CodecError> {
71    archive
72        .nodes
73        .iter()
74        .map(|n| rebind_node(n, target))
75        .collect()
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use std::collections::HashMap;
82
83    /// A toy closed world: a name → address table.
84    struct Known(HashMap<String, ContentAddress>);
85
86    impl RebindTarget for Known {
87        fn address_of(&self, name: &str) -> Option<ContentAddress> {
88            self.0.get(name).copied()
89        }
90    }
91
92    fn node(name: &str, edge_target: &str) -> Definition {
93        Definition {
94            kind: "Concept".into(),
95            name: name.into(),
96            edges: vec![("Subsumption".into(), edge_target.into())],
97            axioms: vec![],
98            lexical: None,
99        }
100    }
101
102    #[test]
103    fn binds_when_name_and_address_agree() {
104        let n = node("Employer", "Agent");
105        let mut known = HashMap::new();
106        known.insert("Employer".to_string(), n.address().unwrap());
107        assert_eq!(
108            rebind_node(&n, &Known(known)).unwrap(),
109            Rebound::Bound("Employer".into())
110        );
111    }
112
113    #[test]
114    fn stays_free_when_unknown() {
115        let n = node("Employer", "Agent");
116        assert!(!rebind_node(&n, &Known(HashMap::new())).unwrap().is_bound());
117    }
118
119    #[test]
120    fn stays_free_on_address_disagreement_even_with_same_name() {
121        // The G5 fix at rebind time: same NAME, different DEFINITION (hence a
122        // different address) does NOT bind — that would be a wrong rebind.
123        let loaded = node("Employer", "Agent");
124        let different = node("Employer", "Person"); // different edge → different address
125        let mut known = HashMap::new();
126        known.insert("Employer".to_string(), different.address().unwrap());
127        assert!(!rebind_node(&loaded, &Known(known)).unwrap().is_bound());
128    }
129
130    #[test]
131    fn partial_rebind_keeps_unknowns_free() {
132        let a = node("Employer", "Agent");
133        let b = node("Stranger", "Thing");
134        let mut known = HashMap::new();
135        known.insert("Employer".to_string(), a.address().unwrap());
136        let archive = Archive {
137            nodes: vec![a, b],
138            connections: vec![],
139        };
140        let rebound = rebind_nodes(&archive, &Known(known)).unwrap();
141        let bound: Vec<&str> = rebound
142            .iter()
143            .filter(|r| r.is_bound())
144            .map(|r| r.name())
145            .collect();
146        let free: Vec<&str> = rebound
147            .iter()
148            .filter(|r| !r.is_bound())
149            .map(|r| r.name())
150            .collect();
151        assert_eq!(bound, vec!["Employer"]);
152        assert_eq!(free, vec!["Stranger"]);
153    }
154}