Skip to main content

polydat_core/
binder.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Typed-binding contracts between adapters and the polydat runtime.
5//!
6//! ## What a binder is
7//!
8//! A [`Binder`] is an adapter's declaration, at op-template
9//! construction time, of one typed-parameter binding shape it
10//! expects: which wires supply values, and what *lvalue type*
11//! each value will be assigned into at the wire-protocol layer
12//! (CQL column type, RPC parameter type, HTTP body schema, etc.).
13//!
14//! Polydat consumes the binder to:
15//!
16//! 1. **Verify wiring sanity at construction time** — for each
17//!    slot, check that the wire's rvalue type satisfies the
18//!    declared lvalue type. Concretely, the load-bearing rule is:
19//!    *a string detour (rvalue → `to_display_string` → bytes →
20//!    cluster parser → typed wire form) is permitted only when
21//!    the lvalue is itself a string-natural type.* Anywhere else,
22//!    text-rendering a typed wire to feed a typed parameter is
23//!    the workload-shape defect the binder API exists to catch.
24//!
25//! 2. **Plan typed pass-through at compile time** — when the
26//!    kernel knows a wire's value will flow only into typed
27//!    slots, it can skip string-form codegen entirely and
28//!    materialise the value as the adapter's expected lvalue
29//!    type directly. (Compile-time use of binder metadata is a
30//!    follow-on; for now polydat consumes the binder only for
31//!    verification.)
32//!
33//! ## What a binder is NOT
34//!
35//! - It is **not** a workload-author syntax. Workload authors
36//!   keep writing their adapter-native op templates (e.g. a CQL
37//!   `prepared:` statement with `{wire}` references); the
38//!   adapter constructs binders internally during its op-template
39//!   processing (CQL: prepare the statement, read parameter
40//!   metadata, build the binder from that).
41//!
42//! - It is **not** compulsory for polydat callers in general.
43//!   Polydat works without any binders submitted; the binder API
44//!   is an opt-in surface for callers that have typed-parameter
45//!   intent to declare.
46//!
47//! - It IS compulsory for **adapters** via the
48//!   `DriverAdapter::binders_for` trait method — every adapter
49//!   has to either declare its binders or explicitly acknowledge
50//!   it has none. Default no-op declarations are forbidden by
51//!   trait design so the question can't be skipped silently.
52//!
53//! ## Pre-canned binder patterns
54//!
55//! Three [`Binder`] variants cover the protocol shapes seen so
56//! far: positional (CQL `?` placeholders, ordinal RPC params),
57//! named (CQL `:name`, JSON-RPC), and single-value (one whole
58//! field bound as one typed value).
59
60use crate::ast::PortType;
61use std::collections::BTreeMap;
62
63/// One adapter-declared binding shape for one op-template field.
64///
65/// Three variants cover the common protocol shapes. The `field`
66/// names the op-template field this binder describes — surfaced
67/// in error diagnostics so the operator can locate the slot
68/// quickly.
69#[derive(Debug, Clone)]
70pub enum Binder {
71    /// Positional binding (slot index = position in vec).
72    ///
73    /// Example: a CQL prepared statement with three `?`
74    /// placeholders → three positional slots. The slot at index 0
75    /// binds to the first `?`, etc.
76    Positional {
77        /// The op-template field bound.
78        field: String,
79        /// One slot per placeholder, in position order.
80        slots: Vec<BinderSlot>,
81    },
82
83    /// Named binding (slot key = parameter name).
84    ///
85    /// Example: a CQL prepared statement with `:id` / `:val`
86    /// named parameters → two named slots keyed by `id` / `val`.
87    /// `BTreeMap` for deterministic diagnostic ordering.
88    Named {
89        /// The op-template field bound.
90        field: String,
91        /// One slot per named parameter.
92        slots: BTreeMap<String, BinderSlot>,
93    },
94
95    /// Single-value binding (the whole field IS one typed value).
96    ///
97    /// Example: an HTTP body bound as one JSON object, an MQTT
98    /// payload bound as Bytes. The slot supplies the field's
99    /// entire value with the lvalue type declared by the
100    /// protocol.
101    Single {
102        /// The op-template field bound.
103        field: String,
104        /// The slot supplying the field's whole value.
105        slot: BinderSlot,
106    },
107}
108
109/// One slot in a binder.
110///
111/// `wire` names the polydat wire that supplies the value at
112/// execute time (bare wire name, resolved through the kernel's
113/// Polydat context). `lvalue_type` is what the protocol expects at
114/// this binding site — typically obtained from adapter-side
115/// introspection (CQL: column type from prepared-statement
116/// metadata).
117///
118/// `allow_fusion` is the per-slot policy bit the caller sets
119/// when it has license — usually from a workload-author opt-in
120/// like the bind-point `:*` wildcard syntax — to accept polydat
121/// type fusion at this slot. When `true`, the verifier skips
122/// the strict rvalue→lvalue rule for this slot and accepts any
123/// rvalue (the wire-existence check still fires; an unknown
124/// wire is always a violation). When `false` (the default), the
125/// strict rule applies: string-detour into a non-text-natural
126/// lvalue is rejected.
127///
128/// The `lvalue_type` field stays honest in both cases —
129/// callers don't fake the type to Str to bypass the check;
130/// they keep the real cluster-reported type AND set
131/// `allow_fusion: true`. That way downstream consumers
132/// (compile-time analysis, diagnostics, future runtime-typed
133/// bind paths) see the true protocol-side type.
134#[derive(Debug, Clone)]
135pub struct BinderSlot {
136    /// The wire the slot reads.
137    pub wire: String,
138    /// The type the protocol side requires.
139    pub lvalue_type: PortType,
140    /// Whether a wire of another type may be fused into the lvalue type.
141    pub allow_fusion: bool,
142}
143
144impl Binder {
145    /// Op-template field this binder describes.
146    pub fn field(&self) -> &str {
147        match self {
148            Binder::Positional { field, .. } => field,
149            Binder::Named { field, .. } => field,
150            Binder::Single { field, .. } => field,
151        }
152    }
153
154    /// Walk every slot in this binder for verification or
155    /// codegen. Yields `(slot_label, slot)` pairs where
156    /// `slot_label` is a human-readable position hint
157    /// (`"[0]"` for positional, `":name"` for named, `""` for
158    /// single).
159    pub fn slots(&self) -> Vec<(String, &BinderSlot)> {
160        match self {
161            Binder::Positional { slots, .. } => slots
162                .iter()
163                .enumerate()
164                .map(|(i, s)| (format!("[{i}]"), s))
165                .collect(),
166            Binder::Named { slots, .. } => slots
167                .iter()
168                .map(|(name, s)| (format!(":{name}"), s))
169                .collect(),
170            Binder::Single { slot, .. } => vec![(String::new(), slot)],
171        }
172    }
173}
174
175/// One wire-type-vs-lvalue-type mismatch found by
176/// [`verify_binders`].
177#[derive(Debug, Clone)]
178pub struct BinderViolation {
179    /// The op-template field.
180    pub field: String,
181    /// The slot's position hint: `[i]`, `:name`, or empty.
182    pub slot_label: String,
183    /// The wire named.
184    pub wire: String,
185    /// Wire's rvalue type as resolved from the program, or
186    /// `None` if the wire wasn't declared in the program at all
187    /// (a separate error — the binder names a wire the kernel
188    /// doesn't know).
189    pub rvalue_type: Option<PortType>,
190    /// The type the protocol side requires.
191    pub lvalue_type: PortType,
192    /// What is wrong.
193    pub message: String,
194}
195
196/// Verify each binder slot's rvalue (wire) type against its
197/// declared lvalue type, using the lookup closure to resolve
198/// wire names to their `PortType`. The closure returns `None`
199/// for unknown wires.
200///
201/// Returns every violation found (not just the first) so the
202/// operator fixes them in one pass.
203///
204/// Why a closure instead of `&PolydatProgram`: the verifier
205/// shouldn't be coupled to one program/kernel surface. Callers
206/// supply whatever lookup matches their wire-resolution
207/// context — kernel program output table, scope-init constants,
208/// auto-externed parent-scope wires, or test fixtures.
209/// Verify binders against a [`crate::kernel::PolydatKernel`] directly,
210/// returning `Ok(())` when every slot's rvalue→lvalue check
211/// passes and `Err(Vec<BinderViolation>)` listing every failure
212/// otherwise.
213///
214/// Convenience wrapper around [`verify_binders`] for the common
215/// case where the wire-type lookup comes from a polydat kernel
216/// the host was handed during dispenser init. A host adapter calls
217/// this inline while mapping an op — completing the currying stack
218/// — to verify any typed binders before returning the constructed
219/// dispenser.
220///
221/// Looks up each binder slot's wire as either an output or an
222/// input of the kernel's program (outputs are checked first;
223/// inputs are the fallback for coordinate/extern wires the
224/// op-template kernel auto-externs from outer scope). A wire
225/// that resolves to neither surfaces as a "not declared" binder
226/// violation rather than silent passthrough.
227pub fn verify_against_kernel(
228    binders: &[Binder],
229    kernel: &crate::kernel::PolydatKernel,
230) -> Result<(), Vec<BinderViolation>> {
231    use crate::kernel::Metadata;
232    let violations = verify_binders(binders, |name: &str| {
233        // Output first (most common: phase-scope bindings,
234        // op-template binding outputs). Fall back to input
235        // (coordinate / extern wires the program declared as
236        // typed input slots).
237        kernel
238            .output_port_type(name)
239            .or_else(|| kernel.input_port_type(name))
240    });
241    if violations.is_empty() {
242        Ok(())
243    } else {
244        Err(violations)
245    }
246}
247
248/// Every slot whose wire type cannot satisfy its lvalue type, given a
249/// lookup from wire name to the program's type for it.
250pub fn verify_binders(
251    binders: &[Binder],
252    wire_type: impl Fn(&str) -> Option<PortType>,
253) -> Vec<BinderViolation> {
254    let mut violations = Vec::new();
255    for binder in binders {
256        for (slot_label, slot) in binder.slots() {
257            let rvalue = wire_type(&slot.wire);
258            if let Some(msg) =
259                check_compatibility(rvalue, slot.lvalue_type, &slot.wire, slot.allow_fusion)
260            {
261                violations.push(BinderViolation {
262                    field: binder.field().to_string(),
263                    slot_label,
264                    wire: slot.wire.clone(),
265                    rvalue_type: rvalue,
266                    lvalue_type: slot.lvalue_type,
267                    message: msg,
268                });
269            }
270        }
271    }
272    violations
273}
274
275/// The load-bearing rule, stated once:
276///
277/// > A string detour (rvalue → `to_display_string` → bytes →
278/// > parser → typed wire form) is permitted only when the lvalue
279/// > is itself a string-natural type.
280///
281/// Applied as: if the rvalue type is `Str` and the lvalue type
282/// is *not* text-natural, that's a string detour into a non-
283/// string lvalue — the bug class the binder API exists to catch.
284///
285/// For non-string-detour cases, this function applies a
286/// conservative match: same type → OK, structurally-compatible
287/// vector type → OK, otherwise → reject. The intent is to catch
288/// the obviously-wrong cases (VecF32 wire into a text lvalue,
289/// Str wire into a vector lvalue) without trying to express
290/// every coercion polydat's [`crate::compile::assembly::auto_adapter`]
291/// would happily insert. Refinement against that adapter table
292/// is a follow-on.
293fn check_compatibility(
294    rvalue: Option<PortType>,
295    lvalue: PortType,
296    wire: &str,
297    allow_fusion: bool,
298) -> Option<String> {
299    let Some(rv) = rvalue else {
300        // Unknown-wire violation is structural — always fires,
301        // independent of `allow_fusion`. A binder that names a
302        // non-existent wire is broken regardless of whether the
303        // caller would tolerate type fusion at that slot.
304        return Some(format!(
305            "binder names wire `{wire}` (lvalue type {lvalue}) but the \
306             wire is not declared in the kernel's Polydat context — this \
307             binder names a wire that doesn't exist."
308        ));
309    };
310
311    if rv == lvalue {
312        return None;
313    }
314
315    // Caller-licensed fusion: the slot was tagged `allow_fusion`.
316    // The rvalue→lvalue strict rule is intentionally skipped for
317    // this slot — polydat trusts the caller's judgement that any
318    // protocol-side coercion / text-detour the adapter performs
319    // at bind time is acceptable for this position.
320    //
321    // The text-natural-lvalue auto-permit that used to live in
322    // this file moved out to the caller side: an adapter whose
323    // protocol can text-coerce anything to its string parameter
324    // type is expected to set `allow_fusion: true` on slots
325    // with `Str`-lvalue (in non-strict mode). That makes the
326    // fusion policy explicit and visible on the binder slot
327    // rather than implicit in polydat's rule. Strict-mode
328    // adapters that DO want polydat to reject `Str + non-Str`
329    // simply leave `allow_fusion: false`.
330    if allow_fusion {
331        return None;
332    }
333
334    // Strict path. The only way the rvalue is OK is if it
335    // structurally matches the lvalue. Bail with a clear
336    // diagnostic pointing at both types.
337    if structurally_compatible(rv, lvalue) {
338        return None;
339    }
340    Some(format!(
341        "wire `{wire}` holds {rv} but the binder declares an lvalue \
342         type of {lvalue} — strict binder verification (no \
343         `allow_fusion`) rejects the rvalue/lvalue pair. Bind a wire \
344         whose type matches the lvalue directly, change the lvalue \
345         type at the adapter side, or — if the workload author \
346         intends to license polydat to fuse types at this slot — \
347         spell the bind-point with the `:*` wildcard suffix \
348         (e.g. `{{{wire}:*}}` in place of `{{{wire}}}`) so the binder \
349         slot's `allow_fusion` flag is set."
350    ))
351}
352
353/// Conservative structural match for the non-text-natural case.
354/// Same type matches; vector types match by element type. The
355/// intent is to catch the obvious mismatches without trying to
356/// be the full polydat auto-adapter table — that's a follow-on
357/// once we have call sites that need finer-grained rules.
358fn structurally_compatible(rv: PortType, lv: PortType) -> bool {
359    match (rv, lv) {
360        (PortType::VecF32, PortType::VecF32) => true,
361        (PortType::VecI32, PortType::VecI32) => true,
362        (PortType::Bytes, PortType::Bytes) => true,
363        // Numeric widening — both sides are scalar numerics
364        // (none of these is text-natural, so this branch is
365        // about within-numeric compatibility).
366        (PortType::U32, PortType::U64) => true,
367        (PortType::I32, PortType::I64) => true,
368        (PortType::F32, PortType::F64) => true,
369        _ => false,
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use std::collections::HashMap;
377
378    fn wire_lookup(types: &[(&str, PortType)]) -> impl Fn(&str) -> Option<PortType> {
379        let map: HashMap<String, PortType> =
380            types.iter().map(|(n, t)| (n.to_string(), *t)).collect();
381        move |name: &str| map.get(name).copied()
382    }
383
384    #[test]
385    fn positional_binder_matches_types_cleanly() {
386        let binder = Binder::Positional {
387            field: "prepared".into(),
388            slots: vec![
389                BinderSlot {
390                    wire: "id".into(),
391                    lvalue_type: PortType::Str,
392                    allow_fusion: false,
393                },
394                BinderSlot {
395                    wire: "vec".into(),
396                    lvalue_type: PortType::VecF32,
397                    allow_fusion: false,
398                },
399            ],
400        };
401        let v = verify_binders(
402            &[binder],
403            wire_lookup(&[("id", PortType::Str), ("vec", PortType::VecF32)]),
404        );
405        assert!(
406            v.is_empty(),
407            "matched-type binder should verify clean: {v:?}"
408        );
409    }
410
411    /// The load-bearing case: a wire holding VecF32 is bound
412    /// to a non-text lvalue of a different type → reject. This
413    /// is the equivalent of "decimal-stringify the 128 f32s and
414    /// hope the cluster parses them back" → no.
415    #[test]
416    fn vec_f32_wire_into_non_text_non_vector_lvalue_is_rejected() {
417        let binder = Binder::Positional {
418            field: "prepared".into(),
419            slots: vec![BinderSlot {
420                wire: "vec".into(),
421                lvalue_type: PortType::Bytes,
422                allow_fusion: false,
423            }],
424        };
425        let v = verify_binders(&[binder], wire_lookup(&[("vec", PortType::VecF32)]));
426        assert_eq!(v.len(), 1);
427        assert!(
428            v[0].message.contains("vec_f32"),
429            "diagnostic should name rvalue: {}",
430            v[0].message
431        );
432        assert!(
433            v[0].message.contains("bytes") || v[0].message.contains("Bytes"),
434            "diagnostic should name lvalue: {}",
435            v[0].message
436        );
437        assert_eq!(v[0].rvalue_type, Some(PortType::VecF32));
438        assert_eq!(v[0].lvalue_type, PortType::Bytes);
439    }
440
441    /// Strict mode rejects rvalue→Str-lvalue mismatches. The
442    /// text-natural auto-permit USED to live in polydat
443    /// (`is_text_natural`); it has moved to the caller side as
444    /// an explicit `allow_fusion: true` policy bit. With
445    /// `allow_fusion: false`, even a Str lvalue rejects a
446    /// non-Str rvalue — and the diagnostic points at the `:*`
447    /// opt-in.
448    #[test]
449    fn strict_rejects_non_str_rvalue_into_str_lvalue() {
450        let strict_binder = Binder::Positional {
451            field: "prepared".into(),
452            slots: vec![BinderSlot {
453                wire: "vec".into(),
454                lvalue_type: PortType::Str,
455                allow_fusion: false,
456            }],
457        };
458        let v = verify_binders(&[strict_binder], wire_lookup(&[("vec", PortType::VecF32)]));
459        assert_eq!(
460            v.len(),
461            1,
462            "strict (allow_fusion=false) should reject VecF32→Str: {v:?}"
463        );
464    }
465
466    /// Caller-side opt-in: the same rvalue/lvalue pair is
467    /// accepted when the slot carries `allow_fusion: true`.
468    /// The adapter sets this for `Str`-lvalue slots in
469    /// non-strict mode; the workload-author can also opt in
470    /// per-slot via the `:*` wildcard syntax.
471    #[test]
472    fn allow_fusion_accepts_non_str_rvalue_into_str_lvalue() {
473        let fusing_binder = Binder::Positional {
474            field: "prepared".into(),
475            slots: vec![
476                BinderSlot {
477                    wire: "vec".into(),
478                    lvalue_type: PortType::Str,
479                    allow_fusion: true,
480                },
481                BinderSlot {
482                    wire: "num".into(),
483                    lvalue_type: PortType::Json,
484                    allow_fusion: true,
485                },
486            ],
487        };
488        let v = verify_binders(
489            &[fusing_binder],
490            wire_lookup(&[("vec", PortType::VecF32), ("num", PortType::F64)]),
491        );
492        assert!(
493            v.is_empty(),
494            "allow_fusion=true should accept any rvalue into any lvalue: {v:?}"
495        );
496    }
497
498    /// Unknown wire → loud error, no silent skipping. A binder
499    /// that names a wire the kernel doesn't know is a bug (the
500    /// adapter built the binder against an out-of-date wire
501    /// list, or a workload typo'd the field).
502    #[test]
503    fn unknown_wire_in_binder_is_loud_error() {
504        let binder = Binder::Positional {
505            field: "prepared".into(),
506            slots: vec![BinderSlot {
507                wire: "nonexistent".into(),
508                lvalue_type: PortType::Str,
509                allow_fusion: false,
510            }],
511        };
512        let v = verify_binders(&[binder], wire_lookup(&[]));
513        assert_eq!(v.len(), 1);
514        assert_eq!(v[0].rvalue_type, None);
515        assert!(
516            v[0].message.contains("not declared"),
517            "diagnostic should call out the unknown wire: {}",
518            v[0].message
519        );
520    }
521
522    /// Named binder: same rules, keyed-by-name diagnostics.
523    #[test]
524    fn named_binder_violations_carry_name_label() {
525        let mut slots = BTreeMap::new();
526        slots.insert(
527            "vec_param".into(),
528            BinderSlot {
529                wire: "v".into(),
530                lvalue_type: PortType::I64,
531                allow_fusion: false,
532            },
533        );
534        let binder = Binder::Named {
535            field: "prepared".into(),
536            slots,
537        };
538        let v = verify_binders(&[binder], wire_lookup(&[("v", PortType::VecF32)]));
539        assert_eq!(v.len(), 1);
540        assert_eq!(
541            v[0].slot_label, ":vec_param",
542            "named-slot diagnostic should carry the name: {:?}",
543            v[0]
544        );
545    }
546
547    /// Single-value binder: one slot, slot label empty.
548    #[test]
549    fn single_binder_violation_is_locatable() {
550        let binder = Binder::Single {
551            field: "body".into(),
552            slot: BinderSlot {
553                wire: "payload".into(),
554                lvalue_type: PortType::Bytes,
555                allow_fusion: false,
556            },
557        };
558        let v = verify_binders(&[binder], wire_lookup(&[("payload", PortType::VecF32)]));
559        assert_eq!(v.len(), 1);
560        assert_eq!(
561            v[0].slot_label, "",
562            "single-slot label is empty: {:?}",
563            v[0]
564        );
565        assert_eq!(v[0].field, "body");
566    }
567
568    /// Numeric widening within the non-text-natural side is
569    /// allowed. U32 wire → U64 lvalue, etc.
570    #[test]
571    fn numeric_widening_is_accepted() {
572        let binder = Binder::Positional {
573            field: "prepared".into(),
574            slots: vec![
575                BinderSlot {
576                    wire: "a".into(),
577                    lvalue_type: PortType::U64,
578                    allow_fusion: false,
579                },
580                BinderSlot {
581                    wire: "b".into(),
582                    lvalue_type: PortType::F64,
583                    allow_fusion: false,
584                },
585            ],
586        };
587        let v = verify_binders(
588            &[binder],
589            wire_lookup(&[("a", PortType::U32), ("b", PortType::F32)]),
590        );
591        assert!(
592            v.is_empty(),
593            "widening U32→U64 / F32→F64 should verify clean: {v:?}"
594        );
595    }
596
597    /// Per-slot `allow_fusion: true` skips the strict
598    /// rvalue→lvalue check. A wire holding `Str` bound to an
599    /// `I32` lvalue normally fails (the load-bearing
600    /// string-detour-into-non-text rule), but with the slot
601    /// tagged `allow_fusion: true` polydat accepts it — the
602    /// caller has licensed type fusion at this position.
603    #[test]
604    fn allow_fusion_skips_strict_check_for_wired_slot() {
605        let strict_binder = Binder::Positional {
606            field: "prepared".into(),
607            slots: vec![BinderSlot {
608                wire: "x".into(),
609                lvalue_type: PortType::I32,
610                allow_fusion: false,
611            }],
612        };
613        let fusing_binder = Binder::Positional {
614            field: "prepared".into(),
615            slots: vec![BinderSlot {
616                wire: "x".into(),
617                lvalue_type: PortType::I32,
618                allow_fusion: true,
619            }],
620        };
621        let lookup = wire_lookup(&[("x", PortType::Str)]);
622        // Strict: rejected (Str into I32, not text-natural).
623        let strict = verify_binders(&[strict_binder], &lookup);
624        assert_eq!(strict.len(), 1, "strict slot should reject: {strict:?}");
625        // Fusing: accepted; same rvalue/lvalue pair.
626        let fusing = verify_binders(&[fusing_binder], &lookup);
627        assert!(
628            fusing.is_empty(),
629            "allow_fusion=true should skip strict rule: {fusing:?}"
630        );
631    }
632
633    /// `allow_fusion: true` does NOT silence the unknown-wire
634    /// violation. Naming a non-existent wire is a structural
635    /// bug regardless of how the caller wants typed binding
636    /// applied at that slot.
637    #[test]
638    fn allow_fusion_still_reports_unknown_wires() {
639        let binder = Binder::Positional {
640            field: "prepared".into(),
641            slots: vec![BinderSlot {
642                wire: "ghost".into(),
643                lvalue_type: PortType::I32,
644                allow_fusion: true,
645            }],
646        };
647        let v = verify_binders(&[binder], wire_lookup(&[]));
648        assert_eq!(v.len(), 1, "unknown wire must fire even with fusion: {v:?}");
649        assert!(
650            v[0].message.contains("not declared"),
651            "expected unknown-wire diagnostic: {}",
652            v[0].message
653        );
654    }
655
656    /// Multiple violations across binders are all reported.
657    #[test]
658    fn all_violations_across_binders_are_reported() {
659        let b1 = Binder::Positional {
660            field: "f1".into(),
661            slots: vec![BinderSlot {
662                wire: "a".into(),
663                lvalue_type: PortType::Bytes,
664                allow_fusion: false,
665            }],
666        };
667        let b2 = Binder::Positional {
668            field: "f2".into(),
669            slots: vec![BinderSlot {
670                wire: "b".into(),
671                lvalue_type: PortType::VecF32,
672                allow_fusion: false,
673            }],
674        };
675        let v = verify_binders(
676            &[b1, b2],
677            wire_lookup(&[
678                ("a", PortType::VecF32), // VecF32 → Bytes : reject
679                ("b", PortType::Str),    // Str → VecF32 : reject (str-detour-into-non-text)
680            ]),
681        );
682        assert_eq!(v.len(), 2, "both violations expected: {v:?}");
683    }
684}