Skip to main content

panproto_inst/
instance_hom.rs

1//! First-class instance homomorphisms.
2//!
3//! An instance homomorphism is a structure-preserving map between two
4//! instances of the *same* schema. [`WInstanceHom`] maps the nodes of one
5//! [`WInstance`] to the nodes of another; [`FInstanceHom`] maps the rows of
6//! one [`FInstance`] table by table to the rows of another. Both carry a
7//! `check` that verifies the map respects instance structure (anchors, arcs,
8//! fans, and root for W-types; foreign keys for F-instances), plus
9//! [`identity`](WInstanceHom::identity), [`compose`](WInstanceHom::compose),
10//! and [`is_isomorphism`](WInstanceHom::is_isomorphism).
11//!
12//! These homomorphisms are the morphisms of the category of instances over a
13//! fixed schema. They are the arrows the Sigma/Delta adjunction transposes and
14//! the equality witnesses that compare instances when a migration square is
15//! checked for commutativity.
16
17use std::collections::{HashMap, HashSet};
18
19use panproto_gat::Name;
20
21use crate::fan::Fan;
22use crate::functor::FInstance;
23use crate::wtype::WInstance;
24
25/// Error raised when an instance homomorphism fails its structural checks or
26/// when two homomorphisms cannot be composed.
27#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
28#[non_exhaustive]
29pub enum HomError {
30    /// The node map is not total: a domain node has no image.
31    #[error("node map is not total: domain node {0} has no image")]
32    NodeNotMapped(u32),
33
34    /// The node map sends a domain node to a codomain node that does not exist.
35    #[error("node map sends domain node {src} to nonexistent codomain node {tgt}")]
36    ImageNodeMissing {
37        /// The domain node.
38        src: u32,
39        /// The claimed image, absent from the codomain.
40        tgt: u32,
41    },
42
43    /// A domain node and its image sit over different schema vertices, so the
44    /// map does not preserve anchors.
45    #[error(
46        "anchor not preserved: node {src} anchored at `{src_anchor}` \
47         maps to node {tgt} anchored at `{tgt_anchor}`"
48    )]
49    AnchorMismatch {
50        /// The domain node.
51        src: u32,
52        /// Its image in the codomain.
53        tgt: u32,
54        /// The domain node's anchor.
55        src_anchor: Name,
56        /// The image node's anchor.
57        tgt_anchor: Name,
58    },
59
60    /// A domain arc has no corresponding arc in the codomain under the map, so
61    /// the map does not preserve the tree structure.
62    #[error(
63        "arc ({parent},{child}) is not preserved: \
64         no codomain arc ({mapped_parent},{mapped_child}) of the same edge"
65    )]
66    ArcNotPreserved {
67        /// The domain arc's parent node.
68        parent: u32,
69        /// The domain arc's child node.
70        child: u32,
71        /// The image of the parent.
72        mapped_parent: u32,
73        /// The image of the child.
74        mapped_child: u32,
75    },
76
77    /// A domain fan has no corresponding fan in the codomain under the map.
78    #[error("fan `{hyper_edge_id}` at parent {parent} is not preserved")]
79    FanNotPreserved {
80        /// The hyper-edge the fan instantiates.
81        hyper_edge_id: String,
82        /// The domain fan's parent node.
83        parent: u32,
84    },
85
86    /// A domain node (or row) and its image carry different attribute values,
87    /// so the map does not preserve the attribute assignment.
88    #[error("attributes not preserved: node {src} and its image {tgt} carry different values")]
89    AttributeMismatch {
90        /// The domain node id (W-type) or row index (F-instance).
91        src: u32,
92        /// The image node id or row index.
93        tgt: u32,
94    },
95
96    /// The map does not send the domain root to the codomain root.
97    #[error("root not preserved: domain root {src} maps to {mapped}, but codomain root is {cod}")]
98    RootNotPreserved {
99        /// The domain root.
100        src: u32,
101        /// Its image.
102        mapped: u32,
103        /// The codomain root.
104        cod: u32,
105    },
106
107    /// A table present in the domain has no row map, or the row map disagrees
108    /// with the table's row count, or the codomain lacks the table.
109    #[error("row map for table `{table}` is missing or malformed: {detail}")]
110    RowMapMalformed {
111        /// The table (schema vertex) whose row map is malformed.
112        table: String,
113        /// What is wrong.
114        detail: String,
115    },
116
117    /// A foreign key of the domain is not preserved by the row maps.
118    #[error(
119        "foreign key `{edge}` is not preserved: mapped pair \
120         ({mapped_src},{mapped_tgt}) absent from the codomain"
121    )]
122    ForeignKeyNotPreserved {
123        /// A rendering of the edge whose foreign key is broken.
124        edge: String,
125        /// The image of the source row index.
126        mapped_src: usize,
127        /// The image of the target row index.
128        mapped_tgt: usize,
129    },
130
131    /// Two homomorphisms cannot be composed because the image of the first is
132    /// not in the domain of the second.
133    #[error("cannot compose: intermediate node {0} is outside the second map's domain")]
134    ComposeNodeMismatch(u32),
135
136    /// Two F-instance homomorphisms cannot be composed because their table
137    /// row maps do not line up.
138    #[error("cannot compose F-instance homs on table `{table}`: {detail}")]
139    ComposeRowMismatch {
140        /// The table where composition fails.
141        table: String,
142        /// What is wrong.
143        detail: String,
144    },
145}
146
147/// A homomorphism between two W-type instances of the same schema.
148///
149/// The map sends each node of the domain instance to a node of the codomain
150/// instance. A well-formed homomorphism preserves anchors, arcs, fans, and the
151/// root; [`check`](Self::check) verifies these conditions.
152#[derive(Clone, Debug, Default, PartialEq, Eq)]
153pub struct WInstanceHom {
154    /// Node map: domain node id to codomain node id.
155    pub node_map: HashMap<u32, u32>,
156}
157
158impl WInstanceHom {
159    /// Build a homomorphism from an explicit node map.
160    #[must_use]
161    pub const fn new(node_map: HashMap<u32, u32>) -> Self {
162        Self { node_map }
163    }
164
165    /// The identity homomorphism on `instance` (each node maps to itself).
166    #[must_use]
167    pub fn identity(instance: &WInstance) -> Self {
168        Self {
169            node_map: instance.nodes.keys().map(|&id| (id, id)).collect(),
170        }
171    }
172
173    /// Verify that this homomorphism from `dom` to `cod` preserves the W-type
174    /// structure.
175    ///
176    /// Checks, in order: totality of the node map over `dom`'s nodes;
177    /// existence of every image in `cod`; anchor preservation; arc naturality
178    /// (each domain arc maps to a codomain arc of the same edge); fan
179    /// naturality; and root preservation.
180    ///
181    /// # Errors
182    ///
183    /// Returns the first [`HomError`] encountered.
184    pub fn check(&self, dom: &WInstance, cod: &WInstance) -> Result<(), HomError> {
185        // Totality, image existence, and anchor preservation.
186        for (&src, node) in &dom.nodes {
187            let &tgt = self
188                .node_map
189                .get(&src)
190                .ok_or(HomError::NodeNotMapped(src))?;
191            let image = cod
192                .nodes
193                .get(&tgt)
194                .ok_or(HomError::ImageNodeMissing { src, tgt })?;
195            if node.anchor != image.anchor {
196                return Err(HomError::AnchorMismatch {
197                    src,
198                    tgt,
199                    src_anchor: node.anchor.clone(),
200                    tgt_anchor: image.anchor.clone(),
201                });
202            }
203            // Attribute preservation: a morphism of attributed C-sets acts as
204            // the identity on attribute values, so a node and its image must
205            // agree on their leaf value and extra fields.
206            if node.value != image.value || node.extra_fields != image.extra_fields {
207                return Err(HomError::AttributeMismatch { src, tgt });
208            }
209        }
210
211        // Arc naturality: each domain arc has an image arc of the same edge.
212        let cod_arcs: HashSet<(u32, u32, &panproto_schema::Edge)> =
213            cod.arcs.iter().map(|(p, c, e)| (*p, *c, e)).collect();
214        for (parent, child, edge) in &dom.arcs {
215            let mapped_parent = self.node_map[parent];
216            let mapped_child = self.node_map[child];
217            if !cod_arcs.contains(&(mapped_parent, mapped_child, edge)) {
218                return Err(HomError::ArcNotPreserved {
219                    parent: *parent,
220                    child: *child,
221                    mapped_parent,
222                    mapped_child,
223                });
224            }
225        }
226
227        // Fan naturality: each domain fan maps to a codomain fan. Fans hold a
228        // label map, so they are not hashable; compare by equality instead.
229        for fan in &dom.fans {
230            let mapped = Fan {
231                hyper_edge_id: fan.hyper_edge_id.clone(),
232                parent: self.node_map[&fan.parent],
233                children: fan
234                    .children
235                    .iter()
236                    .map(|(label, id)| (label.clone(), self.node_map[id]))
237                    .collect(),
238            };
239            if !cod.fans.contains(&mapped) {
240                return Err(HomError::FanNotPreserved {
241                    hyper_edge_id: fan.hyper_edge_id.clone(),
242                    parent: fan.parent,
243                });
244            }
245        }
246
247        // Root preservation.
248        let mapped_root = self.node_map[&dom.root];
249        if mapped_root != cod.root {
250            return Err(HomError::RootNotPreserved {
251                src: dom.root,
252                mapped: mapped_root,
253                cod: cod.root,
254            });
255        }
256
257        Ok(())
258    }
259
260    /// Compose two homomorphisms: `self` from `A` to `B` followed by `other`
261    /// from `B` to `C`, yielding a homomorphism from `A` to `C`.
262    ///
263    /// # Errors
264    ///
265    /// Returns [`HomError::ComposeNodeMismatch`] if some image of `self` is
266    /// outside the domain of `other`.
267    pub fn compose(&self, other: &Self) -> Result<Self, HomError> {
268        let mut node_map = HashMap::with_capacity(self.node_map.len());
269        for (&a, &b) in &self.node_map {
270            let &c = other
271                .node_map
272                .get(&b)
273                .ok_or(HomError::ComposeNodeMismatch(b))?;
274            node_map.insert(a, c);
275        }
276        Ok(Self { node_map })
277    }
278
279    /// Returns `true` iff this homomorphism from `dom` to `cod` is an
280    /// isomorphism: a structure-preserving bijection whose inverse is also
281    /// structure preserving.
282    #[must_use]
283    pub fn is_isomorphism(&self, dom: &WInstance, cod: &WInstance) -> bool {
284        if self.check(dom, cod).is_err() {
285            return false;
286        }
287        // The map must be a bijection onto the codomain's nodes.
288        if self.node_map.len() != cod.nodes.len() {
289            return false;
290        }
291        let mut inverse = HashMap::with_capacity(self.node_map.len());
292        for (&src, &tgt) in &self.node_map {
293            // Injectivity: no two domain nodes share an image.
294            if inverse.insert(tgt, src).is_some() {
295                return false;
296            }
297        }
298        // Surjectivity onto cod is now implied by len equality + injectivity,
299        // provided every image is a genuine codomain node (checked above).
300        Self { node_map: inverse }.check(cod, dom).is_ok()
301    }
302}
303
304/// A homomorphism between two set-valued functor instances of the same schema.
305///
306/// For each table (schema vertex), a row map sends each row index of the
307/// domain to a row index of the codomain. A well-formed homomorphism preserves
308/// foreign keys; [`check`](Self::check) verifies this.
309#[derive(Clone, Debug, Default, PartialEq, Eq)]
310pub struct FInstanceHom {
311    /// Per-table row maps: table name to a vector indexed by domain row index,
312    /// whose entry is the corresponding codomain row index.
313    pub row_maps: HashMap<String, Vec<usize>>,
314}
315
316impl FInstanceHom {
317    /// Build a homomorphism from explicit per-table row maps.
318    #[must_use]
319    pub const fn new(row_maps: HashMap<String, Vec<usize>>) -> Self {
320        Self { row_maps }
321    }
322
323    /// The identity homomorphism on `instance` (each row maps to itself).
324    #[must_use]
325    pub fn identity(instance: &FInstance) -> Self {
326        Self {
327            row_maps: instance
328                .tables
329                .iter()
330                .map(|(name, rows)| (name.clone(), (0..rows.len()).collect()))
331                .collect(),
332        }
333    }
334
335    /// Verify that this homomorphism from `dom` to `cod` preserves the
336    /// relational structure.
337    ///
338    /// Checks totality (every domain table has a row map of the right length,
339    /// present in the codomain, whose entries are in range) and foreign-key
340    /// naturality (each domain foreign-key pair maps to a codomain pair).
341    ///
342    /// # Errors
343    ///
344    /// Returns the first [`HomError`] encountered.
345    pub fn check(&self, dom: &FInstance, cod: &FInstance) -> Result<(), HomError> {
346        for (table, rows) in &dom.tables {
347            let map = self
348                .row_maps
349                .get(table)
350                .ok_or_else(|| HomError::RowMapMalformed {
351                    table: table.clone(),
352                    detail: "no row map for this table".to_owned(),
353                })?;
354            if map.len() != rows.len() {
355                return Err(HomError::RowMapMalformed {
356                    table: table.clone(),
357                    detail: format!(
358                        "row map has {} entries but the table has {} rows",
359                        map.len(),
360                        rows.len()
361                    ),
362                });
363            }
364            let cod_rows = cod.tables.get(table).map_or(&[][..], Vec::as_slice);
365            for (i, (&j, row)) in map.iter().zip(rows).enumerate() {
366                if j >= cod_rows.len() {
367                    return Err(HomError::RowMapMalformed {
368                        table: table.clone(),
369                        detail: format!(
370                            "image row {j} out of range (codomain has {} rows)",
371                            cod_rows.len()
372                        ),
373                    });
374                }
375                // Attribute preservation: the image row equals the domain row.
376                if *row != cod_rows[j] {
377                    return Err(HomError::AttributeMismatch {
378                        src: u32::try_from(i).unwrap_or(u32::MAX),
379                        tgt: u32::try_from(j).unwrap_or(u32::MAX),
380                    });
381                }
382            }
383        }
384
385        for (edge, pairs) in &dom.foreign_keys {
386            let src_map =
387                self.row_maps
388                    .get(edge.src.as_ref())
389                    .ok_or_else(|| HomError::RowMapMalformed {
390                        table: edge.src.to_string(),
391                        detail: "foreign-key source table has no row map".to_owned(),
392                    })?;
393            let tgt_map =
394                self.row_maps
395                    .get(edge.tgt.as_ref())
396                    .ok_or_else(|| HomError::RowMapMalformed {
397                        table: edge.tgt.to_string(),
398                        detail: "foreign-key target table has no row map".to_owned(),
399                    })?;
400            let cod_pairs: HashSet<(usize, usize)> = cod
401                .foreign_keys
402                .get(edge)
403                .into_iter()
404                .flatten()
405                .copied()
406                .collect();
407            for &(si, ti) in pairs {
408                let mapped = (src_map[si], tgt_map[ti]);
409                if !cod_pairs.contains(&mapped) {
410                    return Err(HomError::ForeignKeyNotPreserved {
411                        edge: format!("{}->{}", edge.src, edge.tgt),
412                        mapped_src: mapped.0,
413                        mapped_tgt: mapped.1,
414                    });
415                }
416            }
417        }
418
419        Ok(())
420    }
421
422    /// Compose two homomorphisms: `self` from `A` to `B` followed by `other`
423    /// from `B` to `C`.
424    ///
425    /// # Errors
426    ///
427    /// Returns [`HomError::ComposeRowMismatch`] if the tables or row indices do
428    /// not line up.
429    pub fn compose(&self, other: &Self) -> Result<Self, HomError> {
430        let mut row_maps = HashMap::with_capacity(self.row_maps.len());
431        for (table, map) in &self.row_maps {
432            let next = other
433                .row_maps
434                .get(table)
435                .ok_or_else(|| HomError::ComposeRowMismatch {
436                    table: table.clone(),
437                    detail: "second map has no row map for this table".to_owned(),
438                })?;
439            let mut composed = Vec::with_capacity(map.len());
440            for &j in map {
441                let &k = next.get(j).ok_or_else(|| HomError::ComposeRowMismatch {
442                    table: table.clone(),
443                    detail: format!("intermediate row {j} is outside the second map's domain"),
444                })?;
445                composed.push(k);
446            }
447            row_maps.insert(table.clone(), composed);
448        }
449        Ok(Self { row_maps })
450    }
451
452    /// Returns `true` iff this homomorphism from `dom` to `cod` is an
453    /// isomorphism: each row map is a bijection and the inverse preserves
454    /// foreign keys.
455    #[must_use]
456    pub fn is_isomorphism(&self, dom: &FInstance, cod: &FInstance) -> bool {
457        if self.check(dom, cod).is_err() {
458            return false;
459        }
460        if self.row_maps.len() != cod.tables.len() {
461            return false;
462        }
463        let mut inverse = HashMap::with_capacity(self.row_maps.len());
464        for (table, map) in &self.row_maps {
465            let cod_rows = cod.tables.get(table).map_or(0, Vec::len);
466            if map.len() != cod_rows {
467                return false;
468            }
469            let mut inv = vec![usize::MAX; cod_rows];
470            for (i, &j) in map.iter().enumerate() {
471                if inv[j] != usize::MAX {
472                    return false; // not injective
473                }
474                inv[j] = i;
475            }
476            if inv.contains(&usize::MAX) {
477                return false; // not surjective
478            }
479            inverse.insert(table.clone(), inv);
480        }
481        Self { row_maps: inverse }.check(cod, dom).is_ok()
482    }
483}
484
485#[cfg(test)]
486#[allow(clippy::expect_used, clippy::unwrap_used)]
487mod tests {
488    use std::collections::HashMap;
489
490    use panproto_schema::Edge;
491
492    use super::*;
493    use crate::metadata::Node;
494    use crate::value::Value;
495
496    fn edge(src: &str, tgt: &str) -> Edge {
497        Edge {
498            src: src.into(),
499            tgt: tgt.into(),
500            kind: "prop".into(),
501            name: None,
502        }
503    }
504
505    /// A two-node W-instance: root `r` (id 0) with one child (id 1) via edge.
506    fn w_pair(root_id: u32, child_id: u32) -> WInstance {
507        let mut nodes = HashMap::new();
508        nodes.insert(root_id, Node::new(root_id, "root"));
509        nodes.insert(child_id, Node::new(child_id, "leaf"));
510        WInstance::new(
511            nodes,
512            vec![(root_id, child_id, edge("root", "leaf"))],
513            vec![],
514            root_id,
515            "root".into(),
516        )
517    }
518
519    #[test]
520    fn identity_checks_and_composes() {
521        let inst = w_pair(0, 1);
522        let id = WInstanceHom::identity(&inst);
523        assert!(id.check(&inst, &inst).is_ok());
524
525        // A relabeling hom sending {0->10, 1->11} into a renumbered copy.
526        let renamed = w_pair(10, 11);
527        let h = WInstanceHom::new(HashMap::from([(0, 10), (1, 11)]));
528        assert!(h.check(&inst, &renamed).is_ok());
529
530        // identity ∘ h == h (apply h, then identity on the codomain).
531        let id_cod = WInstanceHom::identity(&renamed);
532        let composed = h.compose(&id_cod).expect("compose with identity");
533        assert_eq!(composed, h);
534
535        assert!(h.is_isomorphism(&inst, &renamed));
536    }
537
538    #[test]
539    fn arc_breaking_node_map_fails_check() {
540        // Domain: 0 -> 1. Codomain has nodes but no arc between the images.
541        let dom = w_pair(0, 1);
542        let mut nodes = HashMap::new();
543        nodes.insert(0, Node::new(0, "root"));
544        nodes.insert(1, Node::new(1, "leaf"));
545        nodes.insert(2, Node::new(2, "leaf"));
546        // Root arc goes to node 2, not node 1.
547        let cod = WInstance::new(
548            nodes,
549            vec![(0, 2, edge("root", "leaf"))],
550            vec![],
551            0,
552            "root".into(),
553        );
554        // Map the child (1) to the non-adjacent node 2's sibling... map 1->1,
555        // which has no incoming arc in cod, breaking arc naturality.
556        let h = WInstanceHom::new(HashMap::from([(0, 0), (1, 1)]));
557        let err = h.check(&dom, &cod).expect_err("arc naturality must fail");
558        assert!(matches!(err, HomError::ArcNotPreserved { .. }));
559    }
560
561    #[test]
562    fn anchor_mismatch_fails_check() {
563        let dom = w_pair(0, 1);
564        let mut nodes = HashMap::new();
565        nodes.insert(0, Node::new(0, "root"));
566        // Image of node 1 sits over the wrong vertex.
567        nodes.insert(1, Node::new(1, "other"));
568        let cod = WInstance::new(
569            nodes,
570            vec![(0, 1, edge("root", "leaf"))],
571            vec![],
572            0,
573            "root".into(),
574        );
575        let h = WInstanceHom::identity(&dom);
576        let err = h.check(&dom, &cod).expect_err("anchor mismatch must fail");
577        assert!(matches!(err, HomError::AnchorMismatch { .. }));
578    }
579
580    #[test]
581    fn incompatible_compose_errors() {
582        // self maps 1 -> 99, but other is only defined on {0,1}.
583        let h1 = WInstanceHom::new(HashMap::from([(0, 0), (1, 99)]));
584        let h2 = WInstanceHom::new(HashMap::from([(0, 0), (1, 1)]));
585        let err = h1
586            .compose(&h2)
587            .expect_err("compose must reject dangling image");
588        assert_eq!(err, HomError::ComposeNodeMismatch(99));
589    }
590
591    #[test]
592    fn non_root_preserving_map_fails() {
593        let dom = w_pair(0, 1);
594        let renamed = w_pair(10, 11);
595        // Send the root to a non-root node.
596        let h = WInstanceHom::new(HashMap::from([(0, 11), (1, 10)]));
597        let err = h.check(&dom, &renamed).expect_err("root must be preserved");
598        // Anchors differ too, so either error is acceptable; assert it fails.
599        assert!(matches!(
600            err,
601            HomError::RootNotPreserved { .. } | HomError::AnchorMismatch { .. }
602        ));
603    }
604
605    fn f_pair() -> FInstance {
606        let posts = vec![
607            HashMap::from([("id".to_owned(), Value::Int(1))]),
608            HashMap::from([("id".to_owned(), Value::Int(2))]),
609        ];
610        let users = vec![HashMap::from([("id".to_owned(), Value::Int(10))])];
611        FInstance::new()
612            .with_table("post", posts)
613            .with_table("user", users)
614            .with_foreign_key(edge("post", "user"), vec![(0, 0), (1, 0)])
615    }
616
617    #[test]
618    fn finstance_identity_and_fk_naturality() {
619        let inst = f_pair();
620        let id = FInstanceHom::identity(&inst);
621        assert!(id.check(&inst, &inst).is_ok());
622        assert!(id.is_isomorphism(&inst, &inst));
623
624        // A codomain whose foreign key omits (1,0) breaks FK naturality under
625        // the identity row maps (the rows themselves are unchanged, so
626        // attribute preservation still holds).
627        let mut broken = f_pair();
628        broken
629            .foreign_keys
630            .insert(edge("post", "user"), vec![(0, 0)]);
631        let err = id
632            .check(&inst, &broken)
633            .expect_err("FK naturality must fail");
634        assert!(matches!(err, HomError::ForeignKeyNotPreserved { .. }));
635    }
636
637    #[test]
638    fn winstance_attribute_change_fails_check() {
639        // Domain node 1 carries a value; the image carries a different value.
640        let mut dom_nodes = HashMap::new();
641        dom_nodes.insert(0, Node::new(0, "root"));
642        dom_nodes.insert(
643            1,
644            Node::new(1, "leaf").with_extra_field("weight", Value::Int(1)),
645        );
646        let dom = WInstance::new(
647            dom_nodes,
648            vec![(0, 1, edge("root", "leaf"))],
649            vec![],
650            0,
651            "root".into(),
652        );
653        let mut cod_nodes = HashMap::new();
654        cod_nodes.insert(0, Node::new(0, "root"));
655        cod_nodes.insert(
656            1,
657            Node::new(1, "leaf").with_extra_field("weight", Value::Int(2)),
658        );
659        let cod = WInstance::new(
660            cod_nodes,
661            vec![(0, 1, edge("root", "leaf"))],
662            vec![],
663            0,
664            "root".into(),
665        );
666        let h = WInstanceHom::identity(&dom);
667        let err = h
668            .check(&dom, &cod)
669            .expect_err("differing attribute must fail check");
670        assert!(matches!(
671            err,
672            HomError::AttributeMismatch { src: 1, tgt: 1 }
673        ));
674    }
675
676    #[test]
677    fn finstance_attribute_change_fails_check() {
678        let inst = f_pair();
679        let id = FInstanceHom::identity(&inst);
680        // A codomain whose first post row carries a different id breaks
681        // attribute preservation under the identity row map.
682        let mut altered = f_pair();
683        altered.tables.get_mut("post").expect("post table")[0]
684            .insert("id".to_owned(), Value::Int(99));
685        let err = id
686            .check(&inst, &altered)
687            .expect_err("differing row must fail check");
688        assert!(matches!(err, HomError::AttributeMismatch { src: 0, .. }));
689    }
690}