From dbae67e21e47ea20de426f29d4d3c3bf9a97b0e7 Mon Sep 17 00:00:00 2001 From: Rahul Tomar Date: Fri, 6 Nov 2026 16:16:15 +0100 Subject: [PATCH] Why is it this value? Explain any value in an example's timeline Clicking a value in "What actually happened" shows the line of the design that wrote it, why that line ran (each if/case around it and which way it went, with the values decided on) and the values it used, each clickable in turn, through block ports back to the example step that set an input. The explanation followed goes with requests to the AI. The simulator records, for every value change, the assignment that made it: WaveSignal::by and SimResult::origins (line, text, reads, decisions), exposed to the browser through wafer-sim-wasm. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++ apps/web/src/components/Design.tsx | 116 +++++++++++++++++++++++-- apps/web/src/lib/replay.ts | 37 +++++++- apps/web/src/lib/why.ts | 129 ++++++++++++++++++++++++++++ apps/web/src/sim/protocol.ts | 20 +++++ apps/web/src/sim/worker.ts | 5 +- apps/web/src/styles.css | 23 +++++ crates/wafer-sim-wasm/src/lib.rs | 71 +++++++++++++++ crates/wafer-sim/src/elab.rs | 133 ++++++++++++++++++++++++----- crates/wafer-sim/src/lib.rs | 2 +- crates/wafer-sim/src/sim.rs | 55 +++++++++--- crates/wafer-sim/src/tests.rs | 70 +++++++++++++++ docs/architecture.md | 2 +- docs/user-guide/design-view.md | 14 ++- 14 files changed, 639 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/lib/why.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f496b6..38e1a0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ All notable changes to Wafersmith are recorded here. The format follows ### Added +- **Why is it this value?**: click any value in an example's "What actually happened" timeline to + see the line of the design that wrote it, why that line ran (each `if`/`case` around it and + which way it went, with the values decided on) and the values it used; each of those can be + clicked in turn, back through blocks' ports to the example step that set an input. The + explanation followed goes with a request to the AI to fix the design or the example. The + simulator now records, for every value change, which assignment made it (`WaveSignal::by`, + `SimResult::origins`). Runs in the browser; no AI and no cost. - **Find an example**: for a case no example tries, or a planted mistake that slipped through, Health can have the simulator search the design's inputs for an example that reaches it. The example found is cut to the shortest and simplest, ends with checks of what the design did, and diff --git a/apps/web/src/components/Design.tsx b/apps/web/src/components/Design.tsx index fe606b4..7129db1 100644 --- a/apps/web/src/components/Design.tsx +++ b/apps/web/src/components/Design.tsx @@ -16,6 +16,7 @@ import { } from "../api"; import { extendTestbench, fmt as fmtValue, replay, targetSpec, type Replay } from "../lib/replay"; import { findFix, type Fix } from "../lib/fixexample"; +import { decided, why, whyText, type Because, type Read } from "../lib/why"; import { StateMachine } from "./StateMachine"; import { TryIt } from "./TryIt"; import { SimClient } from "../sim/client"; @@ -973,6 +974,12 @@ function Happened({ onReplaceSteps: (steps: SpecExample["steps"]) => void; }) { const [rep, setRep] = useState(null); + // "Why is it this value?": the values clicked and asked about, each explained, in order. + const [trail, setTrail] = useState([]); + const ask = (full: string, t: number, after: number) => { + const b = rep && why(rep, full, t); + if (b) setTrail((tr) => [...tr.slice(0, after), b]); + }; // "The design is right": a change to the example that makes it pass, worked out and re-checked. const [fix, setFix] = useState(null); async function acceptDesign() { @@ -998,6 +1005,7 @@ function Happened({ } } const [error, setError] = useState(null); + const [pickedCycle, setPickedCycle] = useState(null); // Fit the cycles to the space available; long examples scroll. const box = useRef(null); const [room, setRoom] = useState(600); @@ -1019,7 +1027,10 @@ function Happened({ .filter(([p]) => /\.s?v$/.test(p)) .map(([name, source]) => ({ name, source: name === `tb/examples/tb_ex_${ex.id}.v` ? extendTestbench(source, spec) : source })); const sim = await client.run(files, `tb_ex_${ex.id}`); - if (!cancelled) setRep(replay(spec, ex, sim)); + if (!cancelled) { + setRep(replay(spec, ex, sim)); + setTrail([]); + } })().catch((e: Error) => !cancelled && setError(e.message)); return () => { cancelled = true; @@ -1033,6 +1044,9 @@ function Happened({ const width = rep.cycles * cell; const every = rep.cycles > 200 ? 50 : rep.cycles > 80 ? 20 : rep.cycles > 30 ? 10 : 5; const failing = rep.firstFail; + // The AI gets the explanation too, when someone followed one. + const story = trail.length ? `${rep.story} Why, traced in the simulation: ${trail.map(whyText).join(" ")}` : rep.story; + const picked = trail[0]; return (
@@ -1066,6 +1080,13 @@ function Happened({ after={after} checks={checks} sets={rep.sets.filter((s) => s.signal === r.name).map((s) => s.cycle)} + picked={picked?.name === r.name ? pickedCycle : null} + onPick={(c) => { + const t = rep.at(c); + if (t === undefined) return; + setPickedCycle(c); + ask(`${rep.top}.${r.name}`, t, 0); + }} /> ); })} @@ -1073,18 +1094,28 @@ function Happened({

Cycle by cycle, from the simulator. Amber: what the example checks; red: where it differs; blue marks: inputs the example sets; - shaded: the cycles after the example ends. + shaded: the cycles after the example ends. Click any value to see why it is what it is.

+ {trail.length > 0 && ( + r.t !== null && ask(r.full, r.t, level + 1)} + onClose={() => { + setTrail([]); + setPickedCycle(null); + }} + /> + )} {failing && canEdit && (
Which is wrong? - -
@@ -1133,6 +1164,8 @@ function HpRow({ after, checks, sets, + picked, + onPick, }: { row: Replay["rows"][number]; cell: number; @@ -1141,6 +1174,9 @@ function HpRow({ after: number; checks: Replay["checks"]; sets: number[]; + /** The cycle being explained, marked. */ + picked: number | null; + onPick: (cycle: number) => void; }) { const H = 26; const hi = 5; @@ -1184,18 +1220,88 @@ function HpRow({ } for (const c of sets) parts.push(); if (after > 0) parts.push(); + if (picked !== null) parts.push(); return ( <> {row.name} - fmtValue(v, row.width).split(" ")[0]).join(", ")}`}> + { + const c = Math.floor((e.clientX - e.currentTarget.getBoundingClientRect().left) / cell); + if (c >= 0 && c < row.values.length && row.values[c] !== null) onPick(c); + }} + aria-label={`${row.name}: ${row.values.map((v) => fmtValue(v, row.width).split(" ")[0]).join(", ")}`} + > + Click a value to see why it is what it is {parts} ); } +/** "Why is it this value?": each value asked about, explained; the values an explanation mentions + * can be asked about in turn, building a chain back to the cause. */ +function WhyPanel({ trail, onAsk, onClose }: { trail: Because[]; onAsk: (r: Read, level: number) => void; onClose: () => void }) { + const chip = (r: Read, level: number) => ( + + ); + return ( +
+
+ Why? + +
+
    + {trail.map((b, level) => ( +
  1. + + {b.name} = {b.value} + {" "} + from cycle {b.since} + {b.kind === "start" && <>: nothing has written it; it has had this value from the start.} + {b.kind === "input" && ( + <> + : an input, {b.step ? <>set by step {b.step} of the example. : <>set by the testbench ({b.code}).} + + )} + {b.kind === "design" && ( + <> + : line {b.line} of {b.file?.split("/").pop()} wrote it: {b.code} + {b.conds.length > 0 && ( +
    + It ran because{" "} + {b.conds.map((c, k) => ( + + {k > 0 && ", and "} + {withCode(decided(c))} + {c.reads.length > 0 && <> ({c.reads.map((r, j) => [j > 0 && ", ", chip(r, level)])})} + + ))} + . +
    + )} + {b.reads.length > 0 &&
    It used {b.reads.map((r, j) => [j > 0 && ", ", chip(r, level)])}.
    } + + )} +
  2. + ))} +
+

+ Click a value to follow it back. When you ask the AI to fix something, this explanation goes with it. +

+
+ ); +} + /** What the steps ask for, laid out over clock cycles (not a simulation). */ function Timeline({ spec, ex }: { spec: Spec; ex: SpecExample }) { const model = useMemo(() => { diff --git a/apps/web/src/lib/replay.ts b/apps/web/src/lib/replay.ts index da3269c..70aff79 100644 --- a/apps/web/src/lib/replay.ts +++ b/apps/web/src/lib/replay.ts @@ -38,8 +38,8 @@ export interface Replay { exampleCycles: number; rows: Row[]; checks: Check[]; - /** Inputs the example sets: cycle and connection. */ - sets: { cycle: number; signal: string; value: bigint }[]; + /** Inputs the example sets: cycle, connection and step number. */ + sets: { cycle: number; signal: string; value: bigint; step: number }[]; firstFail: Check | null; /** Plain-language account of the first difference (or of the pass). */ story: string; @@ -47,6 +47,13 @@ export interface Replay { problem: string | null; /** A connection's value at a cycle's check point, including the cycles after the example. */ read: (port: string, cycle: number) => bigint | null; + /** The simulation replayed, and its testbench module (signals are named `.`). */ + sim: SimResult; + top: string; + /** The simulation time of a cycle's check point. */ + at: (cycle: number) => number | undefined; + /** The first cycle whose check point sees a change at time `t` (0 for anything before the example). */ + cycleAt: (t: number) => number; } const MAX_CYCLES = 4000; @@ -131,7 +138,7 @@ export function replay(spec: Spec, ex: SpecExample, sim: SimResult, internal: { if (problem) return; const n = i + 1; const expected = parse(st.value) ?? 0n; - if (st.op === "set") sets.push({ cycle: c, signal: st.signal, value: expected }); + if (st.op === "set") sets.push({ cycle: c, signal: st.signal, value: expected, step: n }); else if (st.op === "wait") c += Math.max(1, st.cycles); else if (st.op === "expect") { const actual = read(st.signal, c); @@ -175,7 +182,29 @@ export function replay(spec: Spec, ex: SpecExample, sim: SimResult, internal: { ...internal.map((p): Row => ({ name: p.name, dir: "internal", width: p.width, values: Array.from({ length: cycles }, (_, k) => read(p.name, k)) })), ]; const firstFail = checks.find((ch) => !ch.ok) ?? null; - return { cycles, exampleCycles, rows, checks, sets, firstFail, story: explain(spec, read, firstFail, checks.length), problem, read }; + const at = (k: number) => { + const t = timeOf(k); + return t === undefined ? undefined : t + eps; + }; + const cycleAt = (t: number) => { + for (let k = 0; k < cycles; k++) if ((at(k) ?? Infinity) >= t) return k; + return cycles; + }; + return { + cycles, + exampleCycles, + rows, + checks, + sets, + firstFail, + story: explain(spec, read, firstFail, checks.length), + problem, + read, + sim, + top, + at, + cycleAt, + }; } function explain(spec: Spec, read: (port: string, c: number) => bigint | null, f: Check | null, total: number): string { diff --git a/apps/web/src/lib/why.ts b/apps/web/src/lib/why.ts new file mode 100644 index 0000000..568de43 --- /dev/null +++ b/apps/web/src/lib/why.ts @@ -0,0 +1,129 @@ +// "Why is it this value?": the assignment in the design that wrote a value, the if/case decisions +// that led to it and the values it read, from the simulator's record of what wrote each change. +// Port connections are followed through; the example's own inputs end the chain. + +import { NO_ORIGIN, type SimSignal } from "../sim/protocol"; +import type { Replay } from "./replay"; + +/** A value an explanation mentions, which can be explained in turn. */ +export interface Read { + /** As the person knows it: `count`, or `u_rx.state` inside a block. */ + name: string; + full: string; + /** In decimal, as examples write values. */ + value: string; + /** The moment to explain it at; null if it isn't recorded (a memory). */ + t: number | null; +} + +export interface Because { + name: string; + full: string; + value: string; + /** The first cycle that sees this value. */ + since: number; + /** `design`: a line of the design wrote it; `input`: the example (or the testbench) set it; + * `start`: nothing has written it. */ + kind: "design" | "input" | "start"; + /** For an input the example sets: the step. */ + step?: number; + file?: string; + line?: number; + /** The assignment as written. */ + code?: string; + /** The decisions that led to the assignment, outermost first, with the values they read. */ + conds: { line: number; code: string; taken: string; reads: Read[] }[]; + /** The values the assignment computed from. */ + reads: Read[]; +} + +/** The latest change of `s` at or before `t`. */ +function changeAt(s: SimSignal, t: number): number { + let lo = 0; + let hi = s.times.length - 1; + let found = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (s.times[mid] <= t) { + found = mid; + lo = mid + 1; + } else hi = mid - 1; + } + return found; +} + +/** A nonblocking assignment (`q <= d`), as opposed to `=` or a comparison inside one. */ +const nonblocking = (code: string) => /^[^=<]*<=/.test(code); + +/** Why `full` (a signal's full name) has the value it has at time `t`. */ +export function why(rep: Replay, full: string, t: number): Because | null { + const signals = new Map(rep.sim.signals.map((s) => [s.name, s])); + const name = (f: string) => { + const rest = f.startsWith(`${rep.top}.`) ? f.slice(rep.top.length + 1) : f; + return rest.startsWith("dut.") ? rest.slice(4) : rest; + }; + const read = (f: string, at: number): Read => { + const s = signals.get(f); + const i = s ? changeAt(s, at) : -1; + return { name: name(f), full: f, value: s && i >= 0 ? s.values[i].toString() : "?", t: s && i >= 0 ? at : null }; + }; + const shown = full; + let cur = full; + let time = t; + // Port connections pass a value on unchanged: follow them to where it was made. + for (let hop = 0; hop < 32; hop++) { + const s = signals.get(cur); + if (!s) return null; + const i = changeAt(s, time); + if (i < 0) return null; + const base = { name: name(shown), full: shown, value: s.values[i].toString(), since: rep.cycleAt(s.times[i]), conds: [], reads: [] }; + const by = s.by[i]; + if (by === NO_ORIGIN) return { ...base, kind: "start" }; + const o = rep.sim.origins[by]; + if (/^(input|output) \S+ of /.test(o.text) && o.reads.length === 1) { + cur = o.reads[0]; + time = s.times[i]; + continue; + } + if (o.scope === rep.top) { + const set = [...rep.sets].reverse().find((x) => x.signal === name(cur) && x.cycle <= base.since); + return { ...base, kind: "input", step: set?.step, code: o.text }; + } + // A nonblocking assignment computes from the values just before the clock edge that stores it. + const at = nonblocking(o.text) ? Math.max(0, s.times[i] - 1) : s.times[i]; + return { + ...base, + kind: "design", + file: o.file, + line: o.line, + code: o.text, + conds: o.conds.map((c) => ({ line: c.line, code: c.text, taken: c.taken, reads: c.reads.map((r) => read(r, at)) })), + reads: o.reads.map((r) => read(r, at)), + }; + } + return null; +} + +/** How a decision went, in words: "`up && !down` was true", "`state` was 2'h1". */ +export function decided(c: Because["conds"][number]): string { + if (c.taken === "true" || c.taken === "false") return `\`${c.code}\` was ${c.taken}`; + if (c.taken === "default") return `\`${c.code}\` matched none of the listed values`; + return `\`${c.code}\` was ${c.taken.replace(/, /g, " or ")}`; +} + +/** The explanation as one paragraph, for the AI: "count became 3 at cycle 7: line 13 of + * src/updown.v, `count <= count + 1`, ran because `up && !down` was true (up = 1, down = 0); it + * read count = 2." */ +export function whyText(b: Because): string { + const head = `${b.name} was ${b.value} from cycle ${b.since}`; + if (b.kind === "start") return `${head}: nothing had written it.`; + if (b.kind === "input") return `${head}: ${b.step ? `step ${b.step} of the example set it` : `the testbench set it (${b.code})`}.`; + const vals = (rs: Read[]) => rs.map((r) => `${r.name} = ${r.value}`).join(", "); + const conds = b.conds.map((c) => `${decided(c)}${c.reads.length ? ` (${vals(c.reads)})` : ""}`).join(", and "); + return ( + `${head}: line ${b.line} of ${b.file}, \`${b.code}\`, wrote it` + + (conds ? `, because ${conds}` : "") + + (b.reads.length ? `; it read ${vals(b.reads)}` : "") + + "." + ); +} diff --git a/apps/web/src/sim/protocol.ts b/apps/web/src/sim/protocol.ts index cada5ff..23e28b0 100644 --- a/apps/web/src/sim/protocol.ts +++ b/apps/web/src/sim/protocol.ts @@ -5,6 +5,25 @@ export interface SimSignal { times: Float64Array; /** Value from each change time on. */ values: BigUint64Array; + /** What wrote each value: an index into `origins`, or NO_ORIGIN (nothing in the design). */ + by: Uint32Array; +} + +/** `SimSignal.by` for a value nothing in the design wrote. */ +export const NO_ORIGIN = 0xffffffff; + +/** Where written values come from: an assignment (or port connection) in the design. */ +export interface SimOrigin { + file: string; + line: number; + /** The instance it is in, e.g. `tb.dut`. */ + scope: string; + /** As written: `count <= count + 1`, `assign busy = state != 0`, `output led of tb.dut`. */ + text: string; + /** Full names of the signals its value is computed from. */ + reads: string[]; + /** The `if`/`case` decisions it is inside, outermost first, and which way each went. */ + conds: { line: number; text: string; reads: string[]; taken: string }[]; } export interface SimResult { @@ -17,6 +36,7 @@ export interface SimResult { steps: number; ms: number; signals: SimSignal[]; + origins: SimOrigin[]; } export type SimRequest = diff --git a/apps/web/src/sim/worker.ts b/apps/web/src/sim/worker.ts index c72f723..539ffef 100644 --- a/apps/web/src/sim/worker.ts +++ b/apps/web/src/sim/worker.ts @@ -22,7 +22,7 @@ self.onmessage = async (ev: MessageEvent) => { const ms = performance.now() - t0; const names = last.names; const widths = last.widths; - const signals = names.map((name, i) => ({ name, width: widths[i], times: last!.times(i), values: last!.values(i) })); + const signals = names.map((name, i) => ({ name, width: widths[i], times: last!.times(i), values: last!.values(i), by: last!.by(i) })); reply( { type: "result", @@ -37,9 +37,10 @@ self.onmessage = async (ev: MessageEvent) => { steps: last.steps, ms, signals, + origins: JSON.parse(last.origins()), }, }, - signals.flatMap((s) => [s.times.buffer, s.values.buffer]), + signals.flatMap((s) => [s.times.buffer, s.values.buffer, s.by.buffer]), ); } } catch (e) { diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index b7a8bc4..8728cdd 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -2659,3 +2659,26 @@ a.button { margin: 4px 0 8px; padding-left: 20px; } + +/* "Why is it this value?": clickable timeline values and the chain of causes. */ +.hp-pick { + cursor: pointer; +} + +.hp-picked { + fill: none; + stroke: var(--accent); + stroke-width: 2; +} + +.why-chain { + margin: 4px 0; + padding-left: 20px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.why-chain button.link { + padding: 0; +} diff --git a/crates/wafer-sim-wasm/src/lib.rs b/crates/wafer-sim-wasm/src/lib.rs index 361fb75..4209902 100644 --- a/crates/wafer-sim-wasm/src/lib.rs +++ b/crates/wafer-sim-wasm/src/lib.rs @@ -90,7 +90,78 @@ impl Simulation { self.r.signals.get(i).map(|s| s.values.clone()).unwrap_or_default() } + /// What wrote each value of signal `i`: indexes into `origins`, 0xFFFFFFFF for nothing. + pub fn by(&self, i: usize) -> Vec { + self.r.signals.get(i).map(|s| s.by.clone()).unwrap_or_default() + } + + /// Where written values come from, as JSON: `[{file, line, scope, text, reads, conds: [{line, + /// text, reads, taken}]}]`. + pub fn origins(&self) -> String { + let list: Vec = self + .r + .origins + .iter() + .map(|o| { + let conds: Vec = o + .conds + .iter() + .map(|c| { + format!( + r#"{{"line":{},"text":{},"reads":{},"taken":{}}}"#, + c.line, + json(&c.text), + strings(&c.reads), + json(&c.taken) + ) + }) + .collect(); + format!( + r#"{{"file":{},"line":{},"scope":{},"text":{},"reads":{},"conds":[{}]}}"#, + json(&o.file), + o.line, + json(&o.scope), + json(&o.text), + strings(&o.reads), + conds.join(",") + ) + }) + .collect(); + format!("[{}]", list.join(",")) + } + pub fn vcd(&self) -> String { wafer_sim::vcd::to_vcd(&self.r) } } + +/// A JSON string. +fn json(s: &str) -> String { + let mut o = String::with_capacity(s.len() + 2); + o.push('"'); + for c in s.chars() { + match c { + '"' => o.push_str("\\\""), + '\\' => o.push_str("\\\\"), + c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)), + c => o.push(c), + } + } + o.push('"'); + o +} + +fn strings(v: &[String]) -> String { + format!("[{}]", v.iter().map(|s| json(s)).collect::>().join(",")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_strings_are_escaped() { + assert_eq!(json("a \"b\" \\ c\n"), r#""a \"b\" \\ c\u000a""#); + assert_eq!(strings(&["x".into(), "y".into()]), r#"["x","y"]"#); + } +} diff --git a/crates/wafer-sim/src/elab.rs b/crates/wafer-sim/src/elab.rs index 31b7b93..6721cba 100644 --- a/crates/wafer-sim/src/elab.rs +++ b/crates/wafer-sim/src/elab.rs @@ -300,6 +300,8 @@ pub enum Op { Halt, /// Counts one visit of coverage point `n` (`Design::cover`). Cover(usize), + /// The assignments that follow come from `Design::origins[n]` (until the next `From`). + From(u32), /// Counts each part of a combined condition as true (its point) or false (the next point). CoverParts(Vec<(CExpr, usize)>), } @@ -334,6 +336,34 @@ pub enum CoverKind { Side { value: bool }, } +/// Where a value written in the simulation came from: an assignment in the design (or a port +/// connection), with what it reads and the decisions that led to it, so a value can be explained. +#[derive(Debug, Clone, PartialEq)] +pub struct Origin { + pub file: String, + pub line: u32, + /// The instance it is in, e.g. `tb.dut`. + pub scope: String, + /// As written: `count <= count + 1`, `assign busy = state != 0`, or a port connection + /// (`output led of tb.dut`). + pub text: String, + /// The signals its value is computed from (full names, e.g. `tb.dut.count`). + pub reads: Vec, + /// The `if` and `case` decisions it is inside, outermost first. + pub conds: Vec, +} + +/// A decision an assignment is inside, and which way it went for the assignment to run. +#[derive(Debug, Clone, PartialEq)] +pub struct Cond { + pub line: u32, + /// The condition or `case` selector, as written. + pub text: String, + pub reads: Vec, + /// `true` or `false` for an `if`; the arm's labels for a `case`, or `default`. + pub taken: String, +} + #[derive(Debug, Clone)] pub struct Process { pub ops: Vec, @@ -354,6 +384,8 @@ pub struct Design { pub instances: Vec, /// Branch coverage points, numbered by `Op::Cover`. pub cover: Vec, + /// Where assignments come from, numbered by `Op::From`. + pub origins: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -388,6 +420,7 @@ pub fn elaborate_with(modules: &[Module], top: &str, overrides: &HashMap { tmp: usize, /// While compiling an expression in a statement: the operations its function calls need first. hoist: Option>, + /// The `if`/`case` decisions around the statement being compiled. + conds: Vec, /// Functions and tasks being compiled: return variable, and `return` jumps to patch. rets: Vec<(Option, Vec)>, /// Function and task calls being expanded (`inst.name`), to refuse recursion. @@ -581,10 +616,12 @@ impl<'a> Elab<'a> { if let Some(init) = &n.init { let lv = self.lval(ds, &Expr::Ident(n.name.clone(), dl.line))?; if dl.kind == Some(Kind::Wire) { - self.continuous(ds, lv, init, dl.line)?; + self.continuous(ds, lv, init, dl.line, format!("wire {} = {init}", n.name))?; } else { let mut ops = Vec::new(); let rhs = self.sexpr(ds, init, &mut ops)?; + let from = self.origin(ds, dl.line, format!("{} = {init} (its starting value)", n.name), &rhs); + ops.push(from); ops.push(Op::Assign(lv, rhs)); ops.push(Op::Halt); self.d.processes.push(Process { @@ -602,7 +639,7 @@ impl<'a> Elab<'a> { match item { Item::Assign { lhs, rhs, line } => { let lv = self.lval(ds, lhs)?; - self.continuous(ds, lv, rhs, *line)?; + self.continuous(ds, lv, rhs, *line, format!("assign {lhs} = {rhs}"))?; } Item::Initial { body, line } => { let mut ops = Vec::new(); @@ -782,16 +819,43 @@ impl<'a> Elab<'a> { self.d.cover.len() - 1 } + /// A new origin in `s` for an assignment written `text` computing `from`, inside the current + /// decisions; returns the op that marks it. + fn origin(&mut self, s: &Scope, line: u32, text: String, from: &CExpr) -> Op { + let reads = self.read_names(from); + self.d.origins.push(Origin { + file: s.module.file.clone(), + line, + scope: s.path.clone(), + text, + reads, + conds: self.conds.clone(), + }); + Op::From((self.d.origins.len() - 1) as u32) + } + + /// The names of the (visible) signals `e` reads. + fn read_names(&self, e: &CExpr) -> Vec { + let mut sigs = BTreeSet::new(); + e.reads(&mut sigs); + sigs.into_iter().filter(|&i| !self.d.signals[i].hidden).map(|i| self.d.signals[i].name.clone()).collect() + } + fn temp(&mut self, s: &Scope) -> usize { self.tmp += 1; self.add_signal(format!("{}.$tmp{}", s.path, self.tmp), 64, false, 63, 0, None, true) } - /// `assign lhs = rhs` → loop { lhs = rhs; wait for any input change }. - fn continuous(&mut self, s: &Scope, lv: LVal, rhs: &Expr, line: u32) -> Result<()> { + /// `assign lhs = rhs` → loop { lhs = rhs; wait for any input change }. `text` says what it is + /// for explanations (`assign busy = state != 0`). + fn continuous(&mut self, s: &Scope, lv: LVal, rhs: &Expr, line: u32, text: String) -> Result<()> { // Function calls in the expression run first, every time it is recomputed. let mut ops = Vec::new(); let rhs = self.sexpr(s, rhs, &mut ops)?; + let saved = std::mem::take(&mut self.conds); + let from = self.origin(s, line, text, &rhs); + self.conds = saved; + ops.push(from); ops.push(Op::Assign(lv, rhs)); let mut reads = BTreeSet::new(); for op in &ops { @@ -917,7 +981,9 @@ impl<'a> Elab<'a> { .unwrap_or(Dir::Input); let port_sig = self.d.names[&format!("{path}.{port}")]; match dir { - Dir::Input => self.continuous_into(LVal::Sig(port_sig), s, expr, line)?, + Dir::Input => { + self.continuous_into(LVal::Sig(port_sig), s, expr, line, format!("input {port} of {path}"))? + } Dir::Output => { let lv = self.lval(s, expr).map_err(|_| Error::Elab { file: s.module.file.clone(), @@ -929,7 +995,8 @@ impl<'a> Elab<'a> { let rhs = CExpr::Sig { sig: port_sig, w: pw, signed: ps }; let mut reads = BTreeSet::new(); reads.insert(port_sig); - let ops = vec![Op::Assign(lv, rhs), Op::Wait(self.any_change(&reads)), Op::Jump(0)]; + let from = self.origin(s, line, format!("output {port} of {path}"), &rhs); + let ops = vec![from, Op::Assign(lv, rhs), Op::Wait(self.any_change(&reads)), Op::Jump(0)]; self.d.processes.push(Process { ops, scope: s.path.clone(), file: s.module.file.clone(), line }); } Dir::Inout => return self.err(&cs, line, format!("inout port '{port}' is not supported")), @@ -938,8 +1005,8 @@ impl<'a> Elab<'a> { Ok(()) } - fn continuous_into(&mut self, lv: LVal, s: &Scope, rhs: &Expr, line: u32) -> Result<()> { - self.continuous(s, lv, rhs, line) + fn continuous_into(&mut self, lv: LVal, s: &Scope, rhs: &Expr, line: u32, text: String) -> Result<()> { + self.continuous(s, lv, rhs, line, text) } // ---- name resolution ---- @@ -1561,14 +1628,18 @@ impl<'a> Elab<'a> { self.stmt(s, x, ops)?; } } - Stmt::Blocking { lhs, rhs, .. } => { + Stmt::Blocking { lhs, rhs: r, line } => { let lv = self.lval(s, lhs)?; - let rhs = self.sexpr(s, rhs, ops)?; + let rhs = self.sexpr(s, r, ops)?; + let from = self.origin(s, *line, format!("{lhs} = {r}"), &rhs); + ops.push(from); ops.push(Op::Assign(lv, rhs)); } - Stmt::NonBlocking { lhs, rhs, delay, .. } => { + Stmt::NonBlocking { lhs, rhs: r, delay, line } => { let lv = self.lval(s, lhs)?; - let rhs = self.sexpr(s, rhs, ops)?; + let rhs = self.sexpr(s, r, ops)?; + let from = self.origin(s, *line, format!("{lhs} <= {r}"), &rhs); + ops.push(from); let delay = match delay { Some(d) => Some(self.sexpr(s, d, ops)?), None => None, @@ -1601,15 +1672,22 @@ impl<'a> Elab<'a> { ops.push(Op::Halt); // patched let cov = self.cover(s, line, CoverKind::Then, what.clone()); ops.push(cov); - self.stmt(s, t, ops)?; + let reads = self.read_names(&c); + self.conds.push(Cond { line, text: what.clone(), reads: reads.clone(), taken: "true".into() }); + let r = self.stmt(s, t, ops); + self.conds.pop(); + r?; // An implicit else is a branch too: "the condition was never false". let jend = ops.len(); ops.push(Op::Halt); ops[jz] = Op::JumpIfZero(c, ops.len()); - let cov = self.cover(s, line, CoverKind::Else { written: f.is_some() }, what); + let cov = self.cover(s, line, CoverKind::Else { written: f.is_some() }, what.clone()); ops.push(cov); if let Some(f) = f { - self.stmt(s, f, ops)?; + self.conds.push(Cond { line, text: what, reads, taken: "false".into() }); + let r = self.stmt(s, f, ops); + self.conds.pop(); + r?; } ops[jend] = Op::Jump(ops.len()); } @@ -1624,6 +1702,7 @@ impl<'a> Elab<'a> { .map(|l| self.expr(s, l).map(|c| c.width(&self.d))) .collect::>>()?; let w = label_w.into_iter().fold(sel_w, u32::max); + let sel_reads = self.read_names(&sel_c); // Store at the comparison width; width of the temp is 64 so no truncation. ops.push(Op::Assign(LVal::Sig(tmp), sel_c)); let tmp_e = || CExpr::Sig { sig: tmp, w, signed: false }; @@ -1656,9 +1735,17 @@ impl<'a> Elab<'a> { let jz = ops.len(); ops.push(Op::Halt); let labels = labels.iter().map(|l| l.to_string()).collect::>().join(", "); - let cov = self.cover(s, *line, CoverKind::Arm { labels }, sel.to_string()); + let cov = self.cover(s, *line, CoverKind::Arm { labels: labels.clone() }, sel.to_string()); ops.push(cov); - self.stmt(s, body, ops)?; + self.conds.push(Cond { + line: *line, + text: sel.to_string(), + reads: sel_reads.clone(), + taken: labels, + }); + let r = self.stmt(s, body, ops); + self.conds.pop(); + r?; end_jumps.push(ops.len()); ops.push(Op::Halt); ops[jz] = Op::JumpIfZero(cond.expect("case item has labels"), ops.len()); @@ -1666,7 +1753,15 @@ impl<'a> Elab<'a> { let cov = self.cover(s, *line, CoverKind::Default { written: default.is_some() }, sel.to_string()); ops.push(cov); if let Some(d) = default { - self.stmt(s, d, ops)?; + self.conds.push(Cond { + line: *line, + text: sel.to_string(), + reads: sel_reads, + taken: "default".into(), + }); + let r = self.stmt(s, d, ops); + self.conds.pop(); + r?; } let end = ops.len(); for j in end_jumps { @@ -1849,7 +1944,7 @@ fn op_reads(op: &Op, out: &mut BTreeSet) { Op::Display { args, .. } => args.iter().for_each(|a| a.reads(out)), Op::Wait(items) => items.iter().for_each(|i| i.expr.reads(out)), Op::CoverParts(parts) => parts.iter().for_each(|(e, _)| e.reads(out)), - Op::Jump(_) | Op::Finish | Op::Halt | Op::Cover(_) => {} + Op::Jump(_) | Op::Finish | Op::Halt | Op::Cover(_) | Op::From(_) => {} } } diff --git a/crates/wafer-sim/src/lib.rs b/crates/wafer-sim/src/lib.rs index ce57268..1501526 100644 --- a/crates/wafer-sim/src/lib.rs +++ b/crates/wafer-sim/src/lib.rs @@ -18,7 +18,7 @@ pub mod vcd; use std::collections::{HashMap, HashSet}; -pub use sim::{Limits, SimResult, WaveSignal}; +pub use sim::{Limits, NO_ORIGIN, SimResult, WaveSignal}; #[derive(Debug, Clone, thiserror::Error, PartialEq)] pub enum Error { diff --git a/crates/wafer-sim/src/sim.rs b/crates/wafer-sim/src/sim.rs index 7aa3cd2..df175bf 100644 --- a/crates/wafer-sim/src/sim.rs +++ b/crates/wafer-sim/src/sim.rs @@ -41,8 +41,14 @@ pub struct WaveSignal { /// Change times (ascending) and the value from each time on. pub times: Vec, pub values: Vec, + /// What wrote each value: an index into [`SimResult::origins`], or [`NO_ORIGIN`] (a starting + /// value nothing wrote). A starting value is credited to the first assignment that writes it. + pub by: Vec, } +/// [`WaveSignal::by`] for a value nothing in the design wrote. +pub const NO_ORIGIN: u32 = u32::MAX; + #[derive(Debug, Clone)] pub struct SimResult { pub log: Vec, @@ -58,6 +64,8 @@ pub struct SimResult { pub steps: u64, /// Every branch coverage point of the design, with how often it was taken. pub coverage: Vec<(crate::elab::CoverPoint, u64)>, + /// Where written values come from ([`WaveSignal::by`]). + pub origins: Vec, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -76,6 +84,8 @@ struct Proc { wait_prev: Vec, /// Index of the `Wait`/`WaitCond` op the process is blocked on. wait_op: usize, + /// The origin of its next assignment (`Op::From`). + from: u32, } enum Scheduled { @@ -90,6 +100,8 @@ struct Write { lo: u32, w: u32, value: u64, + /// The origin of the value. + by: u32, } pub struct Sim<'d> { @@ -144,6 +156,7 @@ impl<'d> Sim<'d> { width: s.width, times: vec![0], values: vec![0], + by: vec![NO_ORIGIN], }) }) .collect(); @@ -154,7 +167,14 @@ impl<'d> Sim<'d> { procs: d .processes .iter() - .map(|_| Proc { pc: 0, state: State::Ready, epoch: 0, wait_prev: Vec::new(), wait_op: 0 }) + .map(|_| Proc { + pc: 0, + state: State::Ready, + epoch: 0, + wait_prev: Vec::new(), + wait_op: 0, + from: NO_ORIGIN, + }) .collect(), waiters: vec![Vec::new(); d.signals.len()], active: (0..d.processes.len()).collect(), @@ -192,6 +212,7 @@ impl<'d> Sim<'d> { warnings: self.warnings, steps: self.steps, coverage: self.d.cover.iter().cloned().zip(self.cover_hits.iter().map(|c| c.get())).collect(), + origins: self.d.origins.clone(), }) } @@ -299,10 +320,11 @@ impl<'d> Sim<'d> { }; self.procs[p].pc += 1; match op { + Op::From(n) => self.procs[p].from = *n, Op::Assign(lv, e) => { let w = lv.width(self.d); let v = self.eval_ctx(e, w); - let writes = self.resolve(lv, v); + let writes = self.resolve(lv, v, self.procs[p].from); for wr in &writes { self.write(wr); } @@ -310,7 +332,7 @@ impl<'d> Sim<'d> { Op::NbAssign(lv, e, delay) => { let w = lv.width(self.d); let v = self.eval_ctx(e, w); - let writes = self.resolve(lv, v); + let writes = self.resolve(lv, v, self.procs[p].from); match delay { Some(d) => { let d = self.eval(d); @@ -456,6 +478,13 @@ impl<'d> Sim<'d> { let m = mask(w.w) << w.lo; let new = ((old & !m) | ((w.value & mask(w.w)) << w.lo)) & mask(self.d.signals[w.sig].width); if new == old { + // A value the design confirms without changing (its starting value) is credited to + // the first assignment that writes it, so it can be explained too. + if let Some(wave) = self.waves[w.sig].as_mut() + && wave.by.last() == Some(&NO_ORIGIN) + { + *wave.by.last_mut().unwrap() = w.by; + } return; } self.vals[w.slot] = new; @@ -464,9 +493,11 @@ impl<'d> Sim<'d> { { if wave.times.last() == Some(&self.now) { *wave.values.last_mut().unwrap() = new; + *wave.by.last_mut().unwrap() = w.by; } else { wave.times.push(self.now); wave.values.push(new); + wave.by.push(w.by); self.changes += 1; if self.changes == self.limits.max_changes { self.warnings.push(format!("waveform recording stopped after {} changes", self.changes)); @@ -495,38 +526,38 @@ impl<'d> Sim<'d> { } /// Turns an assignment of `v` to `lv` into slot writes, evaluating indices now. - fn resolve(&self, lv: &LVal, v: u64) -> Vec { + fn resolve(&self, lv: &LVal, v: u64, by: u32) -> Vec { let mut out = Vec::new(); - self.resolve_into(lv, v, &mut out); + self.resolve_into(lv, v, by, &mut out); out } - fn resolve_into(&self, lv: &LVal, v: u64, out: &mut Vec) { + fn resolve_into(&self, lv: &LVal, v: u64, by: u32, out: &mut Vec) { match lv { LVal::Sig(sig) => { let s = &self.d.signals[*sig]; - out.push(Write { slot: s.slot, sig: *sig, lo: 0, w: s.width, value: v }); + out.push(Write { slot: s.slot, sig: *sig, lo: 0, w: s.width, value: v, by }); } LVal::Word { sig, idx } => { if let Some(slot) = self.word_slot(*sig, Some(idx)) { - out.push(Write { slot, sig: *sig, lo: 0, w: self.d.signals[*sig].width, value: v }); + out.push(Write { slot, sig: *sig, lo: 0, w: self.d.signals[*sig].width, value: v, by }); } } LVal::Bit { sig, word, idx } => { let s = &self.d.signals[*sig]; let i = self.index(idx); if let (Some(off), Some(slot)) = (s.offset(i), self.word_slot(*sig, word.as_ref())) { - out.push(Write { slot, sig: *sig, lo: off, w: 1, value: v }); + out.push(Write { slot, sig: *sig, lo: off, w: 1, value: v, by }); } } LVal::Part { sig, word, lo, w } => { if let Some(slot) = self.word_slot(*sig, word.as_ref()) { - out.push(Write { slot, sig: *sig, lo: *lo, w: *w, value: v }); + out.push(Write { slot, sig: *sig, lo: *lo, w: *w, value: v, by }); } } LVal::IPart { sig, start, w, up } => { if let Some(lo) = self.ipart_lo(*sig, start, *w, *up) { - out.push(Write { slot: self.d.signals[*sig].slot, sig: *sig, lo, w: *w, value: v }); + out.push(Write { slot: self.d.signals[*sig].slot, sig: *sig, lo, w: *w, value: v, by }); } } LVal::Concat(parts) => { @@ -534,7 +565,7 @@ impl<'d> Sim<'d> { let mut shift = 0; for part in parts.iter().rev() { let w = part.width(self.d); - self.resolve_into(part, if shift >= 64 { 0 } else { v >> shift }, out); + self.resolve_into(part, if shift >= 64 { 0 } else { v >> shift }, by, out); shift += w; } } diff --git a/crates/wafer-sim/src/tests.rs b/crates/wafer-sim/src/tests.rs index 8b21ed0..d4d47e6 100644 --- a/crates/wafer-sim/src/tests.rs +++ b/crates/wafer-sim/src/tests.rs @@ -417,3 +417,73 @@ endmodule"#); assert!(count("Side { value: false }") > 0); assert_eq!(count("Side { value: true }"), 0, "full was always 0"); } + +#[test] +fn every_change_says_what_wrote_it() { + let src = r#" +module updown (input wire clk, input wire rst_n, input wire up, input wire down, output reg [3:0] count, output wire top); + assign top = count == 4'd15; + always @(posedge clk or negedge rst_n) begin + if (!rst_n) count <= 4'd0; + else if (up && !down) count <= count + 1; + else case (down) + 1'b1: count <= count - 1; + default: ; + endcase + end +endmodule + +module tb; + reg clk = 0, rst_n = 0, up = 0, down = 0; + wire [3:0] count; + wire top; + updown dut (.clk(clk), .rst_n(rst_n), .up(up), .down(down), .count(count), .top(top)); + always #5 clk = ~clk; + initial begin + #12 rst_n = 1; + up = 1; + repeat (2) @(posedge clk); + #1 up = 0; down = 1; + @(posedge clk); + #1 $finish; + end +endmodule +"#; + let r = run(src); + let wave = |n: &str| r.signals.iter().find(|s| s.name == n).unwrap_or_else(|| panic!("{n}")); + let origin = |n: &str, i: usize| &r.origins[wave(n).by[i] as usize]; + let count = wave("tb.dut.count"); + assert_eq!(count.values, [0, 1, 2, 1]); + assert_eq!( + origin("tb.dut.count", 0).text, + "count <= 0", + "reset confirms the starting value (numbers as the AST prints them)" + ); + assert_eq!(wave("tb.clk").by[0], 0, "clk = 0 from its declaration"); + assert_eq!(r.origins[0].text, "clk = 0 (its starting value)"); + + let up = origin("tb.dut.count", 1); + assert_eq!((up.text.as_str(), up.line, up.scope.as_str()), ("count <= count + 1", 6, "tb.dut")); + assert_eq!(up.reads, ["tb.dut.count"]); + let conds: Vec<(&str, &str)> = up.conds.iter().map(|c| (c.text.as_str(), c.taken.as_str())).collect(); + assert_eq!(conds, [("!rst_n", "false"), ("up && !down", "true")]); + assert_eq!(up.conds[1].reads, ["tb.dut.up", "tb.dut.down"], "in declaration order"); + + let down = origin("tb.dut.count", 3); + assert_eq!(down.text, "count <= count - 1"); + assert_eq!( + down.conds.last().map(|c| (c.text.as_str(), c.taken.as_str())), + Some(("down", "1")), + "labels as the coverage prints them" + ); + + // Through the port to the testbench, and from the testbench's own assignments. + let port = origin("tb.count", 1); + assert_eq!( + (port.text.as_str(), port.reads.as_slice()), + ("output count of tb.dut", &["tb.dut.count".to_string()][..]) + ); + assert_eq!(origin("tb.dut.up", 1).text, "input up of tb.dut"); + assert_eq!(origin("tb.up", 1).text, "up = 1"); + assert_eq!(origin("tb.dut.top", 0).text, "assign top = count == 4'hf", "computed at time 0"); +} diff --git a/docs/architecture.md b/docs/architecture.md index 2503e08..85e2adb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,7 +30,7 @@ | `crates/wafer-api` | The only server process: HTTP and WebSocket API, background workers for flow runs and agent proposals, database migrations (`migrations/`). | | `crates/wafer-git` | Bare Git repositories via libgit2: commits without a worktree, optimistic parents, path normalisation. | | `crates/wafer-jobs` | The `Backend` trait for flow runs and its Docker implementation; LibreLane metrics and report parsing; safe reading of run outputs. | -| `crates/wafer-sim` | Event-driven Verilog simulator (pure Rust), checked line by line against Icarus Verilog (`tests/conformance`). | +| `crates/wafer-sim` | Event-driven Verilog simulator (pure Rust), checked line by line against Icarus Verilog (`tests/conformance`). Besides waveforms it records branch coverage and, for every value change, the assignment that made it (`origins`: line, text, the signals read and the `if`/`case` decisions around it), which the web app uses to explain values. | | `crates/wafer-gds` | GDSII reader: parse, flatten hierarchy, tessellate for the GPU. | | `crates/wafer-sim-wasm`, `crates/wafer-gds-wasm` | WebAssembly bindings used by the web app. | | `apps/web` | React + TypeScript app (Vite): Design view, Code view (Monaco), runs, layout viewer, agent, IP. | diff --git a/docs/user-guide/design-view.md b/docs/user-guide/design-view.md index 5a19953..24ef9b0 100644 --- a/docs/user-guide/design-view.md +++ b/docs/user-guide/design-view.md @@ -83,7 +83,19 @@ Selecting an example simulates it in your browser and replays its steps against by cycle: each connection's value, the example's checks laid over it (amber), any difference in red, and the inputs it sets. The first difference is explained in plain words, including when the expected value did appear ("it became 1 one cycle later, so the design may be one cycle slower -than the example says"). Three buttons answer the one question to decide, **Which is wrong?**: +than the example says"). + +**Click any value in the timeline to see why it is what it is.** The simulator records which line of +the design wrote every value, so the explanation says when the value appeared, the line that wrote +it ("line 20 of blinker.v wrote it: `led <= ~led`"), why that line ran (each `if` and `case` +around it and which way it went: "`cnt == N - 1` was true (cnt = 3)"), and the values it used. +Every value mentioned can be clicked in turn, so you can follow a wrong output back, one step at +a time, to the decision or the example input that caused it. Values passed through a port of a +block are followed into the block; an input the example sets ends the chain ("set by step 1 of +the example"). When you then ask the AI to fix the design or the assistant to correct the +example, the explanation you followed goes with the request. + +Three buttons answer the one question to decide, **Which is wrong?**: - **The design: ask the AI to fix it**, with this explanation; - **The example: use what the design did**: rewrites the failing check from the simulation,