Skip to main content

pr4xis_runtime/
apply.rs

1//! `apply` — the data-driven `FreeExtension`: interpret a loaded projection
2//! ([`GeneratorAction`]) over a source [`Archive`], producing the target.
3//!
4//! This is the one irreducible runtime primitive behind "projections live in
5//! `.prx`, not code". A projection — e.g. WordNet's relations into praxis kinds
6//! (`hypernym` → `Subsumption`, a synset → a `ConceptNode`) — IS a functor, and
7//! a functor's whole content is its finite action on generators (the
8//! finite-presentation theorem; Lawvere functorial semantics, Fong & Spivak
9//! *Seven Sketches* Ch. 3), which [`GeneratorAction::Functor`] already
10//! serializes as data. So a projection ships as a content-addressed
11//! [`Connection`](crate::connection) node in a `.prx` and is APPLIED here —
12//! re-emitting the node updates the projection with no recompile.
13//!
14//! This is the runtime, data-driven generalization of the compile-time
15//! `FreeExtension` (`pr4xis::category::quiver`): where that functor's
16//! `on_vertex` / `on_edge` are compiled trait methods, here they are lookups
17//! into the loaded `map_object` / `map_morphism` tables. The finite action on
18//! generators is *sufficient* — no AST/lambda evaluator is needed (the Unison
19//! floor: a content hash is inert, but praxis's evaluator is the cheapest
20//! possible one, a finite table lookup), because a structure-preserving map is
21//! fully determined by its action on the schema's generators.
22
23use std::collections::BTreeMap;
24
25use crate::archive::Archive;
26use crate::connection::GeneratorAction;
27use crate::definition::Definition;
28
29/// Why a projection could not be applied. Fail-closed: an unsupported action is
30/// refused, never silently producing a wrong archive.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ApplyError {
33    /// The action is not a [`GeneratorAction::Functor`]. Only the functor
34    /// projection is interpreted today; lens / adjunction / natural-
35    /// transformation replay are tracked follow-ups, not stubbed here.
36    UnsupportedAction { kind: &'static str },
37}
38
39impl core::fmt::Display for ApplyError {
40    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
41        match self {
42            ApplyError::UnsupportedAction { kind } => write!(
43                f,
44                "apply: only a Functor projection is interpreted; got {kind} \
45                 (lens/adjunction/nat-trans replay is a tracked follow-up)"
46            ),
47        }
48    }
49}
50
51impl std::error::Error for ApplyError {}
52
53/// The categorical-family name of an action — for the fail-closed error.
54fn action_kind(action: &GeneratorAction) -> &'static str {
55    match action {
56        GeneratorAction::Functor { .. } => "Functor",
57        GeneratorAction::NaturalTransformation { .. } => "NaturalTransformation",
58        GeneratorAction::Lens { .. } => "Lens",
59        GeneratorAction::Adjunction { .. } => "Adjunction",
60    }
61}
62
63/// Apply a loaded projection `action` over a `source` [`Archive`], producing the
64/// target archive.
65///
66/// The functor is a SCHEMA relabeling applied element-wise: `map_object` maps a
67/// source node's KIND to its target kind, `map_morphism` maps a source edge's
68/// KIND to its target kind. A node's name, an edge's target, the lexical
69/// grounding and the axioms are the atom's identity-bearing content and are
70/// carried UNCHANGED — the functor relabels the *kinds* (the schema's
71/// generators), never the instances. (Connections on the source archive are
72/// carried through; B1 projects only nodes + edges.)
73///
74/// A kind absent from the relevant map is the IDENTITY image (carried with its
75/// own name) — the open-world stance: an unmapped relation (e.g. `antonym`,
76/// pending the full relation-kind vocabulary) is REPRESENTABLE, carried into the
77/// target; it is simply not yet folded into a closure by
78/// `materialize` (it is not one of the transitive kinds). Nothing is dropped.
79pub fn apply(action: &GeneratorAction, source: &Archive) -> Result<Archive, ApplyError> {
80    let GeneratorAction::Functor {
81        map_object,
82        map_morphism,
83    } = action
84    else {
85        return Err(ApplyError::UnsupportedAction {
86            kind: action_kind(action),
87        });
88    };
89
90    // The finite action on generators, as O(1) lookups — the data-driven
91    // `on_vertex` / `on_edge` of the free extension.
92    let on_vertex: BTreeMap<&str, &str> = map_object
93        .iter()
94        .map(|(s, t)| (s.as_str(), t.as_str()))
95        .collect();
96    let on_edge: BTreeMap<&str, &str> = map_morphism
97        .iter()
98        .map(|(s, t)| (s.as_str(), t.as_str()))
99        .collect();
100
101    let nodes = source
102        .nodes
103        .iter()
104        .map(|d| Definition {
105            kind: on_vertex
106                .get(d.kind.as_str())
107                .map_or_else(|| d.kind.clone(), |t| t.to_string()),
108            name: d.name.clone(),
109            edges: d
110                .edges
111                .iter()
112                .map(|(edge_kind, target)| {
113                    (
114                        on_edge
115                            .get(edge_kind.as_str())
116                            .map_or_else(|| edge_kind.clone(), |t| t.to_string()),
117                        target.clone(),
118                    )
119                })
120                .collect(),
121            axioms: d.axioms.clone(),
122            lexical: d.lexical.clone(),
123        })
124        .collect();
125
126    Ok(Archive {
127        nodes,
128        connections: source.connections.clone(),
129    })
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::definition::EdgeTarget;
136
137    fn synset(name: &str, hypernym: &str) -> Definition {
138        Definition {
139            kind: "Synset".into(),
140            name: name.into(),
141            edges: vec![("hypernym".into(), hypernym.into())],
142            axioms: vec![],
143            lexical: Some("a four-legged animal".into()),
144        }
145    }
146
147    /// The WordNet→praxis projection-as-data: a `Synset` node relabels to
148    /// `ConceptNode`, a `hypernym` edge to `Subsumption` — names, targets and
149    /// the gloss carried unchanged. This is the whole point: the relabeling is
150    /// the functor's data, applied here, never a compiled `match`.
151    fn wordnet_functor() -> GeneratorAction {
152        GeneratorAction::Functor {
153            map_object: vec![("Synset".into(), "ConceptNode".into())],
154            map_morphism: vec![
155                ("hypernym".into(), "Subsumption".into()),
156                ("holo_part".into(), "Parthood".into()),
157            ],
158        }
159    }
160
161    #[test]
162    fn relabels_kinds_carries_identity_content() {
163        let source = Archive {
164            nodes: vec![synset("dog", "mammal")],
165            connections: vec![],
166        };
167        let target = apply(&wordnet_functor(), &source).unwrap();
168        let node = &target.nodes[0];
169        assert_eq!(
170            node.kind, "ConceptNode",
171            "node kind relabeled by map_object"
172        );
173        assert_eq!(node.name, "dog", "name (identity) carried unchanged");
174        assert_eq!(
175            node.edges,
176            vec![(
177                "Subsumption".to_string(),
178                EdgeTarget::Local("mammal".to_string())
179            )]
180        );
181        assert_eq!(
182            node.lexical.as_deref(),
183            Some("a four-legged animal"),
184            "the gloss rides the object map's image — it is the node's lexical"
185        );
186    }
187
188    #[test]
189    fn unmapped_kind_is_the_identity_image_not_dropped() {
190        // `antonym` is not in the functor (pending the full relation-kind
191        // vocabulary). It is carried with its own kind — representable, just not
192        // closure-folded — never silently dropped.
193        let source = Archive {
194            nodes: vec![Definition {
195                kind: "Synset".into(),
196                name: "hot".into(),
197                edges: vec![("antonym".into(), "cold".into())],
198                axioms: vec![],
199                lexical: None,
200            }],
201            connections: vec![],
202        };
203        let target = apply(&wordnet_functor(), &source).unwrap();
204        assert_eq!(target.nodes[0].kind, "ConceptNode");
205        assert_eq!(
206            target.nodes[0].edges,
207            vec![("antonym".to_string(), EdgeTarget::Local("cold".to_string()))],
208            "an unmapped relation is carried as-is (representable), not dropped"
209        );
210    }
211
212    #[test]
213    fn re_emitting_a_different_functor_changes_the_projection() {
214        // The user's directive realized: the projection is data. A different
215        // functor (hypernym → Parthood instead of Subsumption) yields a
216        // different target — no code change.
217        let source = Archive {
218            nodes: vec![synset("dog", "mammal")],
219            connections: vec![],
220        };
221        let remapped = GeneratorAction::Functor {
222            map_object: vec![("Synset".into(), "ConceptNode".into())],
223            map_morphism: vec![("hypernym".into(), "Parthood".into())],
224        };
225        let target = apply(&remapped, &source).unwrap();
226        assert_eq!(
227            target.nodes[0].edges,
228            vec![(
229                "Parthood".to_string(),
230                EdgeTarget::Local("mammal".to_string())
231            )]
232        );
233    }
234
235    #[test]
236    fn refuses_a_non_functor_action_fail_closed() {
237        let lens = GeneratorAction::Lens {
238            view: "Source".into(),
239            get: "parse".into(),
240            put: "generate".into(),
241        };
242        let source = Archive {
243            nodes: vec![synset("dog", "mammal")],
244            connections: vec![],
245        };
246        assert_eq!(
247            apply(&lens, &source).unwrap_err(),
248            ApplyError::UnsupportedAction { kind: "Lens" }
249        );
250    }
251}