Skip to main content

panproto_inst/
wtype.rs

1//! W-type instance representation and the `wtype_restrict` pipeline.
2//!
3//! A [`WInstance`] is a tree-shaped data instance conforming to a schema.
4//! The restrict operation (`wtype_restrict`) is a fused single-pass pipeline
5//! that projects a W-type instance along a migration mapping.
6//!
7//! The pipeline fuses four concerns into one BFS traversal:
8//! 1. Anchor survival check: does this node's schema vertex survive?
9//! 2. Reachability: is this node reachable from the root?
10//! 3. Ancestor contraction: who is the nearest surviving ancestor?
11//! 4. Edge resolution: what edge connects the contracted arc?
12//!
13//! Fan reconstruction (step 5) runs as a separate pass since it operates
14//! on the original fan list, not the BFS tree.
15//!
16//! The five individual step functions are retained for testing and debugging.
17
18use std::collections::{HashMap, HashSet, VecDeque};
19
20use panproto_gat::Name;
21use panproto_schema::{Edge, Schema};
22use rustc_hash::{FxHashMap, FxHashSet};
23use serde::{Deserialize, Serialize};
24use smallvec::SmallVec;
25
26use crate::error::RestrictError;
27use crate::fan::Fan;
28use crate::metadata::Node;
29use crate::value::Value;
30
31/// A compiled migration specification (minimal version for panproto-inst).
32///
33/// The full `CompiledMigration` lives in `panproto-mig`. This type provides
34/// the subset of fields that `wtype_restrict` and `functor_restrict` need.
35#[derive(Clone, Debug, Default, Serialize, Deserialize)]
36pub struct CompiledMigration {
37    /// Vertices that survive the migration.
38    pub surviving_verts: HashSet<Name>,
39    /// Edges that survive the migration.
40    pub surviving_edges: HashSet<Edge>,
41    /// Vertex remapping: source vertex ID to target vertex ID.
42    pub vertex_remap: HashMap<Name, Name>,
43    /// Edge remapping: source edge to target edge.
44    pub edge_remap: HashMap<Edge, Edge>,
45    /// Binary contraction resolver: (`src_anchor`, `tgt_anchor`) to resolved edge.
46    pub resolver: HashMap<(Name, Name), Edge>,
47    /// Hyper-edge contraction resolver.
48    pub hyper_resolver: HashMap<Name, (Name, HashMap<Name, Name>)>,
49    /// Value-level field transforms applied to surviving nodes' `extra_fields`.
50    ///
51    /// Keyed by source vertex anchor. Each entry is a list of field operations
52    /// applied in order after the node survives and is remapped.
53    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
54    pub field_transforms: HashMap<Name, Vec<FieldTransform>>,
55    /// Value-dependent survival predicates.
56    ///
57    /// During `wtype_restrict`, after checking that a node's anchor vertex
58    /// is in `surviving_verts`, the conditional survival predicate (if any)
59    /// is evaluated with the node's `extra_fields` bound as variables.
60    /// If the predicate evaluates to `false`, the node is dropped despite
61    /// its anchor surviving.
62    ///
63    /// This enables value-dependent filtering: "keep this vertex only if
64    /// attrs.level == 2" (matchAttrs), or "keep this vertex only if
65    /// class contains 'u-url'" (matchAttrsAll).
66    ///
67    /// Categorically, this is a refinement of the survival predicate
68    /// from a structural predicate (vertex set membership) to a
69    /// value-dependent predicate (vertex set membership AND value predicate).
70    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
71    pub conditional_survival: HashMap<Name, panproto_expr::Expr>,
72    /// Value-level op-to-term assignments applied to surviving rows/nodes.
73    ///
74    /// Keyed by source vertex anchor, mirroring [`Self::field_transforms`].
75    /// Each assignment computes a migrated field by substituting the row's
76    /// field values into a term; this is the substitution action of
77    /// `Delta` and `Sigma` on values. `panproto-mig`'s compiler emits its
78    /// value transforms here rather than as direct [`FieldTransform`]s.
79    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
80    pub op_term_assignments: HashMap<Name, Vec<TermAssignment>>,
81    /// Multi-hop expansion paths for nest-style migrations.
82    ///
83    /// When a direct edge `src --> tgt` existed in the source schema but
84    /// only a multi-hop path `src --> i1 --> i2 --> ... --> tgt` exists in
85    /// the target (as happens after `combinators::nest_field`), this map
86    /// records the sequence of intermediate target anchor ids to insert
87    /// when walking the source arc during `wtype_restrict`. The value is
88    /// the intermediate anchors only (endpoints excluded), ordered from
89    /// parent-adjacent to child-adjacent.
90    ///
91    /// Dual of the ancestor-contraction mechanism: contraction collapses
92    /// a path to a direct arc (hoist), expansion fans a direct arc out
93    /// into a path by materializing fresh view nodes (nest).
94    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
95    pub expansion_path: HashMap<(Name, Name), Vec<Name>>,
96}
97
98/// A value-level transformation on a node's `extra_fields`.
99///
100/// These are applied during `wtype_restrict` after structural operations
101/// (anchor remapping, vertex survival). They enable the instance pipeline
102/// to handle value-dependent migrations (attribute renames, drops, value
103/// transforms) that go beyond pure structural schema changes.
104#[derive(Clone, Debug, Serialize, Deserialize)]
105pub enum FieldTransform {
106    /// Rename a field key: `old_key` → `new_key`.
107    RenameField {
108        /// The current field name.
109        old_key: String,
110        /// The new field name.
111        new_key: String,
112    },
113    /// Drop a field by key.
114    DropField {
115        /// The field to remove.
116        key: String,
117    },
118    /// Add a field with a constant default value.
119    AddField {
120        /// The field name to add.
121        key: String,
122        /// The default value.
123        value: Value,
124    },
125    /// Keep only the specified fields (all others are dropped).
126    KeepFields {
127        /// The field names to retain.
128        keys: Vec<String>,
129    },
130    /// Apply an expression to a field's value, storing the result.
131    ApplyExpr {
132        /// The field whose value is transformed.
133        key: String,
134        /// The expression to evaluate (receives the field value as input).
135        expr: panproto_expr::Expr,
136        /// Optional inverse expression for round-tripping.
137        inverse: Option<panproto_expr::Expr>,
138        /// Round-trip classification of this transformation.
139        coercion_class: panproto_gat::CoercionClass,
140    },
141    /// Apply a field transform at a nested path within the Value tree.
142    ///
143    /// The path is a sequence of string keys navigating through nested
144    /// `Value::Unknown` (object) structures. The inner transform is applied
145    /// to the `extra_fields` map at the resolved path.
146    ///
147    /// This generalizes flat field transforms to operate on the full
148    /// Value algebra. A `PathTransform` with an empty path is equivalent
149    /// to applying the inner transform directly.
150    ///
151    /// Categorically, this is the action of a path functor on the
152    /// endomorphism algebra of field transforms; it lifts a transform
153    /// from a leaf to an inner node of the Value tree.
154    PathTransform {
155        /// Path to navigate (e.g., `vec!["attrs"]` for nested attrs objects).
156        path: Vec<String>,
157        /// The transform to apply at the resolved path.
158        inner: Box<Self>,
159    },
160    /// Compute a field value from an expression with access to the full
161    /// fiber over the parent vertex.
162    ///
163    /// Unlike `ApplyExpr` which binds a single field, `ComputeField` binds
164    /// all `extra_fields`, nested attrs, AND scalar values from immediate
165    /// child nodes (the dependent-sum projection) as variables, evaluates
166    /// the expression, and stores the result in the target field.
167    ///
168    /// This means `ComputeField` can access any scalar property of the
169    /// parent object, whether it was parsed as an extra field or as a
170    /// schema-defined child vertex (e.g., a string field with a `"format"`
171    /// annotation like `"at-uri"`). Computed results are always written to
172    /// `extra_fields`, making them available to subsequent transforms and
173    /// to `to_json` serialization (where `extra_fields` overwrite child
174    /// values with the same key).
175    ///
176    /// Computed fields are classified by `coercion_class`:
177    /// - `Iso`: the computation is invertible via `inverse`; the lens law
178    ///   `PutGet` holds for modifications to the computed field.
179    /// - `Opaque`: no inverse exists; the complement stores the entire
180    ///   original value. Modifications to the computed field in the view
181    ///   are not independently round-trippable. This is analogous to SQL
182    ///   computed columns: the lens law holds for the independent
183    ///   (non-derived) components of the view, and the derived components
184    ///   are re-computed deterministically.
185    ///
186    /// This enables template name computation like
187    ///   `target_key`: "name",
188    ///   `expr`: `(concat "h" (int_to_str attrs.level))`
189    /// which computes "h1", "h2", etc. from the level attribute, as well
190    /// as AT-URI decomposition where the `repo` field is a schema-defined
191    /// child vertex.
192    ComputeField {
193        /// The field to store the computed result in.
194        target_key: String,
195        /// The expression, with all `extra_fields` bound as variables.
196        expr: panproto_expr::Expr,
197        /// Optional inverse expression for round-tripping.
198        inverse: Option<panproto_expr::Expr>,
199        /// Round-trip classification of this transformation.
200        coercion_class: panproto_gat::CoercionClass,
201    },
202    /// Case analysis on node values: the coproduct eliminator for the
203    /// field transform algebra.
204    ///
205    /// Each branch is a (predicate, transforms) pair. Branches are evaluated
206    /// in order with the node's `extra_fields` (and nested `attrs.*` keys)
207    /// bound as expression variables. The first branch whose predicate
208    /// evaluates to `true` has its transforms applied. If no branch matches,
209    /// the node passes through unchanged.
210    ///
211    /// This is the dependent function space lift of field transforms:
212    /// `Π(x : Value). FieldTransform`, a transform that depends on the
213    /// runtime value of the node, not just its schema vertex. It composes
214    /// naturally with all other transform variants (including nesting
215    /// inside `PathTransform`).
216    ///
217    /// Use cases:
218    /// - `matchAttrs`: "if `level == 1` then rename to `h1`, if `level == 2`
219    ///   then rename to `h2`", where each heading level is a branch.
220    /// - Conditional attribute injection: "if `list == 'ordered'` then add
221    ///   `type: ol`, else add `type: ul`".
222    Case {
223        /// Ordered branches: first matching predicate wins.
224        branches: Vec<CaseBranch>,
225    },
226    /// Update string values that reference vertex names.
227    ///
228    /// When vertices are renamed or dropped during migration, string fields
229    /// that reference those vertices by name must be updated to reflect the
230    /// new names. This is the functorial action of the vertex rename map
231    /// on the name-reference algebra.
232    ///
233    /// For each field value:
234    /// - If the value is a `Value::Str` matching a key in `rename_map`,
235    ///   it is replaced with the mapped value (or removed if mapped to None).
236    /// - If the value is a `Value::List`, each string element is checked
237    ///   and the list is rebuilt with renames applied and drops removed.
238    ///
239    /// This handles parent reference arrays, cross-annotation links,
240    /// and any other string fields that carry vertex identity.
241    MapReferences {
242        /// The field containing references (e.g., "parents").
243        field: String,
244        /// Map from old name to new name (None = remove the reference).
245        rename_map: HashMap<String, Option<String>>,
246    },
247}
248
249impl FieldTransform {
250    /// Compute the coercion class of this field transform.
251    ///
252    /// The class describes the round-trip properties: whether the transform
253    /// is lossless (`Iso`), has a left inverse (`Retraction`), is a
254    /// deterministic derivation (`Projection`), or has no structural
255    /// round-trip property (`Opaque`).
256    #[must_use]
257    pub fn coercion_class(&self) -> panproto_gat::CoercionClass {
258        match self {
259            Self::RenameField { .. } => panproto_gat::CoercionClass::Iso,
260            Self::DropField { .. } | Self::KeepFields { .. } => panproto_gat::CoercionClass::Opaque,
261            Self::AddField { .. } | Self::MapReferences { .. } => {
262                panproto_gat::CoercionClass::Retraction
263            }
264            Self::ApplyExpr { coercion_class, .. } | Self::ComputeField { coercion_class, .. } => {
265                *coercion_class
266            }
267            Self::PathTransform { inner, .. } => inner.coercion_class(),
268            Self::Case { branches } => branches
269                .iter()
270                .flat_map(|b| b.transforms.iter())
271                .fold(panproto_gat::CoercionClass::Iso, |acc, t| {
272                    acc.compose(t.coercion_class())
273                }),
274        }
275    }
276}
277
278/// A branch in a [`FieldTransform::Case`] analysis.
279///
280/// Contains a predicate expression and a sequence of transforms to apply
281/// if the predicate evaluates to `true`.
282#[derive(Clone, Debug, Serialize, Deserialize)]
283pub struct CaseBranch {
284    /// Predicate evaluated with the node's `extra_fields` as variables.
285    pub predicate: panproto_expr::Expr,
286    /// Transforms to apply if the predicate is true.
287    pub transforms: Vec<FieldTransform>,
288}
289
290/// The scope of field values a [`TermAssignment::Compute`] term sees.
291#[derive(Clone, Debug, Serialize, Deserialize)]
292pub enum TermScope {
293    /// Bind only the target field (single-field substitution). The term's
294    /// free variable is the target field's own name.
295    Field,
296    /// Bind the whole row: every field plus, for tree instances, the
297    /// scalar values of immediate child nodes.
298    Row,
299}
300
301/// A branch of a [`TermAssignment::Case`] analysis.
302#[derive(Clone, Debug, Serialize, Deserialize)]
303pub struct TermBranch {
304    /// Predicate evaluated with the row's values bound as variables.
305    pub predicate: panproto_expr::Expr,
306    /// Assignments applied when the predicate holds.
307    pub assignments: Vec<TermAssignment>,
308}
309
310/// An op-to-term assignment: how one migrated field is produced from a
311/// source row.
312///
313/// A [`Self::Compute`] assignment carries a term (`panproto_expr::Expr`)
314/// whose free variables are source field names; evaluating it substitutes
315/// the row's field values for those variables. This is the substitution
316/// semantics through which the migration functors act on values: `Delta`
317/// ([`crate::functor::functor_restrict`]) and `Sigma`
318/// ([`wtype_extend`], [`crate::functor::functor_extend`]) compute migrated
319/// columns by substituting each surviving row. The remaining variants
320/// describe structural field operations (rename, drop, keep, default,
321/// reference remap, nested-path scoping, and case analysis).
322///
323/// Every [`FieldTransform`] variant translates to a `TermAssignment` via
324/// [`Self::from_field_transform`] and lowers back via
325/// [`Self::to_field_transform`]; applying a translated assignment produces
326/// the same result as applying the original transform. Migrations produced
327/// by `panproto-mig`'s compiler carry their value transforms as term
328/// assignments rather than direct [`FieldTransform`]s.
329#[derive(Clone, Debug, Serialize, Deserialize)]
330pub enum TermAssignment {
331    /// Compute `target` by substituting the row's values into `term`.
332    Compute {
333        /// The migrated field written by this assignment.
334        target: String,
335        /// Which source fields the term sees.
336        scope: TermScope,
337        /// The term computing the field.
338        term: panproto_expr::Expr,
339        /// Optional inverse for round-tripping.
340        inverse: Option<panproto_expr::Expr>,
341        /// Round-trip classification of this assignment.
342        coercion_class: panproto_gat::CoercionClass,
343    },
344    /// Rename field `old` to `new`.
345    Rename {
346        /// The current field name.
347        old: String,
348        /// The new field name.
349        new: String,
350    },
351    /// Drop field `key`.
352    Drop {
353        /// The field to remove.
354        key: String,
355    },
356    /// Add `value` at `key` when the field is absent.
357    Default {
358        /// The field to add.
359        key: String,
360        /// The default value.
361        value: Value,
362    },
363    /// Keep only the listed fields.
364    Keep {
365        /// The fields to retain.
366        keys: Vec<String>,
367    },
368    /// Remap string references in `field` through `rename_map`.
369    MapReferences {
370        /// The field carrying references.
371        field: String,
372        /// Old-name to new-name map (`None` removes the reference).
373        rename_map: HashMap<String, Option<String>>,
374    },
375    /// Apply `inner` at a nested `path` within the row's `Value` tree.
376    AtPath {
377        /// The path of nested object keys.
378        path: Vec<String>,
379        /// The assignment applied at the resolved path.
380        inner: Box<Self>,
381    },
382    /// First matching branch's assignments apply.
383    Case {
384        /// Ordered branches; the first matching predicate wins.
385        branches: Vec<TermBranch>,
386    },
387}
388
389impl TermAssignment {
390    /// Translate a [`FieldTransform`] into the equivalent term assignment.
391    #[must_use]
392    pub fn from_field_transform(ft: &FieldTransform) -> Self {
393        match ft {
394            FieldTransform::RenameField { old_key, new_key } => Self::Rename {
395                old: old_key.clone(),
396                new: new_key.clone(),
397            },
398            FieldTransform::DropField { key } => Self::Drop { key: key.clone() },
399            FieldTransform::AddField { key, value } => Self::Default {
400                key: key.clone(),
401                value: value.clone(),
402            },
403            FieldTransform::KeepFields { keys } => Self::Keep { keys: keys.clone() },
404            FieldTransform::ApplyExpr {
405                key,
406                expr,
407                inverse,
408                coercion_class,
409            } => Self::Compute {
410                target: key.clone(),
411                scope: TermScope::Field,
412                term: expr.clone(),
413                inverse: inverse.clone(),
414                coercion_class: *coercion_class,
415            },
416            FieldTransform::ComputeField {
417                target_key,
418                expr,
419                inverse,
420                coercion_class,
421            } => Self::Compute {
422                target: target_key.clone(),
423                scope: TermScope::Row,
424                term: expr.clone(),
425                inverse: inverse.clone(),
426                coercion_class: *coercion_class,
427            },
428            FieldTransform::PathTransform { path, inner } => Self::AtPath {
429                path: path.clone(),
430                inner: Box::new(Self::from_field_transform(inner)),
431            },
432            FieldTransform::MapReferences { field, rename_map } => Self::MapReferences {
433                field: field.clone(),
434                rename_map: rename_map.clone(),
435            },
436            FieldTransform::Case { branches } => Self::Case {
437                branches: branches
438                    .iter()
439                    .map(|b| TermBranch {
440                        predicate: b.predicate.clone(),
441                        assignments: b
442                            .transforms
443                            .iter()
444                            .map(Self::from_field_transform)
445                            .collect(),
446                    })
447                    .collect(),
448            },
449        }
450    }
451
452    /// Lower this term assignment to the equivalent [`FieldTransform`].
453    #[must_use]
454    pub fn to_field_transform(&self) -> FieldTransform {
455        match self {
456            Self::Rename { old, new } => FieldTransform::RenameField {
457                old_key: old.clone(),
458                new_key: new.clone(),
459            },
460            Self::Drop { key } => FieldTransform::DropField { key: key.clone() },
461            Self::Default { key, value } => FieldTransform::AddField {
462                key: key.clone(),
463                value: value.clone(),
464            },
465            Self::Keep { keys } => FieldTransform::KeepFields { keys: keys.clone() },
466            Self::Compute {
467                target,
468                scope: TermScope::Field,
469                term,
470                inverse,
471                coercion_class,
472            } => FieldTransform::ApplyExpr {
473                key: target.clone(),
474                expr: term.clone(),
475                inverse: inverse.clone(),
476                coercion_class: *coercion_class,
477            },
478            Self::Compute {
479                target,
480                scope: TermScope::Row,
481                term,
482                inverse,
483                coercion_class,
484            } => FieldTransform::ComputeField {
485                target_key: target.clone(),
486                expr: term.clone(),
487                inverse: inverse.clone(),
488                coercion_class: *coercion_class,
489            },
490            Self::MapReferences { field, rename_map } => FieldTransform::MapReferences {
491                field: field.clone(),
492                rename_map: rename_map.clone(),
493            },
494            Self::AtPath { path, inner } => FieldTransform::PathTransform {
495                path: path.clone(),
496                inner: Box::new(inner.to_field_transform()),
497            },
498            Self::Case { branches } => FieldTransform::Case {
499                branches: branches
500                    .iter()
501                    .map(|b| CaseBranch {
502                        predicate: b.predicate.clone(),
503                        transforms: b.assignments.iter().map(Self::to_field_transform).collect(),
504                    })
505                    .collect(),
506            },
507        }
508    }
509
510    /// Round-trip classification of this assignment, matching the
511    /// classification of its lowered [`FieldTransform`].
512    #[must_use]
513    pub fn coercion_class(&self) -> panproto_gat::CoercionClass {
514        self.to_field_transform().coercion_class()
515    }
516}
517
518/// Apply a sequence of op-to-term assignments to a flat relational row,
519/// substituting the row's field values.
520///
521/// The row is wrapped in a scratch node so the shared field-transform
522/// evaluator ([`apply_field_transforms`]) performs the substitution; a
523/// flat row has no child fibers, so the child-scalar environment is empty.
524/// This is the value action of `Delta` and `Sigma` on set-valued
525/// (relational) instances.
526///
527/// # Errors
528///
529/// Returns [`RestrictError::FieldTransformFailed`] if an assignment's
530/// lowered transform fails to evaluate. The row is restored to its
531/// pre-transform contents before the error propagates.
532pub fn apply_term_assignments_to_row(
533    row: &mut HashMap<String, Value>,
534    assignments: &[TermAssignment],
535) -> Result<(), RestrictError> {
536    if assignments.is_empty() {
537        return Ok(());
538    }
539    let transforms: Vec<FieldTransform> = assignments
540        .iter()
541        .map(TermAssignment::to_field_transform)
542        .collect();
543    let mut node = Node::new(0, "");
544    node.extra_fields = std::mem::take(row);
545    let outcome = apply_field_transforms(&mut node, &transforms, &TransformContext::detached());
546    // Restore the row whether or not the transforms succeeded: `mem::take`
547    // emptied it, so an early return would hand back a blank row.
548    *row = node.extra_fields;
549    outcome
550}
551
552impl CompiledMigration {
553    /// Compute the composite coercion class of every value transform in
554    /// this migration.
555    ///
556    /// Folds over all vertices using `CoercionClass::compose`, starting
557    /// from `Iso` (the identity element), so a migration carrying no value
558    /// transforms classifies as `Iso` and one carrying a single `Opaque`
559    /// transform classifies as `Opaque`.
560    ///
561    /// Both carriers are folded: the direct [`FieldTransform`]s and the
562    /// lowered [`TermAssignment`]s. `panproto-mig`'s compiler emits its
563    /// value transforms as term assignments rather than as field
564    /// transforms, so folding only the latter would report `Iso` — the
565    /// identity element of an empty fold — for exactly the migrations that
566    /// carry the most value-level coercion.
567    #[must_use]
568    pub fn coercion_class(&self) -> panproto_gat::CoercionClass {
569        let from_fields = self
570            .field_transforms
571            .values()
572            .flat_map(|ts| ts.iter())
573            .fold(panproto_gat::CoercionClass::Iso, |acc, t| {
574                acc.compose(t.coercion_class())
575            });
576        self.op_term_assignments
577            .values()
578            .flat_map(|ts| ts.iter())
579            .fold(from_fields, |acc, t| acc.compose(t.coercion_class()))
580    }
581
582    /// All value transforms for `anchor`: the legacy [`FieldTransform`]s
583    /// followed by the lowered op-to-term assignments.
584    ///
585    /// Tree-instance consumers apply this unified sequence so that a
586    /// migration whose value transforms are carried as op-to-term
587    /// assignments behaves the same as one carrying direct field
588    /// transforms.
589    #[must_use]
590    pub fn value_transforms(&self, anchor: &Name) -> Vec<FieldTransform> {
591        let mut out: Vec<FieldTransform> = self
592            .field_transforms
593            .get(anchor)
594            .cloned()
595            .unwrap_or_default();
596        if let Some(assignments) = self.op_term_assignments.get(anchor) {
597            out.extend(assignments.iter().map(TermAssignment::to_field_transform));
598        }
599        out
600    }
601
602    /// Add a field rename transform for a vertex.
603    ///
604    /// After the node survives and its anchor is remapped, the field
605    /// `old_key` in `extra_fields` is renamed to `new_key`.
606    pub fn add_field_rename(&mut self, vertex: &str, old_key: &str, new_key: &str) {
607        self.field_transforms
608            .entry(Name::from(vertex))
609            .or_default()
610            .push(FieldTransform::RenameField {
611                old_key: old_key.to_owned(),
612                new_key: new_key.to_owned(),
613            });
614    }
615
616    /// Add a field drop transform for a vertex.
617    ///
618    /// The field `key` is removed from the node's `extra_fields`.
619    pub fn add_field_drop(&mut self, vertex: &str, key: &str) {
620        self.field_transforms
621            .entry(Name::from(vertex))
622            .or_default()
623            .push(FieldTransform::DropField {
624                key: key.to_owned(),
625            });
626    }
627
628    /// Add a field with a default value for a vertex.
629    ///
630    /// The field `key` is added to `extra_fields` with the given value
631    /// if it does not already exist.
632    pub fn add_field_default(&mut self, vertex: &str, key: &str, value: Value) {
633        self.field_transforms
634            .entry(Name::from(vertex))
635            .or_default()
636            .push(FieldTransform::AddField {
637                key: key.to_owned(),
638                value,
639            });
640    }
641
642    /// Add a keep-fields transform for a vertex.
643    ///
644    /// Only the specified fields are retained in `extra_fields`;
645    /// all others are dropped.
646    pub fn add_field_keep(&mut self, vertex: &str, keys: &[&str]) {
647        self.field_transforms
648            .entry(Name::from(vertex))
649            .or_default()
650            .push(FieldTransform::KeepFields {
651                keys: keys.iter().map(|k| (*k).to_owned()).collect(),
652            });
653    }
654
655    /// Add an expression transform for a field on a vertex.
656    ///
657    /// The expression is evaluated with the field's current value
658    /// bound to the variable named `key`, and the result replaces
659    /// the field value.
660    pub fn add_field_expr(&mut self, vertex: &str, key: &str, expr: panproto_expr::Expr) {
661        self.field_transforms
662            .entry(Name::from(vertex))
663            .or_default()
664            .push(FieldTransform::ApplyExpr {
665                key: key.to_owned(),
666                expr,
667                inverse: None,
668                coercion_class: panproto_gat::CoercionClass::Opaque,
669            });
670    }
671
672    /// Add a path-based field transform for a vertex.
673    ///
674    /// The inner transform is applied at the nested path within the
675    /// node's `extra_fields` tree, navigating through `Value::Unknown`
676    /// maps at each path segment.
677    pub fn add_path_transform(&mut self, vertex: &str, path: &[&str], inner: FieldTransform) {
678        self.field_transforms
679            .entry(Name::from(vertex))
680            .or_default()
681            .push(FieldTransform::PathTransform {
682                path: path.iter().map(|s| (*s).to_owned()).collect(),
683                inner: Box::new(inner),
684            });
685    }
686
687    /// Add a computed field transform for a vertex.
688    ///
689    /// The expression is evaluated with all `extra_fields` (and nested
690    /// attrs) bound as variables, and the result is stored in `target_key`.
691    pub fn add_computed_field(
692        &mut self,
693        vertex: &str,
694        target_key: &str,
695        expr: panproto_expr::Expr,
696    ) {
697        self.field_transforms
698            .entry(Name::from(vertex))
699            .or_default()
700            .push(FieldTransform::ComputeField {
701                target_key: target_key.to_owned(),
702                expr,
703                inverse: None,
704                coercion_class: panproto_gat::CoercionClass::Opaque,
705            });
706    }
707
708    /// Add a conditional survival predicate for a vertex.
709    ///
710    /// The expression is evaluated with the node's `extra_fields` bound
711    /// as variables. If it returns false, the node is dropped.
712    pub fn add_conditional_survival(&mut self, vertex: &str, predicate: panproto_expr::Expr) {
713        self.conditional_survival
714            .entry(Name::from(vertex))
715            .or_insert(predicate);
716    }
717
718    /// Add a reference map transform for a vertex's field.
719    ///
720    /// String values (or encoded array elements) in the given field
721    /// are renamed or removed according to the `rename_map`.
722    pub fn add_map_references(
723        &mut self,
724        vertex: &str,
725        field: &str,
726        rename_map: HashMap<String, Option<String>>,
727    ) {
728        self.field_transforms
729            .entry(Name::from(vertex))
730            .or_default()
731            .push(FieldTransform::MapReferences {
732                field: field.to_owned(),
733                rename_map,
734            });
735    }
736
737    /// Add a case-analysis transform for a vertex.
738    ///
739    /// The branches are evaluated in order; the first matching predicate's
740    /// transforms are applied. This is the dependent function space lift
741    /// of field transforms.
742    pub fn add_case_transform(&mut self, vertex: &str, branches: Vec<CaseBranch>) {
743        self.field_transforms
744            .entry(Name::from(vertex))
745            .or_default()
746            .push(FieldTransform::Case { branches });
747    }
748}
749
750/// A W-type instance: tree-shaped data conforming to a schema.
751///
752/// Nodes are anchored to schema vertices, connected by arcs that
753/// correspond to schema edges. The tree is rooted at `root`.
754/// Precomputed `parent_map` and `children_map` enable fast traversal.
755#[derive(Clone, Debug, Serialize, Deserialize)]
756pub struct WInstance {
757    /// All nodes keyed by their numeric ID.
758    pub nodes: HashMap<u32, Node>,
759    /// Arcs: (`parent_id`, `child_id`, `schema_edge`).
760    pub arcs: Vec<(u32, u32, Edge)>,
761    /// Hyper-edge fans.
762    pub fans: Vec<Fan>,
763    /// Root node ID.
764    pub root: u32,
765    /// Schema vertex that the root node is anchored to.
766    pub schema_root: Name,
767    /// Precomputed parent map: `child_id` -> `parent_id`.
768    pub parent_map: HashMap<u32, u32>,
769    /// Precomputed children map: `parent_id` -> child IDs.
770    pub children_map: HashMap<u32, SmallVec<u32, 4>>,
771}
772
773impl WInstance {
774    /// Build a new W-type instance, computing parent and children maps from arcs.
775    #[must_use]
776    pub fn new(
777        nodes: HashMap<u32, Node>,
778        arcs: Vec<(u32, u32, Edge)>,
779        fans: Vec<Fan>,
780        root: u32,
781        schema_root: Name,
782    ) -> Self {
783        let mut parent_map = HashMap::with_capacity(arcs.len());
784        let mut children_map: HashMap<u32, SmallVec<u32, 4>> = HashMap::new();
785        for &(parent, child, _) in &arcs {
786            parent_map.insert(child, parent);
787            children_map.entry(parent).or_default().push(child);
788        }
789        Self {
790            nodes,
791            arcs,
792            fans,
793            root,
794            schema_root,
795            parent_map,
796            children_map,
797        }
798    }
799
800    /// Returns the number of nodes.
801    #[inline]
802    #[must_use]
803    pub fn node_count(&self) -> usize {
804        self.nodes.len()
805    }
806
807    /// Returns the number of arcs.
808    #[inline]
809    #[must_use]
810    pub fn arc_count(&self) -> usize {
811        self.arcs.len()
812    }
813
814    /// Get a node by ID.
815    #[inline]
816    #[must_use]
817    pub fn node(&self, id: u32) -> Option<&Node> {
818        self.nodes.get(&id)
819    }
820
821    /// Get the children of a node.
822    #[inline]
823    #[must_use]
824    pub fn children(&self, id: u32) -> &[u32] {
825        self.children_map.get(&id).map_or(&[], SmallVec::as_slice)
826    }
827
828    /// Get the parent of a node.
829    #[inline]
830    #[must_use]
831    pub fn parent(&self, id: u32) -> Option<u32> {
832        self.parent_map.get(&id).copied()
833    }
834}
835
836// ---------------------------------------------------------------------------
837// Step 1: Signature restriction (retained for testing)
838// ---------------------------------------------------------------------------
839
840/// Keep nodes whose anchor vertex is in the surviving vertex set.
841#[must_use]
842pub fn anchor_surviving(instance: &WInstance, surviving_verts: &HashSet<Name>) -> HashSet<u32> {
843    instance
844        .nodes
845        .iter()
846        .filter(|(_, node)| surviving_verts.contains(&node.anchor))
847        .map(|(&id, _)| id)
848        .collect()
849}
850
851// ---------------------------------------------------------------------------
852// Step 2: Reachability BFS (retained for testing)
853// ---------------------------------------------------------------------------
854
855// ---------------------------------------------------------------------------
856// Step 3: Ancestor contraction with path compression (retained for testing)
857// ---------------------------------------------------------------------------
858
859/// For each surviving non-root node, find its nearest surviving ancestor.
860///
861/// Uses path compression: when we walk the parent chain for a node,
862/// we cache the result for every intermediate node visited. Subsequent
863/// queries hitting a cached node return in O(1). This gives O(n)
864/// amortized complexity instead of O(n × depth).
865#[must_use]
866pub fn ancestor_contraction(instance: &WInstance, surviving: &HashSet<u32>) -> HashMap<u32, u32> {
867    let mut cache: FxHashMap<u32, u32> = FxHashMap::default();
868    let mut ancestors = HashMap::new();
869
870    for &node_id in surviving {
871        if node_id == instance.root {
872            continue;
873        }
874
875        // Check cache first
876        if let Some(&cached) = cache.get(&node_id) {
877            ancestors.insert(node_id, cached);
878            continue;
879        }
880
881        // Walk the parent chain, recording the path for compression
882        let mut path = Vec::new();
883        let mut current = node_id;
884        let mut found_ancestor = None;
885
886        while let Some(parent) = instance.parent(current) {
887            if let Some(&cached) = cache.get(&parent) {
888                found_ancestor = Some(cached);
889                break;
890            }
891            if surviving.contains(&parent) {
892                found_ancestor = Some(parent);
893                break;
894            }
895            path.push(parent);
896            current = parent;
897        }
898
899        // Path compression: cache the ancestor for all nodes on the path
900        if let Some(ancestor) = found_ancestor {
901            ancestors.insert(node_id, ancestor);
902            cache.insert(node_id, ancestor);
903            for &intermediate in &path {
904                cache.insert(intermediate, ancestor);
905            }
906        }
907    }
908    ancestors
909}
910
911// ---------------------------------------------------------------------------
912// Step 4: Edge resolution (retained for testing)
913// ---------------------------------------------------------------------------
914
915/// Resolve the edge for a contracted arc in the target schema.
916///
917/// Avoids allocating a `(String, String)` tuple for the resolver lookup
918/// by checking the resolver with borrowed references.
919///
920/// # Errors
921///
922/// Returns `RestrictError::NoEdgeFound` if no edge exists, or
923/// `RestrictError::AmbiguousEdge` if multiple edges exist without
924/// a resolver entry.
925pub fn resolve_edge(
926    tgt_schema: &Schema,
927    resolver: &HashMap<(Name, Name), Edge>,
928    src_v: &str,
929    tgt_v: &str,
930) -> Result<Edge, RestrictError> {
931    // Check resolver: avoid allocation by scanning for matching key
932    for ((k_src, k_tgt), edge) in resolver {
933        if k_src == src_v && k_tgt == tgt_v {
934            return Ok(edge.clone());
935        }
936    }
937
938    // Fall back to unique-edge lookup
939    let candidates = tgt_schema.edges_between(src_v, tgt_v);
940    match candidates.len() {
941        0 => Err(RestrictError::NoEdgeFound {
942            src: src_v.to_string(),
943            tgt: tgt_v.to_string(),
944        }),
945        1 => Ok(candidates[0].clone()),
946        n => Err(RestrictError::AmbiguousEdge {
947            src: src_v.to_string(),
948            tgt: tgt_v.to_string(),
949            count: n,
950        }),
951    }
952}
953
954// ---------------------------------------------------------------------------
955// Step 5: Fan reconstruction (retained for testing)
956// ---------------------------------------------------------------------------
957
958/// Reconstruct fans after restriction.
959///
960/// # Errors
961///
962/// Returns `RestrictError::FanReconstructionFailed` if a fan cannot
963/// be validly reconstructed.
964pub fn reconstruct_fans(
965    instance: &WInstance,
966    surviving: &FxHashSet<u32>,
967    _ancestors: &FxHashMap<u32, u32>,
968    migration: &CompiledMigration,
969    _tgt_schema: &Schema,
970) -> Result<Vec<Fan>, RestrictError> {
971    let mut result = Vec::new();
972
973    for fan in &instance.fans {
974        if !surviving.contains(&fan.parent) {
975            continue;
976        }
977
978        let surviving_children: HashMap<String, u32> = fan
979            .children
980            .iter()
981            .filter(|(_, node_id)| surviving.contains(node_id))
982            .map(|(label, node_id)| (label.clone(), *node_id))
983            .collect();
984
985        if surviving_children.is_empty() {
986            continue;
987        }
988
989        if let Some((new_he_id, label_map)) =
990            migration.hyper_resolver.get(fan.hyper_edge_id.as_str())
991        {
992            let mut new_children = HashMap::new();
993            for (old_label, &node_id) in &surviving_children {
994                let new_label = label_map
995                    .get(old_label.as_str())
996                    .map_or_else(|| old_label.clone(), std::string::ToString::to_string);
997                new_children.insert(new_label, node_id);
998            }
999            result.push(Fan {
1000                hyper_edge_id: new_he_id.to_string(),
1001                parent: fan.parent,
1002                children: new_children,
1003            });
1004        } else {
1005            result.push(Fan {
1006                hyper_edge_id: fan.hyper_edge_id.clone(),
1007                parent: fan.parent,
1008                children: surviving_children,
1009            });
1010        }
1011    }
1012
1013    Ok(result)
1014}
1015
1016// ---------------------------------------------------------------------------
1017// Main restrict function: fused single-pass pipeline
1018// ---------------------------------------------------------------------------
1019
1020/// The restrict operation for W-type instances.
1021///
1022/// Executes a fused single-pass pipeline that combines anchor checking,
1023/// BFS reachability, ancestor contraction, and edge resolution into one
1024/// traversal. Fan reconstruction runs as a separate pass.
1025///
1026/// The fused approach visits each node at most once (O(n)) versus
1027/// the sequential 5-step approach which makes 3-4 passes.
1028///
1029/// # Errors
1030///
1031/// Returns `RestrictError` if edge resolution fails or the root
1032/// is pruned during restriction.
1033pub fn wtype_restrict(
1034    instance: &WInstance,
1035    src_schema: &Schema,
1036    tgt_schema: &Schema,
1037    migration: &CompiledMigration,
1038) -> Result<WInstance, RestrictError> {
1039    // Check root survives
1040    let root_node = instance
1041        .nodes
1042        .get(&instance.root)
1043        .ok_or(RestrictError::RootPruned)?;
1044    let root_target_anchor = migration
1045        .vertex_remap
1046        .get(&root_node.anchor)
1047        .unwrap_or(&root_node.anchor);
1048    if !migration.surviving_verts.contains(root_target_anchor) {
1049        return Err(RestrictError::RootPruned);
1050    }
1051
1052    let conditional_fail = precompute_conditional_fail(instance, migration);
1053
1054    // Fused BFS: traverse the tree from root, tracking the nearest
1055    // surviving ancestor for each node as we go.
1056    //
1057    // For each node in the BFS:
1058    //   - If its anchor survives (and not in conditional_fail): it
1059    //     becomes part of the result. Its nearest surviving ancestor
1060    //     is used to build an arc. It becomes the "current surviving
1061    //     ancestor" for its subtree.
1062    //   - If its anchor does not survive: skip it, but continue BFS
1063    //     into its children (they might survive). Pass along the
1064    //     current surviving ancestor unchanged.
1065
1066    let mut new_nodes: HashMap<u32, Node> = HashMap::new();
1067    let mut new_arcs: Vec<(u32, u32, Edge)> = Vec::new();
1068    let mut surviving_set: FxHashSet<u32> = FxHashSet::default();
1069
1070    // Counter for synthesized intermediate node ids used by nest-style
1071    // `expansion_path` handling. Starts above any id present in the
1072    // source so we never collide with instance-owned node ids.
1073    let mut next_synth_id: u32 = instance
1074        .nodes
1075        .keys()
1076        .copied()
1077        .max()
1078        .map_or(0, |m| m.saturating_add(1));
1079
1080    // Queue entries: (node_id, nearest_surviving_ancestor_id)
1081    let mut queue: VecDeque<(u32, Option<u32>)> = VecDeque::new();
1082
1083    // Process root: remap, check conditional survival, apply field transforms.
1084    let root_node_cloned = prepare_root_node(root_node, migration, instance, src_schema)?;
1085    new_nodes.insert(instance.root, root_node_cloned);
1086    surviving_set.insert(instance.root);
1087    queue.push_back((instance.root, None));
1088
1089    while let Some((current_id, ancestor_id)) = queue.pop_front() {
1090        let current_survives = surviving_set.contains(&current_id);
1091        // The ancestor for children: if current survives, it's the new ancestor;
1092        // otherwise, pass along the existing ancestor.
1093        let child_ancestor = if current_survives {
1094            Some(current_id)
1095        } else {
1096            ancestor_id
1097        };
1098
1099        for &child_id in instance.children(current_id) {
1100            let Some(child_node) = instance.nodes.get(&child_id) else {
1101                continue;
1102            };
1103
1104            // Check if this vertex survives: look up the remapped target name,
1105            // falling back to the source name for unmapped vertices.
1106            let target_anchor = migration
1107                .vertex_remap
1108                .get(&child_node.anchor)
1109                .unwrap_or(&child_node.anchor);
1110            if migration.surviving_verts.contains(target_anchor)
1111                && !conditional_fail.contains(&child_id)
1112            {
1113                // This child survives; add it to results
1114                surviving_set.insert(child_id);
1115                let mut new_node = child_node.clone();
1116                if let Some(remapped) = migration.vertex_remap.get(&child_node.anchor) {
1117                    new_node.anchor.clone_from(remapped);
1118                }
1119                // Apply value-level field transforms if any exist for this vertex.
1120                // Collect scalar child values from the original instance so that
1121                // ComputeField / Case / ApplyExpr can access the full fiber.
1122                let transforms = migration.value_transforms(&child_node.anchor);
1123                if !transforms.is_empty() {
1124                    let ctx =
1125                        TransformContext::new(Some(src_schema), instance, child_id, &transforms);
1126                    apply_field_transforms(&mut new_node, &transforms, &ctx)?;
1127                }
1128                new_nodes.insert(child_id, new_node.clone());
1129
1130                // Build the arc from nearest surviving ancestor to this node.
1131                //
1132                // Fast path: a direct edge exists in the target between the
1133                // ancestor and this node.
1134                //
1135                // Expansion path: `resolve_edge` fails because a nest-style
1136                // migration removed the direct arc and replaced it with a
1137                // multi-hop path through newly introduced intermediates.
1138                // The compiled migration's `expansion_path` records the
1139                // intermediate anchor ids; we synthesize fresh view nodes
1140                // for each of them and stitch the chain.
1141                if let Some(anc_id) = child_ancestor {
1142                    connect_ancestor_to_child(
1143                        anc_id,
1144                        child_id,
1145                        &new_node.anchor,
1146                        &mut new_nodes,
1147                        &mut new_arcs,
1148                        &mut surviving_set,
1149                        &mut next_synth_id,
1150                        migration,
1151                        tgt_schema,
1152                    )?;
1153                }
1154            }
1155
1156            // Always continue BFS into children (non-surviving intermediate
1157            // nodes may have surviving descendants)
1158            queue.push_back((child_id, child_ancestor));
1159        }
1160    }
1161
1162    // Step 5: Fan reconstruction (separate pass: operates on original fans)
1163    let fused_surviving = &surviving_set;
1164    let empty_ancestors = FxHashMap::default();
1165    let new_fans = reconstruct_fans(
1166        instance,
1167        fused_surviving,
1168        &empty_ancestors,
1169        migration,
1170        tgt_schema,
1171    )?;
1172
1173    let new_schema_root = migration
1174        .vertex_remap
1175        .get(&instance.schema_root)
1176        .cloned()
1177        .unwrap_or_else(|| instance.schema_root.clone());
1178
1179    Ok(WInstance::new(
1180        new_nodes,
1181        new_arcs,
1182        new_fans,
1183        instance.root,
1184        new_schema_root,
1185    ))
1186}
1187
1188/// Precompute the set of node ids whose conditional-survival predicate
1189/// evaluates to `false` against their original extra fields.
1190///
1191/// This ensures the BFS result is order-independent (functorial): the
1192/// predicate is evaluated against original values, not values that may
1193/// have been modified by ancestor contraction during restrict.
1194fn precompute_conditional_fail(
1195    instance: &WInstance,
1196    migration: &CompiledMigration,
1197) -> FxHashSet<u32> {
1198    if migration.conditional_survival.is_empty() {
1199        return FxHashSet::default();
1200    }
1201    instance
1202        .nodes
1203        .iter()
1204        .filter_map(|(&id, node)| {
1205            let pred = migration.conditional_survival.get(&node.anchor)?;
1206            let env = build_env_from_extra_fields(&node.extra_fields);
1207            let config = panproto_expr::EvalConfig::default();
1208            matches!(
1209                panproto_expr::eval(pred, &env, &config),
1210                Ok(panproto_expr::Literal::Bool(false))
1211            )
1212            .then_some(id)
1213        })
1214        .collect()
1215}
1216
1217/// Emit arcs connecting a surviving ancestor to a surviving child during
1218/// restrict, handling both the direct-edge fast path and the nest-style
1219/// expansion path that introduces synthesized intermediate nodes.
1220#[allow(clippy::too_many_arguments)]
1221fn connect_ancestor_to_child(
1222    anc_id: u32,
1223    child_id: u32,
1224    child_anchor: &Name,
1225    new_nodes: &mut HashMap<u32, Node>,
1226    new_arcs: &mut Vec<(u32, u32, Edge)>,
1227    surviving_set: &mut FxHashSet<u32>,
1228    next_synth_id: &mut u32,
1229    migration: &CompiledMigration,
1230    tgt_schema: &Schema,
1231) -> Result<(), RestrictError> {
1232    let anc_anchor = new_nodes
1233        .get(&anc_id)
1234        .ok_or(RestrictError::RootPruned)?
1235        .anchor
1236        .clone();
1237    let child_anchor = child_anchor.clone();
1238    match resolve_edge(tgt_schema, &migration.resolver, &anc_anchor, &child_anchor) {
1239        Ok(edge) => {
1240            new_arcs.push((anc_id, child_id, edge));
1241            Ok(())
1242        }
1243        Err(restrict_err) => {
1244            let Some(intermediates) = migration
1245                .expansion_path
1246                .get(&(anc_anchor.clone(), child_anchor.clone()))
1247            else {
1248                return Err(restrict_err);
1249            };
1250            // Emit the expansion chain:
1251            //   anc_id --> synth_1 --> synth_2 --> ... --> child_id
1252            let mut prev_id = anc_id;
1253            let mut prev_anchor = anc_anchor;
1254            for intermediate_anchor in intermediates {
1255                let synth_id = *next_synth_id;
1256                *next_synth_id = next_synth_id.saturating_add(1);
1257                let synth_node = Node::new(synth_id, intermediate_anchor.clone());
1258                new_nodes.insert(synth_id, synth_node);
1259                surviving_set.insert(synth_id);
1260                let edge = resolve_edge(
1261                    tgt_schema,
1262                    &migration.resolver,
1263                    &prev_anchor,
1264                    intermediate_anchor,
1265                )?;
1266                new_arcs.push((prev_id, synth_id, edge));
1267                prev_id = synth_id;
1268                prev_anchor = intermediate_anchor.clone();
1269            }
1270            let final_edge =
1271                resolve_edge(tgt_schema, &migration.resolver, &prev_anchor, &child_anchor)?;
1272            new_arcs.push((prev_id, child_id, final_edge));
1273            Ok(())
1274        }
1275    }
1276}
1277
1278// ---------------------------------------------------------------------------
1279// Value-level field transforms
1280// ---------------------------------------------------------------------------
1281
1282/// Apply a sequence of field transforms to a node's `extra_fields`.
1283///
1284/// Called during `wtype_restrict` after a node survives and its anchor
1285/// is remapped. Operations are applied in order.
1286///
1287/// The `child_scalars` parameter provides the dependent-sum projection:
1288/// scalar values from the node's immediate child vertices, keyed by edge
1289/// name. This extends the expression environment beyond `extra_fields`
1290/// to include the full fiber data over the parent vertex in the
1291/// Grothendieck fibration. Binding precedence: `extra_fields` override
1292/// `child_scalars` on key collision, which is correct because
1293/// `extra_fields` may contain values already transformed by prior steps
1294/// in the transform sequence.
1295///
1296/// Computed fields (via `ComputeField`) are derived data in the sense of
1297/// dependent projections: they are functionally determined by the source
1298/// fiber data. The `CoercionClass` on each `ComputeField` classifies the
1299/// round-trip behavior:
1300/// - `Iso`: the computation is invertible; `PutGet` holds for
1301///   modifications to the computed field (via the `inverse` expression).
1302/// - `Opaque`: no inverse exists; the complement stores the entire
1303///   original value. Modifications to the computed field in the view
1304///   are not independently round-trippable. This is analogous to SQL
1305///   computed columns or database views with derived columns. `PutGet`
1306///   holds for the independent (non-derived) components of the view,
1307///   and derived components are re-computed deterministically.
1308///
1309/// # Errors
1310///
1311/// Returns [`RestrictError::FieldTransformFailed`] if a transform's
1312/// expression fails to evaluate. A failed transform is reported rather
1313/// than skipped: leaving the field untouched makes a broken lens
1314/// indistinguishable from one that ran and changed nothing.
1315pub fn apply_field_transforms(
1316    node: &mut Node,
1317    transforms: &[FieldTransform],
1318    ctx: &TransformContext<'_>,
1319) -> Result<(), RestrictError> {
1320    let child_scalars = &ctx.child_values;
1321    for transform in transforms {
1322        match transform {
1323            FieldTransform::RenameField { old_key, new_key } => {
1324                if let Some(val) = node.extra_fields.remove(old_key) {
1325                    node.extra_fields.insert(new_key.clone(), val);
1326                }
1327            }
1328            FieldTransform::DropField { key } => {
1329                node.extra_fields.remove(key);
1330            }
1331            FieldTransform::AddField { key, value } => {
1332                node.extra_fields
1333                    .entry(key.clone())
1334                    .or_insert_with(|| value.clone());
1335            }
1336            FieldTransform::KeepFields { keys } => {
1337                node.extra_fields.retain(|k, _| keys.contains(k));
1338            }
1339            FieldTransform::ApplyExpr { key, expr, .. } => {
1340                apply_expr_transform(node, key, expr, child_scalars, ctx)?;
1341            }
1342            FieldTransform::ComputeField {
1343                target_key, expr, ..
1344            } => {
1345                let env = build_env_with_children(&node.extra_fields, child_scalars);
1346                let config = panproto_expr::EvalConfig::default();
1347                let result = ctx.eval(expr, &env, &config).map_err(|source| {
1348                    RestrictError::FieldTransformFailed {
1349                        key: target_key.clone(),
1350                        source,
1351                    }
1352                })?;
1353                node.extra_fields
1354                    .insert(target_key.clone(), expr_literal_to_value(&result));
1355            }
1356            FieldTransform::PathTransform { path, inner } => {
1357                if path.is_empty() {
1358                    // Empty path = apply directly. PathTransform operates on nested
1359                    // extra_fields, not the instance tree, so child_scalars is empty.
1360                    apply_field_transforms(
1361                        node,
1362                        std::slice::from_ref(inner),
1363                        &ctx.with_child_values(HashMap::new()),
1364                    )?;
1365                } else {
1366                    apply_path_transform(node, path, inner, ctx)?;
1367                }
1368            }
1369            FieldTransform::MapReferences { field, rename_map } => {
1370                apply_map_references(node, field, rename_map);
1371            }
1372            FieldTransform::Case { branches } => {
1373                // Case predicates evaluate against the full fiber (extra_fields +
1374                // child scalars) so that branching can depend on schema-defined
1375                // scalar child values.
1376                let env = build_env_with_children(&node.extra_fields, child_scalars);
1377                let config = panproto_expr::EvalConfig::default();
1378                for (index, branch) in branches.iter().enumerate() {
1379                    let result = ctx
1380                        .eval(&branch.predicate, &env, &config)
1381                        .map_err(|source| RestrictError::FieldTransformFailed {
1382                            key: format!("<case branch {index}>"),
1383                            source,
1384                        })?;
1385                    if matches!(result, panproto_expr::Literal::Bool(true)) {
1386                        apply_field_transforms(node, &branch.transforms, ctx)?;
1387                        break;
1388                    }
1389                }
1390            }
1391        }
1392    }
1393    Ok(())
1394}
1395
1396/// Apply an `ApplyExpr` transform: evaluate `expr` over the value bound at
1397/// `key` and store the result.
1398///
1399/// The key `"__value__"` targets the node's own leaf value rather than an
1400/// `extra_fields` entry, which is how a coercion (a kind change) is
1401/// applied; the expression sees it under both `v` and `__value__`.
1402///
1403/// Any other key is looked up in `extra_fields` first, so that a value
1404/// rewritten by an earlier transform in the same sequence is the one read,
1405/// and in the child scalars second. The result is written to
1406/// `extra_fields` whichever side it came from: `to_json` serializes
1407/// `extra_fields` after children, so the transform's output stays
1408/// authoritative over the original child vertex value.
1409fn apply_expr_transform(
1410    node: &mut Node,
1411    key: &str,
1412    expr: &panproto_expr::Expr,
1413    child_scalars: &HashMap<String, Value>,
1414    ctx: &TransformContext<'_>,
1415) -> Result<(), RestrictError> {
1416    let config = panproto_expr::EvalConfig::default();
1417    let failed = |source| RestrictError::FieldTransformFailed {
1418        key: key.to_string(),
1419        source,
1420    };
1421
1422    if key == "__value__" {
1423        if let Some(crate::value::FieldPresence::Present(val)) = &node.value {
1424            let input = value_to_expr_literal(val);
1425            let env = panproto_expr::Env::new()
1426                .extend(std::sync::Arc::from("v"), input.clone())
1427                .extend(std::sync::Arc::from("__value__"), input);
1428            let result = ctx.eval(expr, &env, &config).map_err(failed)?;
1429            node.value = Some(crate::value::FieldPresence::Present(expr_literal_to_value(
1430                &result,
1431            )));
1432        }
1433        return Ok(());
1434    }
1435
1436    if let Some(val) = node
1437        .extra_fields
1438        .get(key)
1439        .or_else(|| child_scalars.get(key))
1440    {
1441        let input = value_to_expr_literal(val);
1442        let env = panproto_expr::Env::new().extend(std::sync::Arc::from(key), input);
1443        let result = ctx.eval(expr, &env, &config).map_err(failed)?;
1444        node.extra_fields
1445            .insert(key.to_string(), expr_literal_to_value(&result));
1446    }
1447    Ok(())
1448}
1449
1450/// Navigate into nested `Value::Unknown` maps along `path` and apply the
1451/// inner transform at the resolved location.
1452fn apply_path_transform(
1453    node: &mut Node,
1454    path: &[String],
1455    inner: &FieldTransform,
1456    ctx: &TransformContext<'_>,
1457) -> Result<(), RestrictError> {
1458    let first = &path[0];
1459    let Some(Value::Unknown(map)) = node.extra_fields.get_mut(first) else {
1460        return Ok(());
1461    };
1462
1463    // Move the nested map into a temporary node so the transform can run
1464    // against it as an ordinary `extra_fields` map.
1465    let mut temp_node = Node::new(0, "");
1466    temp_node.extra_fields = std::mem::take(map);
1467
1468    let outcome = if path.len() == 1 {
1469        // At the target; apply inner transform to this map.
1470        // PathTransform operates on nested extra_fields, not the
1471        // instance tree, so child_scalars is empty.
1472        apply_field_transforms(
1473            &mut temp_node,
1474            std::slice::from_ref(inner),
1475            &ctx.with_child_values(HashMap::new()),
1476        )
1477    } else {
1478        apply_path_transform(&mut temp_node, &path[1..], inner, ctx)
1479    };
1480
1481    // Restore the map before propagating: the node was left holding an
1482    // empty map by `mem::take`, so an early return on failure would
1483    // otherwise erase the nested fields it was asked to transform.
1484    if let Some(Value::Unknown(slot)) = node.extra_fields.get_mut(first) {
1485        *slot = temp_node.extra_fields;
1486    }
1487    outcome
1488}
1489
1490/// Apply a `MapReferences` transform to a node's field, handling both
1491/// a scalar `Value::Str` reference and a `Value::List` of references.
1492///
1493/// This is the action of the rename map on string-typed leaves that
1494/// denote vertex names. The transform is functorial: it commutes with
1495/// the list constructor (renaming a list of references is the same as
1496/// mapping the rename over the list).
1497fn apply_map_references(
1498    node: &mut Node,
1499    field: &str,
1500    rename_map: &HashMap<String, Option<String>>,
1501) {
1502    if let Some(val) = node.extra_fields.get_mut(field) {
1503        match val {
1504            Value::Str(s) => {
1505                if let Some(replacement) = rename_map.get(s.as_str()) {
1506                    match replacement {
1507                        Some(new_name) => *s = new_name.clone(),
1508                        None => {
1509                            node.extra_fields.remove(field);
1510                        }
1511                    }
1512                }
1513            }
1514            Value::List(items) => {
1515                // Rebuild the list with renames applied and entries
1516                // mapped to None dropped. Non-string items pass through
1517                // unchanged: `MapReferences` is specifically the action
1518                // of the rename map on string leaves.
1519                let mut new_items = Vec::with_capacity(items.len());
1520                for item in items.iter() {
1521                    match item {
1522                        Value::Str(s) => match rename_map.get(s.as_str()) {
1523                            Some(Some(new_name)) => {
1524                                new_items.push(Value::Str(new_name.clone()));
1525                            }
1526                            Some(None) => {} // drop
1527                            None => new_items.push(Value::Str(s.clone())),
1528                        },
1529                        other => new_items.push(other.clone()),
1530                    }
1531                }
1532                *items = new_items;
1533            }
1534            _ => {}
1535        }
1536    }
1537}
1538
1539/// What a field transform's expressions may read besides the node itself.
1540///
1541/// Two things live here. `child_values` binds the node's children by edge
1542/// name: scalars always, and structural children when the caller asked
1543/// for them via [`collect_child_values`]. `instance` carries the instance
1544/// and the node's id, which is what makes the graph-traversal builtins
1545/// resolvable: `children("self")`, `edge("self", k)`, `edge_count("self")`
1546/// and `anchor("self")` all need a current node to walk from, and without
1547/// one they evaluate to null.
1548///
1549/// A caller that holds a node but no instance (an edit lens rewriting a
1550/// node in isolation, say) builds this with [`TransformContext::detached`]
1551/// and gets the `extra_fields`-only behaviour.
1552#[derive(Debug, Clone)]
1553pub struct TransformContext<'a> {
1554    /// The node's immediate children, keyed by edge name.
1555    pub child_values: HashMap<String, Value>,
1556    /// The instance and the id of the node being transformed.
1557    pub instance: Option<(&'a WInstance, u32)>,
1558}
1559
1560impl Default for TransformContext<'_> {
1561    fn default() -> Self {
1562        Self::detached()
1563    }
1564}
1565
1566impl<'a> TransformContext<'a> {
1567    /// A context with neither children nor an instance: expressions see
1568    /// only the node's own `extra_fields`.
1569    #[must_use]
1570    pub fn detached() -> Self {
1571        Self {
1572            child_values: HashMap::new(),
1573            instance: None,
1574        }
1575    }
1576
1577    /// A context over `instance` at `node_id`, binding every child the
1578    /// expressions in `transforms` might read.
1579    #[must_use]
1580    pub fn new(
1581        schema: Option<&Schema>,
1582        instance: &'a WInstance,
1583        node_id: u32,
1584        transforms: &[FieldTransform],
1585    ) -> Self {
1586        let wanted = referenced_names(transforms);
1587        // A reference to the whole fiber cannot say which children it
1588        // will reach, so it takes all of them.
1589        let demand = if wanted.contains("self") {
1590            None
1591        } else {
1592            Some(&wanted)
1593        };
1594        Self {
1595            child_values: collect_child_values(schema, instance, node_id, demand),
1596            instance: Some((instance, node_id)),
1597        }
1598    }
1599
1600    /// A context carrying pre-collected child values and no instance.
1601    #[must_use]
1602    pub const fn from_child_values(child_values: HashMap<String, Value>) -> Self {
1603        Self {
1604            child_values,
1605            instance: None,
1606        }
1607    }
1608
1609    /// The same context with the child bindings replaced.
1610    #[must_use]
1611    pub const fn with_child_values(&self, child_values: HashMap<String, Value>) -> Self {
1612        Self {
1613            child_values,
1614            instance: self.instance,
1615        }
1616    }
1617
1618    /// Evaluate `expr`, resolving graph-traversal builtins against the
1619    /// instance when one is present.
1620    ///
1621    /// # Errors
1622    ///
1623    /// Returns whatever the evaluator reports.
1624    pub fn eval(
1625        &self,
1626        expr: &panproto_expr::Expr,
1627        env: &panproto_expr::Env,
1628        config: &panproto_expr::EvalConfig,
1629    ) -> Result<panproto_expr::Literal, panproto_expr::ExprError> {
1630        match self.instance {
1631            Some((instance, node_id)) => {
1632                crate::instance_env::eval_with_instance(expr, env, config, instance, Some(node_id))
1633            }
1634            None => panproto_expr::eval(expr, env, config),
1635        }
1636    }
1637}
1638
1639/// Every top-level name the expressions in `transforms` read.
1640///
1641/// Used to bound how much of the instance a transform context
1642/// materializes: a structural child is only worth walking when something
1643/// is about to name it.
1644#[must_use]
1645pub fn referenced_names(transforms: &[FieldTransform]) -> std::collections::HashSet<String> {
1646    let mut names = std::collections::HashSet::new();
1647    collect_referenced_names(transforms, &mut names);
1648    names
1649}
1650
1651fn collect_referenced_names(
1652    transforms: &[FieldTransform],
1653    names: &mut std::collections::HashSet<String>,
1654) {
1655    let add = |expr: &panproto_expr::Expr, names: &mut std::collections::HashSet<String>| {
1656        for var in panproto_expr::free_vars(expr) {
1657            names.insert(var.to_string());
1658        }
1659    };
1660    for transform in transforms {
1661        match transform {
1662            FieldTransform::ComputeField { expr, inverse, .. }
1663            | FieldTransform::ApplyExpr { expr, inverse, .. } => {
1664                add(expr, names);
1665                if let Some(inv) = inverse {
1666                    add(inv, names);
1667                }
1668            }
1669            FieldTransform::Case { branches } => {
1670                for branch in branches {
1671                    add(&branch.predicate, names);
1672                    collect_referenced_names(&branch.transforms, names);
1673                }
1674            }
1675            FieldTransform::PathTransform { inner, .. } => {
1676                collect_referenced_names(std::slice::from_ref(inner), names);
1677            }
1678            _ => {}
1679        }
1680    }
1681}
1682
1683/// Collect scalar values from a node's immediate children, keyed by edge name.
1684///
1685/// This is the dependent-sum projection from the total fiber over vertex
1686/// `v` in the Grothendieck fibration. In the W-type model, a node at `v`
1687/// with children via edges `e_i: v -> w_i` has total fiber
1688///
1689/// ```text
1690/// Fiber(v) = ExtraFields(v) x Product_{i} Fiber(w_i)
1691/// ```
1692///
1693/// This function projects the leaf (scalar) components of the product
1694/// into a flat map, making them available to fiber endomorphisms (field
1695/// transforms). Only children with a present leaf value are included;
1696/// structural children (objects, arrays) are omitted.
1697///
1698/// For the structural components too, an array-of-objects child bound as
1699/// a [`Value::List`] of [`Value::Unknown`] being what a parent-level
1700/// aggregate needs, use [`collect_child_values`].
1701#[must_use]
1702pub fn collect_scalar_child_values(instance: &WInstance, node_id: u32) -> HashMap<String, Value> {
1703    let mut result = HashMap::new();
1704    for &(parent, child, ref edge) in &instance.arcs {
1705        if parent != node_id {
1706            continue;
1707        }
1708        let Some(child_node) = instance.nodes.get(&child) else {
1709            continue;
1710        };
1711        if let Some(crate::value::FieldPresence::Present(val)) = &child_node.value {
1712            let field_name = edge.name.as_deref().unwrap_or(&*edge.tgt);
1713            result.insert(field_name.to_string(), val.clone());
1714        }
1715    }
1716    result
1717}
1718
1719/// Collect a node's immediate children, keyed by edge name, including the
1720/// structural ones.
1721///
1722/// [`collect_scalar_child_values`] projects only the leaf components of
1723/// the fiber, so a child that is an object or an array of objects binds to
1724/// nothing and a parent-level aggregate over it (the minimum and maximum
1725/// of a field across an array of child records, say) cannot be written at
1726/// all. This function materializes those children as well: an ordered
1727/// collection becomes a [`Value::List`] and a record becomes a
1728/// [`Value::Unknown`], recursively. [`value_to_expr_literal`] carries both
1729/// through structurally, so `map` and `fold` and field projection reach
1730/// them directly.
1731///
1732/// Materializing a subtree is not free, so `wanted` bounds the work: a
1733/// structural child is materialized only when its name appears in that
1734/// set, which callers derive from the free variables of the expressions
1735/// about to run. `None` materializes every child, which is what a
1736/// reference to the whole fiber (`self`) needs. Scalar children are always
1737/// included, as before, so an empty `wanted` gives exactly
1738/// [`collect_scalar_child_values`].
1739#[must_use]
1740pub fn collect_child_values(
1741    schema: Option<&Schema>,
1742    instance: &WInstance,
1743    node_id: u32,
1744    wanted: Option<&std::collections::HashSet<String>>,
1745) -> HashMap<String, Value> {
1746    let mut result = HashMap::new();
1747    for &(parent, child, ref edge) in &instance.arcs {
1748        if parent != node_id {
1749            continue;
1750        }
1751        let Some(child_node) = instance.nodes.get(&child) else {
1752            continue;
1753        };
1754        let field_name = edge.name.as_deref().unwrap_or(&*edge.tgt);
1755        if let Some(crate::value::FieldPresence::Present(val)) = &child_node.value {
1756            result.insert(field_name.to_string(), val.clone());
1757        } else if wanted.is_none_or(|w| w.contains(field_name)) {
1758            result.insert(
1759                field_name.to_string(),
1760                node_to_value(schema, instance, child),
1761            );
1762        }
1763    }
1764    result
1765}
1766
1767/// Materialize a node's subtree as a [`Value`].
1768///
1769/// Mirrors the shape decisions `to_json` makes, so what an expression sees
1770/// under a structural child is what serialization would emit for it: a
1771/// present leaf is its value, an ordered collection is a [`Value::List`]
1772/// of its children in arc order, and anything else is a
1773/// [`Value::Unknown`] of its children and `extra_fields`, with
1774/// `extra_fields` taking precedence on a key collision exactly as in
1775/// `to_json`.
1776#[must_use]
1777pub fn node_to_value(schema: Option<&Schema>, instance: &WInstance, node_id: u32) -> Value {
1778    let Some(node) = instance.nodes.get(&node_id) else {
1779        return Value::Null;
1780    };
1781
1782    if let Some(presence) = &node.value {
1783        return match presence {
1784            crate::value::FieldPresence::Present(val) => val.clone(),
1785            crate::value::FieldPresence::Null | crate::value::FieldPresence::Absent => Value::Null,
1786        };
1787    }
1788
1789    if is_collection_node(schema, instance, node) {
1790        let items = instance
1791            .children(node_id)
1792            .iter()
1793            .map(|&child| node_to_value(schema, instance, child))
1794            .collect();
1795        return Value::List(items);
1796    }
1797
1798    let mut fields = HashMap::new();
1799    for &(parent, child, ref edge) in &instance.arcs {
1800        if parent != node_id {
1801            continue;
1802        }
1803        let field_name = edge.name.as_deref().unwrap_or(&*edge.tgt);
1804        fields.insert(
1805            field_name.to_string(),
1806            node_to_value(schema, instance, child),
1807        );
1808    }
1809    for (key, val) in &node.extra_fields {
1810        fields.insert(key.clone(), val.clone());
1811    }
1812    Value::Unknown(fields)
1813}
1814
1815/// Whether a node denotes an ordered collection rather than a record.
1816///
1817/// The same three signals `to_json` uses, and for the same reason: the
1818/// schema shape (every outgoing edge anonymous) is a heuristic that a
1819/// hand-built record can trip, so evidence about the *data* (the parser's
1820/// list annotation, or repeated same-named arcs, which no object can
1821/// have) is what decides, and object-only evidence on the node vetoes the
1822/// schema heuristic.
1823fn is_collection_node(schema: Option<&Schema>, instance: &WInstance, node: &Node) -> bool {
1824    if node.is_list() {
1825        return true;
1826    }
1827
1828    let mut signature: Option<(panproto_gat::Name, Option<panproto_gat::Name>)> = None;
1829    let mut count = 0_usize;
1830    let mut uniform = true;
1831    for &(parent, _, ref edge) in &instance.arcs {
1832        if parent != node.id {
1833            continue;
1834        }
1835        let key = (edge.kind.clone(), edge.name.clone());
1836        match &signature {
1837            Some(existing) if existing != &key => {
1838                uniform = false;
1839                break;
1840            }
1841            Some(_) => {}
1842            None => signature = Some(key),
1843        }
1844        count += 1;
1845    }
1846    if uniform && count >= 2 {
1847        return true;
1848    }
1849
1850    let Some(schema) = schema else {
1851        return false;
1852    };
1853    let outgoing = schema.outgoing_edges(&node.anchor);
1854    let via_schema = !outgoing.is_empty() && outgoing.iter().all(|e| e.name.is_none());
1855    let object_only = !node.extra_fields.is_empty() || node.discriminator.is_some();
1856    via_schema && !object_only
1857}
1858
1859/// Build an expression evaluation environment from the full fiber over a
1860/// vertex: both `extra_fields` and scalar child values.
1861///
1862/// The binding order is `child_scalars` first, then `extra_fields`. This
1863/// ensures that `extra_fields` take precedence on key collision, which
1864/// is correct because `extra_fields` may contain values modified by
1865/// earlier transforms in the same sequence, and the transform pipeline
1866/// must see the most recent values.
1867///
1868/// Categorically, this constructs the left-biased coproduct injection
1869/// `ExtraFields + ChildScalars → Env` where `ExtraFields` has priority:
1870/// both maps contribute bindings, but on key collision the `ExtraFields`
1871/// value wins. This models the fiber projection
1872/// `π : ExtraFields(v) × Π_e Fiber(target(e)) → Env` where
1873/// `ExtraFields` carries transform-local state and `ChildScalars`
1874/// carries the dependent-sum projection of the structural children.
1875///
1876/// The whole fiber is additionally bound as `self`, so an expression can
1877/// reach a field it also names directly, `self.timeMs` and `timeMs` being
1878/// the same binding, and so that a name colliding with a builtin or a
1879/// reserved word stays reachable. A field genuinely called `self` wins
1880/// over the handle, since the flat bindings are applied last.
1881#[must_use]
1882pub fn build_env_with_children(
1883    fields: &HashMap<String, Value>,
1884    child_scalars: &HashMap<String, Value>,
1885) -> panproto_expr::Env {
1886    // Start with child scalars, then overlay extra_fields so that
1887    // extra_fields take precedence.
1888    let mut combined = child_scalars.clone();
1889    for (key, val) in fields {
1890        combined.insert(key.clone(), val.clone());
1891    }
1892    let this = Value::Unknown(combined.clone());
1893    let env = panproto_expr::Env::new()
1894        .extend(std::sync::Arc::from("self"), value_to_expr_literal(&this));
1895    extend_env_from_extra_fields(env, &combined)
1896}
1897
1898/// Build an evaluation environment from a node's `extra_fields`.
1899///
1900/// Each field is bound as a top-level variable. If an `attrs` field
1901/// contains a `Value::Unknown` map, its entries are also bound with
1902/// qualified names (e.g., `attrs.level`).
1903///
1904/// Those qualified names are a compatibility surface, not the primary
1905/// access path. They exist because `Value::Unknown` used to convert to
1906/// `Literal::Null`, so a genuine field projection — `Expr::Field` over
1907/// `attrs` — failed with "expected record, got null", and reaching a
1908/// nested entry required a flattened variable whose name happened to
1909/// contain a dot. [`value_to_expr_literal`] is now structure-preserving,
1910/// so `attrs` binds to a `Literal::Record` and field projection resolves
1911/// natively; the qualified bindings are redundant with it rather than
1912/// load-bearing. They are kept so that an expression already written
1913/// against the flattened names keeps working, and because the flat
1914/// aliasing also serves records that are *not* nested under `attrs`.
1915#[must_use]
1916pub fn build_env_from_extra_fields(fields: &HashMap<String, Value>) -> panproto_expr::Env {
1917    extend_env_from_extra_fields(panproto_expr::Env::new(), fields)
1918}
1919
1920/// [`build_env_from_extra_fields`] onto an existing environment, so a
1921/// caller can seed bindings that the field bindings then take precedence
1922/// over.
1923#[must_use]
1924pub fn extend_env_from_extra_fields(
1925    base: panproto_expr::Env,
1926    fields: &HashMap<String, Value>,
1927) -> panproto_expr::Env {
1928    let mut env = base;
1929    for (key, val) in fields {
1930        let lit = value_to_expr_literal(val);
1931        // Bind flat key
1932        env = env.extend(std::sync::Arc::from(key.as_str()), lit.clone());
1933        // Also bind as attrs.key (so predicates work regardless of nesting style)
1934        if key != "attrs" && key != "name" && key != "$type" && key != "parents" {
1935            let qualified = format!("attrs.{key}");
1936            env = env.extend(std::sync::Arc::from(qualified.as_str()), lit);
1937        }
1938    }
1939    // Also bind nested "attrs" entries as both qualified and flat
1940    if let Some(Value::Unknown(attrs)) = fields.get("attrs") {
1941        for (key, val) in attrs {
1942            let lit = value_to_expr_literal(val);
1943            let qualified = format!("attrs.{key}");
1944            env = env.extend(std::sync::Arc::from(qualified.as_str()), lit.clone());
1945            // Also bind as flat key if not already present
1946            if !fields.contains_key(key) {
1947                env = env.extend(std::sync::Arc::from(key.as_str()), lit);
1948            }
1949        }
1950    }
1951    env
1952}
1953
1954/// Convert an instance `Value` to a `panproto_expr::Literal` for expression evaluation.
1955///
1956/// The conversion is structure-preserving on the two container
1957/// variants: `Value::List` maps to `Literal::List` and `Value::Unknown`
1958/// maps to `Literal::Record`, in both cases converting the contents
1959/// recursively. This makes list- and record-valued fields reachable by
1960/// `map` / `fold` / `head` and by field projection, rather than
1961/// collapsing them to a scalar before the expression ever runs.
1962///
1963/// Record fields are emitted in sorted key order. `Value::Unknown` is
1964/// backed by a `HashMap`, whose iteration order varies between runs, so
1965/// sorting is what makes the conversion a function: the same map always
1966/// yields the same `Literal::Record`, and equality and hashing over the
1967/// result are stable.
1968///
1969/// Membership predicates over a list are served by `Contains`, which
1970/// accepts a list argument directly and tests element membership. The
1971/// list is therefore never flattened to a joined string on the way into
1972/// the environment.
1973///
1974/// The remaining variants — `CidLink`, `Blob`, `Token`, `Opaque`, and
1975/// `LabeledNull` — have no faithful `Literal` counterpart and convert to
1976/// `Literal::Null`. An expression reading one of those fields sees a
1977/// null rather than a lossy re-encoding.
1978#[must_use]
1979pub fn value_to_expr_literal(val: &Value) -> panproto_expr::Literal {
1980    match val {
1981        Value::Bool(b) => panproto_expr::Literal::Bool(*b),
1982        Value::Int(i) => panproto_expr::Literal::Int(*i),
1983        Value::Float(f) => panproto_expr::Literal::Float(*f),
1984        Value::Str(s) => panproto_expr::Literal::Str(s.clone()),
1985        Value::Bytes(b) => panproto_expr::Literal::Bytes(b.clone()),
1986        Value::List(items) => {
1987            panproto_expr::Literal::List(items.iter().map(value_to_expr_literal).collect())
1988        }
1989        Value::Unknown(map) => {
1990            let mut fields: Vec<(std::sync::Arc<str>, panproto_expr::Literal)> = map
1991                .iter()
1992                .map(|(k, v)| (std::sync::Arc::from(k.as_str()), value_to_expr_literal(v)))
1993                .collect();
1994            fields.sort_by(|(a, _), (b, _)| a.cmp(b));
1995            panproto_expr::Literal::Record(fields)
1996        }
1997        _ => panproto_expr::Literal::Null,
1998    }
1999}
2000
2001/// Convert a `panproto_expr::Literal` back to an instance `Value`.
2002///
2003/// The inverse direction of [`value_to_expr_literal`], and
2004/// structure-preserving on the same containers: `Literal::List` maps to
2005/// `Value::List` and `Literal::Record` to `Value::Unknown`, recursively.
2006/// An expression that *returns* a list of records — the shape produced
2007/// by `map (\x -> { .. }) xs` — is therefore written back as structured
2008/// data rather than collapsing to a null.
2009///
2010/// Integer-valued floats are normalized to `Value::Int` for round-trip
2011/// fidelity with JSON (which doesn't distinguish int/float).
2012///
2013/// `Literal::Closure` has no instance counterpart and converts to
2014/// `Value::Null`: a closure is an intermediate of evaluation, never a
2015/// value that should be persisted into a record.
2016#[must_use]
2017pub fn expr_literal_to_value(lit: &panproto_expr::Literal) -> Value {
2018    match lit {
2019        panproto_expr::Literal::Bool(b) => Value::Bool(*b),
2020        panproto_expr::Literal::Int(i) => Value::Int(*i),
2021        panproto_expr::Literal::Float(f) => {
2022            // Normalize integer-valued floats to Int for JSON round-trip fidelity.
2023            // Use safe bounds that avoid precision loss in f64→i64 conversion.
2024            #[allow(clippy::cast_precision_loss)]
2025            let fits = f.fract() == 0.0 && *f >= i64::MIN as f64 && *f <= i64::MAX as f64;
2026            if fits {
2027                #[allow(clippy::cast_possible_truncation)]
2028                let i = *f as i64;
2029                Value::Int(i)
2030            } else {
2031                Value::Float(*f)
2032            }
2033        }
2034        panproto_expr::Literal::Str(s) => Value::Str(s.clone()),
2035        panproto_expr::Literal::Bytes(b) => Value::Bytes(b.clone()),
2036        panproto_expr::Literal::List(items) => {
2037            Value::List(items.iter().map(expr_literal_to_value).collect())
2038        }
2039        panproto_expr::Literal::Record(fields) => Value::Unknown(
2040            fields
2041                .iter()
2042                .map(|(k, v)| (k.to_string(), expr_literal_to_value(v)))
2043                .collect(),
2044        ),
2045        panproto_expr::Literal::Null | panproto_expr::Literal::Closure { .. } => Value::Null,
2046    }
2047}
2048
2049// ---------------------------------------------------------------------------
2050// Left Kan extension (Σ_F) for W-type instances
2051// ---------------------------------------------------------------------------
2052
2053/// Prepare the root node for restriction: remap anchor, check conditional
2054/// survival, and apply field transforms.
2055fn prepare_root_node(
2056    root_node: &Node,
2057    migration: &CompiledMigration,
2058    instance: &WInstance,
2059    src_schema: &Schema,
2060) -> Result<Node, RestrictError> {
2061    let mut node = root_node.clone();
2062    if let Some(remapped) = migration.vertex_remap.get(&root_node.anchor) {
2063        node.anchor.clone_from(remapped);
2064    }
2065    if let Some(pred) = migration.conditional_survival.get(&root_node.anchor) {
2066        let env = build_env_from_extra_fields(&root_node.extra_fields);
2067        let config = panproto_expr::EvalConfig::default();
2068        if matches!(
2069            panproto_expr::eval(pred, &env, &config),
2070            Ok(panproto_expr::Literal::Bool(false))
2071        ) {
2072            return Err(RestrictError::RootPruned);
2073        }
2074    }
2075    let transforms = migration.value_transforms(&root_node.anchor);
2076    if !transforms.is_empty() {
2077        let ctx = TransformContext::new(Some(src_schema), instance, root_node.id, &transforms);
2078        apply_field_transforms(&mut node, &transforms, &ctx)?;
2079    }
2080    Ok(node)
2081}
2082
2083/// Left Kan extension (`Sigma_F`) for W-type instances.
2084///
2085/// Pushes a W-type instance forward along a migration morphism, mapping
2086/// every source node into the target schema and remapping anchors and edges
2087/// according to the compiled migration.
2088///
2089/// This is a *total* operation. Left Kan extension along a total functor
2090/// never deletes: every source node's anchor must be either remapped
2091/// (present as a key in `vertex_remap`) or surviving (present in
2092/// `surviving_verts`). A node whose anchor satisfies neither has no image in
2093/// the target schema, and rather than drop it silently `wtype_extend`
2094/// returns [`RestrictError::UnmappedAnchor`]. Callers that genuinely want
2095/// partial extension — keeping the mappable nodes and learning which nodes
2096/// were dropped — should use [`wtype_extend_partial`] instead.
2097///
2098/// # Errors
2099///
2100/// Returns [`RestrictError::UnmappedAnchor`] if any source node's anchor is
2101/// neither remapped nor surviving, [`RestrictError::RootPruned`] if the root
2102/// itself cannot be mapped, or another [`RestrictError`] variant if edge
2103/// resolution fails.
2104pub fn wtype_extend(
2105    instance: &WInstance,
2106    tgt_schema: &Schema,
2107    migration: &CompiledMigration,
2108) -> Result<WInstance, RestrictError> {
2109    let (extended, _dropped) = wtype_extend_inner(instance, tgt_schema, migration, false)?;
2110    Ok(extended)
2111}
2112
2113/// Partial left Kan extension: extend what can be mapped, report the rest.
2114///
2115/// Like [`wtype_extend`], but instead of failing on a node whose anchor is
2116/// neither remapped nor surviving, this variant drops that node and records
2117/// its id. The returned `Vec<u32>` lists the dropped source node ids (empty
2118/// when the migration is total, in which case the result equals that of
2119/// [`wtype_extend`]). Arcs touching a dropped node are dropped as well.
2120///
2121/// Use this variant when partial extension is intended, and inspect the
2122/// returned ids so the dropped nodes are surfaced rather than lost silently.
2123///
2124/// # Errors
2125///
2126/// Returns [`RestrictError::RootPruned`] if the root cannot be mapped, or
2127/// another [`RestrictError`] variant if edge resolution fails.
2128pub fn wtype_extend_partial(
2129    instance: &WInstance,
2130    tgt_schema: &Schema,
2131    migration: &CompiledMigration,
2132) -> Result<(WInstance, Vec<u32>), RestrictError> {
2133    wtype_extend_inner(instance, tgt_schema, migration, true)
2134}
2135
2136/// Shared implementation for [`wtype_extend`] and [`wtype_extend_partial`].
2137///
2138/// When `partial` is `false`, an unmapped-anchor node yields
2139/// [`RestrictError::UnmappedAnchor`]. When `partial` is `true`, such a node
2140/// is dropped and its id is collected into the returned vector.
2141fn wtype_extend_inner(
2142    instance: &WInstance,
2143    tgt_schema: &Schema,
2144    migration: &CompiledMigration,
2145    partial: bool,
2146) -> Result<(WInstance, Vec<u32>), RestrictError> {
2147    // Check root can be mapped
2148    let mut dropped_nodes: Vec<u32> = Vec::new();
2149    let root_node = instance
2150        .nodes
2151        .get(&instance.root)
2152        .ok_or(RestrictError::RootPruned)?;
2153
2154    let root_anchor = &root_node.anchor;
2155    if !migration.surviving_verts.contains(root_anchor)
2156        && !migration.vertex_remap.contains_key(root_anchor)
2157    {
2158        return Err(RestrictError::RootPruned);
2159    }
2160
2161    // Build new nodes: remap anchors where applicable
2162    let mut new_nodes: HashMap<u32, Node> = HashMap::with_capacity(instance.nodes.len());
2163    for (&id, node) in &instance.nodes {
2164        let mut new_node = node.clone();
2165        if let Some(remapped) = migration.vertex_remap.get(&node.anchor) {
2166            new_node.anchor.clone_from(remapped);
2167        } else if !migration.surviving_verts.contains(&node.anchor) {
2168            // Node's anchor has no image in the target schema. A total left
2169            // Kan extension cannot map it, so report the loss; only the
2170            // partial variant records the id and continues.
2171            if partial {
2172                dropped_nodes.push(id);
2173                continue;
2174            }
2175            return Err(RestrictError::UnmappedAnchor {
2176                anchor: node.anchor.clone(),
2177                node_id: id,
2178            });
2179        }
2180        // Apply field transforms (coercions) to the extended node.
2181        // Collect scalar child values from the original instance for the
2182        // full fiber projection.
2183        let transforms = migration.value_transforms(&node.anchor);
2184        if !transforms.is_empty() {
2185            let ctx = TransformContext::new(None, instance, id, &transforms);
2186            apply_field_transforms(&mut new_node, &transforms, &ctx)?;
2187        }
2188        new_nodes.insert(id, new_node);
2189    }
2190
2191    // Build new arcs: remap edges where applicable
2192    let mut new_arcs: Vec<(u32, u32, Edge)> = Vec::with_capacity(instance.arcs.len());
2193    for &(parent, child, ref edge) in &instance.arcs {
2194        // Both endpoints must be in the new node set
2195        if !new_nodes.contains_key(&parent) || !new_nodes.contains_key(&child) {
2196            continue;
2197        }
2198
2199        if let Some(new_edge) = migration.edge_remap.get(edge) {
2200            new_arcs.push((parent, child, new_edge.clone()));
2201        } else if migration.surviving_edges.contains(edge) {
2202            // Edge survives unchanged, but anchors may have been remapped.
2203            // Rebuild the edge with the remapped src/tgt vertex names.
2204            let parent_anchor = &new_nodes[&parent].anchor;
2205            let child_anchor = &new_nodes[&child].anchor;
2206            if edge.src == *parent_anchor && edge.tgt == *child_anchor {
2207                new_arcs.push((parent, child, edge.clone()));
2208            } else {
2209                // Anchors were remapped; resolve the edge in the target schema
2210                let resolved =
2211                    resolve_edge(tgt_schema, &migration.resolver, parent_anchor, child_anchor)?;
2212                new_arcs.push((parent, child, resolved));
2213            }
2214        } else {
2215            // Edge not in surviving_edges or edge_remap; try to resolve
2216            // from remapped anchors
2217            let parent_anchor = &new_nodes[&parent].anchor;
2218            let child_anchor = &new_nodes[&child].anchor;
2219            let resolved =
2220                resolve_edge(tgt_schema, &migration.resolver, parent_anchor, child_anchor)?;
2221            new_arcs.push((parent, child, resolved));
2222        }
2223    }
2224
2225    // Handle fans similarly to restrict's reconstruct_fans
2226    let surviving_ids: FxHashSet<u32> = new_nodes.keys().copied().collect();
2227    let empty_ancestors = FxHashMap::default();
2228    let new_fans = reconstruct_fans(
2229        instance,
2230        &surviving_ids,
2231        &empty_ancestors,
2232        migration,
2233        tgt_schema,
2234    )?;
2235
2236    let new_schema_root = migration
2237        .vertex_remap
2238        .get(&instance.schema_root)
2239        .cloned()
2240        .unwrap_or_else(|| instance.schema_root.clone());
2241
2242    Ok((
2243        WInstance::new(
2244            new_nodes,
2245            new_arcs,
2246            new_fans,
2247            instance.root,
2248            new_schema_root,
2249        ),
2250        dropped_nodes,
2251    ))
2252}
2253
2254#[cfg(test)]
2255mod tests {
2256    use super::*;
2257    use crate::value::{FieldPresence, Value};
2258
2259    /// Helper: build a simple 3-node instance (object with two string children).
2260    fn three_node_instance() -> WInstance {
2261        let mut nodes = HashMap::new();
2262        nodes.insert(0, Node::new(0, panproto_gat::Name::from("post:body")));
2263        nodes.insert(
2264            1,
2265            Node::new(1, "post:body.text")
2266                .with_value(FieldPresence::Present(Value::Str("hello".into()))),
2267        );
2268        nodes.insert(
2269            2,
2270            Node::new(2, "post:body.createdAt")
2271                .with_value(FieldPresence::Present(Value::Str("2024-01-01".into()))),
2272        );
2273
2274        let arcs = vec![
2275            (
2276                0,
2277                1,
2278                Edge {
2279                    src: "post:body".into(),
2280                    tgt: "post:body.text".into(),
2281                    kind: "prop".into(),
2282                    name: Some("text".into()),
2283                },
2284            ),
2285            (
2286                0,
2287                2,
2288                Edge {
2289                    src: "post:body".into(),
2290                    tgt: "post:body.createdAt".into(),
2291                    kind: "prop".into(),
2292                    name: Some("createdAt".into()),
2293                },
2294            ),
2295        ];
2296
2297        WInstance::new(
2298            nodes,
2299            arcs,
2300            vec![],
2301            0,
2302            panproto_gat::Name::from("post:body"),
2303        )
2304    }
2305
2306    #[test]
2307    fn anchor_surviving_keeps_matching_nodes() {
2308        let inst = three_node_instance();
2309        let surviving_verts: HashSet<Name> = ["post:body", "post:body.text"]
2310            .iter()
2311            .map(|&s| Name::from(s))
2312            .collect();
2313
2314        let result = anchor_surviving(&inst, &surviving_verts);
2315        assert_eq!(result.len(), 2);
2316        assert!(result.contains(&0));
2317        assert!(result.contains(&1));
2318        assert!(!result.contains(&2));
2319    }
2320
2321    #[test]
2322    fn ancestor_contraction_direct_parent() {
2323        let inst = three_node_instance();
2324        let surviving: HashSet<u32> = [0, 1, 2].iter().copied().collect();
2325        let ancestors = ancestor_contraction(&inst, &surviving);
2326        assert_eq!(ancestors.get(&1), Some(&0));
2327        assert_eq!(ancestors.get(&2), Some(&0));
2328    }
2329
2330    #[test]
2331    fn resolve_edge_unique() {
2332        use smallvec::smallvec;
2333        let mut between = HashMap::new();
2334        let edge = Edge {
2335            src: "a".into(),
2336            tgt: "b".into(),
2337            kind: "prop".into(),
2338            name: Some("x".into()),
2339        };
2340        between.insert((Name::from("a"), Name::from("b")), smallvec![edge.clone()]);
2341
2342        let schema = Schema {
2343            protocol: "test".into(),
2344            vertices: HashMap::new(),
2345            edges: HashMap::new(),
2346            hyper_edges: HashMap::new(),
2347            constraints: HashMap::new(),
2348            required: HashMap::new(),
2349            nsids: HashMap::new(),
2350            entries: Vec::new(),
2351            variants: HashMap::new(),
2352            orderings: HashMap::new(),
2353            recursion_points: HashMap::new(),
2354            spans: HashMap::new(),
2355            usage_modes: HashMap::new(),
2356            nominal: HashMap::new(),
2357            coercions: HashMap::new(),
2358            mergers: HashMap::new(),
2359            defaults: HashMap::new(),
2360            policies: HashMap::new(),
2361            outgoing: HashMap::new(),
2362            incoming: HashMap::new(),
2363            between,
2364        };
2365
2366        let resolver = HashMap::new();
2367        let result = resolve_edge(&schema, &resolver, "a", "b");
2368        assert!(result.is_ok());
2369        assert_eq!(result.ok(), Some(edge));
2370    }
2371
2372    #[test]
2373    fn resolve_edge_uses_resolver() {
2374        let schema = Schema {
2375            protocol: "test".into(),
2376            vertices: HashMap::new(),
2377            edges: HashMap::new(),
2378            hyper_edges: HashMap::new(),
2379            constraints: HashMap::new(),
2380            required: HashMap::new(),
2381            nsids: HashMap::new(),
2382            entries: Vec::new(),
2383            variants: HashMap::new(),
2384            orderings: HashMap::new(),
2385            recursion_points: HashMap::new(),
2386            spans: HashMap::new(),
2387            usage_modes: HashMap::new(),
2388            nominal: HashMap::new(),
2389            coercions: HashMap::new(),
2390            mergers: HashMap::new(),
2391            defaults: HashMap::new(),
2392            policies: HashMap::new(),
2393            outgoing: HashMap::new(),
2394            incoming: HashMap::new(),
2395            between: HashMap::new(),
2396        };
2397
2398        let resolved_edge = Edge {
2399            src: "a".into(),
2400            tgt: "b".into(),
2401            kind: "prop".into(),
2402            name: Some("resolved".into()),
2403        };
2404        let mut resolver = HashMap::new();
2405        resolver.insert((Name::from("a"), Name::from("b")), resolved_edge.clone());
2406
2407        let result = resolve_edge(&schema, &resolver, "a", "b");
2408        assert!(result.is_ok());
2409        assert_eq!(result.ok(), Some(resolved_edge));
2410    }
2411
2412    // --- wtype_extend tests ---
2413
2414    #[allow(clippy::unwrap_used)]
2415    fn make_test_schema(vertices: &[&str], edges: &[Edge]) -> Schema {
2416        use smallvec::smallvec;
2417        let mut between = HashMap::new();
2418        for edge in edges {
2419            between
2420                .entry((Name::from(&*edge.src), Name::from(&*edge.tgt)))
2421                .or_insert_with(|| smallvec![])
2422                .push(edge.clone());
2423        }
2424        Schema {
2425            protocol: "test".into(),
2426            vertices: vertices
2427                .iter()
2428                .map(|&v| {
2429                    (
2430                        Name::from(v),
2431                        panproto_schema::Vertex {
2432                            id: Name::from(v),
2433                            kind: Name::from("object"),
2434                            nsid: None,
2435                        },
2436                    )
2437                })
2438                .collect(),
2439            edges: HashMap::new(),
2440            hyper_edges: HashMap::new(),
2441            constraints: HashMap::new(),
2442            required: HashMap::new(),
2443            nsids: HashMap::new(),
2444            entries: Vec::new(),
2445            variants: HashMap::new(),
2446            orderings: HashMap::new(),
2447            recursion_points: HashMap::new(),
2448            spans: HashMap::new(),
2449            usage_modes: HashMap::new(),
2450            nominal: HashMap::new(),
2451            coercions: HashMap::new(),
2452            mergers: HashMap::new(),
2453            defaults: HashMap::new(),
2454            policies: HashMap::new(),
2455            outgoing: HashMap::new(),
2456            incoming: HashMap::new(),
2457            between,
2458        }
2459    }
2460
2461    #[test]
2462    #[allow(clippy::unwrap_used)]
2463    fn extend_identity_migration() {
2464        let inst = three_node_instance();
2465        let edge_text = Edge {
2466            src: "post:body".into(),
2467            tgt: "post:body.text".into(),
2468            kind: "prop".into(),
2469            name: Some("text".into()),
2470        };
2471        let edge_time = Edge {
2472            src: "post:body".into(),
2473            tgt: "post:body.createdAt".into(),
2474            kind: "prop".into(),
2475            name: Some("createdAt".into()),
2476        };
2477        let surviving_edges = HashSet::from([edge_text.clone(), edge_time.clone()]);
2478        let schema = make_test_schema(
2479            &["post:body", "post:body.text", "post:body.createdAt"],
2480            &[edge_text, edge_time],
2481        );
2482        let migration = CompiledMigration {
2483            surviving_verts: HashSet::from([
2484                Name::from("post:body"),
2485                Name::from("post:body.text"),
2486                Name::from("post:body.createdAt"),
2487            ]),
2488            surviving_edges,
2489            vertex_remap: HashMap::new(),
2490            edge_remap: HashMap::new(),
2491            resolver: HashMap::new(),
2492            hyper_resolver: HashMap::new(),
2493            field_transforms: HashMap::new(),
2494            conditional_survival: HashMap::new(),
2495            op_term_assignments: HashMap::new(),
2496            expansion_path: HashMap::new(),
2497        };
2498        let result = wtype_extend(&inst, &schema, &migration).unwrap();
2499        assert_eq!(result.node_count(), 3);
2500        assert_eq!(result.arc_count(), 2);
2501        assert_eq!(result.schema_root, Name::from("post:body"));
2502    }
2503
2504    #[test]
2505    #[allow(clippy::unwrap_used)]
2506    fn extend_with_vertex_remap() {
2507        let inst = three_node_instance();
2508        let tgt_edge_text = Edge {
2509            src: "article:body".into(),
2510            tgt: "article:body.text".into(),
2511            kind: "prop".into(),
2512            name: Some("text".into()),
2513        };
2514        let tgt_edge_time = Edge {
2515            src: "article:body".into(),
2516            tgt: "article:body.createdAt".into(),
2517            kind: "prop".into(),
2518            name: Some("createdAt".into()),
2519        };
2520        let tgt_schema = make_test_schema(
2521            &[
2522                "article:body",
2523                "article:body.text",
2524                "article:body.createdAt",
2525            ],
2526            &[tgt_edge_text, tgt_edge_time],
2527        );
2528        let mut vertex_remap = HashMap::new();
2529        vertex_remap.insert(Name::from("post:body"), Name::from("article:body"));
2530        vertex_remap.insert(
2531            Name::from("post:body.text"),
2532            Name::from("article:body.text"),
2533        );
2534        vertex_remap.insert(
2535            Name::from("post:body.createdAt"),
2536            Name::from("article:body.createdAt"),
2537        );
2538        let migration = CompiledMigration {
2539            surviving_verts: HashSet::from([
2540                Name::from("article:body"),
2541                Name::from("article:body.text"),
2542                Name::from("article:body.createdAt"),
2543            ]),
2544            surviving_edges: HashSet::new(),
2545            vertex_remap,
2546            edge_remap: HashMap::new(),
2547            resolver: HashMap::new(),
2548            hyper_resolver: HashMap::new(),
2549            field_transforms: HashMap::new(),
2550            conditional_survival: HashMap::new(),
2551            op_term_assignments: HashMap::new(),
2552            expansion_path: HashMap::new(),
2553        };
2554        let result = wtype_extend(&inst, &tgt_schema, &migration).unwrap();
2555        assert_eq!(result.node_count(), 3);
2556        assert_eq!(result.arc_count(), 2);
2557        assert_eq!(result.schema_root, Name::from("article:body"));
2558        assert_eq!(result.nodes[&0].anchor, Name::from("article:body"));
2559        assert_eq!(result.nodes[&1].anchor, Name::from("article:body.text"));
2560    }
2561
2562    #[test]
2563    #[allow(clippy::unwrap_used)]
2564    fn extend_with_edge_remap() {
2565        let inst = three_node_instance();
2566        let src_edge_text = Edge {
2567            src: "post:body".into(),
2568            tgt: "post:body.text".into(),
2569            kind: "prop".into(),
2570            name: Some("text".into()),
2571        };
2572        let new_edge_text = Edge {
2573            src: "post:body".into(),
2574            tgt: "post:body.text".into(),
2575            kind: "prop".into(),
2576            name: Some("content".into()),
2577        };
2578        let edge_time = Edge {
2579            src: "post:body".into(),
2580            tgt: "post:body.createdAt".into(),
2581            kind: "prop".into(),
2582            name: Some("createdAt".into()),
2583        };
2584        let surviving_edges = HashSet::from([edge_time.clone()]);
2585        let tgt_schema = make_test_schema(
2586            &["post:body", "post:body.text", "post:body.createdAt"],
2587            &[new_edge_text.clone(), edge_time],
2588        );
2589        let mut edge_remap = HashMap::new();
2590        edge_remap.insert(src_edge_text, new_edge_text);
2591        let migration = CompiledMigration {
2592            surviving_verts: HashSet::from([
2593                Name::from("post:body"),
2594                Name::from("post:body.text"),
2595                Name::from("post:body.createdAt"),
2596            ]),
2597            surviving_edges,
2598            vertex_remap: HashMap::new(),
2599            edge_remap,
2600            resolver: HashMap::new(),
2601            hyper_resolver: HashMap::new(),
2602            field_transforms: HashMap::new(),
2603            conditional_survival: HashMap::new(),
2604            op_term_assignments: HashMap::new(),
2605            expansion_path: HashMap::new(),
2606        };
2607        let result = wtype_extend(&inst, &tgt_schema, &migration).unwrap();
2608        assert_eq!(result.arc_count(), 2);
2609        // Check that the remapped edge is used
2610        let text_arc = result.arcs.iter().find(|a| a.1 == 1).unwrap();
2611        assert_eq!(text_arc.2.name.as_deref(), Some("content"));
2612    }
2613
2614    #[test]
2615    #[allow(clippy::unwrap_used)]
2616    fn extend_preserves_structure() {
2617        let inst = three_node_instance();
2618        let edge_text = Edge {
2619            src: "post:body".into(),
2620            tgt: "post:body.text".into(),
2621            kind: "prop".into(),
2622            name: Some("text".into()),
2623        };
2624        let edge_time = Edge {
2625            src: "post:body".into(),
2626            tgt: "post:body.createdAt".into(),
2627            kind: "prop".into(),
2628            name: Some("createdAt".into()),
2629        };
2630        let surviving_edges = HashSet::from([edge_text.clone(), edge_time.clone()]);
2631        let schema = make_test_schema(
2632            &["post:body", "post:body.text", "post:body.createdAt"],
2633            &[edge_text, edge_time],
2634        );
2635        let migration = CompiledMigration {
2636            surviving_verts: HashSet::from([
2637                Name::from("post:body"),
2638                Name::from("post:body.text"),
2639                Name::from("post:body.createdAt"),
2640            ]),
2641            surviving_edges,
2642            vertex_remap: HashMap::new(),
2643            edge_remap: HashMap::new(),
2644            resolver: HashMap::new(),
2645            hyper_resolver: HashMap::new(),
2646            field_transforms: HashMap::new(),
2647            conditional_survival: HashMap::new(),
2648            op_term_assignments: HashMap::new(),
2649            expansion_path: HashMap::new(),
2650        };
2651        let result = wtype_extend(&inst, &schema, &migration).unwrap();
2652        // Verify parent/children maps are correctly rebuilt
2653        assert_eq!(result.parent(1), Some(0));
2654        assert_eq!(result.parent(2), Some(0));
2655        assert!(result.children(0).contains(&1));
2656        assert!(result.children(0).contains(&2));
2657        // Verify values are preserved
2658        assert!(result.nodes[&1].has_value());
2659        assert!(result.nodes[&2].has_value());
2660    }
2661
2662    #[test]
2663    #[allow(clippy::unwrap_used)]
2664    fn extend_errors_on_unmapped_anchor() {
2665        // three_node_instance: post:body(0) -> post:body.text(1),
2666        //                      post:body(0) -> post:body.createdAt(2).
2667        let inst = three_node_instance();
2668        let edge_text = Edge {
2669            src: "post:body".into(),
2670            tgt: "post:body.text".into(),
2671            kind: "prop".into(),
2672            name: Some("text".into()),
2673        };
2674        // Target schema omits createdAt entirely.
2675        let schema = make_test_schema(
2676            &["post:body", "post:body.text"],
2677            std::slice::from_ref(&edge_text),
2678        );
2679        // Migration keeps body and body.text but neither remaps nor survives
2680        // post:body.createdAt, so node 2 has no image in the target schema.
2681        let migration = CompiledMigration {
2682            surviving_verts: HashSet::from([Name::from("post:body"), Name::from("post:body.text")]),
2683            surviving_edges: HashSet::from([edge_text]),
2684            vertex_remap: HashMap::new(),
2685            edge_remap: HashMap::new(),
2686            resolver: HashMap::new(),
2687            hyper_resolver: HashMap::new(),
2688            field_transforms: HashMap::new(),
2689            conditional_survival: HashMap::new(),
2690            op_term_assignments: HashMap::new(),
2691            expansion_path: HashMap::new(),
2692        };
2693
2694        // Total extend must error rather than silently drop node 2.
2695        let err = wtype_extend(&inst, &schema, &migration).unwrap_err();
2696        assert!(
2697            matches!(err, RestrictError::UnmappedAnchor { node_id: 2, .. }),
2698            "expected UnmappedAnchor for node 2, got {err:?}"
2699        );
2700
2701        // The explicit partial variant drops node 2 and reports its id.
2702        let (extended, dropped) = wtype_extend_partial(&inst, &schema, &migration).unwrap();
2703        assert_eq!(dropped, vec![2]);
2704        assert_eq!(extended.node_count(), 2);
2705        assert!(!extended.nodes.contains_key(&2));
2706    }
2707
2708    /// Regression test: renamed vertices must survive restrict.
2709    ///
2710    /// When a migration maps source vertex `A` to target vertex `B`, the
2711    /// `surviving_verts` set contains `B` (the target). The restrict BFS
2712    /// must remap `A` → `B` before checking membership, otherwise the
2713    /// node is incorrectly pruned and its value is lost.
2714    #[test]
2715    #[allow(clippy::expect_used, clippy::too_many_lines)]
2716    fn restrict_renamed_vertex_preserves_value() {
2717        use smallvec::smallvec;
2718
2719        // Source instance: post:body { text: "hello", title: "world" }
2720        let mut nodes = HashMap::new();
2721        nodes.insert(0, Node::new(0, Name::from("post:body")));
2722        nodes.insert(
2723            1,
2724            Node::new(1, "post:text")
2725                .with_value(FieldPresence::Present(Value::Str("hello".into()))),
2726        );
2727        nodes.insert(
2728            2,
2729            Node::new(2, "post:title")
2730                .with_value(FieldPresence::Present(Value::Str("world".into()))),
2731        );
2732        let arcs = vec![
2733            (
2734                0,
2735                1,
2736                Edge {
2737                    src: "post:body".into(),
2738                    tgt: "post:text".into(),
2739                    kind: "prop".into(),
2740                    name: Some("text".into()),
2741                },
2742            ),
2743            (
2744                0,
2745                2,
2746                Edge {
2747                    src: "post:body".into(),
2748                    tgt: "post:title".into(),
2749                    kind: "prop".into(),
2750                    name: Some("title".into()),
2751                },
2752            ),
2753        ];
2754        let inst = WInstance::new(nodes, arcs, vec![], 0, Name::from("post:body"));
2755
2756        // Target schema: post:body has edges to post:content and post:title
2757        let tgt_content_edge = Edge {
2758            src: "post:body".into(),
2759            tgt: "post:content".into(),
2760            kind: "prop".into(),
2761            name: Some("content".into()),
2762        };
2763        let tgt_title_edge = Edge {
2764            src: "post:body".into(),
2765            tgt: "post:title".into(),
2766            kind: "prop".into(),
2767            name: Some("title".into()),
2768        };
2769        let mut tgt_between = HashMap::new();
2770        tgt_between.insert(
2771            (Name::from("post:body"), Name::from("post:content")),
2772            smallvec![tgt_content_edge],
2773        );
2774        tgt_between.insert(
2775            (Name::from("post:body"), Name::from("post:title")),
2776            smallvec![tgt_title_edge],
2777        );
2778        let tgt_schema = Schema {
2779            protocol: "test".into(),
2780            vertices: HashMap::new(),
2781            edges: HashMap::new(),
2782            hyper_edges: HashMap::new(),
2783            constraints: HashMap::new(),
2784            required: HashMap::new(),
2785            nsids: HashMap::new(),
2786            entries: Vec::new(),
2787            variants: HashMap::new(),
2788            orderings: HashMap::new(),
2789            recursion_points: HashMap::new(),
2790            spans: HashMap::new(),
2791            usage_modes: HashMap::new(),
2792            nominal: HashMap::new(),
2793            coercions: HashMap::new(),
2794            mergers: HashMap::new(),
2795            defaults: HashMap::new(),
2796            policies: HashMap::new(),
2797            outgoing: HashMap::new(),
2798            incoming: HashMap::new(),
2799            between: tgt_between,
2800        };
2801
2802        // Migration: post:text → post:content (renamed), post:title stays
2803        let mut surviving_verts = HashSet::new();
2804        surviving_verts.insert(Name::from("post:body"));
2805        surviving_verts.insert(Name::from("post:content")); // target name
2806        surviving_verts.insert(Name::from("post:title"));
2807
2808        let mut vertex_remap = HashMap::new();
2809        vertex_remap.insert(Name::from("post:text"), Name::from("post:content"));
2810
2811        let migration = CompiledMigration {
2812            surviving_verts,
2813            surviving_edges: HashSet::new(),
2814            vertex_remap,
2815            edge_remap: HashMap::new(),
2816            resolver: HashMap::new(),
2817            hyper_resolver: HashMap::new(),
2818            field_transforms: HashMap::new(),
2819            conditional_survival: HashMap::new(),
2820            op_term_assignments: HashMap::new(),
2821            expansion_path: HashMap::new(),
2822        };
2823
2824        let src_schema = Schema {
2825            protocol: "test".into(),
2826            vertices: HashMap::new(),
2827            edges: HashMap::new(),
2828            hyper_edges: HashMap::new(),
2829            constraints: HashMap::new(),
2830            required: HashMap::new(),
2831            nsids: HashMap::new(),
2832            entries: Vec::new(),
2833            variants: HashMap::new(),
2834            orderings: HashMap::new(),
2835            recursion_points: HashMap::new(),
2836            spans: HashMap::new(),
2837            usage_modes: HashMap::new(),
2838            nominal: HashMap::new(),
2839            coercions: HashMap::new(),
2840            mergers: HashMap::new(),
2841            defaults: HashMap::new(),
2842            policies: HashMap::new(),
2843            outgoing: HashMap::new(),
2844            incoming: HashMap::new(),
2845            between: HashMap::new(),
2846        };
2847
2848        let result = wtype_restrict(&inst, &src_schema, &tgt_schema, &migration)
2849            .expect("restrict should succeed");
2850
2851        // All three nodes must survive (root + renamed + unchanged)
2852        assert_eq!(result.nodes.len(), 3, "all three nodes should survive");
2853
2854        // The renamed node should now have anchor "post:content"
2855        let renamed_node = result.nodes.get(&1).expect("node 1 should survive");
2856        assert_eq!(renamed_node.anchor.as_ref(), "post:content");
2857        assert!(renamed_node.has_value(), "renamed node must keep its value");
2858
2859        // The value should be preserved
2860        assert!(
2861            matches!(
2862                &renamed_node.value,
2863                Some(FieldPresence::Present(Value::Str(s))) if s.as_str() == "hello"
2864            ),
2865            "expected Some(Present(Str(\"hello\"))), got {:?}",
2866            renamed_node.value,
2867        );
2868    }
2869
2870    // --- PathTransform tests ---
2871
2872    #[test]
2873    #[allow(clippy::expect_used)]
2874    fn path_transform_renames_nested_field() {
2875        let mut node = Node::new(0, "v");
2876        let mut inner_map = HashMap::new();
2877        inner_map.insert("old_attr".to_string(), Value::Str("val".into()));
2878        node.extra_fields
2879            .insert("attrs".to_string(), Value::Unknown(inner_map));
2880
2881        let transform = FieldTransform::PathTransform {
2882            path: vec!["attrs".to_string()],
2883            inner: Box::new(FieldTransform::RenameField {
2884                old_key: "old_attr".to_string(),
2885                new_key: "new_attr".to_string(),
2886            }),
2887        };
2888        apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
2889            .expect("transform should evaluate");
2890
2891        match node.extra_fields.get("attrs") {
2892            Some(Value::Unknown(map)) => {
2893                assert!(!map.contains_key("old_attr"));
2894                assert_eq!(map.get("new_attr"), Some(&Value::Str("val".into())));
2895            }
2896            other => panic!("expected Unknown map, got {other:?}"),
2897        }
2898    }
2899
2900    #[test]
2901    #[allow(clippy::expect_used)]
2902    fn path_transform_empty_path_is_identity() {
2903        let mut node = Node::new(0, "v");
2904        node.extra_fields
2905            .insert("color".to_string(), Value::Str("red".into()));
2906
2907        let transform = FieldTransform::PathTransform {
2908            path: vec![],
2909            inner: Box::new(FieldTransform::RenameField {
2910                old_key: "color".to_string(),
2911                new_key: "colour".to_string(),
2912            }),
2913        };
2914        apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
2915            .expect("transform should evaluate");
2916
2917        assert!(!node.extra_fields.contains_key("color"));
2918        assert_eq!(
2919            node.extra_fields.get("colour"),
2920            Some(&Value::Str("red".into()))
2921        );
2922    }
2923
2924    // --- MapReferences tests ---
2925
2926    #[test]
2927    #[allow(clippy::expect_used)]
2928    fn map_references_renames_string_field() {
2929        let mut node = Node::new(0, "v");
2930        node.extra_fields
2931            .insert("parent".to_string(), Value::Str("old_name".into()));
2932
2933        let mut rename_map = HashMap::new();
2934        rename_map.insert("old_name".to_string(), Some("new_name".to_string()));
2935
2936        let transform = FieldTransform::MapReferences {
2937            field: "parent".to_string(),
2938            rename_map,
2939        };
2940        apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
2941            .expect("transform should evaluate");
2942
2943        assert_eq!(
2944            node.extra_fields.get("parent"),
2945            Some(&Value::Str("new_name".into()))
2946        );
2947    }
2948
2949    #[test]
2950    #[allow(clippy::expect_used)]
2951    fn map_references_filters_list() {
2952        let mut node = Node::new(0, "v");
2953        node.extra_fields.insert(
2954            "parents".to_string(),
2955            Value::List(vec![
2956                Value::Str("alpha".into()),
2957                Value::Str("beta".into()),
2958                Value::Str("gamma".into()),
2959            ]),
2960        );
2961
2962        let mut rename_map = HashMap::new();
2963        rename_map.insert("alpha".to_string(), Some("alpha_v2".to_string()));
2964        rename_map.insert("beta".to_string(), None); // drop
2965
2966        let transform = FieldTransform::MapReferences {
2967            field: "parents".to_string(),
2968            rename_map,
2969        };
2970        apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
2971            .expect("transform should evaluate");
2972
2973        match node.extra_fields.get("parents") {
2974            Some(Value::List(items)) => {
2975                assert_eq!(items.len(), 2);
2976                assert_eq!(items[0], Value::Str("alpha_v2".into()));
2977                assert_eq!(items[1], Value::Str("gamma".into()));
2978            }
2979            other => panic!("expected List, got {other:?}"),
2980        }
2981    }
2982
2983    #[test]
2984    #[allow(clippy::expect_used)]
2985    fn map_references_drops_removed_entries() {
2986        let mut node = Node::new(0, "v");
2987        node.extra_fields.insert(
2988            "refs".to_string(),
2989            Value::List(vec![
2990                Value::Str("gone".into()),
2991                Value::Str("also_gone".into()),
2992            ]),
2993        );
2994
2995        let mut rename_map = HashMap::new();
2996        rename_map.insert("gone".to_string(), None);
2997        rename_map.insert("also_gone".to_string(), None);
2998
2999        let transform = FieldTransform::MapReferences {
3000            field: "refs".to_string(),
3001            rename_map,
3002        };
3003        apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3004            .expect("transform should evaluate");
3005
3006        match node.extra_fields.get("refs") {
3007            Some(Value::List(items)) => {
3008                assert!(items.is_empty(), "expected empty list, got {items:?}");
3009            }
3010            other => panic!("expected List, got {other:?}"),
3011        }
3012    }
3013
3014    #[test]
3015    fn value_to_expr_literal_preserves_string_list() {
3016        // A list converts to `Literal::List`, element-wise, so that `map`
3017        // and `fold` can reach it. It is NOT joined into a string.
3018        let val = Value::List(vec![
3019            Value::Str("a".into()),
3020            Value::Str("b".into()),
3021            Value::Str("c".into()),
3022        ]);
3023        match value_to_expr_literal(&val) {
3024            panproto_expr::Literal::List(items) => assert_eq!(
3025                items,
3026                vec![
3027                    panproto_expr::Literal::Str("a".into()),
3028                    panproto_expr::Literal::Str("b".into()),
3029                    panproto_expr::Literal::Str("c".into()),
3030                ]
3031            ),
3032            other => panic!("expected Literal::List, got {other:?}"),
3033        }
3034    }
3035
3036    #[test]
3037    fn value_to_expr_literal_keeps_non_string_list_elements() {
3038        // Mixed-type elements all survive. The previous joined-string
3039        // projection dropped every non-string element, which turned a list
3040        // of integers into an empty string.
3041        let val = Value::List(vec![
3042            Value::Str("keep".into()),
3043            Value::Int(42),
3044            Value::Bool(true),
3045            Value::Null,
3046        ]);
3047        match value_to_expr_literal(&val) {
3048            panproto_expr::Literal::List(items) => assert_eq!(
3049                items,
3050                vec![
3051                    panproto_expr::Literal::Str("keep".into()),
3052                    panproto_expr::Literal::Int(42),
3053                    panproto_expr::Literal::Bool(true),
3054                    panproto_expr::Literal::Null,
3055                ],
3056                "non-string list elements must survive the conversion"
3057            ),
3058            other => panic!("expected Literal::List, got {other:?}"),
3059        }
3060    }
3061
3062    #[test]
3063    fn value_to_expr_literal_empty_list_is_empty_list() {
3064        match value_to_expr_literal(&Value::List(Vec::new())) {
3065            panproto_expr::Literal::List(items) => assert!(items.is_empty()),
3066            other => panic!("expected Literal::List, got {other:?}"),
3067        }
3068    }
3069
3070    #[test]
3071    fn value_to_expr_literal_nests_lists_and_records() {
3072        // A list of records: the shape an ATProto array-of-objects field
3073        // takes, and the one `map (\o -> o.a) objs` has to traverse.
3074        let val = Value::List(vec![Value::Unknown(HashMap::from([
3075            ("a".to_string(), Value::Int(1)),
3076            ("b".to_string(), Value::Int(10)),
3077        ]))]);
3078        match value_to_expr_literal(&val) {
3079            panproto_expr::Literal::List(items) => match &items[..] {
3080                [panproto_expr::Literal::Record(fields)] => {
3081                    assert_eq!(fields.len(), 2);
3082                    assert_eq!(&*fields[0].0, "a");
3083                    assert_eq!(fields[0].1, panproto_expr::Literal::Int(1));
3084                    assert_eq!(&*fields[1].0, "b");
3085                    assert_eq!(fields[1].1, panproto_expr::Literal::Int(10));
3086                }
3087                other => panic!("expected one Record element, got {other:?}"),
3088            },
3089            other => panic!("expected Literal::List, got {other:?}"),
3090        }
3091    }
3092
3093    #[test]
3094    fn value_to_expr_literal_record_fields_are_sorted() {
3095        // `Value::Unknown` is a HashMap, so field order must be imposed
3096        // rather than inherited: the same map must always produce the same
3097        // `Literal::Record`, or equality and hashing over it are unstable.
3098        let val = Value::Unknown(HashMap::from([
3099            ("zulu".to_string(), Value::Int(3)),
3100            ("alpha".to_string(), Value::Int(1)),
3101            ("mike".to_string(), Value::Int(2)),
3102        ]));
3103        for _ in 0..16 {
3104            match value_to_expr_literal(&val) {
3105                panproto_expr::Literal::Record(fields) => {
3106                    let keys: Vec<&str> = fields.iter().map(|(k, _)| &**k).collect();
3107                    assert_eq!(keys, vec!["alpha", "mike", "zulu"]);
3108                }
3109                other => panic!("expected Literal::Record, got {other:?}"),
3110            }
3111        }
3112    }
3113
3114    #[test]
3115    fn value_to_expr_literal_non_collection_variants_pass_through() {
3116        assert!(matches!(
3117            value_to_expr_literal(&Value::Bool(true)),
3118            panproto_expr::Literal::Bool(true)
3119        ));
3120        assert!(matches!(
3121            value_to_expr_literal(&Value::Int(7)),
3122            panproto_expr::Literal::Int(7)
3123        ));
3124        assert!(matches!(
3125            value_to_expr_literal(&Value::Null),
3126            panproto_expr::Literal::Null
3127        ));
3128        assert_eq!(
3129            value_to_expr_literal(&Value::Bytes(vec![1, 2, 3])),
3130            panproto_expr::Literal::Bytes(vec![1, 2, 3])
3131        );
3132        // An empty record is a record, not a null.
3133        assert!(matches!(
3134            value_to_expr_literal(&Value::Unknown(HashMap::new())),
3135            panproto_expr::Literal::Record(ref f) if f.is_empty()
3136        ));
3137        // Variants with no faithful Literal counterpart stay Null.
3138        assert!(matches!(
3139            value_to_expr_literal(&Value::Token("t".into())),
3140            panproto_expr::Literal::Null
3141        ));
3142    }
3143
3144    #[test]
3145    fn expr_literal_to_value_preserves_lists_and_records() {
3146        // The reverse direction: an expression that RETURNS a list of
3147        // records must be written back as structured data, not collapsed
3148        // to a null.
3149        let lit = panproto_expr::Literal::List(vec![panproto_expr::Literal::Record(vec![
3150            (std::sync::Arc::from("x"), panproto_expr::Literal::Int(1)),
3151            (
3152                std::sync::Arc::from("y"),
3153                panproto_expr::Literal::Str("s".into()),
3154            ),
3155        ])]);
3156        match expr_literal_to_value(&lit) {
3157            Value::List(items) => match &items[..] {
3158                [Value::Unknown(map)] => {
3159                    assert_eq!(map.get("x"), Some(&Value::Int(1)));
3160                    assert_eq!(map.get("y"), Some(&Value::Str("s".into())));
3161                }
3162                other => panic!("expected one Unknown element, got {other:?}"),
3163            },
3164            other => panic!("expected Value::List, got {other:?}"),
3165        }
3166    }
3167
3168    #[test]
3169    fn value_literal_round_trip_is_identity_on_containers() {
3170        // The two converters are mutually inverse on the container
3171        // variants, which is what makes a transform's output re-readable
3172        // by the next transform in the sequence.
3173        let val = Value::Unknown(HashMap::from([
3174            (
3175                "nums".to_string(),
3176                Value::List(vec![Value::Int(1), Value::Int(2)]),
3177            ),
3178            (
3179                "nested".to_string(),
3180                Value::Unknown(HashMap::from([("a".to_string(), Value::Str("z".into()))])),
3181            ),
3182            ("flag".to_string(), Value::Bool(false)),
3183        ]));
3184        assert_eq!(expr_literal_to_value(&value_to_expr_literal(&val)), val);
3185    }
3186
3187    // -----------------------------------------------------------------
3188    // List- and record-valued field transforms
3189    //
3190    // Each of these mirrors one of the reproduction cases reported
3191    // against `@panproto/core` 0.59.0, where the transform applied
3192    // cleanly to a scalar field but silently no-op'd on a list- or
3193    // object-valued one. They fail on the joined-string conversion.
3194    // -----------------------------------------------------------------
3195
3196    /// A node carrying the four field shapes from the report: a scalar
3197    /// array, an array of objects, and a nested object.
3198    fn node_with_container_fields() -> Node {
3199        let mut node = Node::new(0, "rec");
3200        node.extra_fields.insert(
3201            "nums".to_string(),
3202            Value::List(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
3203        );
3204        node.extra_fields.insert(
3205            "objs".to_string(),
3206            Value::List(vec![
3207                Value::Unknown(HashMap::from([
3208                    ("a".to_string(), Value::Int(1)),
3209                    ("b".to_string(), Value::Int(10)),
3210                ])),
3211                Value::Unknown(HashMap::from([
3212                    ("a".to_string(), Value::Int(2)),
3213                    ("b".to_string(), Value::Int(20)),
3214                ])),
3215            ]),
3216        );
3217        node.extra_fields.insert(
3218            "nested".to_string(),
3219            Value::Unknown(HashMap::from([
3220                ("a".to_string(), Value::Int(7)),
3221                ("b".to_string(), Value::Int(70)),
3222            ])),
3223        );
3224        node
3225    }
3226
3227    /// `\x -> x + 1` as a lambda expression.
3228    fn increment_lambda() -> panproto_expr::Expr {
3229        panproto_expr::Expr::Lam(
3230            std::sync::Arc::from("x"),
3231            Box::new(panproto_expr::Expr::Builtin(
3232                panproto_expr::BuiltinOp::Add,
3233                vec![
3234                    panproto_expr::Expr::Var(std::sync::Arc::from("x")),
3235                    panproto_expr::Expr::Lit(panproto_expr::Literal::Int(1)),
3236                ],
3237            )),
3238        )
3239    }
3240
3241    #[test]
3242    #[allow(clippy::expect_used)]
3243    fn apply_expr_maps_over_an_integer_list() {
3244        // `map (\x -> x + 1) nums` over [1,2,3].
3245        let mut node = node_with_container_fields();
3246        let transform = FieldTransform::ApplyExpr {
3247            key: "nums".to_string(),
3248            expr: panproto_expr::Expr::Builtin(
3249                panproto_expr::BuiltinOp::Map,
3250                vec![
3251                    panproto_expr::Expr::Var(std::sync::Arc::from("nums")),
3252                    increment_lambda(),
3253                ],
3254            ),
3255            inverse: None,
3256            coercion_class: panproto_gat::CoercionClass::Projection,
3257        };
3258        apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3259            .expect("map over a list field should evaluate");
3260
3261        assert_eq!(
3262            node.extra_fields.get("nums"),
3263            Some(&Value::List(vec![
3264                Value::Int(2),
3265                Value::Int(3),
3266                Value::Int(4)
3267            ])),
3268            "map over an integer list must produce the incremented list, not leave it untouched"
3269        );
3270    }
3271
3272    #[test]
3273    #[allow(clippy::expect_used)]
3274    fn apply_expr_maps_a_projection_over_a_record_list() {
3275        // `map (\o -> o.a) objs` over [{a:1,b:10},{a:2,b:20}].
3276        let mut node = node_with_container_fields();
3277        let project_a = panproto_expr::Expr::Lam(
3278            std::sync::Arc::from("o"),
3279            Box::new(panproto_expr::Expr::Field(
3280                Box::new(panproto_expr::Expr::Var(std::sync::Arc::from("o"))),
3281                std::sync::Arc::from("a"),
3282            )),
3283        );
3284        let transform = FieldTransform::ApplyExpr {
3285            key: "objs".to_string(),
3286            expr: panproto_expr::Expr::Builtin(
3287                panproto_expr::BuiltinOp::Map,
3288                vec![
3289                    panproto_expr::Expr::Var(std::sync::Arc::from("objs")),
3290                    project_a,
3291                ],
3292            ),
3293            inverse: None,
3294            coercion_class: panproto_gat::CoercionClass::Projection,
3295        };
3296        apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3297            .expect("map over a record list should evaluate");
3298
3299        assert_eq!(
3300            node.extra_fields.get("objs"),
3301            Some(&Value::List(vec![Value::Int(1), Value::Int(2)])),
3302            "field projection over a list of records must reach each record's fields"
3303        );
3304    }
3305
3306    #[test]
3307    #[allow(clippy::expect_used)]
3308    fn compute_field_reads_through_a_nested_record() {
3309        // `nested.a` where nested = {a:7,b:70}.
3310        let mut node = node_with_container_fields();
3311        let transform = FieldTransform::ComputeField {
3312            target_key: "out".to_string(),
3313            expr: panproto_expr::Expr::Field(
3314                Box::new(panproto_expr::Expr::Var(std::sync::Arc::from("nested"))),
3315                std::sync::Arc::from("a"),
3316            ),
3317            inverse: None,
3318            coercion_class: panproto_gat::CoercionClass::Projection,
3319        };
3320        apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3321            .expect("field access into a nested record should evaluate");
3322
3323        assert_eq!(
3324            node.extra_fields.get("out"),
3325            Some(&Value::Int(7)),
3326            "field access into a nested object must resolve, not emit nothing"
3327        );
3328    }
3329
3330    #[test]
3331    #[allow(clippy::expect_used)]
3332    fn compute_field_folds_over_a_list() {
3333        // `fold (\x y -> x + y) 0 nums` over [1,2,3].
3334        let mut node = node_with_container_fields();
3335        let add = panproto_expr::Expr::Lam(
3336            std::sync::Arc::from("x"),
3337            Box::new(panproto_expr::Expr::Lam(
3338                std::sync::Arc::from("y"),
3339                Box::new(panproto_expr::Expr::Builtin(
3340                    panproto_expr::BuiltinOp::Add,
3341                    vec![
3342                        panproto_expr::Expr::Var(std::sync::Arc::from("x")),
3343                        panproto_expr::Expr::Var(std::sync::Arc::from("y")),
3344                    ],
3345                )),
3346            )),
3347        );
3348        let transform = FieldTransform::ComputeField {
3349            target_key: "out".to_string(),
3350            expr: panproto_expr::Expr::Builtin(
3351                panproto_expr::BuiltinOp::Fold,
3352                vec![
3353                    panproto_expr::Expr::Var(std::sync::Arc::from("nums")),
3354                    panproto_expr::Expr::Lit(panproto_expr::Literal::Int(0)),
3355                    add,
3356                ],
3357            ),
3358            inverse: None,
3359            coercion_class: panproto_gat::CoercionClass::Projection,
3360        };
3361        apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3362            .expect("fold over a list field should evaluate");
3363
3364        assert_eq!(
3365            node.extra_fields.get("out"),
3366            Some(&Value::Int(6)),
3367            "fold over an integer list must sum it"
3368        );
3369    }
3370
3371    #[test]
3372    #[allow(clippy::expect_used)]
3373    fn compute_field_builds_a_nested_record_list() {
3374        // The regroup shape the report is blocked on: build a list of
3375        // records from a list of records, nesting some of the source
3376        // fields one level deeper. Exercises structure preservation in
3377        // BOTH directions in a single transform.
3378        let mut node = node_with_container_fields();
3379        let regroup = panproto_expr::Expr::Lam(
3380            std::sync::Arc::from("o"),
3381            Box::new(panproto_expr::Expr::Record(vec![
3382                (
3383                    std::sync::Arc::from("outer"),
3384                    panproto_expr::Expr::Field(
3385                        Box::new(panproto_expr::Expr::Var(std::sync::Arc::from("o"))),
3386                        std::sync::Arc::from("a"),
3387                    ),
3388                ),
3389                (
3390                    std::sync::Arc::from("inner"),
3391                    panproto_expr::Expr::Record(vec![(
3392                        std::sync::Arc::from("deep"),
3393                        panproto_expr::Expr::Field(
3394                            Box::new(panproto_expr::Expr::Var(std::sync::Arc::from("o"))),
3395                            std::sync::Arc::from("b"),
3396                        ),
3397                    )]),
3398                ),
3399            ])),
3400        );
3401        let transform = FieldTransform::ComputeField {
3402            target_key: "regrouped".to_string(),
3403            expr: panproto_expr::Expr::Builtin(
3404                panproto_expr::BuiltinOp::Map,
3405                vec![
3406                    panproto_expr::Expr::Var(std::sync::Arc::from("objs")),
3407                    regroup,
3408                ],
3409            ),
3410            inverse: None,
3411            coercion_class: panproto_gat::CoercionClass::Projection,
3412        };
3413        apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3414            .expect("building a nested record list should evaluate");
3415
3416        let expected = Value::List(vec![
3417            Value::Unknown(HashMap::from([
3418                ("outer".to_string(), Value::Int(1)),
3419                (
3420                    "inner".to_string(),
3421                    Value::Unknown(HashMap::from([("deep".to_string(), Value::Int(10))])),
3422                ),
3423            ])),
3424            Value::Unknown(HashMap::from([
3425                ("outer".to_string(), Value::Int(2)),
3426                (
3427                    "inner".to_string(),
3428                    Value::Unknown(HashMap::from([("deep".to_string(), Value::Int(20))])),
3429                ),
3430            ])),
3431        ]);
3432        assert_eq!(node.extra_fields.get("regrouped"), Some(&expected));
3433    }
3434
3435    #[test]
3436    #[allow(clippy::expect_used)]
3437    fn contains_tests_membership_on_a_list_field() {
3438        // Membership predicates over a list field are what the joined
3439        // string used to serve. `Contains` now takes the list directly.
3440        let mut node = Node::new(0, "rec");
3441        node.extra_fields.insert(
3442            "tags".to_string(),
3443            Value::List(vec![Value::Str("alpha".into()), Value::Str("beta".into())]),
3444        );
3445
3446        let case = FieldTransform::Case {
3447            branches: vec![CaseBranch {
3448                predicate: panproto_expr::Expr::builtin(
3449                    panproto_expr::BuiltinOp::Contains,
3450                    vec![
3451                        panproto_expr::Expr::Var(std::sync::Arc::from("tags")),
3452                        panproto_expr::Expr::Lit(panproto_expr::Literal::Str("beta".into())),
3453                    ],
3454                ),
3455                transforms: vec![FieldTransform::AddField {
3456                    key: "matched".into(),
3457                    value: Value::Bool(true),
3458                }],
3459            }],
3460        };
3461        apply_field_transforms(&mut node, &[case], &TransformContext::detached())
3462            .expect("membership predicate should evaluate");
3463
3464        assert_eq!(
3465            node.extra_fields.get("matched"),
3466            Some(&Value::Bool(true)),
3467            "Contains must test element membership on a list-valued field"
3468        );
3469    }
3470
3471    #[test]
3472    #[allow(clippy::expect_used)]
3473    fn contains_on_a_list_does_not_match_a_substring_of_an_element() {
3474        // Membership is exact-element, not substring. Under the old
3475        // joined-string form `Contains(["alpha"], "lph")` matched, which
3476        // is not what a membership predicate means.
3477        let mut node = Node::new(0, "rec");
3478        node.extra_fields.insert(
3479            "tags".to_string(),
3480            Value::List(vec![Value::Str("alpha".into())]),
3481        );
3482
3483        let case = FieldTransform::Case {
3484            branches: vec![CaseBranch {
3485                predicate: panproto_expr::Expr::builtin(
3486                    panproto_expr::BuiltinOp::Contains,
3487                    vec![
3488                        panproto_expr::Expr::Var(std::sync::Arc::from("tags")),
3489                        panproto_expr::Expr::Lit(panproto_expr::Literal::Str("lph".into())),
3490                    ],
3491                ),
3492                transforms: vec![FieldTransform::AddField {
3493                    key: "matched".into(),
3494                    value: Value::Bool(true),
3495                }],
3496            }],
3497        };
3498        apply_field_transforms(&mut node, &[case], &TransformContext::detached())
3499            .expect("membership predicate should evaluate");
3500
3501        assert!(
3502            !node.extra_fields.contains_key("matched"),
3503            "list membership must be exact-element, not substring"
3504        );
3505    }
3506
3507    #[test]
3508    #[allow(clippy::expect_used)]
3509    fn failed_transform_reports_instead_of_silently_skipping() {
3510        // The diagnosability half of the fix: an expression that cannot
3511        // evaluate surfaces an error naming the field, rather than
3512        // leaving the field untouched and returning success.
3513        let mut node = Node::new(0, "rec");
3514        node.extra_fields.insert("n".to_string(), Value::Int(1));
3515
3516        let transform = FieldTransform::ComputeField {
3517            target_key: "out".to_string(),
3518            // `missing` is unbound in this environment.
3519            expr: panproto_expr::Expr::Var(std::sync::Arc::from("missing")),
3520            inverse: None,
3521            coercion_class: panproto_gat::CoercionClass::Projection,
3522        };
3523        let err = apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3524            .expect_err("an unevaluable transform must report");
3525
3526        match err {
3527            RestrictError::FieldTransformFailed { key, .. } => assert_eq!(key, "out"),
3528            other => panic!("expected FieldTransformFailed, got {other:?}"),
3529        }
3530        assert!(
3531            !node.extra_fields.contains_key("out"),
3532            "a failed transform must not write a partial result"
3533        );
3534    }
3535
3536    #[test]
3537    #[allow(clippy::expect_used)]
3538    fn failed_nested_transform_preserves_the_nested_map() {
3539        // `apply_path_transform` moves the nested map out to operate on
3540        // it. A failure mid-way must still put it back, or the transform
3541        // that could not run would also erase what it was reading.
3542        let mut node = Node::new(0, "rec");
3543        node.extra_fields.insert(
3544            "outer".to_string(),
3545            Value::Unknown(HashMap::from([("kept".to_string(), Value::Int(5))])),
3546        );
3547
3548        let transform = FieldTransform::PathTransform {
3549            path: vec!["outer".to_string()],
3550            inner: Box::new(FieldTransform::ComputeField {
3551                target_key: "out".to_string(),
3552                expr: panproto_expr::Expr::Var(std::sync::Arc::from("missing")),
3553                inverse: None,
3554                coercion_class: panproto_gat::CoercionClass::Projection,
3555            }),
3556        };
3557        apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3558            .expect_err("an unevaluable nested transform must report");
3559
3560        assert_eq!(
3561            node.extra_fields.get("outer"),
3562            Some(&Value::Unknown(HashMap::from([(
3563                "kept".to_string(),
3564                Value::Int(5)
3565            )]))),
3566            "the nested map must survive a failed transform"
3567        );
3568    }
3569
3570    #[test]
3571    #[allow(clippy::expect_used)]
3572    fn map_references_preserves_non_string_elements() {
3573        // MapReferences is the action of the rename on *string leaves*;
3574        // non-string elements in a list pass through unchanged. This
3575        // pins the functoriality in the Kleisli category of the "rename
3576        // or drop" partial map.
3577        let mut node = Node::new(0, "v");
3578        node.extra_fields.insert(
3579            "mixed".to_string(),
3580            Value::List(vec![
3581                Value::Str("renameme".into()),
3582                Value::Int(42),
3583                Value::Bool(true),
3584                Value::Str("dropme".into()),
3585            ]),
3586        );
3587
3588        let mut rename_map = HashMap::new();
3589        rename_map.insert("renameme".to_string(), Some("renamed".to_string()));
3590        rename_map.insert("dropme".to_string(), None);
3591
3592        let transform = FieldTransform::MapReferences {
3593            field: "mixed".to_string(),
3594            rename_map,
3595        };
3596        apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3597            .expect("transform should evaluate");
3598
3599        match node.extra_fields.get("mixed") {
3600            Some(Value::List(items)) => {
3601                assert_eq!(items.len(), 3);
3602                assert_eq!(items[0], Value::Str("renamed".into()));
3603                assert_eq!(items[1], Value::Int(42));
3604                assert_eq!(items[2], Value::Bool(true));
3605            }
3606            other => panic!("expected List, got {other:?}"),
3607        }
3608    }
3609
3610    // --- ConditionalSurvival tests ---
3611
3612    #[test]
3613    #[allow(clippy::expect_used)]
3614    fn conditional_survival_drops_non_matching_node() {
3615        use smallvec::smallvec;
3616
3617        // Two child nodes anchored to "item", with different level values.
3618        let mut nodes = HashMap::new();
3619        nodes.insert(0, Node::new(0, Name::from("root")));
3620        nodes.insert(
3621            1,
3622            Node::new(1, "item").with_extra_field("level", Value::Int(2)),
3623        );
3624        nodes.insert(
3625            2,
3626            Node::new(2, "item").with_extra_field("level", Value::Int(1)),
3627        );
3628
3629        let edge = Edge {
3630            src: "root".into(),
3631            tgt: "item".into(),
3632            kind: "prop".into(),
3633            name: Some("child".into()),
3634        };
3635        let arcs = vec![(0, 1, edge.clone()), (0, 2, edge.clone())];
3636        let inst = WInstance::new(nodes, arcs, vec![], 0, Name::from("root"));
3637
3638        let mut between = HashMap::new();
3639        between.insert(
3640            (Name::from("root"), Name::from("item")),
3641            smallvec![edge.clone()],
3642        );
3643        let tgt_schema = Schema {
3644            protocol: "test".into(),
3645            vertices: HashMap::new(),
3646            edges: HashMap::new(),
3647            hyper_edges: HashMap::new(),
3648            constraints: HashMap::new(),
3649            required: HashMap::new(),
3650            nsids: HashMap::new(),
3651            entries: Vec::new(),
3652            variants: HashMap::new(),
3653            orderings: HashMap::new(),
3654            recursion_points: HashMap::new(),
3655            spans: HashMap::new(),
3656            usage_modes: HashMap::new(),
3657            nominal: HashMap::new(),
3658            coercions: HashMap::new(),
3659            mergers: HashMap::new(),
3660            defaults: HashMap::new(),
3661            policies: HashMap::new(),
3662            outgoing: HashMap::new(),
3663            incoming: HashMap::new(),
3664            between,
3665        };
3666        let src_schema = Schema {
3667            protocol: "test".into(),
3668            vertices: HashMap::new(),
3669            edges: HashMap::new(),
3670            hyper_edges: HashMap::new(),
3671            constraints: HashMap::new(),
3672            required: HashMap::new(),
3673            nsids: HashMap::new(),
3674            entries: Vec::new(),
3675            variants: HashMap::new(),
3676            orderings: HashMap::new(),
3677            recursion_points: HashMap::new(),
3678            spans: HashMap::new(),
3679            usage_modes: HashMap::new(),
3680            nominal: HashMap::new(),
3681            coercions: HashMap::new(),
3682            mergers: HashMap::new(),
3683            defaults: HashMap::new(),
3684            policies: HashMap::new(),
3685            outgoing: HashMap::new(),
3686            incoming: HashMap::new(),
3687            between: HashMap::new(),
3688        };
3689
3690        // Predicate: (== level 2)
3691        let predicate = panproto_expr::Expr::Builtin(
3692            panproto_expr::BuiltinOp::Eq,
3693            vec![
3694                panproto_expr::Expr::Var(std::sync::Arc::from("level")),
3695                panproto_expr::Expr::Lit(panproto_expr::Literal::Int(2)),
3696            ],
3697        );
3698
3699        let mut migration = CompiledMigration {
3700            surviving_verts: HashSet::from([Name::from("root"), Name::from("item")]),
3701            surviving_edges: HashSet::from([edge]),
3702            vertex_remap: HashMap::new(),
3703            edge_remap: HashMap::new(),
3704            resolver: HashMap::new(),
3705            hyper_resolver: HashMap::new(),
3706            field_transforms: HashMap::new(),
3707            conditional_survival: HashMap::new(),
3708            op_term_assignments: HashMap::new(),
3709            expansion_path: HashMap::new(),
3710        };
3711        migration.add_conditional_survival("item", predicate);
3712
3713        let result =
3714            wtype_restrict(&inst, &src_schema, &tgt_schema, &migration).expect("restrict ok");
3715
3716        // Node 1 (level=2) survives, node 2 (level=1) is dropped
3717        assert_eq!(result.node_count(), 2);
3718        assert!(result.nodes.contains_key(&0));
3719        assert!(result.nodes.contains_key(&1));
3720        assert!(!result.nodes.contains_key(&2));
3721    }
3722
3723    #[test]
3724    #[allow(clippy::expect_used)]
3725    fn conditional_survival_no_predicate_survives() {
3726        use smallvec::smallvec;
3727
3728        let mut nodes = HashMap::new();
3729        nodes.insert(0, Node::new(0, Name::from("root")));
3730        nodes.insert(
3731            1,
3732            Node::new(1, "item").with_extra_field("level", Value::Int(1)),
3733        );
3734
3735        let edge = Edge {
3736            src: "root".into(),
3737            tgt: "item".into(),
3738            kind: "prop".into(),
3739            name: Some("child".into()),
3740        };
3741        let arcs = vec![(0, 1, edge.clone())];
3742        let inst = WInstance::new(nodes, arcs, vec![], 0, Name::from("root"));
3743
3744        let mut between = HashMap::new();
3745        between.insert(
3746            (Name::from("root"), Name::from("item")),
3747            smallvec![edge.clone()],
3748        );
3749        let tgt_schema = Schema {
3750            protocol: "test".into(),
3751            vertices: HashMap::new(),
3752            edges: HashMap::new(),
3753            hyper_edges: HashMap::new(),
3754            constraints: HashMap::new(),
3755            required: HashMap::new(),
3756            nsids: HashMap::new(),
3757            entries: Vec::new(),
3758            variants: HashMap::new(),
3759            orderings: HashMap::new(),
3760            recursion_points: HashMap::new(),
3761            spans: HashMap::new(),
3762            usage_modes: HashMap::new(),
3763            nominal: HashMap::new(),
3764            coercions: HashMap::new(),
3765            mergers: HashMap::new(),
3766            defaults: HashMap::new(),
3767            policies: HashMap::new(),
3768            outgoing: HashMap::new(),
3769            incoming: HashMap::new(),
3770            between,
3771        };
3772        let src_schema = Schema {
3773            protocol: "test".into(),
3774            vertices: HashMap::new(),
3775            edges: HashMap::new(),
3776            hyper_edges: HashMap::new(),
3777            constraints: HashMap::new(),
3778            required: HashMap::new(),
3779            nsids: HashMap::new(),
3780            entries: Vec::new(),
3781            variants: HashMap::new(),
3782            orderings: HashMap::new(),
3783            recursion_points: HashMap::new(),
3784            spans: HashMap::new(),
3785            usage_modes: HashMap::new(),
3786            nominal: HashMap::new(),
3787            coercions: HashMap::new(),
3788            mergers: HashMap::new(),
3789            defaults: HashMap::new(),
3790            policies: HashMap::new(),
3791            outgoing: HashMap::new(),
3792            incoming: HashMap::new(),
3793            between: HashMap::new(),
3794        };
3795
3796        // No conditional_survival predicates; node should survive normally
3797        let migration = CompiledMigration {
3798            surviving_verts: HashSet::from([Name::from("root"), Name::from("item")]),
3799            surviving_edges: HashSet::from([edge]),
3800            vertex_remap: HashMap::new(),
3801            edge_remap: HashMap::new(),
3802            resolver: HashMap::new(),
3803            hyper_resolver: HashMap::new(),
3804            field_transforms: HashMap::new(),
3805            conditional_survival: HashMap::new(),
3806            op_term_assignments: HashMap::new(),
3807            expansion_path: HashMap::new(),
3808        };
3809
3810        let result =
3811            wtype_restrict(&inst, &src_schema, &tgt_schema, &migration).expect("restrict ok");
3812
3813        assert_eq!(result.node_count(), 2);
3814        assert!(result.nodes.contains_key(&1));
3815    }
3816
3817    // --- ComputeField tests ---
3818
3819    #[test]
3820    #[allow(clippy::expect_used)]
3821    fn computed_field_template_name() {
3822        let mut node = Node::new(0, "heading");
3823        node.extra_fields.insert("level".to_string(), Value::Int(2));
3824
3825        // (concat "h" (int_to_str level))
3826        let expr = panproto_expr::Expr::Builtin(
3827            panproto_expr::BuiltinOp::Concat,
3828            vec![
3829                panproto_expr::Expr::Lit(panproto_expr::Literal::Str("h".to_string())),
3830                panproto_expr::Expr::Builtin(
3831                    panproto_expr::BuiltinOp::IntToStr,
3832                    vec![panproto_expr::Expr::Var(std::sync::Arc::from("level"))],
3833                ),
3834            ],
3835        );
3836
3837        let transform = FieldTransform::ComputeField {
3838            target_key: "name".to_string(),
3839            expr,
3840            inverse: None,
3841            coercion_class: panproto_gat::CoercionClass::Opaque,
3842        };
3843        apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3844            .expect("transform should evaluate");
3845
3846        assert_eq!(
3847            node.extra_fields.get("name"),
3848            Some(&Value::Str("h2".into()))
3849        );
3850    }
3851
3852    #[test]
3853    #[allow(clippy::expect_used)]
3854    fn computed_field_reads_nested_attrs() {
3855        let mut node = Node::new(0, "heading");
3856        let mut attrs = HashMap::new();
3857        attrs.insert("level".to_string(), Value::Int(3));
3858        node.extra_fields
3859            .insert("attrs".to_string(), Value::Unknown(attrs));
3860
3861        // (concat "h" (int_to_str attrs.level))
3862        let expr = panproto_expr::Expr::Builtin(
3863            panproto_expr::BuiltinOp::Concat,
3864            vec![
3865                panproto_expr::Expr::Lit(panproto_expr::Literal::Str("h".to_string())),
3866                panproto_expr::Expr::Builtin(
3867                    panproto_expr::BuiltinOp::IntToStr,
3868                    vec![panproto_expr::Expr::Var(std::sync::Arc::from(
3869                        "attrs.level",
3870                    ))],
3871                ),
3872            ],
3873        );
3874
3875        let transform = FieldTransform::ComputeField {
3876            target_key: "name".to_string(),
3877            expr,
3878            inverse: None,
3879            coercion_class: panproto_gat::CoercionClass::Opaque,
3880        };
3881        apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3882            .expect("transform should evaluate");
3883
3884        assert_eq!(
3885            node.extra_fields.get("name"),
3886            Some(&Value::Str("h3".into()))
3887        );
3888    }
3889
3890    #[test]
3891    #[allow(clippy::expect_used)]
3892    fn case_transform_sets_field_conditionally() {
3893        use crate::value::Value;
3894        use panproto_expr::{BuiltinOp, Expr, Literal};
3895        use std::sync::Arc;
3896
3897        let mut node = Node::new(0, "heading");
3898        node.extra_fields.insert("level".into(), Value::Int(1));
3899        node.extra_fields
3900            .insert("name".into(), Value::Str("heading".into()));
3901
3902        let case = FieldTransform::Case {
3903            branches: vec![
3904                CaseBranch {
3905                    predicate: Expr::builtin(
3906                        BuiltinOp::Eq,
3907                        vec![Expr::Var(Arc::from("level")), Expr::Lit(Literal::Int(1))],
3908                    ),
3909                    transforms: vec![FieldTransform::ComputeField {
3910                        target_key: "name".into(),
3911                        expr: Expr::Lit(Literal::Str("h1".into())),
3912                        inverse: None,
3913                        coercion_class: panproto_gat::CoercionClass::Opaque,
3914                    }],
3915                },
3916                CaseBranch {
3917                    predicate: Expr::builtin(
3918                        BuiltinOp::Eq,
3919                        vec![Expr::Var(Arc::from("level")), Expr::Lit(Literal::Int(2))],
3920                    ),
3921                    transforms: vec![FieldTransform::ComputeField {
3922                        target_key: "name".into(),
3923                        expr: Expr::Lit(Literal::Str("h2".into())),
3924                        inverse: None,
3925                        coercion_class: panproto_gat::CoercionClass::Opaque,
3926                    }],
3927                },
3928            ],
3929        };
3930
3931        apply_field_transforms(&mut node, &[case], &TransformContext::detached())
3932            .expect("transform should evaluate");
3933
3934        assert_eq!(
3935            node.extra_fields.get("name"),
3936            Some(&Value::Str("h1".into()))
3937        );
3938    }
3939
3940    // --- Child scalar access tests ---
3941
3942    /// Build a 3-node instance: root object + two string children.
3943    fn instance_with_scalar_children() -> (WInstance, HashMap<String, Value>) {
3944        let mut nodes = HashMap::new();
3945        nodes.insert(0, Node::new(0, "body"));
3946        nodes.insert(
3947            1,
3948            Node::new(1, "body.repo").with_value(FieldPresence::Present(Value::Str(
3949                "at://did:plc:abc/app.bsky.feed.post/rkey123".into(),
3950            ))),
3951        );
3952        nodes.insert(
3953            2,
3954            Node::new(2, "body.text")
3955                .with_value(FieldPresence::Present(Value::Str("hello world".into()))),
3956        );
3957
3958        let edge_repo = Edge {
3959            src: "body".into(),
3960            tgt: "body.repo".into(),
3961            kind: "prop".into(),
3962            name: Some("repo".into()),
3963        };
3964        let edge_text = Edge {
3965            src: "body".into(),
3966            tgt: "body.text".into(),
3967            kind: "prop".into(),
3968            name: Some("text".into()),
3969        };
3970
3971        let arcs = vec![(0, 1, edge_repo), (0, 2, edge_text)];
3972        let instance = WInstance::new(nodes, arcs, vec![], 0, "body".into());
3973        let scalars = collect_scalar_child_values(&instance, 0);
3974        (instance, scalars)
3975    }
3976
3977    #[test]
3978    #[allow(clippy::expect_used)]
3979    fn compute_field_reads_scalar_child() {
3980        // ComputeField must read string fields stored as child
3981        // vertices, not just those in `extra_fields`. This unit test
3982        // covers the basic access path; the integration test
3983        // `at_uri_decomposition_end_to_end` exercises real Split /
3984        // Index expressions for full AT-URI parsing.
3985        let (_instance, scalars) = instance_with_scalar_children();
3986        let mut node = Node::new(0, "body");
3987
3988        let expr = panproto_expr::Expr::Var(std::sync::Arc::from("repo"));
3989
3990        let transform = FieldTransform::ComputeField {
3991            target_key: "repo_copy".to_string(),
3992            expr,
3993            inverse: None,
3994            coercion_class: panproto_gat::CoercionClass::Projection,
3995        };
3996        apply_field_transforms(
3997            &mut node,
3998            &[transform],
3999            &TransformContext::from_child_values(scalars),
4000        )
4001        .expect("transform should evaluate");
4002
4003        assert_eq!(
4004            node.extra_fields.get("repo_copy"),
4005            Some(&Value::Str(
4006                "at://did:plc:abc/app.bsky.feed.post/rkey123".into()
4007            )),
4008            "ComputeField should read scalar child value via dependent-sum projection"
4009        );
4010    }
4011
4012    #[test]
4013    #[allow(clippy::expect_used)]
4014    fn apply_expr_on_scalar_child() {
4015        let (_instance, scalars) = instance_with_scalar_children();
4016        let mut node = Node::new(0, "body");
4017
4018        // ApplyExpr on "text" (a child scalar): should find it and write
4019        // the transformed result to extra_fields.
4020        let expr = panproto_expr::Expr::Builtin(
4021            panproto_expr::BuiltinOp::Concat,
4022            vec![
4023                panproto_expr::Expr::Var(std::sync::Arc::from("text")),
4024                panproto_expr::Expr::Lit(panproto_expr::Literal::Str("!".into())),
4025            ],
4026        );
4027        let transform = FieldTransform::ApplyExpr {
4028            key: "text".to_string(),
4029            expr,
4030            inverse: None,
4031            coercion_class: panproto_gat::CoercionClass::Projection,
4032        };
4033        apply_field_transforms(
4034            &mut node,
4035            &[transform],
4036            &TransformContext::from_child_values(scalars),
4037        )
4038        .expect("transform should evaluate");
4039
4040        assert_eq!(
4041            node.extra_fields.get("text"),
4042            Some(&Value::Str("hello world!".into())),
4043            "ApplyExpr should read child scalar and write result to extra_fields"
4044        );
4045    }
4046
4047    #[test]
4048    #[allow(clippy::expect_used)]
4049    fn case_branch_on_scalar_child() {
4050        use panproto_expr::{BuiltinOp, Expr, Literal};
4051        use std::sync::Arc;
4052
4053        let (_instance, scalars) = instance_with_scalar_children();
4054        let mut node = Node::new(0, "body");
4055
4056        // Branch: if (contains repo "did:plc") then add field "has_did" = true
4057        let case = FieldTransform::Case {
4058            branches: vec![CaseBranch {
4059                predicate: Expr::builtin(
4060                    BuiltinOp::Contains,
4061                    vec![
4062                        Expr::Var(Arc::from("repo")),
4063                        Expr::Lit(Literal::Str("did:plc".into())),
4064                    ],
4065                ),
4066                transforms: vec![FieldTransform::AddField {
4067                    key: "has_did".into(),
4068                    value: Value::Bool(true),
4069                }],
4070            }],
4071        };
4072        apply_field_transforms(
4073            &mut node,
4074            &[case],
4075            &TransformContext::from_child_values(scalars),
4076        )
4077        .expect("transform should evaluate");
4078
4079        assert_eq!(
4080            node.extra_fields.get("has_did"),
4081            Some(&Value::Bool(true)),
4082            "Case predicate should evaluate against child scalar values"
4083        );
4084    }
4085
4086    #[test]
4087    #[allow(clippy::expect_used)]
4088    fn drop_field_on_extra_field_still_works() {
4089        let mut node = Node::new(0, "v");
4090        node.extra_fields
4091            .insert("keep".into(), Value::Str("yes".into()));
4092        node.extra_fields
4093            .insert("drop_me".into(), Value::Str("bye".into()));
4094
4095        let transform = FieldTransform::DropField {
4096            key: "drop_me".into(),
4097        };
4098        apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
4099            .expect("transform should evaluate");
4100
4101        assert!(node.extra_fields.contains_key("keep"));
4102        assert!(!node.extra_fields.contains_key("drop_me"));
4103    }
4104
4105    #[test]
4106    #[allow(clippy::expect_used)]
4107    fn child_scalars_do_not_override_extra_fields() {
4108        // When a key exists in both extra_fields and child_scalars,
4109        // extra_fields must take precedence (binding order correctness).
4110        let mut node = Node::new(0, "v");
4111        node.extra_fields
4112            .insert("repo".into(), Value::Str("from_extra_fields".into()));
4113
4114        let mut child_scalars = HashMap::new();
4115        child_scalars.insert("repo".into(), Value::Str("from_child".into()));
4116
4117        let expr = panproto_expr::Expr::Var(std::sync::Arc::from("repo"));
4118        let transform = FieldTransform::ComputeField {
4119            target_key: "repo_copy".to_string(),
4120            expr,
4121            inverse: None,
4122            coercion_class: panproto_gat::CoercionClass::Projection,
4123        };
4124        apply_field_transforms(
4125            &mut node,
4126            &[transform],
4127            &TransformContext::from_child_values(child_scalars.clone()),
4128        )
4129        .expect("transform should evaluate");
4130
4131        assert_eq!(
4132            node.extra_fields.get("repo_copy"),
4133            Some(&Value::Str("from_extra_fields".into())),
4134            "extra_fields must take precedence over child_scalars"
4135        );
4136    }
4137
4138    #[test]
4139    fn collect_scalar_child_values_completeness() {
4140        let (instance, scalars) = instance_with_scalar_children();
4141        assert_eq!(scalars.len(), 2, "should collect both scalar children");
4142        assert_eq!(
4143            scalars.get("repo"),
4144            Some(&Value::Str(
4145                "at://did:plc:abc/app.bsky.feed.post/rkey123".into()
4146            ))
4147        );
4148        assert_eq!(scalars.get("text"), Some(&Value::Str("hello world".into())));
4149
4150        // Root node has no parent, so collecting from a non-existent parent returns empty
4151        assert!(collect_scalar_child_values(&instance, 99).is_empty());
4152    }
4153
4154    #[test]
4155    fn env_monotonicity() {
4156        // build_env_with_children must bind every key that
4157        // build_env_from_extra_fields binds, with the same value.
4158        let mut extra = HashMap::new();
4159        extra.insert("alpha".into(), Value::Str("a".into()));
4160        extra.insert("beta".into(), Value::Int(42));
4161
4162        let mut children = HashMap::new();
4163        children.insert("gamma".into(), Value::Str("g".into()));
4164        children.insert("delta".into(), Value::Bool(true));
4165
4166        let env_base = build_env_from_extra_fields(&extra);
4167        let env_extended = build_env_with_children(&extra, &children);
4168
4169        // Every binding from base must be present in extended
4170        let config = panproto_expr::EvalConfig::default();
4171        for key in ["alpha", "beta"] {
4172            let var = panproto_expr::Expr::Var(std::sync::Arc::from(key));
4173            let base_result = panproto_expr::eval(&var, &env_base, &config).ok();
4174            let ext_result = panproto_expr::eval(&var, &env_extended, &config).ok();
4175            assert_eq!(
4176                base_result, ext_result,
4177                "binding for {key} must match between base and extended env"
4178            );
4179        }
4180
4181        // Extended env should also have child bindings
4182        for key in ["gamma", "delta"] {
4183            let var = panproto_expr::Expr::Var(std::sync::Arc::from(key));
4184            assert!(
4185                panproto_expr::eval(&var, &env_extended, &config).is_ok(),
4186                "extended env should bind child scalar {key}"
4187            );
4188        }
4189    }
4190
4191    #[test]
4192    #[allow(clippy::expect_used)]
4193    fn compute_field_deterministic() {
4194        // Applying the same ComputeField twice produces the same result
4195        // (fiber endomorphism idempotence when source data is unchanged).
4196        let (_instance, scalars) = instance_with_scalar_children();
4197        let expr = panproto_expr::Expr::Var(std::sync::Arc::from("repo"));
4198        let transform = FieldTransform::ComputeField {
4199            target_key: "derived".to_string(),
4200            expr,
4201            inverse: None,
4202            coercion_class: panproto_gat::CoercionClass::Projection,
4203        };
4204
4205        let mut node1 = Node::new(0, "body");
4206        apply_field_transforms(
4207            &mut node1,
4208            std::slice::from_ref(&transform),
4209            &TransformContext::from_child_values(scalars.clone()),
4210        )
4211        .expect("transform should evaluate");
4212        let result1 = node1.extra_fields.get("derived").cloned();
4213
4214        let mut node2 = Node::new(0, "body");
4215        apply_field_transforms(
4216            &mut node2,
4217            std::slice::from_ref(&transform),
4218            &TransformContext::from_child_values(scalars),
4219        )
4220        .expect("transform should evaluate");
4221        let result2 = node2.extra_fields.get("derived").cloned();
4222
4223        assert_eq!(result1, result2, "ComputeField must be deterministic");
4224    }
4225
4226    /// Every [`FieldTransform`] variant, translated to a [`TermAssignment`]
4227    /// and lowered back, produces the same result as applying the original
4228    /// transform directly. This certifies that the term-assignment algebra
4229    /// faithfully re-expresses each field transform.
4230    #[test]
4231    #[allow(clippy::expect_used)]
4232    fn field_transform_term_equivalence() {
4233        use panproto_expr::{BuiltinOp, Expr, Literal};
4234        use std::sync::Arc;
4235
4236        fn fixture() -> Node {
4237            let mut node = Node::new(0, "row");
4238            node.extra_fields
4239                .insert("a".into(), Value::Str("hello".into()));
4240            node.extra_fields.insert(
4241                "refs".into(),
4242                Value::List(vec![Value::Str("x".into()), Value::Str("keep".into())]),
4243            );
4244            let mut attrs = HashMap::new();
4245            attrs.insert("k".into(), Value::Int(1));
4246            node.extra_fields
4247                .insert("attrs".into(), Value::Unknown(attrs));
4248            node
4249        }
4250
4251        let variants: Vec<FieldTransform> = vec![
4252            FieldTransform::RenameField {
4253                old_key: "a".into(),
4254                new_key: "b".into(),
4255            },
4256            FieldTransform::DropField { key: "a".into() },
4257            FieldTransform::AddField {
4258                key: "c".into(),
4259                value: Value::Int(7),
4260            },
4261            FieldTransform::KeepFields {
4262                keys: vec!["a".into()],
4263            },
4264            FieldTransform::ApplyExpr {
4265                key: "a".into(),
4266                expr: Expr::Builtin(
4267                    BuiltinOp::Concat,
4268                    vec![
4269                        Expr::Var(Arc::from("a")),
4270                        Expr::Lit(Literal::Str("!".into())),
4271                    ],
4272                ),
4273                inverse: None,
4274                coercion_class: panproto_gat::CoercionClass::Opaque,
4275            },
4276            FieldTransform::ComputeField {
4277                target_key: "d".into(),
4278                expr: Expr::Var(Arc::from("a")),
4279                inverse: None,
4280                coercion_class: panproto_gat::CoercionClass::Projection,
4281            },
4282            FieldTransform::PathTransform {
4283                path: vec!["attrs".into()],
4284                inner: Box::new(FieldTransform::RenameField {
4285                    old_key: "k".into(),
4286                    new_key: "kk".into(),
4287                }),
4288            },
4289            FieldTransform::MapReferences {
4290                field: "refs".into(),
4291                rename_map: HashMap::from([("x".to_string(), Some("y".to_string()))]),
4292            },
4293            FieldTransform::Case {
4294                branches: vec![CaseBranch {
4295                    predicate: Expr::Lit(Literal::Bool(true)),
4296                    transforms: vec![FieldTransform::AddField {
4297                        key: "flag".into(),
4298                        value: Value::Bool(true),
4299                    }],
4300                }],
4301            },
4302        ];
4303
4304        let apply = |ft: &FieldTransform| {
4305            let mut node = fixture();
4306            let ctx = TransformContext::detached();
4307            apply_field_transforms(&mut node, std::slice::from_ref(ft), &ctx)
4308                .expect("transform should evaluate");
4309            node
4310        };
4311
4312        for ft in &variants {
4313            let direct = apply(ft);
4314
4315            // Round-trip the transform through the term-assignment algebra
4316            // and apply the lowered assignment.
4317            let assignment = TermAssignment::from_field_transform(ft);
4318            let via_term = apply(&assignment.to_field_transform());
4319
4320            assert_eq!(
4321                direct.extra_fields, via_term.extra_fields,
4322                "term-assignment path must match direct field transform for {ft:?}",
4323            );
4324
4325            // The flat-row substitution path agrees for the whole-row cases
4326            // that carry over to a relational row.
4327            let mut row = fixture().extra_fields;
4328            apply_term_assignments_to_row(&mut row, std::slice::from_ref(&assignment))
4329                .expect("transform should evaluate");
4330            assert_eq!(
4331                row, direct.extra_fields,
4332                "flat-row term substitution must match for {ft:?}",
4333            );
4334        }
4335    }
4336
4337    // --- Property-based tests ---
4338
4339    #[cfg(test)]
4340    #[allow(clippy::unwrap_used)]
4341    mod property {
4342        use super::*;
4343        use proptest::prelude::*;
4344
4345        /// Generate a random schema + instance with N scalar children
4346        /// under a root object node.
4347        fn arb_instance_with_scalars()
4348        -> impl Strategy<Value = (WInstance, HashMap<String, Value>, Vec<String>)> {
4349            (1..=5usize).prop_flat_map(|n| {
4350                prop::collection::vec("[a-z]{1,8}".prop_map(String::from), n..=n).prop_flat_map(
4351                    move |values| {
4352                        prop::collection::vec("[a-z]{1,6}".prop_map(String::from), n..=n).prop_map(
4353                            move |names| {
4354                                let values = values.clone();
4355                                // Deduplicate names
4356                                let mut seen = std::collections::HashSet::new();
4357                                let deduped: Vec<String> = names
4358                                    .iter()
4359                                    .map(|name| {
4360                                        let mut candidate = name.clone();
4361                                        let mut i = 0;
4362                                        while seen.contains(&candidate) {
4363                                            candidate = format!("{name}{i}");
4364                                            i += 1;
4365                                        }
4366                                        seen.insert(candidate.clone());
4367                                        candidate
4368                                    })
4369                                    .collect();
4370
4371                                let mut nodes = HashMap::new();
4372                                nodes.insert(0, Node::new(0, "root"));
4373
4374                                let mut arcs = Vec::new();
4375                                for (i, (name, val)) in
4376                                    deduped.iter().zip(values.iter()).enumerate()
4377                                {
4378                                    let nid = u32::try_from(i + 1).unwrap();
4379                                    let anchor = format!("root.{name}");
4380                                    nodes.insert(
4381                                        nid,
4382                                        Node::new(nid, anchor.as_str()).with_value(
4383                                            FieldPresence::Present(Value::Str(val.clone())),
4384                                        ),
4385                                    );
4386                                    arcs.push((
4387                                        0,
4388                                        nid,
4389                                        Edge {
4390                                            src: "root".into(),
4391                                            tgt: Name::from(anchor.as_str()),
4392                                            kind: "prop".into(),
4393                                            name: Some(Name::from(name.as_str())),
4394                                        },
4395                                    ));
4396                                }
4397
4398                                let instance =
4399                                    WInstance::new(nodes, arcs, vec![], 0, "root".into());
4400                                let scalars = collect_scalar_child_values(&instance, 0);
4401                                (instance, scalars, deduped)
4402                            },
4403                        )
4404                    },
4405                )
4406            })
4407        }
4408
4409        proptest! {
4410            #![proptest_config(ProptestConfig::with_cases(128))]
4411
4412            #[test]
4413            fn prop_child_scalar_collection_complete(
4414                (_instance, scalars, names) in arb_instance_with_scalars()
4415            ) {
4416                // Every child name must appear in the scalar collection.
4417                for name in &names {
4418                    prop_assert!(
4419                        scalars.contains_key(name),
4420                        "child scalar {name} missing from collection"
4421                    );
4422                }
4423                prop_assert_eq!(
4424                    scalars.len(), names.len(),
4425                    "scalar count must match child count"
4426                );
4427            }
4428
4429            #[test]
4430            #[allow(clippy::expect_used)]
4431            fn prop_compute_field_reads_any_child(
4432                (_instance, scalars, names) in arb_instance_with_scalars()
4433            ) {
4434                // ComputeField should be able to read any child scalar by name.
4435                for name in &names {
4436                    let expr = panproto_expr::Expr::Var(std::sync::Arc::from(name.as_str()));
4437                    let transform = FieldTransform::ComputeField {
4438                        target_key: format!("{name}_copy"),
4439                        expr,
4440                        inverse: None,
4441                        coercion_class: panproto_gat::CoercionClass::Projection,
4442                    };
4443                    let mut node = Node::new(0, "root");
4444                    apply_field_transforms(&mut node, &[transform], &TransformContext::from_child_values(scalars.clone())).expect("transform should evaluate");
4445                    let expected = scalars.get(name);
4446                    let actual = node.extra_fields.get(&format!("{name}_copy"));
4447                    prop_assert_eq!(
4448                        actual, expected,
4449                        "ComputeField should read child scalar"
4450                    );
4451                }
4452            }
4453
4454            #[test]
4455            fn prop_env_monotonicity(
4456                (_instance, scalars, _names) in arb_instance_with_scalars()
4457            ) {
4458                // Adding child_scalars must not remove or change any existing
4459                // extra_field binding. (Monotonicity of environment extension.)
4460                let mut extra = HashMap::new();
4461                extra.insert("sentinel".into(), Value::Str("sentinel_val".into()));
4462
4463                let env_base = build_env_from_extra_fields(&extra);
4464                let env_extended = build_env_with_children(&extra, &scalars);
4465
4466                let var = panproto_expr::Expr::Var(std::sync::Arc::from("sentinel"));
4467                let config = panproto_expr::EvalConfig::default();
4468                let base_result = panproto_expr::eval(&var, &env_base, &config).ok();
4469                let ext_result = panproto_expr::eval(&var, &env_extended, &config).ok();
4470                prop_assert_eq!(
4471                    base_result, ext_result,
4472                    "existing extra_field binding must be preserved"
4473                );
4474            }
4475
4476            #[test]
4477            fn prop_identity_restrict_preserves_all_values(
4478                (instance, _scalars, _names) in arb_instance_with_scalars()
4479            ) {
4480                // Identity migration with empty field_transforms: passing
4481                // child_scalars must not corrupt the instance.
4482                use smallvec::SmallVec;
4483
4484                let mut vertices = HashMap::new();
4485                let mut edges_map = HashMap::new();
4486                let mut outgoing: HashMap<Name, SmallVec<Edge, 4>> = HashMap::new();
4487                let mut incoming: HashMap<Name, SmallVec<Edge, 4>> = HashMap::new();
4488                let mut between: HashMap<(Name, Name), SmallVec<Edge, 2>> = HashMap::new();
4489
4490                for node in instance.nodes.values() {
4491                    vertices.insert(
4492                        node.anchor.clone(),
4493                        panproto_schema::Vertex {
4494                            id: node.anchor.clone(),
4495                            kind: if node.value.is_some() { "string".into() } else { "object".into() },
4496                            nsid: None,
4497                        },
4498                    );
4499                }
4500                for (p, c, e) in &instance.arcs {
4501                    let _ = p;
4502                    let _ = c;
4503                    edges_map.insert(e.clone(), e.kind.clone());
4504                    outgoing.entry(e.src.clone()).or_default().push(e.clone());
4505                    incoming.entry(e.tgt.clone()).or_default().push(e.clone());
4506                    between.entry((e.src.clone(), e.tgt.clone())).or_default().push(e.clone());
4507                }
4508
4509                let schema = panproto_schema::Schema {
4510                    protocol: "test".into(),
4511                    vertices,
4512                    edges: edges_map,
4513                    hyper_edges: HashMap::new(),
4514                    constraints: HashMap::new(),
4515                    required: HashMap::new(),
4516                    nsids: HashMap::new(),
4517            entries: Vec::new(),
4518                    variants: HashMap::new(),
4519                    orderings: HashMap::new(),
4520                    recursion_points: HashMap::new(),
4521                    spans: HashMap::new(),
4522                    usage_modes: HashMap::new(),
4523                    nominal: HashMap::new(),
4524                    coercions: HashMap::new(),
4525                    mergers: HashMap::new(),
4526                    defaults: HashMap::new(),
4527                    policies: HashMap::new(),
4528                    outgoing,
4529                    incoming,
4530                    between,
4531                };
4532
4533                let surviving_verts = schema.vertices.keys().cloned().collect();
4534                let surviving_edges = schema.edges.keys().cloned().collect();
4535                let migration = CompiledMigration {
4536                    surviving_verts,
4537                    surviving_edges,
4538                    vertex_remap: HashMap::new(),
4539                    edge_remap: HashMap::new(),
4540                    resolver: HashMap::new(),
4541                    hyper_resolver: HashMap::new(),
4542                    field_transforms: HashMap::new(),
4543                    conditional_survival: HashMap::new(),
4544                    op_term_assignments: HashMap::new(),
4545                    expansion_path: HashMap::new(),
4546                };
4547
4548                let result = wtype_restrict(&instance, &schema, &schema, &migration);
4549                prop_assert!(result.is_ok(), "identity restrict should succeed");
4550                let restricted = result.unwrap();
4551                prop_assert_eq!(
4552                    restricted.node_count(), instance.node_count(),
4553                    "identity restrict must preserve node count"
4554                );
4555                for (&id, node) in &instance.nodes {
4556                    let r_node = restricted.nodes.get(&id).unwrap();
4557                    prop_assert_eq!(&node.anchor, &r_node.anchor);
4558                    prop_assert_eq!(&node.value, &r_node.value);
4559                    prop_assert_eq!(&node.extra_fields, &r_node.extra_fields);
4560                }
4561            }
4562        }
4563    }
4564}