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}