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}