Skip to main content

panproto_gat/
composition.rs

1//! Declarative composition specs for theory colimit recipes.
2//!
3//! A [`CompositionSpec`] records the exact sequence of colimit steps used
4//! to build a composed theory. This serves two purposes:
5//!
6//! 1. **Reproducibility**: given a spec and a theory registry, [`recompose`]
7//!    replays the recipe to reconstruct the theory.
8//! 2. **Serialization**: specs are plain data (`Serialize`/`Deserialize`)
9//!    and can be stored alongside protocols so that downstream tools know
10//!    how a theory was assembled without re-running the registration logic.
11
12use std::collections::HashMap;
13
14use serde::{Deserialize, Serialize};
15
16use crate::colimit::colimit_by_name;
17use crate::error::GatError;
18use crate::op::Operation;
19use crate::sort::Sort;
20use crate::theory::Theory;
21
22/// Declarative recipe for composing theories via colimit.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct CompositionSpec {
25    /// Name of the resulting composed theory.
26    pub result_name: String,
27    /// Ordered sequence of composition steps.
28    pub steps: Vec<CompositionStep>,
29}
30
31/// A single step in a composition recipe.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub enum CompositionStep {
34    /// Reference a named theory from the registry.
35    Base(String),
36    /// Colimit of two theories over a shared sub-theory.
37    Colimit {
38        /// Name of the left theory (from registry or a previous step).
39        left: String,
40        /// Name of the right theory (from registry or a previous step).
41        right: String,
42        /// Sort names shared between left and right (identified in the colimit).
43        shared_sorts: Vec<String>,
44        /// Operation names shared between left and right (identified in the colimit).
45        /// Operations listed here must exist in both theories with compatible signatures.
46        #[serde(default)]
47        shared_ops: Vec<String>,
48    },
49}
50
51/// Replay a composition spec against a theory registry.
52///
53/// Each step either looks up a base theory or computes a colimit.
54/// Intermediate results are stored under generated names (`step_0`,
55/// `step_1`, ...). The final step's result is renamed to
56/// `spec.result_name`.
57///
58/// # Errors
59///
60/// Returns [`GatError::TheoryNotFound`] if a referenced theory is
61/// missing from both the registry and intermediate results.
62/// Propagates any [`GatError`] from [`colimit_by_name`].
63pub fn recompose<S: ::std::hash::BuildHasher>(
64    spec: &CompositionSpec,
65    registry: &HashMap<String, Theory, S>,
66) -> Result<Theory, GatError> {
67    let mut intermediates: HashMap<String, Theory> = registry
68        .iter()
69        .map(|(k, v)| (k.clone(), v.clone()))
70        .collect();
71    let mut last_name: Option<String> = None;
72
73    for (i, step) in spec.steps.iter().enumerate() {
74        let step_name = format!("step_{i}");
75        match step {
76            CompositionStep::Base(name) => {
77                if !intermediates.contains_key(name) {
78                    return Err(GatError::TheoryNotFound(name.clone()));
79                }
80                last_name = Some(name.clone());
81            }
82            CompositionStep::Colimit {
83                left,
84                right,
85                shared_sorts,
86                shared_ops,
87            } => {
88                let left_theory = intermediates
89                    .get(left)
90                    .ok_or_else(|| GatError::TheoryNotFound(left.clone()))?
91                    .clone();
92                let right_theory = intermediates
93                    .get(right)
94                    .ok_or_else(|| GatError::TheoryNotFound(right.clone()))?
95                    .clone();
96
97                // Build the shared sub-theory from named sorts and operations.
98                // Operations must exist in both theories to form a valid span.
99                let mut shared_op_defs: Vec<Operation> = Vec::with_capacity(shared_ops.len());
100                for op_name in shared_ops {
101                    let left_op = left_theory.find_op(op_name).ok_or_else(|| {
102                        GatError::FactorizationError(format!(
103                            "shared operation '{op_name}' not found in left theory '{left}'"
104                        ))
105                    })?;
106                    if !right_theory.has_op(op_name) {
107                        return Err(GatError::FactorizationError(format!(
108                            "shared operation '{op_name}' not found in right theory '{right}'"
109                        )));
110                    }
111                    shared_op_defs.push(left_op.clone());
112                }
113
114                let shared = Theory::new(
115                    &*step_name,
116                    shared_sorts
117                        .iter()
118                        .map(|s| Sort::simple(s.as_str()))
119                        .collect(),
120                    shared_op_defs,
121                    vec![],
122                );
123
124                let result = colimit_by_name(&left_theory, &right_theory, &shared)?;
125                intermediates.insert(step_name.clone(), result);
126                last_name = Some(step_name);
127            }
128        }
129    }
130
131    let final_name = last_name.ok_or_else(|| {
132        GatError::TheoryNotFound(format!("empty composition spec for '{}'", spec.result_name))
133    })?;
134
135    let mut theory = intermediates
136        .get(&final_name)
137        .ok_or_else(|| GatError::TheoryNotFound(final_name.clone()))?
138        .clone();
139    theory.name = spec.result_name.clone().into();
140    Ok(theory)
141}
142
143#[cfg(test)]
144#[allow(clippy::unwrap_used)]
145mod tests {
146    use super::*;
147    use crate::op::Operation;
148    use crate::sort::{Sort, SortParam};
149
150    fn base_registry() -> HashMap<String, Theory> {
151        let mut reg = HashMap::new();
152        reg.insert(
153            "ThGraph".into(),
154            Theory::new(
155                "ThGraph",
156                vec![Sort::simple("Vertex"), Sort::simple("Edge")],
157                vec![
158                    Operation::unary("src", "e", "Edge", "Vertex"),
159                    Operation::unary("tgt", "e", "Edge", "Vertex"),
160                ],
161                vec![],
162            ),
163        );
164        reg.insert(
165            "ThConstraint".into(),
166            Theory::new(
167                "ThConstraint",
168                vec![
169                    Sort::simple("Vertex"),
170                    Sort::dependent("Constraint", vec![SortParam::new("v", "Vertex")]),
171                ],
172                vec![Operation::unary("target", "c", "Constraint", "Vertex")],
173                vec![],
174            ),
175        );
176        reg.insert(
177            "ThMulti".into(),
178            Theory::new(
179                "ThMulti",
180                vec![
181                    Sort::simple("Vertex"),
182                    Sort::simple("Edge"),
183                    Sort::simple("EdgeLabel"),
184                ],
185                vec![Operation::unary("edge_label", "e", "Edge", "EdgeLabel")],
186                vec![],
187            ),
188        );
189        reg.insert(
190            "ThWType".into(),
191            Theory::new(
192                "ThWType",
193                vec![
194                    Sort::simple("Node"),
195                    Sort::simple("Arc"),
196                    Sort::simple("Value"),
197                ],
198                vec![
199                    Operation::unary("anchor", "n", "Node", "Vertex"),
200                    Operation::unary("arc_src", "a", "Arc", "Node"),
201                    Operation::unary("arc_tgt", "a", "Arc", "Node"),
202                    Operation::unary("arc_edge", "a", "Arc", "Edge"),
203                    Operation::unary("node_value", "n", "Node", "Value"),
204                ],
205                vec![],
206            ),
207        );
208        reg
209    }
210
211    #[test]
212    fn base_step_verifies_existence() {
213        let reg = base_registry();
214        let spec = CompositionSpec {
215            result_name: "Result".into(),
216            steps: vec![CompositionStep::Base("ThWType".into())],
217        };
218        let theory = recompose(&spec, &reg).unwrap();
219        assert_eq!(&*theory.name, "Result");
220        assert_eq!(theory.sorts.len(), 3);
221    }
222
223    #[test]
224    fn base_step_missing_theory_errors() {
225        let reg = base_registry();
226        let spec = CompositionSpec {
227            result_name: "Result".into(),
228            steps: vec![CompositionStep::Base("ThNonexistent".into())],
229        };
230        let result = recompose(&spec, &reg);
231        assert!(matches!(result, Err(GatError::TheoryNotFound(_))));
232    }
233
234    #[test]
235    fn single_colimit_step() {
236        let reg = base_registry();
237        let spec = CompositionSpec {
238            result_name: "GraphConstraint".into(),
239            steps: vec![CompositionStep::Colimit {
240                left: "ThGraph".into(),
241                right: "ThConstraint".into(),
242                shared_sorts: vec!["Vertex".into()],
243                shared_ops: vec![],
244            }],
245        };
246        let theory = recompose(&spec, &reg).unwrap();
247        assert_eq!(&*theory.name, "GraphConstraint");
248        // Vertex, Edge, Constraint
249        assert_eq!(theory.sorts.len(), 3);
250        // src, tgt, target
251        assert_eq!(theory.ops.len(), 3);
252    }
253
254    #[test]
255    fn chained_colimit_matches_direct() {
256        let reg = base_registry();
257
258        // Build via spec (the declarative way).
259        let spec = CompositionSpec {
260            result_name: "SchemaA".into(),
261            steps: vec![
262                CompositionStep::Colimit {
263                    left: "ThGraph".into(),
264                    right: "ThConstraint".into(),
265                    shared_sorts: vec!["Vertex".into()],
266                    shared_ops: vec![],
267                },
268                CompositionStep::Colimit {
269                    left: "step_0".into(),
270                    right: "ThMulti".into(),
271                    shared_sorts: vec!["Vertex".into(), "Edge".into()],
272                    shared_ops: vec![],
273                },
274            ],
275        };
276        let from_spec = recompose(&spec, &reg).unwrap();
277
278        // Build directly (the imperative way).
279        let g = reg.get("ThGraph").unwrap();
280        let c = reg.get("ThConstraint").unwrap();
281        let m = reg.get("ThMulti").unwrap();
282
283        let shared_v = Theory::new("sv", vec![Sort::simple("Vertex")], vec![], vec![]);
284        let gc = colimit_by_name(g, c, &shared_v).unwrap();
285
286        let shared_ve = Theory::new(
287            "sve",
288            vec![Sort::simple("Vertex"), Sort::simple("Edge")],
289            vec![],
290            vec![],
291        );
292        let mut direct = colimit_by_name(&gc, m, &shared_ve).unwrap();
293        direct.name = "SchemaA".into();
294
295        // Same sorts and ops.
296        assert_eq!(from_spec.sorts.len(), direct.sorts.len());
297        assert_eq!(from_spec.ops.len(), direct.ops.len());
298        for sort in &direct.sorts {
299            assert!(
300                from_spec.find_sort(&sort.name).is_some(),
301                "missing sort: {}",
302                sort.name
303            );
304        }
305        for op in &direct.ops {
306            assert!(
307                from_spec.find_op(&op.name).is_some(),
308                "missing op: {}",
309                op.name
310            );
311        }
312    }
313
314    #[test]
315    fn empty_spec_errors() {
316        let reg = base_registry();
317        let spec = CompositionSpec {
318            result_name: "Empty".into(),
319            steps: vec![],
320        };
321        let result = recompose(&spec, &reg);
322        assert!(matches!(result, Err(GatError::TheoryNotFound(_))));
323    }
324
325    #[test]
326    fn serde_round_trip() {
327        let spec = CompositionSpec {
328            result_name: "SchemaA".into(),
329            steps: vec![
330                CompositionStep::Colimit {
331                    left: "ThGraph".into(),
332                    right: "ThConstraint".into(),
333                    shared_sorts: vec!["Vertex".into()],
334                    shared_ops: vec![],
335                },
336                CompositionStep::Base("ThWType".into()),
337            ],
338        };
339        let json = serde_json::to_string(&spec).unwrap();
340        let roundtripped: CompositionSpec = serde_json::from_str(&json).unwrap();
341        assert_eq!(spec, roundtripped);
342    }
343}