1use std::collections::BTreeSet;
20
21use crate::model::{BindingsDef, ParsedOp, Workload, WorkloadPhase};
22
23pub fn bind_implementation(
29 blueprint: &mut Workload,
30 implementation: Workload,
31) -> Result<(), String> {
32 let provided_global: BTreeSet<String> = blueprint
36 .declared_params
37 .iter()
38 .cloned()
39 .chain(bindings_wire_names(&blueprint.bindings))
40 .collect();
41
42 let mut unbound: BTreeSet<String> = BTreeSet::new();
43 for (phase_name, phase) in &blueprint.phases {
44 for op in &phase.ops {
45 if op.abstract_interface.is_some() {
46 unbound.insert(format!("{phase_name}.{}", op.name));
47 }
48 }
49 }
50
51 for (phase_name, impl_phase) in implementation.phases {
52 let offending = scaffolding_fields_set(&impl_phase);
53 if !offending.is_empty() {
54 return Err(format!(
55 "implementation phase '{phase_name}' declares scaffolding \
56 fields [{}] — scaffolding belongs to the blueprint; an \
57 implementation phase carries only `ops:`",
58 offending.join(", ")
59 ));
60 }
61 let Some(blueprint_phase) = blueprint.phases.get_mut(&phase_name) else {
62 return Err(format!(
63 "implementation provides phase '{phase_name}', but the \
64 blueprint declares no such phase"
65 ));
66 };
67 for impl_op in impl_phase.ops {
68 let slot_key = format!("{phase_name}.{}", impl_op.name);
69 let Some(slot) = blueprint_phase
70 .ops
71 .iter_mut()
72 .find(|op| op.name == impl_op.name)
73 else {
74 return Err(format!(
75 "implementation op '{slot_key}' names no op in the \
76 blueprint phase"
77 ));
78 };
79 if slot.abstract_interface.is_none() {
80 return Err(format!(
81 "implementation op '{slot_key}' targets a CONCRETE \
82 blueprint op — only abstract slots accept bindings"
83 ));
84 }
85 if !unbound.remove(&slot_key) {
86 return Err(format!("implementation binds slot '{slot_key}' twice"));
87 }
88 bind_slot(slot, impl_op, &slot_key)?;
89
90 let mut provided = provided_global.clone();
96 provided.extend(bindings_wire_names(&blueprint_phase.bindings));
97 if let Some(iface) = slot.abstract_interface.as_ref() {
98 for need in iface.needs.keys() {
99 if !provided.contains(need) {
100 return Err(format!(
101 "slot '{slot_key}' declares need '{need}', but \
102 the blueprint provides no param or binding \
103 by that name"
104 ));
105 }
106 }
107 }
108 }
109 }
110
111 if !unbound.is_empty() {
112 return Err(format!(
113 "abstract slot(s) [{}] remain unbound — the implementation \
114 must cover every abstract op",
115 unbound.into_iter().collect::<Vec<_>>().join(", ")
116 ));
117 }
118
119 let blueprint_declared: BTreeSet<String> = blueprint.declared_params.iter().cloned().collect();
131 let impl_declared: Vec<String> = implementation.declared_params;
132 for k in impl_declared {
133 if let Some(v) = implementation.params.get(&k) {
134 blueprint.params.insert(k.clone(), v.clone());
135 }
136 if !blueprint_declared.contains(&k) {
137 blueprint.declared_params.push(k);
138 }
139 }
140
141 concat_bindings(&mut blueprint.bindings, implementation.bindings)
143 .map_err(|e| format!("workload-level bindings: {e}"))?;
144
145 Ok(())
146}
147
148fn bind_slot(slot: &mut ParsedOp, impl_op: ParsedOp, slot_key: &str) -> Result<(), String> {
153 if impl_op.condition.is_some()
158 || impl_op.delay.is_some()
159 || !impl_op.metrics.is_empty()
160 || impl_op.traverse.is_some()
161 || impl_op.wrappers.is_some()
162 || impl_op.while_cond.is_some()
163 || impl_op.rate.is_some()
164 || !matches!(impl_op.daemon, crate::model::DaemonSpec::Disabled)
165 {
166 return Err(format!(
167 "implementation op '{slot_key}' sets op semantics \
168 (if/delay/metrics/traverse/wrappers/while/rate/\
169 daemon) — those belong to the blueprint slot"
170 ));
171 }
172 if impl_op.abstract_interface.is_some() {
173 return Err(format!(
174 "implementation op '{slot_key}' declares `abstract:` — an \
175 implementation provides bodies, not interfaces"
176 ));
177 }
178
179 for (k, v) in impl_op.op {
180 if slot.op.contains_key(&k) {
181 return Err(format!(
182 "slot '{slot_key}': op field '{k}' declared by BOTH the \
183 blueprint slot and the implementation — the blueprint \
184 is authoritative; remove one"
185 ));
186 }
187 slot.op.insert(k, v);
188 }
189 for (k, v) in impl_op.params {
198 slot.params.insert(k, v);
199 }
200 for (k, v) in impl_op.tags {
201 match slot.tags.get(&k) {
202 Some(existing) if existing != &v => {
203 return Err(format!(
204 "slot '{slot_key}': tag '{k}' has conflicting values \
205 ('{existing}' vs '{v}')"
206 ));
207 }
208 _ => {
209 slot.tags.insert(k, v);
210 }
211 }
212 }
213 if !impl_op.captures.is_empty() {
214 let existing: BTreeSet<&str> = slot.captures.iter().map(|c| c.as_name.as_str()).collect();
215 for cap in &impl_op.captures {
216 if existing.contains(cap.as_name.as_str()) {
217 return Err(format!(
218 "slot '{slot_key}': capture '{}' declared by both \
219 sides",
220 cap.as_name
221 ));
222 }
223 }
224 slot.captures.extend(impl_op.captures);
225 }
226 concat_bindings(&mut slot.bindings, impl_op.bindings)
227 .map_err(|e| format!("slot '{slot_key}' bindings: {e}"))?;
228
229 if let Some(iface) = slot.abstract_interface.as_ref() {
235 let blueprint_declared = result_binding_names(slot.result.as_ref());
236 for results_name in iface.results.keys() {
237 if blueprint_declared.contains(results_name.as_str()) {
238 return Err(format!(
239 "slot '{slot_key}': `results:` wire '{results_name}' \
240 also has a blueprint-side `result:` binding — the \
241 projection path is protocol matter; the \
242 implementation declares it"
243 ));
244 }
245 }
246 }
247 if let Some(impl_result) = impl_op.result {
248 let blueprint_declared = result_binding_names(slot.result.as_ref());
249 for name in result_binding_names(Some(&impl_result)) {
250 if blueprint_declared.contains(name.as_str()) {
251 return Err(format!(
252 "slot '{slot_key}': result binding '{name}' declared \
253 by BOTH the blueprint slot and the implementation — \
254 remove one"
255 ));
256 }
257 }
258 slot.result = Some(match slot.result.take() {
259 None => impl_result,
260 Some(existing) => crate::model::ResultSpec::List(vec![existing, impl_result]),
261 });
262 }
263
264 if let Some(iface) = slot.abstract_interface.as_ref() {
269 let delivered: BTreeSet<&str> = slot.captures.iter().map(|c| c.as_name.as_str()).collect();
270 for yield_name in iface.yields.keys() {
271 if !delivered.contains(yield_name.as_str()) {
272 return Err(format!(
273 "slot '{slot_key}' promises yield '{yield_name}', \
274 but the bound implementation captures [{}] — add a \
275 capture (`[{yield_name}]` or `... as {yield_name}`)",
276 delivered.into_iter().collect::<Vec<_>>().join(", ")
277 ));
278 }
279 }
280 let projected = result_binding_names(slot.result.as_ref());
281 for results_name in iface.results.keys() {
282 if !projected.contains(results_name.as_str()) {
283 return Err(format!(
284 "slot '{slot_key}' promises results wire \
285 '{results_name}', but the bound implementation \
286 declares result bindings [{}] — add a `result:` \
287 entry (`{results_name}: <path-expr>`, e.g. \
288 `{results_name}: rows[*].column`)",
289 projected.iter().cloned().collect::<Vec<_>>().join(", ")
290 ));
291 }
292 }
293 }
294
295 slot.interface_bound = true;
296 Ok(())
297}
298
299fn result_binding_names(spec: Option<&crate::model::ResultSpec>) -> BTreeSet<String> {
302 let mut out = BTreeSet::new();
303 if let Some(spec) = spec {
304 spec.walk_fragments(|frag| match frag {
305 crate::model::ResultFragment::Named { name, .. } => {
306 out.insert(name.to_string());
307 }
308 crate::model::ResultFragment::Source(source) => {
309 for line in source.lines() {
310 if let Some((lhs, _)) = line.trim().split_once(":=") {
311 out.insert(lhs.trim().to_string());
312 }
313 }
314 }
315 });
316 }
317 out
318}
319
320fn scaffolding_fields_set(phase: &WorkloadPhase) -> Vec<&'static str> {
323 let mut out = Vec::new();
324 if phase.cycles.is_some() {
325 out.push("cycles");
326 }
327 if phase.concurrency.is_some() {
328 out.push("concurrency");
329 }
330 if phase.rate.is_some() {
331 out.push("rate");
332 }
333 if phase.daemon {
334 out.push("daemon");
335 }
336 if phase.adapter.is_some() {
337 out.push("adapter");
338 }
339 if phase.errors.is_some() {
340 out.push("errors");
341 }
342 if phase.tries.is_some() {
343 out.push("tries");
344 }
345 if phase.tries_backoff.is_some() {
346 out.push("tries_backoff");
347 }
348 if phase.interval.is_some() {
349 out.push("interval");
350 }
351 if phase.repeat.is_some() {
352 out.push("repeat");
353 }
354 if phase.error_rate_max.is_some() {
355 out.push("error_rate_max");
356 }
357 if phase.timeout.is_some() {
358 out.push("timeout");
359 }
360 if !phase.stop_when.is_empty() {
361 out.push("stop_when");
362 }
363 if phase.tags.is_some() {
364 out.push("tags");
365 }
366 if phase.for_each.is_some() {
367 out.push("for_each");
368 }
369 if phase.continue_if.is_some() {
370 out.push("continue_if");
371 }
372 if phase.loop_scope.is_some() {
373 out.push("loop_scope");
374 }
375 if phase.iter_scope.is_some() {
376 out.push("iter_scope");
377 }
378 if phase.checkpoint.is_some() {
379 out.push("checkpoint");
380 }
381 if !phase.status_metrics.is_empty() {
382 out.push("status_metrics");
383 }
384 if !phase.bindings.is_empty() {
385 out.push("bindings");
386 }
387 if !phase.metrics.is_empty() {
388 out.push("metrics");
389 }
390 if !phase.dimensions.is_empty() {
391 out.push("dimensions");
392 }
393 out
394}
395
396fn bindings_wire_names(bindings: &BindingsDef) -> Vec<String> {
398 match bindings {
399 BindingsDef::PolydatSource(s) => crate::inline::binding_wire_names(s),
400 BindingsDef::Map(m) => m.keys().cloned().collect(),
401 }
402}
403
404fn concat_bindings(base: &mut BindingsDef, extra: BindingsDef) -> Result<(), String> {
408 if extra.is_empty() {
409 return Ok(());
410 }
411 if base.is_empty() {
412 *base = extra;
413 return Ok(());
414 }
415 match (&mut *base, extra) {
416 (BindingsDef::PolydatSource(b), BindingsDef::PolydatSource(e)) => {
417 if !b.ends_with('\n') {
418 b.push('\n');
419 }
420 b.push_str(&e);
421 Ok(())
422 }
423 (BindingsDef::Map(b), BindingsDef::Map(e)) => {
424 for (k, v) in e {
425 if b.contains_key(&k) {
426 return Err(format!("binding '{k}' declared by both sides"));
427 }
428 b.insert(k, v);
429 }
430 Ok(())
431 }
432 _ => Err("mixed bindings forms (map vs polydat source) cannot \
433 be concatenated — use one form on both sides"
434 .into()),
435 }
436}
437
438pub fn unbound_abstract_slots(workload: &Workload) -> Vec<String> {
441 let mut out = Vec::new();
442 for (phase_name, phase) in &workload.phases {
443 for op in &phase.ops {
444 if op.abstract_interface.is_some() && !op.interface_bound {
445 out.push(format!("{phase_name}.{}", op.name));
446 }
447 }
448 }
449 out.sort();
450 out
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use std::collections::HashMap;
457
458 fn parse(yaml: &str) -> Workload {
459 crate::parse::parse_workload(yaml, &HashMap::new()).expect("parse test workload")
460 }
461
462 const BLUEPRINT: &str = r#"
463params:
464 suite_k: "10"
465bindings: |
466 query_vector := "0.1,0.2"
467phases:
468 probe:
469 cycles: 4
470 concurrency: 2
471 ops:
472 search:
473 abstract:
474 needs:
475 query_vector: String
476 suite_k: u64
477 yields:
478 key: String
479"#;
480
481 const IMPL_OK: &str = r#"
482implements: blueprint
483phases:
484 probe:
485 ops:
486 search:
487 stmt: "SEARCH {query_vector} LIMIT {suite_k}"
488 captures: "[key]"
489"#;
490
491 #[test]
492 fn binds_and_marks_the_slot() {
493 let mut blueprint = parse(BLUEPRINT);
494 let implementation = parse(IMPL_OK);
495 bind_implementation(&mut blueprint, implementation).unwrap();
496 let op = &blueprint.phases["probe"].ops[0];
497 assert!(op.interface_bound);
498 assert!(op.op.contains_key("stmt"));
499 assert_eq!(op.captures.len(), 1);
500 assert!(unbound_abstract_slots(&blueprint).is_empty());
501 }
502
503 #[test]
511 fn impl_params_add_knobs_and_redefault_blueprint_params() {
512 let mut blueprint = parse(BLUEPRINT);
513 let implementation = parse(
514 r#"
515implements: blueprint
516params:
517 suite_k: "25"
518 knob: "x"
519phases:
520 probe:
521 ops:
522 search:
523 stmt: "SEARCH {query_vector} LIMIT {suite_k}"
524 captures: "[key]"
525"#,
526 );
527 bind_implementation(&mut blueprint, implementation).unwrap();
528 assert_eq!(blueprint.params["suite_k"], "25");
529 assert_eq!(blueprint.params["knob"], "x");
530 assert_eq!(
531 blueprint
532 .declared_params
533 .iter()
534 .filter(|k| k.as_str() == "suite_k")
535 .count(),
536 1,
537 "re-defaulting must not re-declare"
538 );
539 assert!(blueprint.declared_params.iter().any(|k| k == "knob"));
540 }
541
542 #[test]
543 fn missing_yield_capture_is_named() {
544 let mut blueprint = parse(BLUEPRINT);
545 let implementation = parse(
546 r#"
547implements: blueprint
548phases:
549 probe:
550 ops:
551 search:
552 stmt: "SEARCH"
553"#,
554 );
555 let err = bind_implementation(&mut blueprint, implementation).unwrap_err();
556 assert!(err.contains("yield 'key'"), "err: {err}");
557 }
558
559 #[test]
560 fn unknown_slot_and_uncovered_slot_are_errors() {
561 let mut blueprint = parse(BLUEPRINT);
562 let implementation = parse(
563 r#"
564implements: blueprint
565phases:
566 probe:
567 ops:
568 wrong_name:
569 stmt: "X"
570"#,
571 );
572 let err = bind_implementation(&mut blueprint, implementation).unwrap_err();
573 assert!(err.contains("wrong_name"), "err: {err}");
574
575 let mut blueprint = parse(BLUEPRINT);
576 let err =
577 bind_implementation(&mut blueprint, parse("implements: blueprint\n")).unwrap_err();
578 assert!(err.contains("remain unbound"), "err: {err}");
579 }
580
581 #[test]
582 fn scaffolding_on_implementation_phase_is_rejected() {
583 let mut blueprint = parse(BLUEPRINT);
584 let implementation = parse(
585 r#"
586implements: blueprint
587phases:
588 probe:
589 cycles: 99
590 ops:
591 search:
592 stmt: "X"
593 captures: "[key]"
594"#,
595 );
596 let err = bind_implementation(&mut blueprint, implementation).unwrap_err();
597 assert!(err.contains("scaffolding"), "err: {err}");
598 }
599
600 #[test]
601 fn undeclared_need_is_named() {
602 let mut blueprint = parse(
603 r#"
604phases:
605 probe:
606 cycles: 1
607 ops:
608 search:
609 abstract:
610 needs:
611 not_provided: u64
612"#,
613 );
614 let err = bind_implementation(
615 &mut blueprint,
616 parse(
617 r#"
618implements: blueprint
619phases:
620 probe:
621 ops:
622 search:
623 stmt: "X"
624"#,
625 ),
626 )
627 .unwrap_err();
628 assert!(err.contains("need 'not_provided'"), "err: {err}");
629 }
630
631 #[test]
632 fn op_field_collision_is_an_error() {
633 let mut blueprint = parse(
634 r#"
635phases:
636 probe:
637 cycles: 1
638 ops:
639 search:
640 stmt: "BLUEPRINT SIDE"
641 abstract:
642 yields:
643 key: String
644"#,
645 );
646 let err = bind_implementation(
647 &mut blueprint,
648 parse(
649 r#"
650implements: blueprint
651phases:
652 probe:
653 ops:
654 search:
655 stmt: "IMPL SIDE"
656 captures: "[key]"
657"#,
658 ),
659 )
660 .unwrap_err();
661 assert!(err.contains("'stmt'") && err.contains("BOTH"), "err: {err}");
662 }
663
664 #[test]
665 fn unbound_slots_are_reported() {
666 let blueprint = parse(BLUEPRINT);
667 assert_eq!(unbound_abstract_slots(&blueprint), vec!["probe.search"]);
668 }
669
670 const RESULTS_BLUEPRINT: &str = r#"
673phases:
674 probe:
675 cycles: 1
676 ops:
677 search:
678 abstract:
679 results:
680 keys: vec_i64
681"#;
682
683 #[test]
684 fn results_wire_delivered_by_impl_result_binding() {
685 let mut blueprint = parse(RESULTS_BLUEPRINT);
686 bind_implementation(
687 &mut blueprint,
688 parse(
689 r#"
690implements: blueprint
691phases:
692 probe:
693 ops:
694 search:
695 stmt: "SEARCH"
696 result:
697 keys: "rows[*].key"
698"#,
699 ),
700 )
701 .unwrap();
702 let op = &blueprint.phases["probe"].ops[0];
703 assert!(op.interface_bound);
704 assert!(result_binding_names(op.result.as_ref()).contains("keys"));
705 }
706
707 #[test]
708 fn missing_results_binding_is_named_with_remedy() {
709 let mut blueprint = parse(RESULTS_BLUEPRINT);
710 let err = bind_implementation(
711 &mut blueprint,
712 parse(
713 r#"
714implements: blueprint
715phases:
716 probe:
717 ops:
718 search:
719 stmt: "SEARCH"
720"#,
721 ),
722 )
723 .unwrap_err();
724 assert!(
725 err.contains("results wire") && err.contains("keys") && err.contains("path-expr"),
726 "err: {err}"
727 );
728 }
729
730 #[test]
731 fn blueprint_side_path_for_results_wire_is_a_collision() {
732 let mut blueprint = parse(
733 r#"
734phases:
735 probe:
736 cycles: 1
737 ops:
738 search:
739 result:
740 keys: "rows[*].key"
741 abstract:
742 results:
743 keys: vec_i64
744"#,
745 );
746 let err = bind_implementation(
747 &mut blueprint,
748 parse(
749 r#"
750implements: blueprint
751phases:
752 probe:
753 ops:
754 search:
755 stmt: "SEARCH"
756 result:
757 keys: "other[*].id"
758"#,
759 ),
760 )
761 .unwrap_err();
762 assert!(err.contains("protocol matter"), "err: {err}");
763 }
764
765 #[test]
766 fn result_binding_name_collision_across_sides_is_an_error() {
767 let mut blueprint = parse(
770 r#"
771phases:
772 probe:
773 cycles: 1
774 ops:
775 search:
776 result:
777 row_count: count
778 abstract:
779 results:
780 keys: vec_i64
781"#,
782 );
783 let err = bind_implementation(
784 &mut blueprint,
785 parse(
786 r#"
787implements: blueprint
788phases:
789 probe:
790 ops:
791 search:
792 stmt: "SEARCH"
793 result:
794 row_count: count
795 keys: "rows[*].key"
796"#,
797 ),
798 )
799 .unwrap_err();
800 assert!(
801 err.contains("row_count") && err.contains("BOTH"),
802 "err: {err}"
803 );
804 }
805}