Skip to main content

panproto_inst/
adjunction.rs

1//! The Sigma/Delta migration adjunction on instances.
2//!
3//! A total vertex map `F: S -> T` (the vertex remap of a
4//! [`CompiledMigration`]) induces two instance transports:
5//!
6//! - **`Sigma_F`** (left Kan extension / pushforward): an `S`-instance
7//!   becomes a `T`-instance by relabelling anchors `v` to `F(v)`. For
8//!   set-valued functor instances this is the coproduct over each fibre
9//!   `F^{-1}(t)`; for W-type instances it is [`wtype_extend`].
10//! - **`Delta_F`** (precomposition / pullback): a `T`-instance becomes an
11//!   `S`-instance by reindexing, `(Delta_F Y)(s) = Y(F s)`.
12//!
13//! `Sigma_F` is left adjoint to `Delta_F`, so their hom-sets correspond:
14//!
15//! ```text
16//! Hom_T(Sigma_F X, Y)  ~=  Hom_S(X, Delta_F Y).
17//! ```
18//!
19//! This module realises the adjunction concretely. It builds the **unit**
20//! `eta_X : X -> Delta_F(Sigma_F X)` and **counit**
21//! `eps_Y : Sigma_F(Delta_F Y) -> Y` as instance homomorphisms (the
22//! morphisms from [`crate::instance_hom`]), the two **transpose** maps
23//! that witness the hom-set bijection, and it property-tests the triangle
24//! identities and the bijection.
25//!
26//! # Two shapes, two scopes
27//!
28//! Set-valued functor instances ([`FInstance`]) are closed under both
29//! transports for *every* total vertex map, including vertex-merging
30//! (non-injective) maps: precomposition duplicates a merged table into
31//! each source vertex, which is a perfectly good table. The functor-side
32//! adjunction below therefore holds over renamed, identity, and merging
33//! maps.
34//!
35//! W-type instances ([`WInstance`]) are trees, and precomposition along a
36//! merging map would duplicate a node into several anchors at once, which
37//! is no longer a tree. The W-type adjunction is thus **scoped to
38//! vertex-injective total maps** (renamings and the identity), where
39//! `Sigma_F` and `Delta_F` are mutually inverse relabellings that preserve
40//! node identity. On that class the adjunction is an adjoint equivalence
41//! onto the image of `F`: the unit and counit are identity-on-nodes
42//! natural isomorphisms and the transpose is the identity on the node map.
43//! Merging maps are rejected by [`w_delta`] with
44//! [`AdjunctionError::NonInjectiveVertexMap`]; the functor-side functions
45//! handle them.
46
47use std::collections::HashMap;
48
49use panproto_gat::Name;
50use panproto_schema::{Edge, Schema};
51
52use crate::error::RestrictError;
53use crate::functor::FInstance;
54use crate::instance_hom::{FInstanceHom, WInstanceHom};
55use crate::value::Value;
56use crate::wtype::{CompiledMigration, WInstance, wtype_extend};
57
58/// Error raised when an adjunction transport is undefined for the given
59/// migration or instance.
60#[derive(Debug, thiserror::Error)]
61#[non_exhaustive]
62pub enum AdjunctionError {
63    /// The vertex map merges two source vertices, so the W-type `Delta_F`
64    /// (which would duplicate a node into several anchors and leave the
65    /// tree category) is undefined. Use the functor-side adjunction for
66    /// merging maps.
67    #[error(
68        "vertex map is not injective: source vertices `{first}` and `{second}` \
69         both map to `{target}`, so the W-type pullback is undefined"
70    )]
71    NonInjectiveVertexMap {
72        /// First source vertex sharing the image.
73        first: Name,
74        /// Second source vertex sharing the image.
75        second: Name,
76        /// The shared target vertex.
77        target: Name,
78    },
79
80    /// The edge map merges two source edges, so the W-type pullback cannot
81    /// invert the edge relabelling unambiguously.
82    #[error(
83        "edge map is not injective: two source edges both map to \
84         `{src} -> {tgt}`, so the W-type pullback is undefined"
85    )]
86    NonInjectiveEdgeMap {
87        /// Source anchor of the shared target edge.
88        src: Name,
89        /// Target anchor of the shared target edge.
90        tgt: Name,
91    },
92
93    /// A node's anchor lies outside the image of `F`, so the pullback has
94    /// no source vertex to reindex it to.
95    #[error(
96        "anchor `{0}` lies outside the image of the migration; the pullback is undefined there"
97    )]
98    AnchorOutsideImage(Name),
99
100    /// The underlying left Kan extension ([`wtype_extend`]) failed.
101    #[error("Sigma (left Kan extension) failed: {0}")]
102    Sigma(#[from] RestrictError),
103}
104
105// ---------------------------------------------------------------------------
106// Shared vertex / edge map helpers
107// ---------------------------------------------------------------------------
108
109/// The image of a vertex under the migration's vertex map, defaulting to
110/// the identity for vertices the map leaves untouched.
111fn map_vertex(migration: &CompiledMigration, vertex: &Name) -> Name {
112    migration
113        .vertex_remap
114        .get(vertex)
115        .cloned()
116        .unwrap_or_else(|| vertex.clone())
117}
118
119/// The image of a vertex name (as a string key) under the vertex map.
120fn map_vertex_str(migration: &CompiledMigration, vertex: &str) -> String {
121    migration
122        .vertex_remap
123        .get(vertex)
124        .map_or_else(|| vertex.to_owned(), ToString::to_string)
125}
126
127/// The image of an edge under the migration's edge map, defaulting to the
128/// edge relabelled by the vertex map when no explicit remap is recorded.
129fn map_edge(migration: &CompiledMigration, edge: &Edge) -> Edge {
130    migration
131        .edge_remap
132        .get(edge)
133        .cloned()
134        .unwrap_or_else(|| Edge {
135            src: map_vertex(migration, &edge.src),
136            tgt: map_vertex(migration, &edge.tgt),
137            kind: edge.kind.clone(),
138            name: edge.name.clone(),
139        })
140}
141
142/// Invert the vertex map, failing when two source vertices share an image.
143fn invert_vertex_map(
144    migration: &CompiledMigration,
145) -> Result<HashMap<Name, Name>, AdjunctionError> {
146    let mut inverse: HashMap<Name, Name> = HashMap::with_capacity(migration.vertex_remap.len());
147    for (src, tgt) in &migration.vertex_remap {
148        if let Some(existing) = inverse.insert(tgt.clone(), src.clone()) {
149            if existing != *src {
150                return Err(AdjunctionError::NonInjectiveVertexMap {
151                    first: existing,
152                    second: src.clone(),
153                    target: tgt.clone(),
154                });
155            }
156        }
157    }
158    Ok(inverse)
159}
160
161/// Invert the edge map, failing when two source edges share an image.
162fn invert_edge_map(migration: &CompiledMigration) -> Result<HashMap<Edge, Edge>, AdjunctionError> {
163    let mut inverse: HashMap<Edge, Edge> = HashMap::with_capacity(migration.edge_remap.len());
164    for (src, tgt) in &migration.edge_remap {
165        if let Some(existing) = inverse.insert(tgt.clone(), src.clone()) {
166            if existing != *src {
167                return Err(AdjunctionError::NonInjectiveEdgeMap {
168                    src: tgt.src.clone(),
169                    tgt: tgt.tgt.clone(),
170                });
171            }
172        }
173    }
174    Ok(inverse)
175}
176
177// ---------------------------------------------------------------------------
178// W-type adjunction (injective total maps)
179// ---------------------------------------------------------------------------
180
181/// `Sigma_F` for W-type instances: the left Kan extension that relabels
182/// each node's anchor `v` to `F(v)` and each arc's edge accordingly,
183/// preserving node identity.
184///
185/// This delegates to [`wtype_extend`]; `tgt_schema` is consulted only when
186/// an arc's edge is neither remapped nor surviving (never the case for the
187/// full relabelling migrations this adjunction targets).
188///
189/// # Errors
190///
191/// Returns [`AdjunctionError::Sigma`] if the underlying extension fails,
192/// for instance when the root anchor has no image in the target schema.
193pub fn w_sigma(
194    x: &WInstance,
195    tgt_schema: &Schema,
196    migration: &CompiledMigration,
197) -> Result<WInstance, AdjunctionError> {
198    Ok(wtype_extend(x, tgt_schema, migration)?)
199}
200
201/// `Delta_F` for W-type instances, defined for vertex-injective total maps.
202///
203/// Reindexes a `T`-instance to an `S`-instance by relabelling each node's
204/// anchor `t` back to the unique `s` with `F(s) = t` and each arc's edge to
205/// its unique preimage, preserving node identity, arcs, fans, and the root.
206///
207/// # Errors
208///
209/// Returns [`AdjunctionError::NonInjectiveVertexMap`] or
210/// [`AdjunctionError::NonInjectiveEdgeMap`] when the map merges vertices or
211/// edges (the pullback then leaves the tree category), or
212/// [`AdjunctionError::AnchorOutsideImage`] when a node's anchor is not in
213/// the image of `F`.
214pub fn w_delta(y: &WInstance, migration: &CompiledMigration) -> Result<WInstance, AdjunctionError> {
215    let inverse_vertex = invert_vertex_map(migration)?;
216    let inverse_edge = invert_edge_map(migration)?;
217
218    let mut nodes = HashMap::with_capacity(y.nodes.len());
219    for (&id, node) in &y.nodes {
220        let source_anchor = inverse_vertex
221            .get(&node.anchor)
222            .ok_or_else(|| AdjunctionError::AnchorOutsideImage(node.anchor.clone()))?;
223        let mut reindexed = node.clone();
224        reindexed.anchor = source_anchor.clone();
225        nodes.insert(id, reindexed);
226    }
227
228    let mut arcs = Vec::with_capacity(y.arcs.len());
229    for (parent, child, edge) in &y.arcs {
230        let source_edge = inverse_edge.get(edge).cloned().unwrap_or_else(|| Edge {
231            src: inverse_vertex
232                .get(&edge.src)
233                .cloned()
234                .unwrap_or_else(|| edge.src.clone()),
235            tgt: inverse_vertex
236                .get(&edge.tgt)
237                .cloned()
238                .unwrap_or_else(|| edge.tgt.clone()),
239            kind: edge.kind.clone(),
240            name: edge.name.clone(),
241        });
242        arcs.push((*parent, *child, source_edge));
243    }
244
245    let schema_root = inverse_vertex
246        .get(&y.schema_root)
247        .ok_or_else(|| AdjunctionError::AnchorOutsideImage(y.schema_root.clone()))?
248        .clone();
249
250    Ok(WInstance::new(
251        nodes,
252        arcs,
253        y.fans.clone(),
254        y.root,
255        schema_root,
256    ))
257}
258
259/// The unit `eta_X : X -> Delta_F(Sigma_F X)` of the W-type adjunction.
260///
261/// For an injective total map, `Delta_F(Sigma_F X)` is `X` relabelled
262/// forward then back, hence anchor-for-anchor equal to `X`; the canonical
263/// comparison is the identity on node identifiers.
264#[must_use]
265pub fn w_unit(x: &WInstance) -> WInstanceHom {
266    WInstanceHom::identity(x)
267}
268
269/// The counit `eps_Y : Sigma_F(Delta_F Y) -> Y` of the W-type adjunction.
270///
271/// For an injective total map with `Y` anchored inside the image of `F`,
272/// `Sigma_F(Delta_F Y)` is `Y` relabelled back then forward, hence equal to
273/// `Y`; the canonical comparison is the identity on node identifiers.
274#[must_use]
275pub fn w_counit(y: &WInstance) -> WInstanceHom {
276    WInstanceHom::identity(y)
277}
278
279/// Transpose `Hom_T(Sigma_F X, Y) -> Hom_S(X, Delta_F Y)`.
280///
281/// Because `Sigma_F` and `Delta_F` preserve node identity for injective
282/// maps, the same node map is a homomorphism on both sides; the transpose
283/// carries it across unchanged.
284#[must_use]
285pub fn w_transpose_left(g: &WInstanceHom) -> WInstanceHom {
286    g.clone()
287}
288
289/// Transpose `Hom_S(X, Delta_F Y) -> Hom_T(Sigma_F X, Y)`, the inverse of
290/// [`w_transpose_left`].
291#[must_use]
292pub fn w_transpose_right(f: &WInstanceHom) -> WInstanceHom {
293    f.clone()
294}
295
296// ---------------------------------------------------------------------------
297// Functor (set-valued) adjunction (all total maps, including merging)
298// ---------------------------------------------------------------------------
299
300/// The image of `Sigma_F` together with the row layout needed to build the
301/// unit, counit, and transposes.
302struct SigmaImage {
303    /// The pushed-forward instance.
304    instance: FInstance,
305    /// For each source vertex `s`, the offset of `X.table(s)`'s rows within
306    /// the target table `F(s)` (its block start in the coproduct).
307    offset: HashMap<String, usize>,
308    /// For each target vertex `t`, the ordered `(source vertex, block
309    /// length)` pairs whose concatenation forms `t`'s table.
310    groups: HashMap<String, Vec<(String, usize)>>,
311}
312
313/// Compute `Sigma_F X` and its coproduct layout.
314///
315/// Source vertices are visited in sorted order so the block offsets are
316/// deterministic, which is what makes the transpose maps well defined.
317fn sigma_layout(x: &FInstance, migration: &CompiledMigration) -> SigmaImage {
318    let mut source_vertices: Vec<&String> = x.tables.keys().collect();
319    source_vertices.sort();
320
321    let mut tables: HashMap<String, Vec<HashMap<String, Value>>> = HashMap::new();
322    let mut offset: HashMap<String, usize> = HashMap::with_capacity(source_vertices.len());
323    let mut groups: HashMap<String, Vec<(String, usize)>> = HashMap::new();
324
325    for source in source_vertices {
326        let target = map_vertex_str(migration, source);
327        let rows = &x.tables[source];
328        let block = tables.entry(target.clone()).or_default();
329        offset.insert(source.clone(), block.len());
330        groups
331            .entry(target)
332            .or_default()
333            .push((source.clone(), rows.len()));
334        block.extend(rows.iter().cloned());
335    }
336
337    let mut foreign_keys: HashMap<Edge, Vec<(usize, usize)>> = HashMap::new();
338    for (edge, pairs) in &x.foreign_keys {
339        let target_edge = map_edge(migration, edge);
340        let src_offset = offset.get(edge.src.as_str()).copied().unwrap_or(0);
341        let tgt_offset = offset.get(edge.tgt.as_str()).copied().unwrap_or(0);
342        foreign_keys
343            .entry(target_edge)
344            .or_default()
345            .extend(pairs.iter().map(|(i, j)| (i + src_offset, j + tgt_offset)));
346    }
347
348    SigmaImage {
349        instance: FInstance {
350            tables,
351            foreign_keys,
352        },
353        offset,
354        groups,
355    }
356}
357
358/// `Sigma_F` for set-valued functor instances: the left Kan extension.
359///
360/// For each target vertex `t` it forms the coproduct of the source tables in
361/// its fibre `F^{-1}(t)`, offsetting foreign keys into the concatenation.
362#[must_use]
363pub fn f_sigma(x: &FInstance, migration: &CompiledMigration) -> FInstance {
364    sigma_layout(x, migration).instance
365}
366
367/// `Delta_F` for set-valued functor instances: precomposition.
368///
369/// Each source vertex `s` receives a copy of the target table `F(s)`, and
370/// each source edge `e` a copy of the target foreign key `F(e)`. This is
371/// total and stays within the functor category even for merging maps, where
372/// a merged table is duplicated into each source vertex of its fibre.
373#[must_use]
374pub fn f_delta(y: &FInstance, migration: &CompiledMigration) -> FInstance {
375    let mut tables = HashMap::with_capacity(migration.vertex_remap.len());
376    for (source, target) in &migration.vertex_remap {
377        let rows = y.tables.get(target.as_str()).cloned().unwrap_or_default();
378        tables.insert(source.to_string(), rows);
379    }
380
381    let mut foreign_keys = HashMap::with_capacity(migration.edge_remap.len());
382    for (source_edge, target_edge) in &migration.edge_remap {
383        let pairs = y.foreign_keys.get(target_edge).cloned().unwrap_or_default();
384        foreign_keys.insert(source_edge.clone(), pairs);
385    }
386
387    FInstance {
388        tables,
389        foreign_keys,
390    }
391}
392
393/// The unit `eta_X : X -> Delta_F(Sigma_F X)` of the functor adjunction.
394///
395/// Each row `i` of `X.table(s)` maps to its copy at `offset(s) + i` inside
396/// the coproduct table `Sigma_F(X).table(F s) = Delta_F(Sigma_F X).table(s)`.
397#[must_use]
398pub fn f_unit(x: &FInstance, migration: &CompiledMigration) -> FInstanceHom {
399    let sigma = sigma_layout(x, migration);
400    let row_maps = x
401        .tables
402        .iter()
403        .map(|(source, rows)| {
404            let base = sigma.offset.get(source).copied().unwrap_or(0);
405            (source.clone(), (0..rows.len()).map(|i| base + i).collect())
406        })
407        .collect();
408    FInstanceHom::new(row_maps)
409}
410
411/// The counit `eps_Y : Sigma_F(Delta_F Y) -> Y` of the functor adjunction.
412///
413/// `Sigma_F(Delta_F Y).table(t)` is a stack of `|F^{-1}(t)|` copies of
414/// `Y.table(t)`; the counit collapses every copy back onto `Y` by taking
415/// the row index modulo `|Y.table(t)|`.
416#[must_use]
417pub fn f_counit(y: &FInstance, migration: &CompiledMigration) -> FInstanceHom {
418    let delta = f_delta(y, migration);
419    let sigma = sigma_layout(&delta, migration);
420    let row_maps = sigma
421        .instance
422        .tables
423        .iter()
424        .map(|(target, rows)| {
425            let height = y.tables.get(target).map_or(0, Vec::len);
426            let map = (0..rows.len())
427                .map(|r| if height == 0 { 0 } else { r % height })
428                .collect();
429            (target.clone(), map)
430        })
431        .collect();
432    FInstanceHom::new(row_maps)
433}
434
435/// Transpose `Hom_T(Sigma_F X, Y) -> Hom_S(X, Delta_F Y)`, sending a
436/// homomorphism `g` to `Delta_F(g) . eta_X`.
437///
438/// Concretely, `(psi g).table(s)` at row `i` is `g.table(F s)` at row
439/// `offset(s) + i`.
440#[must_use]
441pub fn f_transpose_left(
442    g: &FInstanceHom,
443    x: &FInstance,
444    migration: &CompiledMigration,
445) -> FInstanceHom {
446    let sigma = sigma_layout(x, migration);
447    let row_maps = x
448        .tables
449        .iter()
450        .map(|(source, rows)| {
451            let target = map_vertex_str(migration, source);
452            let base = sigma.offset.get(source).copied().unwrap_or(0);
453            let image = g.row_maps.get(&target);
454            let map = (0..rows.len())
455                .map(|i| {
456                    image
457                        .and_then(|rows| rows.get(base + i))
458                        .copied()
459                        .unwrap_or(0)
460                })
461                .collect();
462            (source.clone(), map)
463        })
464        .collect();
465    FInstanceHom::new(row_maps)
466}
467
468/// Transpose `Hom_S(X, Delta_F Y) -> Hom_T(Sigma_F X, Y)`, sending a
469/// homomorphism `f` to `eps_Y . Sigma_F(f)`, the inverse of
470/// [`f_transpose_left`].
471///
472/// Concretely, `(phi f).table(t)` at row `offset(s) + i` is `f.table(s)` at
473/// row `i`, for the source vertex `s` owning that block of the coproduct.
474#[must_use]
475pub fn f_transpose_right(
476    f: &FInstanceHom,
477    x: &FInstance,
478    migration: &CompiledMigration,
479) -> FInstanceHom {
480    let sigma = sigma_layout(x, migration);
481    let row_maps = sigma
482        .instance
483        .tables
484        .keys()
485        .map(|target| {
486            let mut map: Vec<usize> = Vec::new();
487            if let Some(blocks) = sigma.groups.get(target) {
488                for (source, length) in blocks {
489                    let image = f.row_maps.get(source);
490                    map.extend(
491                        (0..*length)
492                            .map(|i| image.and_then(|rows| rows.get(i)).copied().unwrap_or(0)),
493                    );
494                }
495            }
496            (target.clone(), map)
497        })
498        .collect();
499    FInstanceHom::new(row_maps)
500}
501
502#[cfg(test)]
503#[allow(clippy::unwrap_used, clippy::expect_used, clippy::too_many_lines)]
504mod tests {
505    use std::collections::{HashMap, HashSet};
506
507    use panproto_schema::Vertex;
508    use smallvec::SmallVec;
509
510    use super::*;
511    use crate::metadata::Node;
512    use crate::value::FieldPresence;
513
514    // -- shared builders -------------------------------------------------
515
516    fn edge(src: &str, tgt: &str, name: &str) -> Edge {
517        Edge {
518            src: src.into(),
519            tgt: tgt.into(),
520            kind: "prop".into(),
521            name: Some(name.into()),
522        }
523    }
524
525    /// A minimal schema over the given vertices and edges, enough for
526    /// [`wtype_extend`] (which only consults it on the resolve fallback).
527    fn schema_of(vertices: &[&str], edges: &[Edge]) -> Schema {
528        let mut between: HashMap<(Name, Name), SmallVec<Edge, 2>> = HashMap::new();
529        let mut outgoing: HashMap<Name, SmallVec<Edge, 4>> = HashMap::new();
530        let mut incoming: HashMap<Name, SmallVec<Edge, 4>> = HashMap::new();
531        let mut edge_map = HashMap::new();
532        for e in edges {
533            between
534                .entry((e.src.clone(), e.tgt.clone()))
535                .or_default()
536                .push(e.clone());
537            outgoing.entry(e.src.clone()).or_default().push(e.clone());
538            incoming.entry(e.tgt.clone()).or_default().push(e.clone());
539            edge_map.insert(e.clone(), e.kind.clone());
540        }
541        Schema {
542            protocol: "test".into(),
543            vertices: vertices
544                .iter()
545                .map(|&v| {
546                    (
547                        Name::from(v),
548                        Vertex {
549                            id: Name::from(v),
550                            kind: "object".into(),
551                            nsid: None,
552                        },
553                    )
554                })
555                .collect(),
556            edges: edge_map,
557            hyper_edges: HashMap::new(),
558            constraints: HashMap::new(),
559            required: HashMap::new(),
560            nsids: HashMap::new(),
561            entries: Vec::new(),
562            variants: HashMap::new(),
563            orderings: HashMap::new(),
564            recursion_points: HashMap::new(),
565            spans: HashMap::new(),
566            usage_modes: HashMap::new(),
567            nominal: HashMap::new(),
568            coercions: HashMap::new(),
569            mergers: HashMap::new(),
570            defaults: HashMap::new(),
571            policies: HashMap::new(),
572            outgoing,
573            incoming,
574            between,
575        }
576    }
577
578    fn row(v: i64) -> HashMap<String, Value> {
579        HashMap::from([("v".to_owned(), Value::Int(v))])
580    }
581
582    // -- a concrete merging example (functor side) -----------------------
583
584    /// Migration merging source vertices `a` and `b` onto target `t`, with
585    /// edges `e_a: a -> c` and `e_b: b -> c` onto `e: t -> u` and `c -> u`.
586    fn merge_migration() -> CompiledMigration {
587        let mut vertex_remap = HashMap::new();
588        vertex_remap.insert(Name::from("a"), Name::from("t"));
589        vertex_remap.insert(Name::from("b"), Name::from("t"));
590        vertex_remap.insert(Name::from("c"), Name::from("u"));
591
592        let mut edge_remap = HashMap::new();
593        edge_remap.insert(edge("a", "c", "ea"), edge("t", "u", "e"));
594        edge_remap.insert(edge("b", "c", "eb"), edge("t", "u", "e"));
595
596        CompiledMigration {
597            surviving_verts: HashSet::from([Name::from("t"), Name::from("u")]),
598            surviving_edges: HashSet::new(),
599            vertex_remap,
600            edge_remap,
601            resolver: HashMap::new(),
602            hyper_resolver: HashMap::new(),
603            field_transforms: HashMap::new(),
604            conditional_survival: HashMap::new(),
605            op_term_assignments: HashMap::new(),
606            expansion_path: HashMap::new(),
607        }
608    }
609
610    #[test]
611    fn f_sigma_merges_fibres_into_coproduct() {
612        let m = merge_migration();
613        let x = FInstance::new()
614            .with_table("a", vec![row(1), row(2)])
615            .with_table("b", vec![row(3)])
616            .with_table("c", vec![row(9)]);
617        let sigma = f_sigma(&x, &m);
618        // a (2 rows) and b (1 row) merge into t (3 rows); c becomes u.
619        assert_eq!(sigma.tables.get("t").map(Vec::len), Some(3));
620        assert_eq!(sigma.tables.get("u").map(Vec::len), Some(1));
621    }
622
623    #[test]
624    fn f_unit_and_counit_check_over_merge() {
625        let m = merge_migration();
626        let x = FInstance::new()
627            .with_table("a", vec![row(1), row(2)])
628            .with_table("b", vec![row(3)])
629            .with_table("c", vec![row(9)])
630            .with_foreign_key(edge("a", "c", "ea"), vec![(0, 0), (1, 0)])
631            .with_foreign_key(edge("b", "c", "eb"), vec![(0, 0)]);
632
633        let sigma = f_sigma(&x, &m);
634        let delta_sigma = f_delta(&sigma, &m);
635        let unit = f_unit(&x, &m);
636        unit.check(&x, &delta_sigma)
637            .expect("unit is a valid homomorphism into Delta(Sigma X)");
638
639        // First triangle identity: phi(eta_X) = id on Sigma X.
640        let phi_unit = f_transpose_right(&unit, &x, &m);
641        assert_eq!(phi_unit, FInstanceHom::identity(&sigma));
642
643        let y = FInstance::new()
644            .with_table("t", vec![row(5), row(6)])
645            .with_table("u", vec![row(7)])
646            .with_foreign_key(edge("t", "u", "e"), vec![(0, 0), (1, 0)]);
647        let delta_y = f_delta(&y, &m);
648        let sigma_delta_y = f_sigma(&delta_y, &m);
649        let counit = f_counit(&y, &m);
650        counit
651            .check(&sigma_delta_y, &y)
652            .expect("counit is a valid homomorphism onto Y");
653
654        // Second triangle identity: psi(eps_Y) = id on Delta Y.
655        let psi_counit = f_transpose_left(&counit, &delta_y, &m);
656        assert_eq!(psi_counit, FInstanceHom::identity(&delta_y));
657    }
658
659    // -- a concrete injective example (W-type side) ----------------------
660
661    /// A rename migration: `root -> box`, `leaf -> item`.
662    fn rename_migration() -> CompiledMigration {
663        let mut vertex_remap = HashMap::new();
664        vertex_remap.insert(Name::from("root"), Name::from("box"));
665        vertex_remap.insert(Name::from("leaf"), Name::from("item"));
666        let mut edge_remap = HashMap::new();
667        edge_remap.insert(edge("root", "leaf", "child"), edge("box", "item", "child"));
668        CompiledMigration {
669            surviving_verts: HashSet::from([Name::from("box"), Name::from("item")]),
670            surviving_edges: HashSet::new(),
671            vertex_remap,
672            edge_remap,
673            resolver: HashMap::new(),
674            hyper_resolver: HashMap::new(),
675            field_transforms: HashMap::new(),
676            conditional_survival: HashMap::new(),
677            op_term_assignments: HashMap::new(),
678            expansion_path: HashMap::new(),
679        }
680    }
681
682    fn w_pair() -> WInstance {
683        let mut nodes = HashMap::new();
684        nodes.insert(0, Node::new(0, "root"));
685        nodes.insert(
686            1,
687            Node::new(1, "leaf").with_value(FieldPresence::Present(Value::Str("x".into()))),
688        );
689        WInstance::new(
690            nodes,
691            vec![(0, 1, edge("root", "leaf", "child"))],
692            vec![],
693            0,
694            "root".into(),
695        )
696    }
697
698    #[test]
699    fn w_delta_inverts_sigma_on_injective_maps() {
700        let m = rename_migration();
701        let tgt = schema_of(&["box", "item"], &[edge("box", "item", "child")]);
702        let x = w_pair();
703        let sigma = w_sigma(&x, &tgt, &m).expect("sigma");
704        // Sigma relabels anchors forward.
705        assert_eq!(sigma.nodes[&0].anchor, Name::from("box"));
706        assert_eq!(sigma.nodes[&1].anchor, Name::from("item"));
707        // Delta inverts it exactly.
708        let round = w_delta(&sigma, &m).expect("delta");
709        assert_eq!(round.nodes[&0].anchor, Name::from("root"));
710        assert_eq!(round.nodes[&1].anchor, Name::from("leaf"));
711
712        let unit = w_unit(&x);
713        unit.check(&x, &round).expect("unit checks");
714    }
715
716    #[test]
717    fn w_delta_rejects_merging_maps() {
718        let m = merge_migration();
719        // A T-instance anchored at the merged image `t`.
720        let mut nodes = HashMap::new();
721        nodes.insert(0, Node::new(0, "t"));
722        let y = WInstance::new(nodes, vec![], vec![], 0, "t".into());
723        let err = w_delta(&y, &m).expect_err("merging map has no W-type pullback");
724        assert!(matches!(err, AdjunctionError::NonInjectiveVertexMap { .. }));
725    }
726
727    // -- property tests --------------------------------------------------
728
729    mod property {
730        use proptest::prelude::*;
731
732        use super::*;
733
734        /// A generated functor-side scenario: an `S`-instance `x`, a
735        /// `T`-instance `y`, and a total vertex map (possibly merging).
736        #[derive(Debug, Clone)]
737        struct FScenario {
738            x: FInstance,
739            y: FInstance,
740            migration: CompiledMigration,
741        }
742
743        /// Round-robin foreign-key pairs from a source table of `src_len`
744        /// rows into a target table of `tgt_len` rows.
745        fn round_robin(src_len: usize, tgt_len: usize) -> Vec<(usize, usize)> {
746            if tgt_len == 0 {
747                return Vec::new();
748            }
749            (0..src_len).map(|i| (i, i % tgt_len)).collect()
750        }
751
752        /// Generate a scenario with `n` source vertices mapped onto `k`
753        /// target vertices (merging whenever two share an image), one
754        /// source edge per adjacent source-vertex pair, and small tables.
755        fn arb_scenario() -> impl Strategy<Value = FScenario> {
756            (2usize..=4, 1usize..=4).prop_flat_map(|(n, k)| {
757                let k = k.min(n);
758                // Assignment of each source vertex to a target index.
759                let assign = prop::collection::vec(0..k, n);
760                // Row counts for source and target tables.
761                let src_rows = prop::collection::vec(0usize..=3, n);
762                let tgt_rows = prop::collection::vec(0usize..=3, k);
763                (Just(n), Just(k), assign, src_rows, tgt_rows).prop_map(
764                    |(n, k, assign, src_rows, tgt_rows)| {
765                        let s_name = |i: usize| format!("s{i}");
766                        let t_name = |j: usize| format!("t{j}");
767
768                        // Vertex + edge maps.
769                        let mut vertex_remap = HashMap::new();
770                        for (i, &a) in assign.iter().enumerate() {
771                            vertex_remap.insert(Name::from(s_name(i)), Name::from(t_name(a)));
772                        }
773                        // One edge s{i} -> s{i+1} per adjacent pair.
774                        let s_edges: Vec<Edge> = (0..n.saturating_sub(1))
775                            .map(|i| edge(&s_name(i), &s_name(i + 1), &format!("e{i}")))
776                            .collect();
777                        let mut edge_remap = HashMap::new();
778                        for (i, e) in s_edges.iter().enumerate() {
779                            edge_remap.insert(
780                                e.clone(),
781                                edge(
782                                    &t_name(assign[i]),
783                                    &t_name(assign[i + 1]),
784                                    &format!("te{i}"),
785                                ),
786                            );
787                        }
788                        let surviving_verts: HashSet<Name> =
789                            (0..k).map(|j| Name::from(t_name(j))).collect();
790
791                        let migration = CompiledMigration {
792                            surviving_verts,
793                            surviving_edges: HashSet::new(),
794                            vertex_remap,
795                            edge_remap: edge_remap.clone(),
796                            resolver: HashMap::new(),
797                            hyper_resolver: HashMap::new(),
798                            field_transforms: HashMap::new(),
799                            conditional_survival: HashMap::new(),
800                            op_term_assignments: HashMap::new(),
801                            expansion_path: HashMap::new(),
802                        };
803
804                        // Source instance X.
805                        let mut counter = 0i64;
806                        let mut x = FInstance::new();
807                        for (i, &count) in src_rows.iter().enumerate() {
808                            let rows: Vec<_> = (0..count)
809                                .map(|_| {
810                                    counter += 1;
811                                    row(counter)
812                                })
813                                .collect();
814                            x = x.with_table(s_name(i), rows);
815                        }
816                        for (i, e) in s_edges.iter().enumerate() {
817                            let pairs = round_robin(src_rows[i], src_rows[i + 1]);
818                            if !pairs.is_empty() {
819                                x = x.with_foreign_key(e.clone(), pairs);
820                            }
821                        }
822
823                        // Target instance Y (independent).
824                        let mut y = FInstance::new();
825                        for (j, &count) in tgt_rows.iter().enumerate() {
826                            let rows: Vec<_> = (0..count)
827                                .map(|_| {
828                                    counter += 1;
829                                    row(counter)
830                                })
831                                .collect();
832                            y = y.with_table(t_name(j), rows);
833                        }
834                        for (i, _e) in s_edges.iter().enumerate() {
835                            let te = edge(
836                                &t_name(assign[i]),
837                                &t_name(assign[i + 1]),
838                                &format!("te{i}"),
839                            );
840                            let pairs = round_robin(tgt_rows[assign[i]], tgt_rows[assign[i + 1]]);
841                            if !pairs.is_empty() {
842                                y = y.with_foreign_key(te, pairs);
843                            }
844                        }
845
846                        FScenario { x, y, migration }
847                    },
848                )
849            })
850        }
851
852        proptest! {
853            #![proptest_config(ProptestConfig::with_cases(256))]
854
855            /// Both triangle identities hold for the functor adjunction over
856            /// renamed, identity, and vertex-merging total maps.
857            #[test]
858            fn sigma_delta_triangle_identities(scenario in arb_scenario()) {
859                let FScenario { x, y, migration } = scenario;
860
861                // Unit and its triangle: phi(eta_X) = id on Sigma X.
862                let sigma_x = f_sigma(&x, &migration);
863                let delta_sigma_x = f_delta(&sigma_x, &migration);
864                let unit = f_unit(&x, &migration);
865                prop_assert!(unit.check(&x, &delta_sigma_x).is_ok());
866                let phi_unit = f_transpose_right(&unit, &x, &migration);
867                prop_assert_eq!(phi_unit, FInstanceHom::identity(&sigma_x));
868
869                // Counit and its triangle: psi(eps_Y) = id on Delta Y.
870                let delta_y = f_delta(&y, &migration);
871                let sigma_delta_y = f_sigma(&delta_y, &migration);
872                let counit = f_counit(&y, &migration);
873                prop_assert!(counit.check(&sigma_delta_y, &y).is_ok());
874                let psi_counit = f_transpose_left(&counit, &delta_y, &migration);
875                prop_assert_eq!(psi_counit, FInstanceHom::identity(&delta_y));
876            }
877
878            /// The transpose maps are mutually inverse and carry valid
879            /// homomorphisms to valid homomorphisms, both directions of
880            /// `Hom_T(Sigma X, Y) ~= Hom_S(X, Delta Y)`.
881            #[test]
882            fn sigma_delta_hom_bijection(scenario in arb_scenario()) {
883                let FScenario { x, y, migration } = scenario;
884
885                // Direction 1. A valid g : Sigma X -> Y_g, built as the
886                // inclusion of Sigma X into a copy of itself with every
887                // table's rows (and foreign keys) duplicated once.
888                let sigma_x = f_sigma(&x, &migration);
889                let mut y_g = FInstance::new();
890                for (t, rows) in &sigma_x.tables {
891                    let mut doubled = rows.clone();
892                    doubled.extend(rows.iter().cloned());
893                    y_g = y_g.with_table(t.clone(), doubled);
894                }
895                for (e, pairs) in &sigma_x.foreign_keys {
896                    let len_src = sigma_x.tables.get(e.src.as_str()).map_or(0, Vec::len);
897                    let len_tgt = sigma_x.tables.get(e.tgt.as_str()).map_or(0, Vec::len);
898                    let mut all = pairs.clone();
899                    all.extend(pairs.iter().map(|(i, j)| (i + len_src, j + len_tgt)));
900                    y_g = y_g.with_foreign_key(e.clone(), all);
901                }
902                let g = FInstanceHom::identity(&sigma_x);
903                prop_assert!(g.check(&sigma_x, &y_g).is_ok());
904
905                let delta_y_g = f_delta(&y_g, &migration);
906                let psi_g = f_transpose_left(&g, &x, &migration);
907                prop_assert!(psi_g.check(&x, &delta_y_g).is_ok());
908                // Round-trip phi . psi = id.
909                let phi_psi_g = f_transpose_right(&psi_g, &x, &migration);
910                prop_assert_eq!(phi_psi_g, g);
911
912                // Direction 2. A valid f : X_f -> Delta Y with X_f = Delta Y
913                // and f the identity; phi(f) must be a valid Sigma X_f -> Y.
914                let delta_y = f_delta(&y, &migration);
915                let f = FInstanceHom::identity(&delta_y);
916                prop_assert!(f.check(&delta_y, &delta_y).is_ok());
917                let phi_f = f_transpose_right(&f, &delta_y, &migration);
918                let sigma_delta_y = f_sigma(&delta_y, &migration);
919                prop_assert!(phi_f.check(&sigma_delta_y, &y).is_ok());
920                // Round-trip psi . phi = id.
921                let psi_phi_f = f_transpose_left(&phi_f, &delta_y, &migration);
922                prop_assert_eq!(psi_phi_f, f);
923            }
924        }
925
926        // -- W-type property tests (injective total maps) ----------------
927
928        /// A generated W-type scenario over an injective (renaming or
929        /// identity) total map: an `S`-instance tree and the schemas /
930        /// migration relabelling it.
931        #[derive(Debug, Clone)]
932        struct WScenario {
933            x: WInstance,
934            tgt_schema: Schema,
935            migration: CompiledMigration,
936        }
937
938        /// Generate a rooted tree of `n` leaf children under one root, plus
939        /// an injective relabelling of its two anchors (identity or rename).
940        fn arb_w_scenario() -> impl Strategy<Value = WScenario> {
941            (1usize..=4, any::<bool>()).prop_map(|(n, rename)| {
942                let (s_root, s_leaf, t_root, t_leaf) = if rename {
943                    ("root", "leaf", "box", "item")
944                } else {
945                    ("root", "leaf", "root", "leaf")
946                };
947
948                let mut nodes = HashMap::new();
949                nodes.insert(0, Node::new(0, s_root));
950                let mut arcs = Vec::new();
951                for i in 0..n {
952                    let id = u32::try_from(i + 1).unwrap();
953                    nodes.insert(
954                        id,
955                        Node::new(id, s_leaf)
956                            .with_value(FieldPresence::Present(Value::Int(i64::from(id)))),
957                    );
958                    arcs.push((0, id, edge(s_root, s_leaf, "child")));
959                }
960                let x = WInstance::new(nodes, arcs, vec![], 0, Name::from(s_root));
961
962                let mut vertex_remap = HashMap::new();
963                vertex_remap.insert(Name::from(s_root), Name::from(t_root));
964                vertex_remap.insert(Name::from(s_leaf), Name::from(t_leaf));
965                let mut edge_remap = HashMap::new();
966                edge_remap.insert(edge(s_root, s_leaf, "child"), edge(t_root, t_leaf, "child"));
967
968                let migration = CompiledMigration {
969                    surviving_verts: HashSet::from([Name::from(t_root), Name::from(t_leaf)]),
970                    surviving_edges: HashSet::new(),
971                    vertex_remap,
972                    edge_remap,
973                    resolver: HashMap::new(),
974                    hyper_resolver: HashMap::new(),
975                    field_transforms: HashMap::new(),
976                    conditional_survival: HashMap::new(),
977                    op_term_assignments: HashMap::new(),
978                    expansion_path: HashMap::new(),
979                };
980                let tgt_schema = schema_of(&[t_root, t_leaf], &[edge(t_root, t_leaf, "child")]);
981                WScenario {
982                    x,
983                    tgt_schema,
984                    migration,
985                }
986            })
987        }
988
989        /// Renumber every node id of `y` by `+shift`, returning the shifted
990        /// instance and the isomorphism `y -> shifted` as a node map.
991        fn shift_nodes(y: &WInstance, shift: u32) -> (WInstance, WInstanceHom) {
992            let nodes = y
993                .nodes
994                .iter()
995                .map(|(&id, node)| {
996                    let mut moved = node.clone();
997                    moved.id = id + shift;
998                    (id + shift, moved)
999                })
1000                .collect();
1001            let arcs = y
1002                .arcs
1003                .iter()
1004                .map(|(p, c, e)| (p + shift, c + shift, e.clone()))
1005                .collect();
1006            let shifted = WInstance::new(
1007                nodes,
1008                arcs,
1009                y.fans.clone(),
1010                y.root + shift,
1011                y.schema_root.clone(),
1012            );
1013            let hom = WInstanceHom::new(y.nodes.keys().map(|&id| (id, id + shift)).collect());
1014            (shifted, hom)
1015        }
1016
1017        proptest! {
1018            #![proptest_config(ProptestConfig::with_cases(256))]
1019
1020            /// Both triangle identities hold for the W-type adjunction over
1021            /// injective (renamed / identity) total maps.
1022            #[test]
1023            fn w_sigma_delta_triangle_identities(scenario in arb_w_scenario()) {
1024                let WScenario { x, tgt_schema, migration } = scenario;
1025
1026                let sigma_x = w_sigma(&x, &tgt_schema, &migration).unwrap();
1027                let delta_sigma_x = w_delta(&sigma_x, &migration).unwrap();
1028                let unit = w_unit(&x);
1029                prop_assert!(unit.check(&x, &delta_sigma_x).is_ok());
1030                // First triangle: phi(eta_X) is the identity on Sigma X.
1031                let phi_unit = w_transpose_right(&unit);
1032                prop_assert!(phi_unit.check(&sigma_x, &sigma_x).is_ok());
1033                prop_assert_eq!(phi_unit, WInstanceHom::identity(&sigma_x));
1034
1035                // Counit over Y = Sigma X (anchored inside the image of F).
1036                let y = sigma_x;
1037                let delta_y = w_delta(&y, &migration).unwrap();
1038                let sigma_delta_y = w_sigma(&delta_y, &tgt_schema, &migration).unwrap();
1039                let counit = w_counit(&y);
1040                prop_assert!(counit.check(&sigma_delta_y, &y).is_ok());
1041                // Second triangle: psi(eps_Y) is the identity on Delta Y.
1042                let psi_counit = w_transpose_left(&counit);
1043                prop_assert!(psi_counit.check(&delta_y, &delta_y).is_ok());
1044                prop_assert_eq!(psi_counit, WInstanceHom::identity(&delta_y));
1045            }
1046
1047            /// The transpose carries valid W-type homomorphisms across the
1048            /// bijection `Hom_T(Sigma X, Y) ~= Hom_S(X, Delta Y)` and back,
1049            /// for injective total maps.
1050            #[test]
1051            fn w_sigma_delta_hom_bijection(scenario in arb_w_scenario()) {
1052                let WScenario { x, tgt_schema, migration } = scenario;
1053
1054                let sigma_x = w_sigma(&x, &tgt_schema, &migration).unwrap();
1055                // A non-trivial g : Sigma X -> Y, the renumbering iso onto a
1056                // shifted copy of Sigma X.
1057                let (y, g) = shift_nodes(&sigma_x, 100);
1058                prop_assert!(g.check(&sigma_x, &y).is_ok());
1059
1060                let delta_y = w_delta(&y, &migration).unwrap();
1061                let psi_g = w_transpose_left(&g);
1062                prop_assert!(psi_g.check(&x, &delta_y).is_ok());
1063                // Round-trip phi . psi = id.
1064                let phi_psi_g = w_transpose_right(&psi_g);
1065                prop_assert_eq!(phi_psi_g, g);
1066
1067                // Reverse direction from f = eta_X : X -> Delta(Sigma X).
1068                let delta_sigma_x = w_delta(&sigma_x, &migration).unwrap();
1069                let f = w_unit(&x);
1070                prop_assert!(f.check(&x, &delta_sigma_x).is_ok());
1071                let phi_f = w_transpose_right(&f);
1072                prop_assert!(phi_f.check(&sigma_x, &sigma_x).is_ok());
1073                let psi_phi_f = w_transpose_left(&phi_f);
1074                prop_assert_eq!(psi_phi_f, f);
1075            }
1076        }
1077    }
1078}