Skip to main content

softgpu_functional/
debug.rs

1//! SoftGPU Phase 9 debugger and deterministic exploration.
2//!
3//! Declared subset (honest):
4//! - Versioned JSONL traces (`softgpu-debug-trace-v1`) with a hard event budget
5//! - Breakpoints on SoftGPU global step and/or memory-access ops
6//! - Wave/lane/register snapshots at stop; global memory peek by address
7//! - Seeded schedule exploration (`schedule_seed` + `wave_size` candidates)
8//! - Trace minimization to the earliest failing SoftGPU step
9//! - Source mapping: **only** `Program.source_provenance` — never invents lines/files
10//!
11//! Hostile trace input is rejected with validation/parse errors (no panic on junk).
12
13use crate::error::{FunctionalError, Result};
14use crate::exec::{run_with_config_sanitized, ExecConfig, LaunchConfig, RunReport, SchedulePolicy};
15use crate::ir::{AddrSpace, Op, Program};
16use crate::memory::GlobalArena;
17use crate::sanitize::{
18    Finding, FindingKind, ReplayBundle, SanitizeMode, SanitizeReport, WorkItemId,
19};
20use serde::{Deserialize, Serialize};
21use std::collections::BTreeMap;
22
23pub const DEBUG_TRACE_SCHEMA: &str = "softgpu-debug-trace-v1";
24pub const DEFAULT_TRACE_EVENT_BUDGET: usize = 64 * 1024;
25pub const MAX_TRACE_BYTES: usize = 8 * 1024 * 1024;
26pub const MAX_TRACE_LINE_BYTES: usize = 64 * 1024;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum DebugAction {
31    Continue,
32    Break,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum Breakpoint {
38    /// Stop after SoftGPU global step `step` completes (1-based sanitizer/debug step).
39    AfterStep { step: u64 },
40    /// Stop after a global or group load/store/atomic.
41    OnMemoryAccess,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum TraceEvent {
47    Header {
48        schema: String,
49        program_name: String,
50        source_provenance: String,
51        grid: [u32; 3],
52        workgroup: [u32; 3],
53        wave_size: u32,
54        schedule: SchedulePolicy,
55        schedule_seed: u64,
56        note: String,
57    },
58    Step {
59        step: u64,
60        barrier_gen: u64,
61        actor: WorkItemId,
62        op: String,
63    },
64    MemoryAccess {
65        step: u64,
66        space: AddrSpace,
67        addr: u64,
68        is_write: bool,
69        is_atomic: bool,
70        actor: WorkItemId,
71    },
72    Barrier {
73        barrier_gen: u64,
74    },
75    Break {
76        step: u64,
77        reason: String,
78    },
79    SanitizeFinding {
80        finding: Finding,
81    },
82    Done {
83        steps: u64,
84        workitems: u64,
85    },
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct StateSnapshot {
90    pub step: u64,
91    pub barrier_gen: u64,
92    pub actor: WorkItemId,
93    pub op: String,
94    pub registers: BTreeMap<String, i64>,
95    /// SoftGPU never invents source locations; this is the program provenance string only.
96    pub source_provenance: String,
97    pub source_mapping: Option<()>,
98}
99
100impl StateSnapshot {
101    pub fn from_lane(
102        step: u64,
103        barrier_gen: u64,
104        actor: WorkItemId,
105        op: &str,
106        regs: &BTreeMap<String, i64>,
107        program: &Program,
108    ) -> Self {
109        Self {
110            step,
111            barrier_gen,
112            actor,
113            op: op.to_string(),
114            registers: regs.clone(),
115            source_provenance: program.source_provenance.clone(),
116            // Explicitly absent: SoftGPU has no verified SFIR→source line map.
117            source_mapping: None,
118        }
119    }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct DebugStop {
124    pub reason: String,
125    pub snapshot: StateSnapshot,
126}
127
128#[derive(Debug, Default)]
129pub struct TraceLog {
130    events: Vec<TraceEvent>,
131    budget: usize,
132    exhausted: bool,
133}
134
135impl TraceLog {
136    pub fn new(budget: usize) -> Self {
137        Self {
138            events: Vec::new(),
139            budget: budget.max(1),
140            exhausted: false,
141        }
142    }
143
144    pub fn push(&mut self, ev: TraceEvent) -> Result<()> {
145        if self.events.len() >= self.budget {
146            self.exhausted = true;
147            return Err(FunctionalError::Validation {
148                detail: format!(
149                    "debug trace event budget exhausted (budget={})",
150                    self.budget
151                ),
152            });
153        }
154        self.events.push(ev);
155        Ok(())
156    }
157
158    pub fn events(&self) -> &[TraceEvent] {
159        &self.events
160    }
161
162    pub fn exhausted(&self) -> bool {
163        self.exhausted
164    }
165
166    pub fn to_jsonl(&self) -> Result<String> {
167        let mut out = String::new();
168        for ev in &self.events {
169            let line =
170                serde_json::to_string(ev).map_err(|e| FunctionalError::Internal(e.to_string()))?;
171            out.push_str(&line);
172            out.push('\n');
173        }
174        Ok(out)
175    }
176}
177
178/// Hostile-input JSONL reader for SoftGPU debug traces.
179pub fn parse_trace_jsonl(input: &str) -> Result<Vec<TraceEvent>> {
180    if input.len() > MAX_TRACE_BYTES {
181        return Err(FunctionalError::Validation {
182            detail: format!(
183                "trace input {} bytes exceeds SoftGPU max {MAX_TRACE_BYTES}",
184                input.len()
185            ),
186        });
187    }
188    let mut events = Vec::new();
189    for (i, line) in input.lines().enumerate() {
190        let line = line.trim();
191        if line.is_empty() {
192            continue;
193        }
194        if line.len() > MAX_TRACE_LINE_BYTES {
195            return Err(FunctionalError::Validation {
196                detail: format!(
197                    "trace line {} length {} exceeds SoftGPU max {MAX_TRACE_LINE_BYTES}",
198                    i + 1,
199                    line.len()
200                ),
201            });
202        }
203        let ev: TraceEvent = serde_json::from_str(line)
204            .map_err(|e| FunctionalError::Parse(format!("trace line {}: {e}", i + 1)))?;
205        if events.len() >= DEFAULT_TRACE_EVENT_BUDGET {
206            return Err(FunctionalError::Validation {
207                detail: format!(
208                    "trace has more than {DEFAULT_TRACE_EVENT_BUDGET} events (SoftGPU limit)"
209                ),
210            });
211        }
212        events.push(ev);
213    }
214    if let Some(TraceEvent::Header { schema, .. }) = events.first() {
215        if schema != DEBUG_TRACE_SCHEMA {
216            return Err(FunctionalError::Validation {
217                detail: format!("trace schema '{schema}' != '{DEBUG_TRACE_SCHEMA}'"),
218            });
219        }
220    } else if !events.is_empty() {
221        return Err(FunctionalError::Validation {
222            detail: "debug trace must begin with a header event".into(),
223        });
224    }
225    Ok(events)
226}
227
228fn op_label(op: &Op) -> String {
229    match op {
230        Op::Const { .. } => "const".into(),
231        Op::GlobalId { .. } => "global_id".into(),
232        Op::LocalId { .. } => "local_id".into(),
233        Op::WorkgroupId { .. } => "workgroup_id".into(),
234        Op::LaneId { .. } => "lane_id".into(),
235        Op::WaveId { .. } => "wave_id".into(),
236        Op::WaveSize { .. } => "wave_size".into(),
237        Op::Add { .. } => "add".into(),
238        Op::Sub { .. } => "sub".into(),
239        Op::Mul { .. } => "mul".into(),
240        Op::CmpEq { .. } => "cmp_eq".into(),
241        Op::CmpNe { .. } => "cmp_ne".into(),
242        Op::And { .. } => "and".into(),
243        Op::KernargLoad { .. } => "kernarg_load".into(),
244        Op::LoadGlobal { .. } => "load_global".into(),
245        Op::StoreGlobal { .. } => "store_global".into(),
246        Op::LoadGroup { .. } => "load_group".into(),
247        Op::StoreGroup { .. } => "store_group".into(),
248        Op::AtomicAdd { .. } => "atomic_add".into(),
249        Op::Barrier => "barrier".into(),
250        Op::If { .. } => "if".into(),
251        Op::While { .. } => "while".into(),
252        Op::Ret => "ret".into(),
253    }
254}
255
256fn is_memory_op(op: &Op) -> bool {
257    matches!(
258        op,
259        Op::LoadGlobal { .. }
260            | Op::StoreGlobal { .. }
261            | Op::LoadGroup { .. }
262            | Op::StoreGroup { .. }
263            | Op::AtomicAdd { .. }
264    )
265}
266
267/// Observer invoked from the SoftGPU interpreter (Phase 9).
268pub trait ExecObserver {
269    fn on_lane_step(
270        &mut self,
271        step: u64,
272        barrier_gen: u64,
273        actor: WorkItemId,
274        op: &Op,
275        regs: &BTreeMap<String, i64>,
276    ) -> Result<DebugAction>;
277
278    fn on_barrier(&mut self, barrier_gen: u64) -> Result<()> {
279        let _ = barrier_gen;
280        Ok(())
281    }
282}
283
284#[derive(Debug)]
285pub struct NoopObserver;
286
287impl ExecObserver for NoopObserver {
288    fn on_lane_step(
289        &mut self,
290        _step: u64,
291        _barrier_gen: u64,
292        _actor: WorkItemId,
293        _op: &Op,
294        _regs: &BTreeMap<String, i64>,
295    ) -> Result<DebugAction> {
296        Ok(DebugAction::Continue)
297    }
298}
299
300pub struct DebugSession<'a> {
301    program: &'a Program,
302    breakpoints: Vec<Breakpoint>,
303    pub trace: TraceLog,
304    stop: Option<DebugStop>,
305    single_step: bool,
306}
307
308impl<'a> DebugSession<'a> {
309    pub fn new(program: &'a Program, breakpoints: Vec<Breakpoint>, trace_budget: usize) -> Self {
310        Self {
311            program,
312            breakpoints,
313            trace: TraceLog::new(trace_budget),
314            stop: None,
315            single_step: false,
316        }
317    }
318
319    pub fn with_single_step(mut self) -> Self {
320        self.single_step = true;
321        self
322    }
323
324    pub fn take_stop(&mut self) -> Option<DebugStop> {
325        self.stop.take()
326    }
327
328    fn should_break(&self, step: u64, op: &Op) -> Option<&'static str> {
329        if self.single_step {
330            return Some("single_step");
331        }
332        for bp in &self.breakpoints {
333            match bp {
334                Breakpoint::AfterStep { step: s } if *s == step => return Some("after_step"),
335                Breakpoint::OnMemoryAccess if is_memory_op(op) => return Some("memory_access"),
336                _ => {}
337            }
338        }
339        None
340    }
341}
342
343impl ExecObserver for DebugSession<'_> {
344    fn on_lane_step(
345        &mut self,
346        step: u64,
347        barrier_gen: u64,
348        actor: WorkItemId,
349        op: &Op,
350        regs: &BTreeMap<String, i64>,
351    ) -> Result<DebugAction> {
352        let label = op_label(op);
353        self.trace.push(TraceEvent::Step {
354            step,
355            barrier_gen,
356            actor,
357            op: label.clone(),
358        })?;
359        if is_memory_op(op) {
360            let (space, is_write, is_atomic) = match op {
361                Op::LoadGlobal { .. } => (AddrSpace::Global, false, false),
362                Op::StoreGlobal { .. } => (AddrSpace::Global, true, false),
363                Op::LoadGroup { .. } => (AddrSpace::Group, false, false),
364                Op::StoreGroup { .. } => (AddrSpace::Group, true, false),
365                Op::AtomicAdd { space, .. } => (*space, true, true),
366                _ => unreachable!(),
367            };
368            // Address is not re-derived here; record actor/step for replay correlation.
369            self.trace.push(TraceEvent::MemoryAccess {
370                step,
371                space,
372                addr: 0,
373                is_write,
374                is_atomic,
375                actor,
376            })?;
377        }
378        if let Some(reason) = self.should_break(step, op) {
379            let snap =
380                StateSnapshot::from_lane(step, barrier_gen, actor, &label, regs, self.program);
381            self.trace.push(TraceEvent::Break {
382                step,
383                reason: reason.into(),
384            })?;
385            self.stop = Some(DebugStop {
386                reason: reason.into(),
387                snapshot: snap,
388            });
389            return Ok(DebugAction::Break);
390        }
391        Ok(DebugAction::Continue)
392    }
393
394    fn on_barrier(&mut self, barrier_gen: u64) -> Result<()> {
395        self.trace.push(TraceEvent::Barrier { barrier_gen })
396    }
397}
398
399#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
400pub struct DebugRunReport {
401    pub fidelity: &'static str,
402    pub mode: &'static str,
403    pub note: &'static str,
404    pub run: Option<RunReport>,
405    pub sanitize: SanitizeReport,
406    pub stop: Option<DebugStop>,
407    pub trace_events: usize,
408}
409
410/// Execute under SoftGPU debugger controls.
411pub fn run_debug(
412    program: &Program,
413    cfg: ExecConfig,
414    arena: &mut GlobalArena,
415    kernarg: &[u8],
416    breakpoints: Vec<Breakpoint>,
417    single_step: bool,
418    trace_budget: usize,
419) -> Result<(DebugRunReport, TraceLog)> {
420    let mut session = DebugSession::new(program, breakpoints, trace_budget);
421    if single_step {
422        session = session.with_single_step();
423    }
424    session.trace.push(TraceEvent::Header {
425        schema: DEBUG_TRACE_SCHEMA.into(),
426        program_name: program.name.clone(),
427        source_provenance: program.source_provenance.clone(),
428        grid: cfg.launch.grid,
429        workgroup: cfg.launch.workgroup,
430        wave_size: cfg.wave_size,
431        schedule: cfg.schedule,
432        schedule_seed: cfg.schedule_seed,
433        note: "not_gfx1201_isa_emulation".into(),
434    })?;
435
436    let outcome = crate::exec::run_with_observer(program, cfg, arena, kernarg, &mut session);
437    let stop = session.take_stop();
438    match outcome {
439        Ok((run, sanitize)) => {
440            for f in &sanitize.findings {
441                let _ = session
442                    .trace
443                    .push(TraceEvent::SanitizeFinding { finding: f.clone() });
444            }
445            session.trace.push(TraceEvent::Done {
446                steps: run.steps,
447                workitems: run.workitems_executed,
448            })?;
449            let n = session.trace.events().len();
450            Ok((
451                DebugRunReport {
452                    fidelity: "functional",
453                    mode: "softgpu_functional_debug_v1",
454                    note: "not_gfx1201_isa_emulation",
455                    run: Some(run),
456                    sanitize,
457                    stop,
458                    trace_events: n,
459                },
460                session.trace,
461            ))
462        }
463        Err(FunctionalError::DebugBreak) => {
464            let n = session.trace.events().len();
465            Ok((
466                DebugRunReport {
467                    fidelity: "functional",
468                    mode: "softgpu_functional_debug_v1",
469                    note: "not_gfx1201_isa_emulation",
470                    run: None,
471                    sanitize: SanitizeReport::clean(),
472                    stop,
473                    trace_events: n,
474                },
475                session.trace,
476            ))
477        }
478        Err(FunctionalError::Sanitize(finding)) => {
479            let _ = session.trace.push(TraceEvent::SanitizeFinding {
480                finding: finding.clone(),
481            });
482            let snap = StateSnapshot {
483                step: finding.step,
484                barrier_gen: finding.barrier_gen,
485                actor: finding.actor,
486                op: format!("{:?}", finding.kind),
487                registers: BTreeMap::new(),
488                source_provenance: program.source_provenance.clone(),
489                source_mapping: None,
490            };
491            let _ = session.trace.push(TraceEvent::Break {
492                step: finding.step,
493                reason: "sanitize_finding".into(),
494            });
495            let n = session.trace.events().len();
496            Ok((
497                DebugRunReport {
498                    fidelity: "sanitized",
499                    mode: "softgpu_functional_debug_v1",
500                    note: "not_gfx1201_isa_emulation",
501                    run: None,
502                    sanitize: SanitizeReport {
503                        fidelity: "sanitized",
504                        mode: "softgpu_functional_sanitizer_v1",
505                        note: "not_gfx1201_isa_emulation",
506                        findings: vec![finding],
507                    },
508                    stop: Some(DebugStop {
509                        reason: "sanitize_finding".into(),
510                        snapshot: snap,
511                    }),
512                    trace_events: n,
513                },
514                session.trace,
515            ))
516        }
517        Err(e) => Err(e),
518    }
519}
520
521/// Replay a sanitizer finding: run FailFast and capture a debug stop at the finding step.
522pub fn inspect_sanitizer_finding(
523    program: &Program,
524    mut cfg: ExecConfig,
525    arena: &mut GlobalArena,
526    kernarg: &[u8],
527    finding: &Finding,
528) -> Result<(DebugRunReport, TraceLog, ReplayBundle)> {
529    cfg.sanitize = SanitizeMode::FailFast;
530    let bps = vec![Breakpoint::AfterStep {
531        step: finding.step.max(1),
532    }];
533    let (report, trace) = run_debug(
534        program,
535        cfg,
536        arena,
537        kernarg,
538        bps,
539        false,
540        DEFAULT_TRACE_EVENT_BUDGET,
541    )?;
542    let bundle = ReplayBundle::from_finding(program, &cfg, finding.clone());
543    Ok((report, trace, bundle))
544}
545
546#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
547pub struct ExploreTrial {
548    pub wave_size: u32,
549    pub schedule_seed: u64,
550    pub workgroup_x: u32,
551    pub found: bool,
552    pub kind: Option<FindingKind>,
553    pub step: Option<u64>,
554}
555
556#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
557pub struct ExploreReport {
558    pub fidelity: &'static str,
559    pub mode: &'static str,
560    pub note: &'static str,
561    pub seed: u64,
562    pub trials: Vec<ExploreTrial>,
563    pub minimized: Option<ExploreTrial>,
564}
565
566/// Bounded seeded search for a SoftGPU ordering/race defect, then minimize WG size.
567pub fn explore_and_minimize_race(
568    program: &Program,
569    base: ExecConfig,
570    kernarg: &[u8],
571    arena_len: usize,
572    seed: u64,
573    max_trials: u32,
574) -> Result<ExploreReport> {
575    let mut trials = Vec::new();
576    let mut hit: Option<ExploreTrial> = None;
577    let wave_candidates = [
578        base.wave_size,
579        ((seed % 31) as u32 + 1).min(base.launch.workgroup[0].max(1)),
580        1,
581        2,
582        4,
583        8,
584        base.launch.workgroup[0].max(1),
585    ];
586    let mut attempt = 0u32;
587    for &ws in &wave_candidates {
588        if attempt >= max_trials {
589            break;
590        }
591        for bit in 0..2u64 {
592            if attempt >= max_trials {
593                break;
594            }
595            attempt += 1;
596            let mut cfg = base;
597            cfg.wave_size = ws.max(1);
598            cfg.schedule_seed = seed ^ (bit << 1) ^ u64::from(ws);
599            cfg.sanitize = SanitizeMode::FailFast;
600            let mut arena = GlobalArena::new(arena_len);
601            let found = match run_with_config_sanitized(program, cfg, &mut arena, kernarg) {
602                Err(FunctionalError::Sanitize(f))
603                    if matches!(
604                        f.kind,
605                        FindingKind::Race
606                            | FindingKind::MissingBarrier
607                            | FindingKind::CrossWorkgroupRace
608                    ) =>
609                {
610                    Some(ExploreTrial {
611                        wave_size: cfg.wave_size,
612                        schedule_seed: cfg.schedule_seed,
613                        workgroup_x: cfg.launch.workgroup[0],
614                        found: true,
615                        kind: Some(f.kind),
616                        step: Some(f.step),
617                    })
618                }
619                Ok(_) | Err(FunctionalError::Sanitize(_)) => Some(ExploreTrial {
620                    wave_size: cfg.wave_size,
621                    schedule_seed: cfg.schedule_seed,
622                    workgroup_x: cfg.launch.workgroup[0],
623                    found: false,
624                    kind: None,
625                    step: None,
626                }),
627                Err(e) => return Err(e),
628            };
629            if let Some(t) = found {
630                if t.found && hit.is_none() {
631                    hit = Some(t.clone());
632                }
633                trials.push(t);
634            }
635        }
636    }
637
638    let minimized = if let Some(h) = hit.clone() {
639        let mut best = h.clone();
640        let mut wg = h.workgroup_x;
641        while wg > 1 {
642            let next = wg / 2;
643            let mut cfg = base;
644            cfg.wave_size = best.wave_size.min(next).max(1);
645            cfg.schedule_seed = best.schedule_seed;
646            cfg.sanitize = SanitizeMode::FailFast;
647            cfg.launch = LaunchConfig {
648                grid: [next, 1, 1],
649                workgroup: [next, 1, 1],
650            };
651            let mut arena = GlobalArena::new(arena_len);
652            match run_with_config_sanitized(program, cfg, &mut arena, kernarg) {
653                Err(FunctionalError::Sanitize(f))
654                    if matches!(
655                        f.kind,
656                        FindingKind::Race
657                            | FindingKind::MissingBarrier
658                            | FindingKind::CrossWorkgroupRace
659                    ) =>
660                {
661                    best = ExploreTrial {
662                        wave_size: cfg.wave_size,
663                        schedule_seed: cfg.schedule_seed,
664                        workgroup_x: next,
665                        found: true,
666                        kind: Some(f.kind),
667                        step: Some(f.step),
668                    };
669                    wg = next;
670                }
671                _ => break,
672            }
673        }
674        // Also minimize to earliest failing step via AfterStep binary search when possible.
675        if let Some(step) = best.step {
676            let mut lo = 1u64;
677            let mut hi = step;
678            let mut earliest = step;
679            while lo < hi {
680                let mid = (lo + hi) / 2;
681                let mut cfg = base;
682                cfg.wave_size = best.wave_size;
683                cfg.schedule_seed = best.schedule_seed;
684                cfg.sanitize = SanitizeMode::FailFast;
685                cfg.launch = LaunchConfig {
686                    grid: [best.workgroup_x, 1, 1],
687                    workgroup: [best.workgroup_x, 1, 1],
688                };
689                cfg.step_budget = mid;
690                let mut arena = GlobalArena::new(arena_len);
691                match run_with_config_sanitized(program, cfg, &mut arena, kernarg) {
692                    Err(FunctionalError::Sanitize(f)) => {
693                        earliest = f.step.min(earliest);
694                        hi = mid;
695                    }
696                    Err(FunctionalError::StepBudgetExceeded { .. }) => {
697                        lo = mid + 1;
698                    }
699                    _ => {
700                        lo = mid + 1;
701                    }
702                }
703            }
704            best.step = Some(earliest);
705        }
706        Some(best)
707    } else {
708        None
709    };
710
711    Ok(ExploreReport {
712        fidelity: "functional",
713        mode: "softgpu_schedule_explore_v1",
714        note: "not_gfx1201_isa_emulation",
715        seed,
716        trials,
717        minimized,
718    })
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    #[test]
726    fn schema_constant_stable() {
727        assert_eq!(DEBUG_TRACE_SCHEMA, "softgpu-debug-trace-v1");
728    }
729
730    #[test]
731    fn snapshot_never_invents_source_mapping() {
732        let p = Program {
733            schema: crate::ir::SFIR_SCHEMA.into(),
734            fidelity: "functional".into(),
735            note: "not_gfx1201_isa_emulation".into(),
736            name: "t".into(),
737            source_provenance: "SoftGPU-authored".into(),
738            kernarg_layout: vec![],
739            group_bytes: 0,
740            body: vec![Op::Ret],
741        };
742        let snap = StateSnapshot::from_lane(
743            1,
744            0,
745            WorkItemId {
746                workgroup: [0, 0, 0],
747                wave: 0,
748                lane: 0,
749                flat_local: 0,
750            },
751            "const",
752            &BTreeMap::new(),
753            &p,
754        );
755        assert!(snap.source_mapping.is_none());
756        assert_eq!(snap.source_provenance, "SoftGPU-authored");
757    }
758}