Skip to main content

nmbrs_workload/
implements.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! SRD-108 Part B — the typed implementation binder.
5//!
6//! Binds an implementation workload's op bodies into a
7//! blueprint's ABSTRACT slots at load time, before any synthesis.
8//! The blueprint owns every piece of scaffolding (extents,
9//! concurrency, stop conditions, metrics, provenance classes);
10//! the implementation contributes exactly the protocol matter:
11//! op fields, adapter selection, captures, and op-level bindings.
12//!
13//! Every rule here is a LOAD error with a slot-coordinate name —
14//! nothing defers to a runtime critical section. The interface's
15//! type proof runs later, still at init: pre-map synthesis
16//! verifies `yields` against the compiled op-template program
17//! (see `nmbrs-runtime`'s SRD-108 verification hook).
18
19use std::collections::BTreeSet;
20
21use crate::model::{BindingsDef, ParsedOp, Workload, WorkloadPhase};
22
23/// Bind `implementation` into `blueprint` in place.
24///
25/// Coverage is total in both directions: every abstract slot in
26/// `blueprint` must be bound exactly once, and every op the
27/// implementation provides must land in an abstract slot.
28pub fn bind_implementation(
29    blueprint: &mut Workload,
30    implementation: Workload,
31) -> Result<(), String> {
32    // The set of wires the blueprint scaffold provides — the
33    // `needs` availability check reads this. Params + workload
34    // bindings + (per-phase) phase bindings.
35    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            // `needs` availability: every guaranteed wire must be
91            // provided by the blueprint scaffold — params, workload
92            // bindings, or this phase's bindings. (The full type
93            // proof is pre-map synthesis; this is the early named
94            // error.)
95            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    // Implementation params layer over the blueprint's: a NEW key
120    // adds a protocol knob; a key the blueprint already declares
121    // RE-DEFAULTS its value — the same specialization the CLI
122    // performs per invocation, made durable in a document (e.g. a
123    // turnkey variant pinning dataset and tier governance).
124    // Declaration ownership stays with the blueprint: the key is
125    // not re-declared, so consumed-param provenance and the CLI
126    // override order are unchanged. Only the YAML-DECLARED subset
127    // participates: `Workload.params` also carries every ad-hoc
128    // CLI arg (tui=, workload=, …) overlaid at parse, and those
129    // are invocation matter, not document matter.
130    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    // Workload-level bindings concatenate blueprint-first.
142    concat_bindings(&mut blueprint.bindings, implementation.bindings)
143        .map_err(|e| format!("workload-level bindings: {e}"))?;
144
145    Ok(())
146}
147
148/// Bind one implementation op into one abstract slot. The slot
149/// keeps everything the blueprint declared; the implementation
150/// fills the protocol surfaces. Any overlap is a collision error
151/// — never a silent override.
152fn bind_slot(slot: &mut ParsedOp, impl_op: ParsedOp, slot_key: &str) -> Result<(), String> {
153    // Op-owned semantics the implementation may NOT set: these
154    // are measurement/scaffolding surfaces. (`result:` is NOT in
155    // this list — it is the delivery surface for the `results:`
156    // interface leg, SRD-109 Part 3.)
157    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    // Op params follow the param layering rule, not the op-field
190    // collision rule: params are the system's override surface
191    // (doc → block → op cascade at parse, CLI last), and both
192    // docs materialize their workload-level params into op params
193    // — so an overlapping key here is normally a workload-level
194    // re-default seen through the cascade. The implementation
195    // (the more concrete layer) wins, mirroring the workload-level
196    // merge above; op FIELDS keep the hard collision error.
197    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    // SRD-109 Part 3 — `result:` bindings deliver the `results:`
230    // interface leg. Blueprint-side entries for interface names
231    // are collisions (paths are protocol matter); impl entries
232    // colliding with blueprint entries are collisions like any
233    // other surface; the merged spec concatenates blueprint-first.
234    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    // `yields` / `results` presence: every promised wire must be
265    // delivered by the bound op — a capture `as`-name for yields,
266    // a `result:` binding LHS for results. (Type equality is
267    // proven at pre-map synthesis against the compiled program.)
268    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
299/// Wire names a `result:` spec declares: map-shape keys plus the
300/// `:=` LHS of string-shape source lines.
301fn 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
320/// Names of scaffolding fields an implementation phase illegally
321/// sets. Only `ops:` is legal on an implementation phase.
322fn 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
396/// Wire names a `BindingsDef` declares.
397fn 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
404/// Concatenate `extra` onto `base`, blueprint-first. Mixed
405/// map/source forms are rejected (no sensible concatenation) —
406/// never silently dropped.
407fn 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
438/// Names of every unbound abstract slot in a workload — non-empty
439/// means the workload cannot run (load error at initiation).
440pub 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    /// Implementation params layer over the blueprint's: a new
504    /// key adds a protocol knob (and is declared), while a key
505    /// the blueprint already declares re-defaults its value with
506    /// declaration ownership unchanged — no duplicate entry in
507    /// `declared_params`. This is what lets a turnkey variant
508    /// (extends-child of an impl) pin dataset/tier params for
509    /// single `workload=` invocation.
510    #[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    // ── SRD-109 Part 3: the `results:` interface leg ──
671
672    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        // A NON-interface result wire declared by both sides is an
768        // ordinary collision.
769        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}