Skip to main content

panproto_lens/
lib.rs

1//! # panproto-lens
2//!
3//! Bidirectional lenses via protolens (schema-independent lens families).
4//!
5//! Every schema migration is a lens with a `get` direction (= restrict,
6//! projecting data forward) and a `put` direction (= restore from
7//! complement, bringing modifications back). The lens laws (`GetPut`
8//! and `PutGet`) guarantee round-trip fidelity.
9//!
10//! This crate provides:
11//!
12//! - **[`Lens`]**: An asymmetric lens backed by a compiled migration.
13//! - **[`get`]** / **[`put`]**: Forward and backward lens directions.
14//! - **[`Complement`]**: Data discarded by `get`, needed by `put`.
15//! - **[`Protolens`]**: A dependent function from schemas to lenses
16//!   (`Π(S : Schema | P(S)). Lens(F(S), G(S))`).
17//! - **[`ProtolensChain`]**: Composable sequence of protolenses.
18//! - **[`auto_generate`]**: Automatic protolens generation from two schemas.
19//! - **[`compose()`]**: Compose two lenses sequentially.
20//! - **[`check_laws`]**: Verify `GetPut` and `PutGet` on a test instance.
21//!
22//! The mathematical foundations are based on asymmetric lenses with
23//! complement (Diskin et al., 2011), GAT-based protolenses (natural
24//! transformations between theory endofunctors), and Cambria's
25//! approach to schema evolution (Ink & Switch, 2020).
26
27// Allow concrete HashMap/HashSet in public API signatures per ENGINEERING.md spec.
28#![allow(clippy::implicit_hasher)]
29
30pub mod asymmetric;
31pub mod auto_lens;
32pub mod candidate;
33pub mod coercion_laws;
34pub mod complement_type;
35pub mod compose;
36pub mod cost;
37pub mod diff_to_protolens;
38pub mod edit_error;
39pub mod edit_laws;
40pub mod edit_lens;
41pub mod edit_pipeline;
42pub mod edit_provenance;
43pub mod enrichment_registry;
44pub mod error;
45pub mod fibration;
46pub mod graph;
47pub mod hint;
48pub mod laws;
49pub mod layout_complement;
50pub mod optic;
51pub mod protolens;
52pub mod symbolic;
53pub mod symmetric;
54
55// Re-exports for convenience.
56pub use asymmetric::{Complement, get, put};
57pub use auto_lens::{
58    AutoLensConfig, AutoLensResult, Stringency, auto_generate, auto_generate_candidates,
59    auto_generate_candidates_with_hints, auto_generate_with_hints,
60};
61pub use candidate::{CandidateStep, LensCandidate};
62pub use complement_type::{
63    CapturedField, ComplementKind, ComplementSpec, DefaultRequirement, chain_complement_spec,
64    complement_spec_at,
65};
66pub use compose::compose;
67pub use cost::{chain_cost, complement_cost, verify_identity_cost, verify_subadditivity};
68pub use diff_to_protolens::{DiffSpec, KindChange, diff_to_lens, diff_to_protolens};
69pub use edit_error::EditLensError;
70pub use edit_lens::EditLens;
71pub use edit_pipeline::EditPipeline;
72pub use edit_provenance::EditProvenance;
73pub use error::{LawViolation, LensError};
74pub use graph::LensGraph;
75pub use laws::{check_get_put, check_laws, check_put_get};
76pub use optic::{OpticKind, classify_transform, refine_scoped_optic};
77pub use protolens::{
78    ComplementConstructor, FleetResult, Protolens, ProtolensChain, SchemaConstraint,
79    apply_to_fleet, combinators, elementary, horizontal_compose as protolens_horizontal,
80    lift_chain, lift_protolens, vertical_compose as protolens_vertical,
81};
82pub use symbolic::{SymbolicStep, simplify_steps};
83pub use symmetric::{CoherenceViolation, SymmetricLens};
84
85use panproto_inst::CompiledMigration;
86use panproto_schema::Schema;
87
88/// An asymmetric lens with complement tracking.
89///
90/// A `Lens` encapsulates a compiled migration between a source and target
91/// schema. The `get` direction projects data forward (restricting to the
92/// target view), while `put` restores the original source from a modified
93/// view and the complement.
94#[derive(Debug)]
95pub struct Lens {
96    /// The compiled migration driving the restrict operation.
97    pub compiled: CompiledMigration,
98    /// The source schema.
99    pub src_schema: Schema,
100    /// The target schema (view).
101    pub tgt_schema: Schema,
102}
103
104impl Lens {
105    /// Compute the composite coercion class of this lens's migration.
106    ///
107    /// Delegates to the underlying `CompiledMigration::coercion_class()`.
108    #[must_use]
109    pub fn coercion_class(&self) -> panproto_gat::CoercionClass {
110        self.compiled.coercion_class()
111    }
112}
113
114#[cfg(test)]
115pub(crate) mod tests {
116    use std::collections::HashMap;
117
118    use panproto_gat::Name;
119    use panproto_inst::value::{FieldPresence, Value};
120    use panproto_inst::{CompiledMigration, Node, WInstance};
121    use panproto_schema::{Edge, Schema, Vertex};
122    use smallvec::SmallVec;
123
124    use crate::Lens;
125
126    /// Build a 3-vertex schema: `post:body` (object) with two children
127    /// `post:body.text` (string) and `post:body.createdAt` (string).
128    pub fn three_node_schema() -> Schema {
129        let mut vertices = HashMap::new();
130        vertices.insert(
131            Name::from("post:body"),
132            Vertex {
133                id: "post:body".into(),
134                kind: "object".into(),
135                nsid: None,
136            },
137        );
138        vertices.insert(
139            Name::from("post:body.text"),
140            Vertex {
141                id: "post:body.text".into(),
142                kind: "string".into(),
143                nsid: None,
144            },
145        );
146        vertices.insert(
147            Name::from("post:body.createdAt"),
148            Vertex {
149                id: "post:body.createdAt".into(),
150                kind: "string".into(),
151                nsid: None,
152            },
153        );
154
155        let edge_text = Edge {
156            src: "post:body".into(),
157            tgt: "post:body.text".into(),
158            kind: "prop".into(),
159            name: Some("text".into()),
160        };
161        let edge_created = Edge {
162            src: "post:body".into(),
163            tgt: "post:body.createdAt".into(),
164            kind: "prop".into(),
165            name: Some("createdAt".into()),
166        };
167
168        let mut edges = HashMap::new();
169        edges.insert(edge_text.clone(), Name::from("prop"));
170        edges.insert(edge_created.clone(), Name::from("prop"));
171
172        let mut outgoing: HashMap<Name, SmallVec<Edge, 4>> = HashMap::new();
173        outgoing
174            .entry("post:body".into())
175            .or_default()
176            .push(edge_text.clone());
177        outgoing
178            .entry("post:body".into())
179            .or_default()
180            .push(edge_created.clone());
181
182        let mut incoming: HashMap<Name, SmallVec<Edge, 4>> = HashMap::new();
183        incoming
184            .entry("post:body.text".into())
185            .or_default()
186            .push(edge_text.clone());
187        incoming
188            .entry("post:body.createdAt".into())
189            .or_default()
190            .push(edge_created.clone());
191
192        let mut between: HashMap<(Name, Name), SmallVec<Edge, 2>> = HashMap::new();
193        between
194            .entry((Name::from("post:body"), Name::from("post:body.text")))
195            .or_default()
196            .push(edge_text);
197        between
198            .entry((Name::from("post:body"), Name::from("post:body.createdAt")))
199            .or_default()
200            .push(edge_created);
201
202        Schema {
203            protocol: "test".into(),
204            vertices,
205            edges,
206            hyper_edges: HashMap::new(),
207            constraints: HashMap::new(),
208            required: HashMap::new(),
209            nsids: HashMap::new(),
210            entries: Vec::new(),
211            variants: HashMap::new(),
212            orderings: HashMap::new(),
213            recursion_points: HashMap::new(),
214            spans: HashMap::new(),
215            usage_modes: HashMap::new(),
216            nominal: HashMap::new(),
217            coercions: HashMap::new(),
218            mergers: HashMap::new(),
219            defaults: HashMap::new(),
220            policies: HashMap::new(),
221            outgoing,
222            incoming,
223            between,
224        }
225    }
226
227    /// Build a 3-node W-type instance matching [`three_node_schema`].
228    pub fn three_node_instance() -> WInstance {
229        let mut nodes = HashMap::new();
230        nodes.insert(0, Node::new(0, "post:body"));
231        nodes.insert(
232            1,
233            Node::new(1, "post:body.text")
234                .with_value(FieldPresence::Present(Value::Str("hello".into()))),
235        );
236        nodes.insert(
237            2,
238            Node::new(2, "post:body.createdAt")
239                .with_value(FieldPresence::Present(Value::Str("2024-01-01".into()))),
240        );
241
242        let arcs = vec![
243            (
244                0,
245                1,
246                Edge {
247                    src: "post:body".into(),
248                    tgt: "post:body.text".into(),
249                    kind: "prop".into(),
250                    name: Some("text".into()),
251                },
252            ),
253            (
254                0,
255                2,
256                Edge {
257                    src: "post:body".into(),
258                    tgt: "post:body.createdAt".into(),
259                    kind: "prop".into(),
260                    name: Some("createdAt".into()),
261                },
262            ),
263        ];
264
265        WInstance::new(nodes, arcs, vec![], 0, Name::from("post:body"))
266    }
267
268    /// Build an identity lens for the given schema.
269    pub fn identity_lens(schema: &Schema) -> Lens {
270        let surviving_verts = schema.vertices.keys().cloned().collect();
271        let surviving_edges = schema.edges.keys().cloned().collect();
272
273        let compiled = CompiledMigration {
274            surviving_verts,
275            surviving_edges,
276            vertex_remap: HashMap::new(),
277            edge_remap: HashMap::new(),
278            resolver: HashMap::new(),
279            hyper_resolver: HashMap::new(),
280            field_transforms: HashMap::new(),
281            conditional_survival: HashMap::new(),
282            expansion_path: HashMap::new(),
283        };
284
285        Lens {
286            compiled,
287            src_schema: schema.clone(),
288            tgt_schema: schema.clone(),
289        }
290    }
291
292    /// Build a projection lens that removes a single field from the schema.
293    pub fn projection_lens(schema: &Schema, field_to_remove: &str) -> Lens {
294        let mut tgt_schema = schema.clone();
295
296        // Find and remove the edge + target vertex for this field
297        let edges_to_remove: Vec<Edge> = tgt_schema
298            .edges
299            .keys()
300            .filter(|e| e.name.as_deref() == Some(field_to_remove))
301            .cloned()
302            .collect();
303
304        let mut removed_vertices = Vec::new();
305        for edge in &edges_to_remove {
306            tgt_schema.edges.remove(edge);
307            tgt_schema.vertices.remove(&edge.tgt);
308            removed_vertices.push(edge.tgt.clone());
309        }
310
311        // Rebuild indices
312        crate::protolens::rebuild_indices(&mut tgt_schema);
313
314        let mut surviving_verts: std::collections::HashSet<Name> =
315            schema.vertices.keys().cloned().collect();
316        let mut surviving_edges: std::collections::HashSet<Edge> =
317            schema.edges.keys().cloned().collect();
318
319        for v in &removed_vertices {
320            surviving_verts.remove(v);
321        }
322        for e in &edges_to_remove {
323            surviving_edges.remove(e);
324        }
325
326        let compiled = CompiledMigration {
327            surviving_verts,
328            surviving_edges,
329            vertex_remap: HashMap::new(),
330            edge_remap: HashMap::new(),
331            resolver: HashMap::new(),
332            hyper_resolver: HashMap::new(),
333            field_transforms: HashMap::new(),
334            conditional_survival: HashMap::new(),
335            expansion_path: HashMap::new(),
336        };
337
338        Lens {
339            compiled,
340            src_schema: schema.clone(),
341            tgt_schema,
342        }
343    }
344
345    // -----------------------------------------------------------------------
346    // Test 1: Identity lens satisfies laws (covered in laws.rs)
347    // Test 2: Compose rename + add_field laws (below)
348    // Test 3: Round-trip get/put (below)
349    // Test 4: Modified view propagation (below)
350    // -----------------------------------------------------------------------
351
352    #[test]
353    fn round_trip_get_then_put_recovers_original() {
354        let schema = three_node_schema();
355        let lens = identity_lens(&schema);
356        let instance = three_node_instance();
357
358        let (view, complement) =
359            crate::get(&lens, &instance).unwrap_or_else(|e| panic!("get failed: {e}"));
360        let restored =
361            crate::put(&lens, &view, &complement).unwrap_or_else(|e| panic!("put failed: {e}"));
362
363        assert_eq!(restored.node_count(), instance.node_count());
364        assert_eq!(restored.root, instance.root);
365        assert_eq!(restored.schema_root, instance.schema_root);
366
367        // Verify all node anchors match
368        for (&id, node) in &instance.nodes {
369            let restored_node = restored
370                .nodes
371                .get(&id)
372                .unwrap_or_else(|| panic!("node {id} missing from restored instance"));
373            assert_eq!(
374                node.anchor, restored_node.anchor,
375                "anchor mismatch for node {id}"
376            );
377        }
378    }
379
380    #[test]
381    fn modified_view_propagates_changes() {
382        let schema = three_node_schema();
383        let lens = identity_lens(&schema);
384        let instance = three_node_instance();
385
386        // Get the view
387        let (mut view, complement) =
388            crate::get(&lens, &instance).unwrap_or_else(|e| panic!("get failed: {e}"));
389
390        // Modify a field in the view
391        if let Some(node) = view.nodes.get_mut(&1) {
392            node.value = Some(FieldPresence::Present(Value::Str("modified".into())));
393        }
394
395        // Put back
396        let restored =
397            crate::put(&lens, &view, &complement).unwrap_or_else(|e| panic!("put failed: {e}"));
398
399        // Verify the modification propagated
400        let node = restored
401            .nodes
402            .get(&1)
403            .unwrap_or_else(|| panic!("node 1 missing"));
404        assert_eq!(
405            node.value,
406            Some(FieldPresence::Present(Value::Str("modified".into()))),
407            "modification should be preserved"
408        );
409    }
410
411    #[test]
412    fn projection_lens_drops_field() {
413        let schema = three_node_schema();
414        let lens = projection_lens(&schema, "createdAt");
415        let instance = three_node_instance();
416
417        let (view, complement) =
418            crate::get(&lens, &instance).unwrap_or_else(|e| panic!("get failed: {e}"));
419
420        assert_eq!(view.node_count(), 2, "projection should drop one node");
421        assert!(
422            !complement.dropped_nodes.is_empty(),
423            "complement should have dropped node"
424        );
425    }
426
427    #[test]
428    fn projection_get_then_put_restores_with_complement() {
429        let schema = three_node_schema();
430        let lens = projection_lens(&schema, "createdAt");
431        let instance = three_node_instance();
432
433        let (view, complement) =
434            crate::get(&lens, &instance).unwrap_or_else(|e| panic!("get failed: {e}"));
435
436        let restored =
437            crate::put(&lens, &view, &complement).unwrap_or_else(|e| panic!("put failed: {e}"));
438
439        assert_eq!(
440            restored.node_count(),
441            instance.node_count(),
442            "restoration should bring back all nodes"
443        );
444    }
445
446    #[test]
447    fn compose_rename_then_identity_preserves_laws() {
448        let schema = three_node_schema();
449        let l1 = identity_lens(&schema);
450        let l2 = identity_lens(&schema);
451
452        let composed = crate::compose(&l1, &l2).unwrap_or_else(|e| panic!("compose failed: {e}"));
453        let instance = three_node_instance();
454
455        let result = crate::check_laws(&composed, &instance);
456        assert!(
457            result.is_ok(),
458            "composed identity lenses should satisfy laws: {result:?}"
459        );
460    }
461}