Skip to main content

panproto_lens/
complement_type.rs

1//! Complement type system for protolenses.
2//!
3//! Given a protolens η and schema S, compute the complement type
4//! `ComplementType(η, S)`. This is a dependent type: the complement
5//! varies with the schema the protolens is instantiated at.
6
7use panproto_gat::Name;
8use panproto_inst::value::Value;
9use panproto_schema::{Protocol, Schema};
10use serde::{Deserialize, Serialize};
11
12use crate::protolens::{ComplementConstructor, Protolens, ProtolensChain};
13
14/// Static specification of what data a complement will contain.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct ComplementSpec {
18    /// Overall classification.
19    pub kind: ComplementKind,
20    /// What the user must supply for forward direction.
21    pub forward_defaults: Vec<DefaultRequirement>,
22    /// What data is captured in the complement for backward direction.
23    pub captured_data: Vec<CapturedField>,
24    /// Human-readable summary.
25    pub summary: String,
26}
27
28/// Classification of a complement's role.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ComplementKind {
32    /// No complement needed (isomorphism).
33    Empty,
34    /// Data captured in complement (lossy forward).
35    DataCaptured,
36    /// User must provide defaults (lossy backward).
37    DefaultsRequired,
38    /// Both.
39    Mixed,
40}
41
42/// A default value that must be supplied for the forward direction.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct DefaultRequirement {
46    /// Name of the element needing a default.
47    pub element_name: Name,
48    /// What kind: "sort" or "op" or "equation".
49    pub element_kind: String,
50    /// Human-readable description.
51    pub description: String,
52    /// Suggested default if known.
53    pub suggested_default: Option<Value>,
54}
55
56/// A field captured in the complement during the forward direction.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct CapturedField {
60    /// Name of the captured element.
61    pub element_name: Name,
62    /// What kind: "sort" or "op".
63    pub element_kind: String,
64    /// Human-readable description.
65    pub description: String,
66}
67
68/// Compute the complement spec for a single protolens at a specific schema.
69#[must_use]
70pub fn complement_spec_at(protolens: &Protolens, schema: &Schema) -> ComplementSpec {
71    spec_from_constructor(&protolens.complement_constructor, schema)
72}
73
74/// Compute the complement spec for a protolens chain at a specific schema.
75#[must_use]
76pub fn chain_complement_spec(
77    chain: &ProtolensChain,
78    schema: &Schema,
79    protocol: &Protocol,
80) -> ComplementSpec {
81    if chain.steps.is_empty() {
82        return ComplementSpec {
83            kind: ComplementKind::Empty,
84            forward_defaults: vec![],
85            captured_data: vec![],
86            summary: "Identity transformation, no complement needed.".into(),
87        };
88    }
89
90    let mut all_defaults = Vec::new();
91    let mut all_captured = Vec::new();
92    let mut current_schema = schema.clone();
93
94    for step in &chain.steps {
95        let spec = complement_spec_at(step, &current_schema);
96        all_defaults.extend(spec.forward_defaults);
97        all_captured.extend(spec.captured_data);
98        if let Ok(next) = step.target_schema(&current_schema, protocol) {
99            current_schema = next;
100        }
101    }
102
103    let kind = classify(&all_defaults, &all_captured);
104    let summary = build_summary(&kind, &all_defaults, &all_captured);
105
106    ComplementSpec {
107        kind,
108        forward_defaults: all_defaults,
109        captured_data: all_captured,
110        summary,
111    }
112}
113
114fn spec_from_constructor(constructor: &ComplementConstructor, schema: &Schema) -> ComplementSpec {
115    match constructor {
116        ComplementConstructor::Empty => ComplementSpec {
117            kind: ComplementKind::Empty,
118            forward_defaults: vec![],
119            captured_data: vec![],
120            summary: "Lossless transformation.".into(),
121        },
122        ComplementConstructor::DroppedSortData { sort } => {
123            // Count how many vertices of this sort exist in the schema.
124            let count = schema.vertices.values().filter(|v| v.kind == *sort).count();
125            ComplementSpec {
126                kind: ComplementKind::DataCaptured,
127                forward_defaults: vec![],
128                captured_data: vec![CapturedField {
129                    element_name: sort.clone(),
130                    element_kind: "sort".into(),
131                    description: format!(
132                        "Data for {count} vertices of kind '{sort}' will be captured in the complement."
133                    ),
134                }],
135                summary: format!("Drops sort '{sort}': {count} vertices captured in complement."),
136            }
137        }
138        ComplementConstructor::DroppedOpData { op } => {
139            let count = schema.edges.keys().filter(|e| e.kind == *op).count();
140            ComplementSpec {
141                kind: ComplementKind::DataCaptured,
142                forward_defaults: vec![],
143                captured_data: vec![CapturedField {
144                    element_name: op.clone(),
145                    element_kind: "op".into(),
146                    description: format!(
147                        "{count} edges of kind '{op}' will be captured in the complement.",
148                    ),
149                }],
150                summary: format!("Drops operation '{op}': {count} edges captured."),
151            }
152        }
153        ComplementConstructor::DroppedEdge {
154            src,
155            tgt,
156            edge_name,
157            ..
158        } => dropped_edge_spec(src, tgt, edge_name.as_ref()),
159        ComplementConstructor::AddedElement {
160            element_name,
161            element_kind,
162            default_value,
163        } => added_element_spec(element_name, element_kind, default_value.as_ref()),
164        ComplementConstructor::NatTransKernel { nat_trans_name } => ComplementSpec {
165            kind: ComplementKind::DataCaptured,
166            forward_defaults: vec![],
167            captured_data: vec![CapturedField {
168                element_name: nat_trans_name.clone(),
169                element_kind: "nat_trans".into(),
170                description: format!(
171                    "Kernel of natural transformation '{nat_trans_name}' captured in complement.",
172                ),
173            }],
174            summary: format!("Value conversion via '{nat_trans_name}': kernel captured."),
175        },
176        ComplementConstructor::CoercedSortData { sort, class } => {
177            coerced_sort_spec(sort, *class, schema)
178        }
179        ComplementConstructor::Composite(parts) => {
180            let mut all_defaults = Vec::new();
181            let mut all_captured = Vec::new();
182            for part in parts {
183                let sub = spec_from_constructor(part, schema);
184                all_defaults.extend(sub.forward_defaults);
185                all_captured.extend(sub.captured_data);
186            }
187            let kind = classify(&all_defaults, &all_captured);
188            let summary = build_summary(&kind, &all_defaults, &all_captured);
189            ComplementSpec {
190                kind,
191                forward_defaults: all_defaults,
192                captured_data: all_captured,
193                summary,
194            }
195        }
196        ComplementConstructor::Scoped { focus, inner } => {
197            let inner_spec = spec_from_constructor(inner, schema);
198            let kind = inner_spec.kind;
199            ComplementSpec {
200                kind,
201                forward_defaults: inner_spec.forward_defaults,
202                captured_data: inner_spec.captured_data,
203                summary: format!("Scoped at '{focus}': {}", inner_spec.summary),
204            }
205        }
206        ComplementConstructor::Enrichment { kind, enricher } => {
207            enrichment_spec(*kind, enricher, schema)
208        }
209    }
210}
211
212/// Build a `ComplementSpec` for an enrichment-fibre complement.
213fn enrichment_spec(
214    kind: panproto_gat::EnrichmentKind,
215    enricher: &std::sync::Arc<str>,
216    schema: &Schema,
217) -> ComplementSpec {
218    let count = schema
219        .constraints
220        .values()
221        .filter(|cs| cs.iter().any(|c| kind.is_member_sort(c.sort.as_ref())))
222        .count();
223    ComplementSpec {
224        kind: ComplementKind::DataCaptured,
225        forward_defaults: vec![],
226        captured_data: vec![CapturedField {
227            element_name: Name::from(format!("enrichment/{kind:?}/{enricher}")),
228            element_kind: "enrichment".into(),
229            description: format!(
230                "{count} vertices carry constraints in the {kind:?} \
231                 enrichment fibre; the registered driver \
232                 '{enricher}' is responsible for materialising \
233                 them in the put direction."
234            ),
235        }],
236        summary: format!(
237            "{kind:?} enrichment via driver '{enricher}'; \
238             per-vertex fibre handled by the driver, not the \
239             WInstance complement."
240        ),
241    }
242}
243
244/// Build a `ComplementSpec` for an added element requiring a default.
245fn added_element_spec(
246    element_name: &Name,
247    element_kind: &str,
248    default_value: Option<&panproto_inst::value::Value>,
249) -> ComplementSpec {
250    ComplementSpec {
251        kind: ComplementKind::DefaultsRequired,
252        forward_defaults: vec![DefaultRequirement {
253            element_name: element_name.clone(),
254            element_kind: element_kind.to_string(),
255            description: format!("Default value needed for added {element_kind} '{element_name}'."),
256            suggested_default: default_value.cloned(),
257        }],
258        captured_data: vec![],
259        summary: format!("Adds {element_kind} '{element_name}': default required."),
260    }
261}
262
263/// Build a `ComplementSpec` for a single dropped edge.
264fn dropped_edge_spec(src: &Name, tgt: &Name, edge_name: Option<&Name>) -> ComplementSpec {
265    let label = edge_name.map_or_else(|| "unnamed".to_string(), ToString::to_string);
266    ComplementSpec {
267        kind: ComplementKind::DataCaptured,
268        forward_defaults: vec![],
269        captured_data: vec![CapturedField {
270            element_name: Name::from(format!("{src}--{label}-->{tgt}")),
271            element_kind: "edge".into(),
272            description: format!(
273                "Single edge '{src} --({label})--> {tgt}' captured in complement."
274            ),
275        }],
276        summary: format!("Drops edge '{src} --({label})--> {tgt}': captured in complement."),
277    }
278}
279
280/// Build a `ComplementSpec` for a coerced sort.
281fn coerced_sort_spec(
282    sort: &Name,
283    class: panproto_gat::CoercionClass,
284    schema: &Schema,
285) -> ComplementSpec {
286    let count = schema.vertices.values().filter(|v| v.kind == *sort).count();
287    let (kind, desc) = match class {
288        panproto_gat::CoercionClass::Iso => (
289            ComplementKind::Empty,
290            format!("Isomorphic coercion on sort '{sort}' ({count} vertices)."),
291        ),
292        panproto_gat::CoercionClass::Retraction => (
293            ComplementKind::DataCaptured,
294            format!("Retraction coercion on sort '{sort}' ({count} vertices): residual captured."),
295        ),
296        panproto_gat::CoercionClass::Projection => (
297            ComplementKind::Empty,
298            format!(
299                "Projection coercion on sort '{sort}' ({count} vertices): \
300                 derived values re-computed by get, no complement storage needed."
301            ),
302        ),
303        panproto_gat::CoercionClass::Opaque | _ => (
304            ComplementKind::DataCaptured,
305            format!(
306                "Opaque coercion on sort '{sort}' ({count} vertices): original values captured."
307            ),
308        ),
309    };
310    ComplementSpec {
311        kind,
312        forward_defaults: vec![],
313        // Only coercions that need complement storage (Retraction, Opaque)
314        // produce captured data. Iso stores nothing (lossless). Projection
315        // stores nothing (the derived value is re-computed by `get`
316        // deterministically from the source fiber; the source data itself
317        // survives via the tree structure's complement, not this coercion's).
318        captured_data: if class.needs_complement_storage() {
319            vec![CapturedField {
320                element_name: sort.clone(),
321                element_kind: "coerced_sort".into(),
322                description: desc.clone(),
323            }]
324        } else {
325            vec![]
326        },
327        summary: desc,
328    }
329}
330
331/// Classify the complement kind from defaults and captured fields.
332const fn classify(defaults: &[DefaultRequirement], captured: &[CapturedField]) -> ComplementKind {
333    match (defaults.is_empty(), captured.is_empty()) {
334        (true, true) => ComplementKind::Empty,
335        (false, true) => ComplementKind::DefaultsRequired,
336        (true, false) => ComplementKind::DataCaptured,
337        (false, false) => ComplementKind::Mixed,
338    }
339}
340
341fn build_summary(
342    kind: &ComplementKind,
343    defaults: &[DefaultRequirement],
344    captured: &[CapturedField],
345) -> String {
346    match kind {
347        ComplementKind::Empty => "Lossless transformation, no complement needed.".into(),
348        ComplementKind::DefaultsRequired => format!(
349            "{} default(s) required: {}",
350            defaults.len(),
351            defaults
352                .iter()
353                .map(|d| d.element_name.to_string())
354                .collect::<Vec<_>>()
355                .join(", ")
356        ),
357        ComplementKind::DataCaptured => format!(
358            "{} field(s) captured in complement: {}",
359            captured.len(),
360            captured
361                .iter()
362                .map(|c| c.element_name.to_string())
363                .collect::<Vec<_>>()
364                .join(", ")
365        ),
366        ComplementKind::Mixed => format!(
367            "{} default(s) required, {} field(s) captured in complement.",
368            defaults.len(),
369            captured.len()
370        ),
371    }
372}
373
374#[cfg(test)]
375#[allow(clippy::unwrap_used)]
376mod tests {
377    use super::*;
378    use crate::protolens::elementary;
379    use crate::tests::three_node_schema;
380    use panproto_inst::value::Value;
381
382    fn test_protocol() -> Protocol {
383        Protocol {
384            name: "test".into(),
385            schema_theory: "ThGraph".into(),
386            instance_theory: "ThWType".into(),
387            edge_rules: vec![],
388            obj_kinds: vec!["object".into(), "string".into(), "array".into()],
389            constraint_sorts: vec![],
390            ..Protocol::default()
391        }
392    }
393
394    /// Pin the JSON wire format. The TypeScript SDK declares this
395    /// shape in `bindings/typescript/src/protolens.ts` (`ComplementSpec`,
396    /// `ComplementKind`, `DefaultRequirement`, `CapturedField`): enum
397    /// variants in `snake_case` (`"empty" | "data_captured" |
398    /// "defaults_required" | "mixed"`), struct fields in `camelCase`
399    /// (`forwardDefaults`, `capturedData`, `elementName`,
400    /// `elementKind`, `suggestedDefault`). Without the
401    /// `#[serde(rename_all = ...)]` attributes Rust would emit
402    /// `"DataCaptured"` enum variants and `element_name` fields, and
403    /// every TS consumer reaching for those fields through the typed
404    /// API would see `undefined`.
405    #[test]
406    fn complement_spec_wire_format_matches_ts_sdk() {
407        use serde_json::{Value as JsonValue, json};
408        let spec = ComplementSpec {
409            kind: ComplementKind::DataCaptured,
410            forward_defaults: vec![DefaultRequirement {
411                element_name: Name::from("field_a"),
412                element_kind: "sort".to_owned(),
413                description: "needs a default".to_owned(),
414                suggested_default: None,
415            }],
416            captured_data: vec![CapturedField {
417                element_name: Name::from("field_b"),
418                element_kind: "op".to_owned(),
419                description: "captured".to_owned(),
420            }],
421            summary: "mixed".to_owned(),
422        };
423        let value: JsonValue = serde_json::to_value(&spec).unwrap();
424        assert_eq!(
425            value,
426            json!({
427                "kind": "data_captured",
428                "forwardDefaults": [{
429                    "elementName": "field_a",
430                    "elementKind": "sort",
431                    "description": "needs a default",
432                    "suggestedDefault": null,
433                }],
434                "capturedData": [{
435                    "elementName": "field_b",
436                    "elementKind": "op",
437                    "description": "captured",
438                }],
439                "summary": "mixed",
440            }),
441            "ComplementSpec wire format must match the TS SDK"
442        );
443        // All four ComplementKind variants must wire as snake_case
444        // to match the TS union type.
445        for (variant, wire) in [
446            (ComplementKind::Empty, "empty"),
447            (ComplementKind::DataCaptured, "data_captured"),
448            (ComplementKind::DefaultsRequired, "defaults_required"),
449            (ComplementKind::Mixed, "mixed"),
450        ] {
451            assert_eq!(
452                serde_json::to_value(&variant).unwrap(),
453                JsonValue::String(wire.to_owned()),
454                "ComplementKind::{variant:?} must serialize as {wire:?}"
455            );
456        }
457    }
458
459    #[test]
460    fn rename_sort_has_empty_complement() {
461        let schema = three_node_schema();
462        let p = elementary::rename_sort("string", "text");
463        let spec = complement_spec_at(&p, &schema);
464        assert_eq!(spec.kind, ComplementKind::Empty);
465        assert!(spec.forward_defaults.is_empty());
466        assert!(spec.captured_data.is_empty());
467    }
468
469    #[test]
470    fn drop_sort_captures_data() {
471        let schema = three_node_schema();
472        let p = elementary::drop_sort("string");
473        let spec = complement_spec_at(&p, &schema);
474        assert_eq!(spec.kind, ComplementKind::DataCaptured);
475        assert!(spec.captured_data.len() == 1);
476        assert_eq!(&*spec.captured_data[0].element_name, "string");
477    }
478
479    #[test]
480    fn add_sort_has_defaults_required_complement() {
481        let schema = three_node_schema();
482        let p = elementary::add_sort("tags", "array", Value::Null);
483        let spec = complement_spec_at(&p, &schema);
484        assert_eq!(spec.kind, ComplementKind::DefaultsRequired);
485        assert_eq!(spec.forward_defaults.len(), 1);
486        assert_eq!(&*spec.forward_defaults[0].element_name, "tags");
487    }
488
489    #[test]
490    fn drop_op_captures_data() {
491        let schema = three_node_schema();
492        let p = elementary::drop_op("prop");
493        let spec = complement_spec_at(&p, &schema);
494        assert_eq!(spec.kind, ComplementKind::DataCaptured);
495        assert!(spec.captured_data.len() == 1);
496        assert_eq!(&*spec.captured_data[0].element_name, "prop");
497    }
498
499    #[test]
500    fn empty_chain_is_empty() {
501        let schema = three_node_schema();
502        let protocol = test_protocol();
503        let chain = crate::protolens::ProtolensChain::new(vec![]);
504        let spec = chain_complement_spec(&chain, &schema, &protocol);
505        assert_eq!(spec.kind, ComplementKind::Empty);
506    }
507
508    #[test]
509    fn chain_with_drop_has_data_captured() {
510        let schema = three_node_schema();
511        let protocol = test_protocol();
512        let chain = crate::protolens::ProtolensChain::new(vec![elementary::drop_sort("string")]);
513        let spec = chain_complement_spec(&chain, &schema, &protocol);
514        assert_eq!(spec.kind, ComplementKind::DataCaptured);
515    }
516
517    #[test]
518    fn chain_mixed() {
519        let schema = three_node_schema();
520        let protocol = test_protocol();
521        // This chain has both adds (defaults required)
522        // and drops (data captured).
523        let chain = crate::protolens::ProtolensChain::new(vec![
524            elementary::add_sort("tags", "array", Value::Null),
525            elementary::drop_sort("string"),
526        ]);
527        let spec = chain_complement_spec(&chain, &schema, &protocol);
528        assert_eq!(spec.kind, ComplementKind::Mixed);
529    }
530
531    #[test]
532    fn summary_describes_complement() {
533        let schema = three_node_schema();
534        let p = elementary::drop_sort("string");
535        let spec = complement_spec_at(&p, &schema);
536        assert!(spec.summary.contains("string"));
537    }
538}