use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CbReplayApiGap {
Mtl4CbHasNoReplayApi,
IcbDecodeGraphNotMigrated,
IcbClassicBindsVsMtl4ArgumentTable,
}
impl CbReplayApiGap {
pub fn as_str(self) -> &'static str {
match self {
Self::Mtl4CbHasNoReplayApi => {
"MTL4CommandBuffer has no replay-prior-encoding API (begin+allocator re-records); path is compute ICB only"
}
Self::IcbDecodeGraphNotMigrated => {
"mini+E4B Hot DecodeIcb layer-graph capture→from_commands+skip landed (flags default OFF); 31B session graph not migrated"
}
Self::IcbClassicBindsVsMtl4ArgumentTable => {
"DecodeIcb default: inheritBuffers+prebuilt arg-tables (setArgumentTable × cmds; last_setAddress=0). Opt-in freeze/range/coarse (v0.5.7–0.5.9) PARKED: setArgTable=0 + execute_icb ranges, but product tok/s loses vs prebuilt — prefer prebuilt; flags default OFF"
}
}
}
}
pub fn survey_cb_replay_api_gaps() -> &'static [CbReplayApiGap] {
&[
CbReplayApiGap::Mtl4CbHasNoReplayApi,
CbReplayApiGap::IcbDecodeGraphNotMigrated,
CbReplayApiGap::IcbClassicBindsVsMtl4ArgumentTable,
]
}
pub fn cb_replay_api_gap_summary() -> String {
survey_cb_replay_api_gaps()
.iter()
.map(|g| g.as_str())
.collect::<Vec<_>>()
.join("; ")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IcbCommandTypeHint {
ConcurrentDispatch,
ConcurrentDispatchThreads,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArgTableSlot {
pub index: u32,
pub label: String,
pub stable: bool,
}
#[derive(Clone, Debug)]
pub struct ArgTableSlotPlan {
pub slots: Vec<ArgTableSlot>,
pub max_commands: u32,
pub command_types: IcbCommandTypeHint,
}
impl ArgTableSlotPlan {
pub fn encode_once_v0_mini() -> Self {
Self {
slots: vec![
ArgTableSlot {
index: 0,
label: "pos_buf".into(),
stable: true,
},
ArgTableSlot {
index: 1,
label: "seed_tok".into(),
stable: true,
},
ArgTableSlot {
index: 2,
label: "argmax_tok".into(),
stable: true,
},
ArgTableSlot {
index: 3,
label: "icb_scalars_softcap".into(),
stable: true,
},
ArgTableSlot {
index: 4,
label: "icb_scalars_u32s_f32s".into(),
stable: true,
},
ArgTableSlot {
index: 5,
label: "const_arena_residual".into(),
stable: false,
},
],
max_commands: 64,
command_types: IcbCommandTypeHint::ConcurrentDispatch,
}
}
pub fn stable_count(&self) -> usize {
self.slots.iter().filter(|s| s.stable).count()
}
pub fn ephemeral_count(&self) -> usize {
self.slots.iter().filter(|s| !s.stable).count()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IcbStubPhase {
Idle,
Planned,
SmokeProven,
Allocated,
}
#[derive(Clone, Debug)]
pub struct IcbReplayStub {
pub phase: IcbStubPhase,
pub plan: ArgTableSlotPlan,
pub allocate_attempts: u64,
pub execute_attempts: u64,
pub smoke_proven: bool,
}
impl Default for IcbReplayStub {
fn default() -> Self {
Self::new()
}
}
impl IcbReplayStub {
pub fn new() -> Self {
Self {
phase: IcbStubPhase::Idle,
plan: ArgTableSlotPlan::encode_once_v0_mini(),
allocate_attempts: 0,
execute_attempts: 0,
smoke_proven: false,
}
}
pub fn mark_planned(&mut self) {
if self.phase == IcbStubPhase::Idle {
self.phase = IcbStubPhase::Planned;
}
}
pub fn mark_smoke_proven(&mut self) {
self.smoke_proven = true;
self.phase = IcbStubPhase::SmokeProven;
}
pub fn try_allocate(&mut self) -> Result<(), CbReplayError> {
self.allocate_attempts = self.allocate_attempts.saturating_add(1);
if self.phase == IcbStubPhase::Idle {
self.phase = IcbStubPhase::Planned;
}
Err(CbReplayError::NotWired)
}
pub fn try_execute(&mut self) -> Result<(), CbReplayError> {
self.execute_attempts = self.execute_attempts.saturating_add(1);
if self.phase == IcbStubPhase::Allocated {
return Ok(());
}
Err(CbReplayError::NotWired)
}
pub fn mark_mini_execute_ok(&mut self) {
self.execute_attempts = self.execute_attempts.saturating_add(1);
self.smoke_proven = true;
self.phase = IcbStubPhase::Allocated;
}
pub fn status_line(&self) -> String {
format!(
"icb_stub phase={:?} cmds={} stable={}/{} alloc_attempts={} exec_attempts={} smoke_proven={} decode_mini_wired={}",
self.phase,
self.plan.max_commands,
self.plan.stable_count(),
self.plan.slots.len(),
self.allocate_attempts,
self.execute_attempts,
self.smoke_proven,
self.phase == IcbStubPhase::Allocated
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CbSlot {
A = 0,
B = 1,
}
impl CbSlot {
pub fn peer(self) -> Self {
match self {
CbSlot::A => CbSlot::B,
CbSlot::B => CbSlot::A,
}
}
pub fn index(self) -> usize {
self as usize
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CbReplayPhase {
Idle,
Recording,
Ready,
InFlight,
}
#[derive(Clone, Debug)]
pub struct CbReplaySlot {
pub phase: CbReplayPhase,
pub generation: u64,
pub label: String,
}
impl Default for CbReplaySlot {
fn default() -> Self {
Self {
phase: CbReplayPhase::Idle,
generation: 0,
label: String::new(),
}
}
}
pub struct PingPongCbReplay {
slots: [CbReplaySlot; 2],
active: CbSlot,
next_gen: AtomicU64,
dry_run: bool,
live_encodes: u64,
not_wired_hits: u64,
icb_execute_failures: u64,
icb_replays: u64,
layer_live_replays: u64,
stale_completes: u64,
icb: IcbReplayStub,
decode_icb: Option<crate::decode_icb::DecodeIcb>,
}
impl Default for PingPongCbReplay {
fn default() -> Self {
Self::new()
}
}
impl PingPongCbReplay {
pub fn new() -> Self {
Self {
slots: [CbReplaySlot::default(), CbReplaySlot::default()],
active: CbSlot::A,
next_gen: AtomicU64::new(1),
dry_run: false,
live_encodes: 0,
not_wired_hits: 0,
icb_execute_failures: 0,
icb_replays: 0,
layer_live_replays: 0,
stale_completes: 0,
icb: IcbReplayStub::new(),
decode_icb: None,
}
}
pub fn with_dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
pub fn active_slot(&self) -> CbSlot {
self.active
}
pub fn slot(&self, s: CbSlot) -> &CbReplaySlot {
&self.slots[s.index()]
}
pub fn live_encodes(&self) -> u64 {
self.live_encodes
}
pub fn not_wired_hits(&self) -> u64 {
self.not_wired_hits
}
pub fn icb_replays(&self) -> u64 {
self.icb_replays
}
pub fn icb_execute_failures(&self) -> u64 {
self.icb_execute_failures
}
pub fn layer_live_replays(&self) -> u64 {
self.layer_live_replays
}
pub fn icb_stub(&self) -> &IcbReplayStub {
&self.icb
}
pub fn icb_stub_mut(&mut self) -> &mut IcbReplayStub {
&mut self.icb
}
pub fn decode_icb_wired(&self) -> bool {
self.decode_icb
.as_ref()
.map(|d| d.encoded())
.unwrap_or(false)
}
pub fn decode_icb_layer_graph(&self) -> bool {
self.decode_icb
.as_ref()
.map(|d| d.is_layer_graph())
.unwrap_or(false)
}
pub fn decode_icb(&self) -> Option<&crate::decode_icb::DecodeIcb> {
self.decode_icb.as_ref()
}
pub fn attach_decode_icb(&mut self, icb: crate::decode_icb::DecodeIcb) {
self.icb.mark_smoke_proven();
self.icb.phase = IcbStubPhase::Allocated;
self.decode_icb = Some(icb);
}
pub fn has_ready_slot(&self) -> bool {
self.slots.iter().any(|s| s.phase == CbReplayPhase::Ready)
}
pub fn execute_decode_icb(&mut self, rt: &crate::runtime::GpuRuntime) -> Result<(), String> {
let icb = self
.decode_icb
.as_mut()
.ok_or_else(|| "execute_decode_icb: no DecodeIcb attached".to_string())?;
icb.execute(rt)?;
self.icb_replays = self.icb_replays.saturating_add(1);
self.icb.mark_mini_execute_ok();
Ok(())
}
pub fn try_replay_icb(
&mut self,
slot: CbSlot,
rt: &crate::runtime::GpuRuntime,
) -> Result<(), CbReplayError> {
if self.slots[slot.index()].phase != CbReplayPhase::Ready {
return Err(CbReplayError::NotReady);
}
if !crate::decode_icb_enabled() || !self.decode_icb_wired() {
self.not_wired_hits = self.not_wired_hits.saturating_add(1);
let _ = self.icb.try_execute();
return Err(CbReplayError::NotWired);
}
self.slots[slot.index()].phase = CbReplayPhase::InFlight;
if let Err(e) = self.execute_decode_icb(rt) {
self.slots[slot.index()].phase = CbReplayPhase::Ready;
self.icb_execute_failures = self.icb_execute_failures.saturating_add(1);
return Err(CbReplayError::IcbExecuteFailed(e));
}
Ok(())
}
pub fn try_replay_ready_icb(
&mut self,
rt: &crate::runtime::GpuRuntime,
) -> Result<CbSlot, CbReplayError> {
for slot in [CbSlot::A, CbSlot::B] {
if self.slots[slot.index()].phase == CbReplayPhase::Ready {
self.try_replay_icb(slot, rt)?;
return Ok(slot);
}
}
Err(CbReplayError::NotReady)
}
pub fn mark_live_step(&mut self, label: impl Into<String>) -> Result<(), CbReplayError> {
self.mark_step_inner(label, true)
}
pub fn mark_replay_step(&mut self, label: impl Into<String>) -> Result<(), CbReplayError> {
self.mark_step_inner(label, false)
}
pub fn note_layer_live_replay(
&mut self,
label: impl Into<String>,
) -> Result<(), CbReplayError> {
self.mark_replay_step(label)?;
self.layer_live_replays = self.layer_live_replays.saturating_add(1);
Ok(())
}
fn mark_step_inner(
&mut self,
label: impl Into<String>,
count_live: bool,
) -> Result<(), CbReplayError> {
let slot = self.active;
match self.slots[slot.index()].phase {
CbReplayPhase::Recording => self.finish_record(slot)?,
CbReplayPhase::InFlight => {
self.on_gpu_complete(slot);
self.begin_record(slot, label)?;
self.finish_record(slot)?;
}
CbReplayPhase::Idle | CbReplayPhase::Ready => {
self.begin_record(slot, label)?;
self.finish_record(slot)?;
}
}
if count_live {
self.live_encodes = self.live_encodes.saturating_add(1);
}
if self.icb.phase == IcbStubPhase::Idle {
self.icb.mark_planned();
}
self.flip_active();
Ok(())
}
pub fn try_replay_ready(&mut self) -> Result<CbSlot, CbReplayError> {
for slot in [CbSlot::A, CbSlot::B] {
if self.slots[slot.index()].phase == CbReplayPhase::Ready {
self.try_replay(slot)?;
return Ok(slot);
}
}
Err(CbReplayError::NotReady)
}
pub fn begin_record(
&mut self,
slot: CbSlot,
label: impl Into<String>,
) -> Result<(), CbReplayError> {
let s = &mut self.slots[slot.index()];
match s.phase {
CbReplayPhase::Idle | CbReplayPhase::Ready => {}
CbReplayPhase::Recording => return Err(CbReplayError::AlreadyRecording),
CbReplayPhase::InFlight => return Err(CbReplayError::InFlight),
}
let gen = self.next_gen.fetch_add(1, Ordering::Relaxed);
s.phase = CbReplayPhase::Recording;
s.generation = gen;
s.label = label.into();
self.active = slot;
Ok(())
}
pub fn finish_record(&mut self, slot: CbSlot) -> Result<(), CbReplayError> {
let s = &mut self.slots[slot.index()];
if s.phase != CbReplayPhase::Recording {
return Err(CbReplayError::NotRecording);
}
s.phase = CbReplayPhase::Ready;
Ok(())
}
pub fn try_replay(&mut self, slot: CbSlot) -> Result<(), CbReplayError> {
if self.slots[slot.index()].phase != CbReplayPhase::Ready {
return Err(CbReplayError::NotReady);
}
if !self.dry_run {
self.not_wired_hits = self.not_wired_hits.saturating_add(1);
let _ = self.icb.try_execute();
return Err(CbReplayError::NotWired);
}
self.slots[slot.index()].phase = CbReplayPhase::InFlight;
Ok(())
}
pub fn on_gpu_complete(&mut self, slot: CbSlot) {
if self.slots[slot.index()].phase == CbReplayPhase::Recording {
self.stale_completes = self.stale_completes.saturating_add(1);
return;
}
let s = &mut self.slots[slot.index()];
s.phase = CbReplayPhase::Idle;
s.label.clear();
}
pub fn flip_active(&mut self) {
self.active = self.active.peer();
}
pub fn status_line(&self) -> String {
let wired = self.decode_icb_wired();
let icb_s = self
.decode_icb
.as_ref()
.map(|d| d.status_line())
.unwrap_or_else(|| self.icb.status_line());
format!(
"cb_replay active={:?} A={:?}/{} B={:?}/{} dry_run={} wired={wired} live_encodes={} not_wired={} icb_replays={} icb_exec_fail={} layer_live_replays={} stale_completes={} | {icb_s}",
self.active,
self.slots[0].phase,
self.slots[0].generation,
self.slots[1].phase,
self.slots[1].generation,
self.dry_run,
self.live_encodes,
self.not_wired_hits,
self.icb_replays,
self.icb_execute_failures,
self.layer_live_replays,
self.stale_completes,
)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CbReplayError {
AlreadyRecording,
NotRecording,
NotReady,
InFlight,
NotWired,
IcbExecuteFailed(String),
}
impl std::fmt::Display for CbReplayError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CbReplayError::AlreadyRecording => write!(f, "CB slot already recording"),
CbReplayError::NotRecording => write!(f, "CB slot not recording"),
CbReplayError::NotReady => write!(f, "CB slot not Ready for replay"),
CbReplayError::InFlight => write!(f, "CB slot still in flight"),
CbReplayError::NotWired => {
write!(f, "encode-once replay not wired: {}", {
let s = cb_replay_api_gap_summary();
if s.is_empty() {
"unknown gap".to_string()
} else {
s
}
})
}
CbReplayError::IcbExecuteFailed(e) => write!(f, "decode ICB execute failed: {e}"),
}
}
}
impl std::error::Error for CbReplayError {}
#[cfg(test)]
mod tests {
use super::*;
use objc2_metal::MTLBuffer;
#[test]
fn ping_pong_record_ready_dry_replay() {
let mut pp = PingPongCbReplay::new().with_dry_run(true);
pp.begin_record(CbSlot::A, "mini_graph").unwrap();
assert_eq!(pp.slot(CbSlot::A).phase, CbReplayPhase::Recording);
pp.finish_record(CbSlot::A).unwrap();
assert_eq!(pp.slot(CbSlot::A).phase, CbReplayPhase::Ready);
pp.try_replay(CbSlot::A).unwrap();
assert_eq!(pp.slot(CbSlot::A).phase, CbReplayPhase::InFlight);
pp.on_gpu_complete(CbSlot::A);
assert_eq!(pp.slot(CbSlot::A).phase, CbReplayPhase::Idle);
}
#[test]
fn try_replay_not_wired_by_default() {
let mut pp = PingPongCbReplay::new();
pp.begin_record(CbSlot::B, "x").unwrap();
pp.finish_record(CbSlot::B).unwrap();
assert_eq!(pp.try_replay(CbSlot::B), Err(CbReplayError::NotWired));
assert_eq!(pp.not_wired_hits(), 1);
assert_eq!(pp.icb_stub().execute_attempts, 1);
}
#[test]
fn mark_live_step_advances_ping_pong() {
let mut pp = PingPongCbReplay::new();
assert_eq!(pp.active_slot(), CbSlot::A);
pp.mark_live_step("step0").unwrap();
assert_eq!(pp.live_encodes(), 1);
assert_eq!(pp.active_slot(), CbSlot::B);
assert_eq!(pp.slot(CbSlot::A).phase, CbReplayPhase::Ready);
assert_eq!(pp.icb_stub().phase, IcbStubPhase::Planned);
pp.mark_live_step("step1").unwrap();
assert_eq!(pp.live_encodes(), 2);
assert_eq!(pp.active_slot(), CbSlot::A);
assert_eq!(pp.try_replay(CbSlot::B), Err(CbReplayError::NotWired));
}
#[test]
fn api_gap_survey_names_mtl4_and_icb() {
let gaps = survey_cb_replay_api_gaps();
assert!(gaps.contains(&CbReplayApiGap::Mtl4CbHasNoReplayApi));
assert!(gaps.contains(&CbReplayApiGap::IcbDecodeGraphNotMigrated));
assert!(gaps.contains(&CbReplayApiGap::IcbClassicBindsVsMtl4ArgumentTable));
let summary = cb_replay_api_gap_summary();
assert!(summary.contains("MTL4CommandBuffer"));
assert!(summary.contains("inheritBuffers") || summary.contains("arg-table"));
assert!(summary.contains("DecodeIcb") || summary.contains("execute_icb"));
}
#[test]
fn icb_stub_allocate_stays_not_wired() {
let mut stub = IcbReplayStub::new();
assert_eq!(stub.try_allocate(), Err(CbReplayError::NotWired));
assert_eq!(stub.phase, IcbStubPhase::Planned);
assert_eq!(stub.allocate_attempts, 1);
assert_eq!(stub.plan.stable_count(), 5);
assert!(stub.plan.ephemeral_count() >= 1);
}
#[test]
fn try_replay_ready_not_wired_after_live() {
let mut pp = PingPongCbReplay::new();
assert_eq!(pp.try_replay_ready(), Err(CbReplayError::NotReady));
pp.mark_live_step("s0").unwrap();
assert_eq!(pp.try_replay_ready(), Err(CbReplayError::NotWired));
assert!(pp.not_wired_hits() >= 1);
}
#[test]
fn decode_icb_mini_replay() {
let _flags = crate::decode_icb::IcbFlagsTestGuard::lock();
crate::set_decode_icb(true);
let rt = crate::GpuRuntime::new().expect("runtime");
let (dicb, out) = crate::DecodeIcb::mini_copy_chain(&rt, 32).expect("mini DecodeIcb");
let mut pp = PingPongCbReplay::new();
pp.attach_decode_icb(dicb);
pp.mark_live_step("capture0").unwrap();
assert_eq!(pp.slot(CbSlot::A).phase, CbReplayPhase::Ready);
let exec_before = pp.icb_stub().execute_attempts;
pp.try_replay_icb(CbSlot::A, &rt).expect("try_replay_icb");
rt.synchronize().unwrap();
assert_eq!(pp.icb_replays(), 1);
assert_eq!(pp.icb_stub().execute_attempts, exec_before + 1);
assert_eq!(pp.slot(CbSlot::A).phase, CbReplayPhase::InFlight);
assert_eq!(pp.not_wired_hits(), 0);
assert_eq!(pp.icb_execute_failures(), 0);
let n = 32usize;
let got =
unsafe { std::slice::from_raw_parts(out.metal().contents().as_ptr() as *const f32, n) };
for (i, v) in got.iter().take(n).enumerate() {
assert_eq!(*v, (i as f32) + 1.0, "mismatch at {i}");
}
pp.on_gpu_complete(CbSlot::A);
pp.begin_record(CbSlot::A, "again").unwrap();
pp.finish_record(CbSlot::A).unwrap();
unsafe {
let q = out.metal().contents().as_ptr() as *mut u8;
std::ptr::write_bytes(q, 0xFF, n * 4);
}
pp.try_replay_icb(CbSlot::A, &rt)
.expect("second try_replay_icb");
rt.synchronize().unwrap();
let got2 =
unsafe { std::slice::from_raw_parts(out.metal().contents().as_ptr() as *const f32, n) };
for (i, v) in got2.iter().take(n).enumerate() {
assert_eq!(*v, (i as f32) + 1.0);
}
assert_eq!(pp.icb_replays(), 2);
eprintln!("decode_icb_mini_replay: {}", pp.status_line());
}
#[test]
fn try_replay_stays_not_wired_with_a_decode_icb_attached() {
let _flags = crate::decode_icb::IcbFlagsTestGuard::lock();
crate::set_decode_icb(true);
let rt = crate::GpuRuntime::new().expect("runtime");
let (dicb, _out) = crate::DecodeIcb::mini_copy_chain(&rt, 32).expect("mini DecodeIcb");
let mut pp = PingPongCbReplay::new();
pp.attach_decode_icb(dicb);
assert!(pp.decode_icb_wired());
pp.mark_live_step("capture0").unwrap();
assert_eq!(pp.slot(CbSlot::A).phase, CbReplayPhase::Ready);
assert_eq!(pp.try_replay(CbSlot::A), Err(CbReplayError::NotWired));
assert_eq!(pp.slot(CbSlot::A).phase, CbReplayPhase::Ready);
assert_eq!(pp.icb_replays(), 0);
assert_eq!(pp.not_wired_hits(), 1);
pp.try_replay_icb(CbSlot::A, &rt).expect("try_replay_icb");
rt.synchronize().unwrap();
assert_eq!(pp.icb_replays(), 1);
}
#[test]
fn ledger_recycles_an_in_flight_slot_instead_of_wedging() {
let mut pp = PingPongCbReplay::new().with_dry_run(true);
pp.mark_live_step("s0").unwrap();
pp.try_replay(CbSlot::A).unwrap();
assert_eq!(pp.slot(CbSlot::A).phase, CbReplayPhase::InFlight);
pp.mark_live_step("s1").unwrap();
assert_eq!(pp.active_slot(), CbSlot::A);
pp.mark_live_step("s2")
.expect("in-flight slot must recycle");
pp.mark_live_step("s3").unwrap();
assert_eq!(pp.live_encodes(), 4);
assert_eq!(pp.slot(CbSlot::A).phase, CbReplayPhase::Ready);
}
#[test]
fn on_gpu_complete_does_not_discard_an_open_recording() {
let mut pp = PingPongCbReplay::new();
pp.begin_record(CbSlot::A, "mid_encode").unwrap();
pp.on_gpu_complete(CbSlot::A);
assert_eq!(pp.slot(CbSlot::A).phase, CbReplayPhase::Recording);
pp.finish_record(CbSlot::A)
.expect("recording must survive a stale completion");
assert_eq!(pp.slot(CbSlot::A).phase, CbReplayPhase::Ready);
assert!(pp.status_line().contains("stale_completes=1"));
pp.on_gpu_complete(CbSlot::A);
assert_eq!(pp.slot(CbSlot::A).phase, CbReplayPhase::Idle);
}
#[test]
fn note_layer_live_replay_is_not_counted_as_a_tape_replay() {
let mut pp = PingPongCbReplay::new();
pp.note_layer_live_replay("live_layer_replay pos=0")
.unwrap();
assert_eq!(pp.layer_live_replays(), 1);
assert_eq!(pp.icb_replays(), 0, "no DecodeIcb tape ran");
assert_eq!(pp.live_encodes(), 0, "mark_replay_step must not count live");
assert_eq!(pp.icb_stub().execute_attempts, 0);
assert_ne!(pp.icb_stub().phase, IcbStubPhase::Allocated);
}
#[test]
fn not_wired_message_names_every_surveyed_gap() {
let msg = CbReplayError::NotWired.to_string();
for gap in survey_cb_replay_api_gaps() {
assert!(
msg.contains(gap.as_str()),
"NotWired message omits {gap:?}: {msg}"
);
}
}
}