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}