1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct ComplementSpec {
18 pub kind: ComplementKind,
20 pub forward_defaults: Vec<DefaultRequirement>,
22 pub captured_data: Vec<CapturedField>,
24 pub summary: String,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ComplementKind {
32 Empty,
34 DataCaptured,
36 DefaultsRequired,
38 Mixed,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct DefaultRequirement {
46 pub element_name: Name,
48 pub element_kind: String,
50 pub description: String,
52 pub suggested_default: Option<Value>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct CapturedField {
60 pub element_name: Name,
62 pub element_kind: String,
64 pub description: String,
66}
67
68#[must_use]
70pub fn complement_spec_at(protolens: &Protolens, schema: &Schema) -> ComplementSpec {
71 spec_from_constructor(&protolens.complement_constructor, schema)
72}
73
74#[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, ¤t_schema);
96 all_defaults.extend(spec.forward_defaults);
97 all_captured.extend(spec.captured_data);
98 if let Ok(next) = step.target_schema(¤t_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 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
212fn 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
244fn 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
263fn 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
280fn 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 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
331const 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 #[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 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 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}