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 binders against a [`crate::kernel::PolydatKernel`] directly,
197/// returning `Ok(())` when every slot's rvalue→lvalue check
198/// passes and `Err(Vec<BinderViolation>)` listing every failure
199/// otherwise.
200///
201/// Convenience wrapper around [`verify_binders`] for the common
202/// case where the wire-type lookup comes from a polydat kernel
203/// the host was handed during dispenser init. A host adapter calls
204/// this inline while mapping an op — completing the currying stack
205/// — to verify any typed binders before returning the constructed
206/// dispenser.
207///
208/// Looks up each binder slot's wire as either an output or an
209/// input of the kernel's program (outputs are checked first;
210/// inputs are the fallback for coordinate/extern wires the
211/// op-template kernel auto-externs from outer scope). A wire
212/// that resolves to neither surfaces as a "not declared" binder
213/// violation rather than silent passthrough.
214pub fn verify_against_kernel(
215    binders: &[Binder],
216    kernel: &crate::kernel::PolydatKernel,
217) -> Result<(), Vec<BinderViolation>> {
218    use crate::kernel::Metadata;
219    let violations = verify_binders(binders, |name: &str| {
220        // Output first (most common: phase-scope bindings,
221        // op-template binding outputs). Fall back to input
222        // (coordinate / extern wires the program declared as
223        // typed input slots).
224        kernel
225            .output_port_type(name)
226            .or_else(|| kernel.input_port_type(name))
227    });
228    if violations.is_empty() {
229        Ok(())
230    } else {
231        Err(violations)
232    }
233}
234
235/// Every slot whose wire type cannot satisfy its lvalue type, given a
236/// lookup from wire name to the program's type for it.
237///
238/// Verify each binder slot's rvalue (wire) type against its
239/// declared lvalue type, using the lookup closure to resolve
240/// wire names to their `PortType`. The closure returns `None`
241/// for unknown wires.
242///
243/// Returns every violation found (not just the first) so the
244/// operator fixes them in one pass.
245///
246/// Why a closure instead of `&PolydatProgram`: the verifier
247/// shouldn't be coupled to one program/kernel surface. Callers
248/// supply whatever lookup matches their wire-resolution
249/// context — kernel program output table, scope-init constants,
250/// auto-externed parent-scope wires, or test fixtures.
251pub fn verify_binders(
252    binders: &[Binder],
253    wire_type: impl Fn(&str) -> Option<PortType>,
254) -> Vec<BinderViolation> {
255    let mut violations = Vec::new();
256    for binder in binders {
257        for (slot_label, slot) in binder.slots() {
258            let rvalue = wire_type(&slot.wire);
259            if let Some(msg) =
260                check_compatibility(rvalue, slot.lvalue_type, &slot.wire, slot.allow_fusion)
261            {
262                violations.push(BinderViolation {
263                    field: binder.field().to_string(),
264                    slot_label,
265                    wire: slot.wire.clone(),
266                    rvalue_type: rvalue,
267                    lvalue_type: slot.lvalue_type,
268                    message: msg,
269                });
270            }
271        }
272    }
273    violations
274}
275
276/// The load-bearing rule, stated once:
277///
278/// > A string detour (rvalue → `to_display_string` → bytes →
279/// > parser → typed wire form) is permitted only when the lvalue
280/// > is itself a string-natural type.
281///
282/// Applied as: if the rvalue type is `Str` and the lvalue type
283/// is *not* text-natural, that's a string detour into a non-
284/// string lvalue — the bug class the binder API exists to catch.
285///
286/// For non-string-detour cases, this function applies a
287/// conservative match: same type → OK, structurally-compatible
288/// vector type → OK, otherwise → reject. The intent is to catch
289/// the obviously-wrong cases (VecF32 wire into a text lvalue,
290/// Str wire into a vector lvalue) without trying to express
291/// every coercion polydat's [`crate::compile::assembly::auto_adapter`]
292/// would happily insert. Refinement against that adapter table
293/// is a follow-on.
294fn check_compatibility(
295    rvalue: Option<PortType>,
296    lvalue: PortType,
297    wire: &str,
298    allow_fusion: bool,
299) -> Option<String> {
300    let Some(rv) = rvalue else {
301        // Unknown-wire violation is structural — always fires,
302        // independent of `allow_fusion`. A binder that names a
303        // non-existent wire is broken regardless of whether the
304        // caller would tolerate type fusion at that slot.
305        return Some(format!(
306            "binder names wire `{wire}` (lvalue type {lvalue}) but the \
307             wire is not declared in the kernel's Polydat context — this \
308             binder names a wire that doesn't exist."
309        ));
310    };
311
312    if rv == lvalue {
313        return None;
314    }
315
316    // Caller-licensed fusion: the slot was tagged `allow_fusion`.
317    // The rvalue→lvalue strict rule is intentionally skipped for
318    // this slot — polydat trusts the caller's judgement that any
319    // protocol-side coercion / text-detour the adapter performs
320    // at bind time is acceptable for this position.
321    //
322    // The text-natural-lvalue auto-permit that used to live in
323    // this file moved out to the caller side: an adapter whose
324    // protocol can text-coerce anything to its string parameter
325    // type is expected to set `allow_fusion: true` on slots
326    // with `Str`-lvalue (in non-strict mode). That makes the
327    // fusion policy explicit and visible on the binder slot
328    // rather than implicit in polydat's rule. Strict-mode
329    // adapters that DO want polydat to reject `Str + non-Str`
330    // simply leave `allow_fusion: false`.
331    if allow_fusion {
332        return None;
333    }
334
335    // Strict path. The only way the rvalue is OK is if it
336    // structurally matches the lvalue. Bail with a clear
337    // diagnostic pointing at both types.
338    if structurally_compatible(rv, lvalue) {
339        return None;
340    }
341    Some(format!(
342        "wire `{wire}` holds {rv} but the binder declares an lvalue \
343         type of {lvalue} — strict binder verification (no \
344         `allow_fusion`) rejects the rvalue/lvalue pair. Bind a wire \
345         whose type matches the lvalue directly, change the lvalue \
346         type at the adapter side, or — if the workload author \
347         intends to license polydat to fuse types at this slot — \
348         spell the bind-point with the `:*` wildcard suffix \
349         (e.g. `{{{wire}:*}}` in place of `{{{wire}}}`) so the binder \
350         slot's `allow_fusion` flag is set."
351    ))
352}
353
354/// Conservative structural match for the non-text-natural case.
355/// Same type matches; vector types match by element type. The
356/// intent is to catch the obvious mismatches without trying to
357/// be the full polydat auto-adapter table — that's a follow-on
358/// once we have call sites that need finer-grained rules.
359fn structurally_compatible(rv: PortType, lv: PortType) -> bool {
360    match (rv, lv) {
361        (PortType::VecF32, PortType::VecF32) => true,
362        (PortType::VecI32, PortType::VecI32) => true,
363        (PortType::Bytes, PortType::Bytes) => true,
364        // Numeric widening — both sides are scalar numerics
365        // (none of these is text-natural, so this branch is
366        // about within-numeric compatibility).
367        (PortType::U32, PortType::U64) => true,
368        (PortType::I32, PortType::I64) => true,
369        (PortType::F32, PortType::F64) => true,
370        _ => false,
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use std::collections::HashMap;
378
379    fn wire_lookup(types: &[(&str, PortType)]) -> impl Fn(&str) -> Option<PortType> {
380        let map: HashMap<String, PortType> =
381            types.iter().map(|(n, t)| (n.to_string(), *t)).collect();
382        move |name: &str| map.get(name).copied()
383    }
384
385    #[test]
386    fn positional_binder_matches_types_cleanly() {
387        let binder = Binder::Positional {
388            field: "prepared".into(),
389            slots: vec![
390                BinderSlot {
391                    wire: "id".into(),
392                    lvalue_type: PortType::Str,
393                    allow_fusion: false,
394                },
395                BinderSlot {
396                    wire: "vec".into(),
397                    lvalue_type: PortType::VecF32,
398                    allow_fusion: false,
399                },
400            ],
401        };
402        let v = verify_binders(
403            &[binder],
404            wire_lookup(&[("id", PortType::Str), ("vec", PortType::VecF32)]),
405        );
406        assert!(
407            v.is_empty(),
408            "matched-type binder should verify clean: {v:?}"
409        );
410    }
411
412    /// The load-bearing case: a wire holding VecF32 is bound
413    /// to a non-text lvalue of a different type → reject. This
414    /// is the equivalent of "decimal-stringify the 128 f32s and
415    /// hope the cluster parses them back" → no.
416    #[test]
417    fn vec_f32_wire_into_non_text_non_vector_lvalue_is_rejected() {
418        let binder = Binder::Positional {
419            field: "prepared".into(),
420            slots: vec![BinderSlot {
421                wire: "vec".into(),
422                lvalue_type: PortType::Bytes,
423                allow_fusion: false,
424            }],
425        };
426        let v = verify_binders(&[binder], wire_lookup(&[("vec", PortType::VecF32)]));
427        assert_eq!(v.len(), 1);
428        assert!(
429            v[0].message.contains("vec_f32"),
430            "diagnostic should name rvalue: {}",
431            v[0].message
432        );
433        assert!(
434            v[0].message.contains("bytes") || v[0].message.contains("Bytes"),
435            "diagnostic should name lvalue: {}",
436            v[0].message
437        );
438        assert_eq!(v[0].rvalue_type, Some(PortType::VecF32));
439        assert_eq!(v[0].lvalue_type, PortType::Bytes);
440    }
441
442    /// Strict mode rejects rvalue→Str-lvalue mismatches. The
443    /// text-natural auto-permit USED to live in polydat
444    /// (`is_text_natural`); it has moved to the caller side as
445    /// an explicit `allow_fusion: true` policy bit. With
446    /// `allow_fusion: false`, even a Str lvalue rejects a
447    /// non-Str rvalue — and the diagnostic points at the `:*`
448    /// opt-in.
449    #[test]
450    fn strict_rejects_non_str_rvalue_into_str_lvalue() {
451        let strict_binder = Binder::Positional {
452            field: "prepared".into(),
453            slots: vec![BinderSlot {
454                wire: "vec".into(),
455                lvalue_type: PortType::Str,
456                allow_fusion: false,
457            }],
458        };
459        let v = verify_binders(&[strict_binder], wire_lookup(&[("vec", PortType::VecF32)]));
460        assert_eq!(
461            v.len(),
462            1,
463            "strict (allow_fusion=false) should reject VecF32→Str: {v:?}"
464        );
465    }
466
467    /// Caller-side opt-in: the same rvalue/lvalue pair is
468    /// accepted when the slot carries `allow_fusion: true`.
469    /// The adapter sets this for `Str`-lvalue slots in
470    /// non-strict mode; the workload-author can also opt in
471    /// per-slot via the `:*` wildcard syntax.
472    #[test]
473    fn allow_fusion_accepts_non_str_rvalue_into_str_lvalue() {
474        let fusing_binder = Binder::Positional {
475            field: "prepared".into(),
476            slots: vec![
477                BinderSlot {
478                    wire: "vec".into(),
479                    lvalue_type: PortType::Str,
480                    allow_fusion: true,
481                },
482                BinderSlot {
483                    wire: "num".into(),
484                    lvalue_type: PortType::Json,
485                    allow_fusion: true,
486                },
487            ],
488        };
489        let v = verify_binders(
490            &[fusing_binder],
491            wire_lookup(&[("vec", PortType::VecF32), ("num", PortType::F64)]),
492        );
493        assert!(
494            v.is_empty(),
495            "allow_fusion=true should accept any rvalue into any lvalue: {v:?}"
496        );
497    }
498
499    /// Unknown wire → loud error, no silent skipping. A binder
500    /// that names a wire the kernel doesn't know is a bug (the
501    /// adapter built the binder against an out-of-date wire
502    /// list, or a workload typo'd the field).
503    #[test]
504    fn unknown_wire_in_binder_is_loud_error() {
505        let binder = Binder::Positional {
506            field: "prepared".into(),
507            slots: vec![BinderSlot {
508                wire: "nonexistent".into(),
509                lvalue_type: PortType::Str,
510                allow_fusion: false,
511            }],
512        };
513        let v = verify_binders(&[binder], wire_lookup(&[]));
514        assert_eq!(v.len(), 1);
515        assert_eq!(v[0].rvalue_type, None);
516        assert!(
517            v[0].message.contains("not declared"),
518            "diagnostic should call out the unknown wire: {}",
519            v[0].message
520        );
521    }
522
523    /// Named binder: same rules, keyed-by-name diagnostics.
524    #[test]
525    fn named_binder_violations_carry_name_label() {
526        let mut slots = BTreeMap::new();
527        slots.insert(
528            "vec_param".into(),
529            BinderSlot {
530                wire: "v".into(),
531                lvalue_type: PortType::I64,
532                allow_fusion: false,
533            },
534        );
535        let binder = Binder::Named {
536            field: "prepared".into(),
537            slots,
538        };
539        let v = verify_binders(&[binder], wire_lookup(&[("v", PortType::VecF32)]));
540        assert_eq!(v.len(), 1);
541        assert_eq!(
542            v[0].slot_label, ":vec_param",
543            "named-slot diagnostic should carry the name: {:?}",
544            v[0]
545        );
546    }
547
548    /// Single-value binder: one slot, slot label empty.
549    #[test]
550    fn single_binder_violation_is_locatable() {
551        let binder = Binder::Single {
552            field: "body".into(),
553            slot: BinderSlot {
554                wire: "payload".into(),
555                lvalue_type: PortType::Bytes,
556                allow_fusion: false,
557            },
558        };
559        let v = verify_binders(&[binder], wire_lookup(&[("payload", PortType::VecF32)]));
560        assert_eq!(v.len(), 1);
561        assert_eq!(
562            v[0].slot_label, "",
563            "single-slot label is empty: {:?}",
564            v[0]
565        );
566        assert_eq!(v[0].field, "body");
567    }
568
569    /// Numeric widening within the non-text-natural side is
570    /// allowed. U32 wire → U64 lvalue, etc.
571    #[test]
572    fn numeric_widening_is_accepted() {
573        let binder = Binder::Positional {
574            field: "prepared".into(),
575            slots: vec![
576                BinderSlot {
577                    wire: "a".into(),
578                    lvalue_type: PortType::U64,
579                    allow_fusion: false,
580                },
581                BinderSlot {
582                    wire: "b".into(),
583                    lvalue_type: PortType::F64,
584                    allow_fusion: false,
585                },
586            ],
587        };
588        let v = verify_binders(
589            &[binder],
590            wire_lookup(&[("a", PortType::U32), ("b", PortType::F32)]),
591        );
592        assert!(
593            v.is_empty(),
594            "widening U32→U64 / F32→F64 should verify clean: {v:?}"
595        );
596    }
597
598    /// Per-slot `allow_fusion: true` skips the strict
599    /// rvalue→lvalue check. A wire holding `Str` bound to an
600    /// `I32` lvalue normally fails (the load-bearing
601    /// string-detour-into-non-text rule), but with the slot
602    /// tagged `allow_fusion: true` polydat accepts it — the
603    /// caller has licensed type fusion at this position.
604    #[test]
605    fn allow_fusion_skips_strict_check_for_wired_slot() {
606        let strict_binder = Binder::Positional {
607            field: "prepared".into(),
608            slots: vec![BinderSlot {
609                wire: "x".into(),
610                lvalue_type: PortType::I32,
611                allow_fusion: false,
612            }],
613        };
614        let fusing_binder = Binder::Positional {
615            field: "prepared".into(),
616            slots: vec![BinderSlot {
617                wire: "x".into(),
618                lvalue_type: PortType::I32,
619                allow_fusion: true,
620            }],
621        };
622        let lookup = wire_lookup(&[("x", PortType::Str)]);
623        // Strict: rejected (Str into I32, not text-natural).
624        let strict = verify_binders(&[strict_binder], &lookup);
625        assert_eq!(strict.len(), 1, "strict slot should reject: {strict:?}");
626        // Fusing: accepted; same rvalue/lvalue pair.
627        let fusing = verify_binders(&[fusing_binder], &lookup);
628        assert!(
629            fusing.is_empty(),
630            "allow_fusion=true should skip strict rule: {fusing:?}"
631        );
632    }
633
634    /// `allow_fusion: true` does NOT silence the unknown-wire
635    /// violation. Naming a non-existent wire is a structural
636    /// bug regardless of how the caller wants typed binding
637    /// applied at that slot.
638    #[test]
639    fn allow_fusion_still_reports_unknown_wires() {
640        let binder = Binder::Positional {
641            field: "prepared".into(),
642            slots: vec![BinderSlot {
643                wire: "ghost".into(),
644                lvalue_type: PortType::I32,
645                allow_fusion: true,
646            }],
647        };
648        let v = verify_binders(&[binder], wire_lookup(&[]));
649        assert_eq!(v.len(), 1, "unknown wire must fire even with fusion: {v:?}");
650        assert!(
651            v[0].message.contains("not declared"),
652            "expected unknown-wire diagnostic: {}",
653            v[0].message
654        );
655    }
656
657    /// Multiple violations across binders are all reported.
658    #[test]
659    fn all_violations_across_binders_are_reported() {
660        let b1 = Binder::Positional {
661            field: "f1".into(),
662            slots: vec![BinderSlot {
663                wire: "a".into(),
664                lvalue_type: PortType::Bytes,
665                allow_fusion: false,
666            }],
667        };
668        let b2 = Binder::Positional {
669            field: "f2".into(),
670            slots: vec![BinderSlot {
671                wire: "b".into(),
672                lvalue_type: PortType::VecF32,
673                allow_fusion: false,
674            }],
675        };
676        let v = verify_binders(
677            &[b1, b2],
678            wire_lookup(&[
679                ("a", PortType::VecF32), // VecF32 → Bytes : reject
680                ("b", PortType::Str),    // Str → VecF32 : reject (str-detour-into-non-text)
681            ]),
682        );
683        assert_eq!(v.len(), 2, "both violations expected: {v:?}");
684    }
685}