From f5bc0f9cce39d36871258c7d4704a561ca62be68 Mon Sep 17 00:00:00 2001 From: Rahul Tomar Date: Fri, 6 Nov 2026 18:10:48 +0100 Subject: [PATCH] Why wasn't it the expected value? Explain a failing check A failing example's account ends with "Why wasn't it N?": every line of the design that could have written the expected value, and for each the decision that kept it from running (with the values decided on), or that it ran and computed something else. Its values continue into the "Why?" chain, and the answer goes with requests to the AI. The simulator records every if/case decision with the branch taken (SimResult::decisions, capped at MAX_DECISIONS) and which signal each assignment writes (Origin::writes); conditions name their branch point. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++ apps/web/src/components/Design.tsx | 99 ++++++++++++++++- apps/web/src/lib/why.ts | 169 ++++++++++++++++++++++++++++- apps/web/src/sim/protocol.ts | 19 +++- apps/web/src/sim/worker.ts | 9 +- crates/wafer-sim-wasm/src/lib.rs | 36 +++++- crates/wafer-sim/src/elab.rs | 85 +++++++++++---- crates/wafer-sim/src/sim.rs | 28 ++++- crates/wafer-sim/src/tests.rs | 22 ++++ docs/architecture.md | 2 +- docs/user-guide/design-view.md | 16 +++ 11 files changed, 456 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e1a0b..8825515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ All notable changes to Wafersmith are recorded here. The format follows ### Added +- **Why wasn't it the expected value?**: a failing example's account ends with a link that lists + every line of the design that could have written the value the example expected, and for each + the decision that kept it from running ("`cnt == N - 1` was false (cnt = 2)"), or that it ran and + computed something else. Its values can be clicked to follow the cause back, and the answer goes + with a request to the AI. The simulator now records every `if`/`case` decision with the branch + taken (`SimResult::decisions`) and which signal each assignment writes (`Origin::writes`). Runs + in the browser; no AI and no cost. - **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 diff --git a/apps/web/src/components/Design.tsx b/apps/web/src/components/Design.tsx index 7129db1..fee30a8 100644 --- a/apps/web/src/components/Design.tsx +++ b/apps/web/src/components/Design.tsx @@ -16,7 +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 { decided, missed, why, whyNot, whyNotText, whyText, type Because, type Read, type WhyNot } from "../lib/why"; import { StateMachine } from "./StateMachine"; import { TryIt } from "./TryIt"; import { SimClient } from "../sim/client"; @@ -980,6 +980,8 @@ function Happened({ const b = rep && why(rep, full, t); if (b) setTrail((tr) => [...tr.slice(0, after), b]); }; + // "Why not?": why the first failing check didn't see the value the example expected. + const [notWhy, setNotWhy] = useState(null); // "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() { @@ -1030,6 +1032,7 @@ function Happened({ if (!cancelled) { setRep(replay(spec, ex, sim)); setTrail([]); + setNotWhy(null); } })().catch((e: Error) => !cancelled && setError(e.message)); return () => { @@ -1045,7 +1048,8 @@ function Happened({ 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 traced = [notWhy && whyNotText(notWhy), ...trail.map(whyText)].filter(Boolean); + const story = traced.length ? `${rep.story} Why, traced in the simulation: ${traced.join(" ")}` : rep.story; const picked = trail[0]; return ( @@ -1055,8 +1059,27 @@ function Happened({ {failing ? "differs from the example" : "matches the example"}

- {rep.story} + {rep.story}{" "} + {failing && failing.failCycle !== undefined && !notWhy && ( + + )}

+ {notWhy && ( + { + if (r.t === null) return; + setPickedCycle(null); + ask(r.full, r.t, 0); + }} + onClose={() => setNotWhy(null)} + /> + )} {rep.problem &&

Note: {rep.problem}.

}
@@ -1244,6 +1267,76 @@ function HpRow({ ); } +/** "Why not?": the lines that could have written the value the example expected, and what kept + * each from doing so; its values can be asked about like any other. */ +function WhyNotPanel({ w, onAsk, onClose }: { w: WhyNot; onAsk: (r: Read) => void; onClose: () => void }) { + const chip = (r: Read) => ( + + ); + const chips = (rs: Read[]) => rs.map((r, j) => [j > 0 && ", ", chip(r)]); + return ( +
+
+ + Why wasn't {w.name} {w.expected} at cycle {w.cycle}? + + +
+ {w.unrecorded &&

The run is too long to have recorded every decision up to this cycle.

} + {w.candidates.length === 0 ? ( +

+ No line of the design can write {w.expected} to {w.name} + {w.others > 0 && <> (the {w.others === 1 ? "line that writes it only writes" : `${w.others} lines that write it only write`} other values)}. The design + needs a line that does. +

+ ) : ( +
    + {w.candidates.map((c, k) => ( +
  1. + Line {c.line} of {c.file.split("/").pop()}: {c.code} + {c.verdict === "never" &&
    It hadn't run yet: nothing had reached its decisions by cycle {w.cycle}.
    } + {c.verdict === "blocked" && c.blocked && ( +
    + Didn't run for cycle {c.cycle} + {c.passed.length > 0 && ( + <> + {" "} + ( + {c.passed.map((p, j) => ( + + {j > 0 && ", "} + {withCode(decided(p))} + + ))} + ) + + )}{" "} + because {withCode(missed(c.blocked))} + {c.blocked.reads.length > 0 && <> ({chips(c.blocked.reads)})}. +
    + )} + {c.verdict === "ran" && ( +
    + It ran{c.cycle !== undefined && <> for cycle {c.cycle}} + {c.reads.length > 0 && <>, with {chips(c.reads)}}, and gave {w.name} another value (or a later line changed it). +
    + )} +
  2. + ))} +
+ )} +

+ {w.candidates.length > 0 && w.others > 0 && <>Not listed: {w.others} line{w.others === 1 ? "" : "s"} writing other constants. } + Click a value to see why it was that. When you ask the AI to fix something, this goes with it. +

+
+ ); +} + /** "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 }) { diff --git a/apps/web/src/lib/why.ts b/apps/web/src/lib/why.ts index 568de43..7400651 100644 --- a/apps/web/src/lib/why.ts +++ b/apps/web/src/lib/why.ts @@ -2,7 +2,7 @@ // 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 { NO_ORIGIN, type SimOrigin, type SimSignal } from "../sim/protocol"; import type { Replay } from "./replay"; /** A value an explanation mentions, which can be explained in turn. */ @@ -127,3 +127,170 @@ export function whyText(b: Because): string { "." ); } + +/** A line of the design that could have given a signal the value an example expected, and what + * happened to it the last time it could have run. */ +export interface Candidate { + file: string; + line: number; + code: string; + /** `blocked`: a decision went another way; `ran`: it ran (and computed something else, or a + * later line changed the value again); `never`: nothing had decided it yet. */ + verdict: "blocked" | "ran" | "never"; + /** The cycle that last decision is seen at. */ + cycle?: number; + /** The decisions that went its way, outermost first, then (for `blocked`) the one that didn't. */ + passed: Because["conds"]; + blocked?: Because["conds"][number]; + /** For `ran`: the values it computed from. */ + reads: Read[]; +} + +export interface WhyNot { + name: string; + full: string; + expected: string; + actual: string; + cycle: number; + /** The lines that write the signal and could have written the expected value. */ + candidates: Candidate[]; + /** Lines that write the signal but only another constant (`valid <= 0` when 1 was expected). */ + others: number; + /** The run made too many decisions to record them all, and this moment is past the record. */ + unrecorded: boolean; +} + +/** A Verilog number (`1`, `4'hf`, `8'd12`), or null for anything else. */ +function constant(s: string): bigint | null { + const v = s.trim().replace(/_/g, "").toLowerCase(); + try { + const m = v.match(/^\d*'s?([hbod])([0-9a-f]+)$/); + if (m) return BigInt({ h: "0x", b: "0b", o: "0o", d: "" }[m[1] as "h"] + m[2]); + return /^\d+$/.test(v) ? BigInt(v) : null; + } catch { + return null; + } +} + +/** Why `full` wasn't `expected` at a cycle's check point: each line that could have written that + * value, and the decision that kept it from running (or that it did run). */ +export function whyNot(rep: Replay, full: string, cycle: number, expected: bigint): WhyNot | null { + const t = rep.at(cycle); + if (t === undefined) return 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 }; + }; + // Through port connections to the signal the design writes. + let cur = full; + for (let hop = 0; hop < 32; hop++) { + const s = signals.get(cur); + const i = s ? changeAt(s, t) : -1; + if (!s || i < 0 || s.by[i] === NO_ORIGIN) break; + const o = rep.sim.origins[s.by[i]]; + if (!/^(input|output) \S+ of /.test(o.text) || o.reads.length !== 1) break; + cur = o.reads[0]; + } + const shown = signals.get(full); + const at = shown ? changeAt(shown, t) : -1; + const out: WhyNot = { + name: name(full), + full, + expected: expected.toString(), + actual: shown && at >= 0 ? shown.values[at].toString() : "?", + cycle, + candidates: [], + others: 0, + unrecorded: false, + }; + + const { times, branches, decisionOf, cut } = rep.sim.decisions; + out.unrecorded = cut && (times.length === 0 || times[times.length - 1] < t); + // The last decision `d` made at or before `t` (or, from `from` on, at time `at` exactly). + const lastBefore = (d: number) => { + let lo = 0; + let hi = times.length - 1; + let end = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (times[mid] <= t) { + end = mid; + lo = mid + 1; + } else hi = mid - 1; + } + for (let j = end; j >= 0; j--) if (decisionOf[branches[j]] === d) return j; + return -1; + }; + const nextAt = (d: number, from: number) => { + for (let j = from + 1; j < times.length && times[j] === times[from]; j++) if (decisionOf[branches[j]] === d) return j; + return -1; + }; + + const seen = new Set(); + for (const o of rep.sim.origins) { + if (o.writes !== cur || /^(input|output) \S+ of /.test(o.text) || o.text.endsWith("(its starting value)")) continue; + const key = `${o.file}:${o.line}:${o.text}`; + if (seen.has(key)) continue; + seen.add(key); + // A line that only ever writes another constant can't be the one. + const m = o.text.match(/ (nb ? Math.max(0, tm - 1) : tm); + const cond = (c: SimOrigin["conds"][number], tm: number) => ({ line: c.line, code: c.text, taken: c.taken, reads: c.reads.map((r) => read(r, valuesAt(tm))) }); + const cand: Candidate = { file: o.file, line: o.line, code: o.text, verdict: "ran", passed: [], reads: [] }; + if (o.conds.length) { + let j = lastBefore(decisionOf[o.conds[0].point]); + if (j < 0) cand.verdict = "never"; + else { + cand.cycle = rep.cycleAt(times[j]); + for (let n = 0; n < o.conds.length; n++) { + const c = o.conds[n]; + if (n > 0) j = nextAt(decisionOf[c.point], j); + if (j < 0) break; + if (branches[j] !== c.point) { + cand.verdict = "blocked"; + cand.blocked = cond(c, times[j]); + break; + } + cand.passed.push(cond(c, times[j])); + } + if (cand.verdict === "ran") cand.reads = o.reads.map((r) => read(r, valuesAt(times[j]))); + } + } else cand.reads = o.reads.map((r) => read(r, valuesAt(t))); + out.candidates.push(cand); + } + return out; +} + +/** How a decision went the other way: "`state == STOP` was false", "`state` wasn't DONE". */ +export function missed(c: Because["conds"][number]): string { + if (c.taken === "true") return `\`${c.code}\` was false`; + if (c.taken === "false") return `\`${c.code}\` was true`; + if (c.taken === "default") return `\`${c.code}\` matched one of the listed values`; + return `\`${c.code}\` wasn't ${c.taken.replace(/, /g, " or ")}`; +} + +/** The answer as one paragraph, for the AI. */ +export function whyNotText(w: WhyNot): string { + const vals = (rs: Read[]) => rs.map((r) => `${r.name} = ${r.value}`).join(", "); + const parts = w.candidates.map((c) => { + const head = `line ${c.line} of ${c.file}, \`${c.code}\``; + if (c.verdict === "never") return `${head}, had never run by then`; + if (c.verdict === "blocked" && c.blocked) + return `${head}, didn't run for cycle ${c.cycle} because ${missed(c.blocked)}${c.blocked.reads.length ? ` (${vals(c.blocked.reads)})` : ""}`; + return `${head}, ran${c.cycle !== undefined ? ` for cycle ${c.cycle}` : ""}${c.reads.length ? ` with ${vals(c.reads)}` : ""}`; + }); + const head = `Why ${w.name} wasn't ${w.expected} at cycle ${w.cycle} (it was ${w.actual}):`; + return parts.length ? `${head} ${parts.join("; ")}.` : `${head} no line of the design writes ${w.expected} to it.`; +} diff --git a/apps/web/src/sim/protocol.ts b/apps/web/src/sim/protocol.ts index 23e28b0..a3645d0 100644 --- a/apps/web/src/sim/protocol.ts +++ b/apps/web/src/sim/protocol.ts @@ -22,8 +22,22 @@ export interface SimOrigin { 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 }[]; + /** Full name of the signal it writes; empty when it writes several. */ + writes: string; + /** The `if`/`case` decisions it is inside, outermost first, and which way each went for it to + * run; `point` is that branch, as in `SimDecisions`. */ + conds: { line: number; text: string; reads: string[]; taken: string; point: number }[]; +} + +/** Every `if`/`case` decision the run made, in order. */ +export interface SimDecisions { + times: Float64Array; + /** The branch taken (a coverage point number). */ + branches: Uint32Array; + /** For each coverage point: the decision it is a branch of, or NO_ORIGIN for one that isn't. */ + decisionOf: Uint32Array; + /** The run made more decisions than were recorded (only the first are). */ + cut: boolean; } export interface SimResult { @@ -37,6 +51,7 @@ export interface SimResult { ms: number; signals: SimSignal[]; origins: SimOrigin[]; + decisions: SimDecisions; } export type SimRequest = diff --git a/apps/web/src/sim/worker.ts b/apps/web/src/sim/worker.ts index 539ffef..afc12b9 100644 --- a/apps/web/src/sim/worker.ts +++ b/apps/web/src/sim/worker.ts @@ -22,6 +22,12 @@ self.onmessage = async (ev: MessageEvent) => { const ms = performance.now() - t0; const names = last.names; const widths = last.widths; + const decisions = { + times: last.decisionTimes(), + branches: last.decisionBranches(), + decisionOf: last.branchDecision(), + cut: last.decisionsCut, + }; const signals = names.map((name, i) => ({ name, width: widths[i], times: last!.times(i), values: last!.values(i), by: last!.by(i) })); reply( { @@ -38,9 +44,10 @@ self.onmessage = async (ev: MessageEvent) => { ms, signals, origins: JSON.parse(last.origins()), + decisions, }, }, - signals.flatMap((s) => [s.times.buffer, s.values.buffer, s.by.buffer]), + [...signals.flatMap((s) => [s.times.buffer, s.values.buffer, s.by.buffer]), decisions.times.buffer, decisions.branches.buffer], ); } } catch (e) { diff --git a/crates/wafer-sim-wasm/src/lib.rs b/crates/wafer-sim-wasm/src/lib.rs index 4209902..900e363 100644 --- a/crates/wafer-sim-wasm/src/lib.rs +++ b/crates/wafer-sim-wasm/src/lib.rs @@ -95,8 +95,32 @@ impl Simulation { 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}]}]`. + /// When each `if`/`case` decision was made, in order (simulation time units). + #[wasm_bindgen(js_name = decisionTimes)] + pub fn decision_times(&self) -> Vec { + self.r.decisions.iter().map(|&(t, _)| t as f64).collect() + } + + /// The branch each decision took (a coverage point), in the same order as `decisionTimes`. + #[wasm_bindgen(js_name = decisionBranches)] + pub fn decision_branches(&self) -> Vec { + self.r.decisions.iter().map(|&(_, p)| p).collect() + } + + /// The decision each coverage point is a branch of; 0xFFFFFFFF for one that isn't a branch. + #[wasm_bindgen(js_name = branchDecision)] + pub fn branch_decision(&self) -> Vec { + self.r.cover_decision.clone() + } + + /// The run made more decisions than are recorded. + #[wasm_bindgen(getter, js_name = decisionsCut)] + pub fn decisions_cut(&self) -> bool { + self.r.decisions_cut + } + + /// Where written values come from, as JSON: `[{file, line, scope, text, reads, writes, conds: + /// [{line, text, reads, taken, point}]}]`. pub fn origins(&self) -> String { let list: Vec = self .r @@ -108,21 +132,23 @@ impl Simulation { .iter() .map(|c| { format!( - r#"{{"line":{},"text":{},"reads":{},"taken":{}}}"#, + r#"{{"line":{},"text":{},"reads":{},"taken":{},"point":{}}}"#, c.line, json(&c.text), strings(&c.reads), - json(&c.taken) + json(&c.taken), + c.point ) }) .collect(); format!( - r#"{{"file":{},"line":{},"scope":{},"text":{},"reads":{},"conds":[{}]}}"#, + r#"{{"file":{},"line":{},"scope":{},"text":{},"reads":{},"writes":{},"conds":[{}]}}"#, json(&o.file), o.line, json(&o.scope), json(&o.text), strings(&o.reads), + json(&o.writes), conds.join(",") ) }) diff --git a/crates/wafer-sim/src/elab.rs b/crates/wafer-sim/src/elab.rs index 6721cba..d8c971c 100644 --- a/crates/wafer-sim/src/elab.rs +++ b/crates/wafer-sim/src/elab.rs @@ -349,6 +349,8 @@ pub struct Origin { pub text: String, /// The signals its value is computed from (full names, e.g. `tb.dut.count`). pub reads: Vec, + /// The signal it writes (full name), or empty when it writes several (`{a, b} = ...`). + pub writes: String, /// The `if` and `case` decisions it is inside, outermost first. pub conds: Vec, } @@ -362,6 +364,9 @@ pub struct Cond { pub reads: Vec, /// `true` or `false` for an `if`; the arm's labels for a `case`, or `default`. pub taken: String, + /// The coverage point of this branch: the simulation records when each was taken + /// ([`crate::SimResult::decisions`]). + pub point: usize, } #[derive(Debug, Clone)] @@ -386,8 +391,14 @@ pub struct Design { pub cover: Vec, /// Where assignments come from, numbered by `Op::From`. pub origins: Vec, + /// For each coverage point that is a branch of an `if` or `case`: the decision it belongs to + /// (numbered from 0); [`NO_DECISION`] for the others. + pub cover_decision: Vec, } +/// [`Design::cover_decision`] of a coverage point that isn't a branch. +pub const NO_DECISION: u32 = u32::MAX; + #[derive(Debug, Clone, PartialEq)] pub struct InstanceInfo { pub path: String, @@ -421,6 +432,7 @@ pub fn elaborate_with(modules: &[Module], top: &str, overrides: &HashMap { hoist: Option>, /// The `if`/`case` decisions around the statement being compiled. conds: Vec, + /// Decisions numbered so far. + decisions: u32, /// 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. @@ -620,7 +634,8 @@ impl<'a> Elab<'a> { } 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); + let from = + self.origin(ds, dl.line, format!("{} = {init} (its starting value)", n.name), &lv, &rhs); ops.push(from); ops.push(Op::Assign(lv, rhs)); ops.push(Op::Halt); @@ -809,26 +824,39 @@ impl<'a> Elab<'a> { } /// A new coverage point in `s`, and the op that counts it. - fn cover(&mut self, s: &Scope, line: u32, kind: CoverKind, what: String) -> Op { - Op::Cover(self.cover_point(s, line, kind, what)) - } - /// A new coverage point in `s`, by number. fn cover_point(&mut self, s: &Scope, line: u32, kind: CoverKind, what: String) -> usize { self.d.cover.push(CoverPoint { file: s.module.file.clone(), line, scope: s.path.clone(), kind, what }); + self.d.cover_decision.push(NO_DECISION); self.d.cover.len() - 1 } + /// A branch of decision `d`: its coverage point, marked as part of `d`. + fn branch(&mut self, s: &Scope, line: u32, kind: CoverKind, what: String, d: u32) -> usize { + let n = self.cover_point(s, line, kind, what); + self.d.cover_decision[n] = d; + n + } + /// 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 { + fn origin(&mut self, s: &Scope, line: u32, text: String, lv: &LVal, from: &CExpr) -> Op { let reads = self.read_names(from); + let writes = match lv { + LVal::Concat(_) => String::new(), + LVal::Sig(sig) + | LVal::Word { sig, .. } + | LVal::Bit { sig, .. } + | LVal::Part { sig, .. } + | LVal::IPart { sig, .. } => self.d.signals[*sig].name.clone(), + }; self.d.origins.push(Origin { file: s.module.file.clone(), line, scope: s.path.clone(), text, reads, + writes, conds: self.conds.clone(), }); Op::From((self.d.origins.len() - 1) as u32) @@ -853,7 +881,7 @@ impl<'a> Elab<'a> { 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); + let from = self.origin(s, line, text, &lv, &rhs); self.conds = saved; ops.push(from); ops.push(Op::Assign(lv, rhs)); @@ -995,7 +1023,7 @@ 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 from = self.origin(s, line, format!("output {port} of {path}"), &rhs); + let from = self.origin(s, line, format!("output {port} of {path}"), &lv, &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 }); } @@ -1631,14 +1659,14 @@ impl<'a> Elab<'a> { Stmt::Blocking { lhs, rhs: r, line } => { let lv = self.lval(s, lhs)?; let rhs = self.sexpr(s, r, ops)?; - let from = self.origin(s, *line, format!("{lhs} = {r}"), &rhs); + let from = self.origin(s, *line, format!("{lhs} = {r}"), &lv, &rhs); ops.push(from); ops.push(Op::Assign(lv, rhs)); } Stmt::NonBlocking { lhs, rhs: r, delay, line } => { let lv = self.lval(s, lhs)?; let rhs = self.sexpr(s, r, ops)?; - let from = self.origin(s, *line, format!("{lhs} <= {r}"), &rhs); + let from = self.origin(s, *line, format!("{lhs} <= {r}"), &lv, &rhs); ops.push(from); let delay = match delay { Some(d) => Some(self.sexpr(s, d, ops)?), @@ -1670,10 +1698,18 @@ impl<'a> Elab<'a> { } let jz = ops.len(); ops.push(Op::Halt); // patched - let cov = self.cover(s, line, CoverKind::Then, what.clone()); - ops.push(cov); + let d = self.decisions; + self.decisions += 1; + let then = self.branch(s, line, CoverKind::Then, what.clone(), d); + ops.push(Op::Cover(then)); let reads = self.read_names(&c); - self.conds.push(Cond { line, text: what.clone(), reads: reads.clone(), taken: "true".into() }); + self.conds.push(Cond { + line, + text: what.clone(), + reads: reads.clone(), + taken: "true".into(), + point: then, + }); let r = self.stmt(s, t, ops); self.conds.pop(); r?; @@ -1681,10 +1717,10 @@ impl<'a> Elab<'a> { 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.clone()); - ops.push(cov); + let other = self.branch(s, line, CoverKind::Else { written: f.is_some() }, what.clone(), d); + ops.push(Op::Cover(other)); if let Some(f) = f { - self.conds.push(Cond { line, text: what, reads, taken: "false".into() }); + self.conds.push(Cond { line, text: what, reads, taken: "false".into(), point: other }); let r = self.stmt(s, f, ops); self.conds.pop(); r?; @@ -1703,6 +1739,8 @@ impl<'a> Elab<'a> { .collect::>>()?; let w = label_w.into_iter().fold(sel_w, u32::max); let sel_reads = self.read_names(&sel_c); + let d = self.decisions; + self.decisions += 1; // 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 }; @@ -1735,13 +1773,14 @@ 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: labels.clone() }, sel.to_string()); - ops.push(cov); + let arm = self.branch(s, *line, CoverKind::Arm { labels: labels.clone() }, sel.to_string(), d); + ops.push(Op::Cover(arm)); self.conds.push(Cond { line: *line, text: sel.to_string(), reads: sel_reads.clone(), taken: labels, + point: arm, }); let r = self.stmt(s, body, ops); self.conds.pop(); @@ -1750,16 +1789,18 @@ impl<'a> Elab<'a> { ops.push(Op::Halt); ops[jz] = Op::JumpIfZero(cond.expect("case item has labels"), ops.len()); } - let cov = self.cover(s, *line, CoverKind::Default { written: default.is_some() }, sel.to_string()); - ops.push(cov); - if let Some(d) = default { + let other = + self.branch(s, *line, CoverKind::Default { written: default.is_some() }, sel.to_string(), d); + ops.push(Op::Cover(other)); + if let Some(body) = default { self.conds.push(Cond { line: *line, text: sel.to_string(), reads: sel_reads, taken: "default".into(), + point: other, }); - let r = self.stmt(s, d, ops); + let r = self.stmt(s, body, ops); self.conds.pop(); r?; } diff --git a/crates/wafer-sim/src/sim.rs b/crates/wafer-sim/src/sim.rs index df175bf..0966b7d 100644 --- a/crates/wafer-sim/src/sim.rs +++ b/crates/wafer-sim/src/sim.rs @@ -66,8 +66,18 @@ pub struct SimResult { pub coverage: Vec<(crate::elab::CoverPoint, u64)>, /// Where written values come from ([`WaveSignal::by`]). pub origins: Vec, + /// Every `if`/`case` decision made, in order: when, and the branch taken (a coverage point). + /// [`crate::elab::Design::cover_decision`] says which decision a branch belongs to. + pub decisions: Vec<(u64, u32)>, + /// The decision of each coverage point ([`crate::elab::NO_DECISION`] for one that isn't a branch). + pub cover_decision: Vec, + /// Set when the run made more than [`MAX_DECISIONS`] decisions: only the first are recorded. + pub decisions_cut: bool, } +/// Decisions recorded per run, at most. +pub const MAX_DECISIONS: usize = 1 << 21; + #[derive(Clone, Copy, PartialEq, Eq)] enum State { Ready, @@ -127,6 +137,8 @@ pub struct Sim<'d> { warnings: Vec, /// Read by expression evaluation too (`?:` sides), hence cells. cover_hits: Vec>, + decisions: Vec<(u64, u32)>, + decisions_cut: bool, } const fn mask(w: u32) -> u64 { @@ -192,6 +204,8 @@ impl<'d> Sim<'d> { changes: 0, warnings: d.warnings.clone(), cover_hits: (0..d.cover.len()).map(|_| std::cell::Cell::new(0)).collect(), + decisions: Vec::new(), + decisions_cut: false, } } @@ -213,6 +227,9 @@ impl<'d> Sim<'d> { steps: self.steps, coverage: self.d.cover.iter().cloned().zip(self.cover_hits.iter().map(|c| c.get())).collect(), origins: self.d.origins.clone(), + decisions: std::mem::take(&mut self.decisions), + cover_decision: self.d.cover_decision.clone(), + decisions_cut: self.decisions_cut, }) } @@ -411,7 +428,16 @@ impl<'d> Sim<'d> { self.procs[p].state = State::Done; return Ok(()); } - Op::Cover(n) => self.hit(*n), + Op::Cover(n) => { + self.hit(*n); + if self.d.cover_decision[*n] != crate::elab::NO_DECISION { + if self.decisions.len() < MAX_DECISIONS { + self.decisions.push((self.now, *n as u32)); + } else { + self.decisions_cut = true; + } + } + } Op::CoverParts(parts) => { for (e, id) in parts { let v = self.eval(e) != 0; diff --git a/crates/wafer-sim/src/tests.rs b/crates/wafer-sim/src/tests.rs index d4d47e6..1cbd3f1 100644 --- a/crates/wafer-sim/src/tests.rs +++ b/crates/wafer-sim/src/tests.rs @@ -486,4 +486,26 @@ endmodule 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"); + assert_eq!(up.writes, "tb.dut.count"); + assert_eq!(port.writes, "tb.count"); + + // Every decision is recorded with the branch it took: at the last clock edge (35), the + // `up && !down` decision went the other way and the case took its `1` arm. + let decision = |point: usize| r.cover_decision[point]; + let at = |t: u64, d: u32| { + r.decisions + .iter() + .filter(|&&(tm, p)| tm == t && decision(p as usize) == d) + .map(|&(_, p)| p as usize) + .collect::>() + }; + let (if_up, arm) = (&up.conds[1], down.conds.last().unwrap()); + assert_ne!(decision(if_up.point), crate::elab::NO_DECISION); + let other = at(35, decision(if_up.point)); + assert_eq!(other.len(), 1); + assert_ne!(other[0], if_up.point, "up && !down was false"); + assert_eq!(at(35, decision(arm.point)), [arm.point]); + assert_eq!(at(25, decision(if_up.point)), [if_up.point], "and true one edge earlier"); + assert!(r.decisions.windows(2).all(|w| w[0].0 <= w[1].0), "in time order"); + assert!(!r.decisions_cut); } diff --git a/docs/architecture.md b/docs/architecture.md index 85e2adb..74b551a 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`). 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-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) and every decision made with the branch taken (`decisions`), which the web app uses to explain values and why a value wasn't the expected one. | | `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 24ef9b0..7f93425 100644 --- a/docs/user-guide/design-view.md +++ b/docs/user-guide/design-view.md @@ -95,6 +95,22 @@ block are followed into the block; an input the example sets ends the chain ("se 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. +**Why wasn't it the expected value?** When an example fails, the account of the difference ends +with a **Why wasn't it N?** link. It lists every line of the design that could have given the +signal the value the example expected, and what happened to each the last time it could have run: + +- **Blocked**: a decision went the other way. "Line 20 of blinker.v: `led <= ~led`. Didn't run for + cycle 3 (`!rst_n` was false, `!en` was false) because **`cnt == N - 1` was false** (cnt = 2)." + The values shown can be clicked to ask why they were what they were (here: why `cnt` was only + 2), so you can follow the cause back. +- **Ran**: it ran, with the values shown, and computed something else (or a later line changed + the value again). +- **Hadn't run yet**: nothing had reached its decisions by then. + +Lines that only write other constants (`valid <= 0` when 1 was expected) are left out and counted. +If no line can write the expected value, it says so: the design needs one. This answer, too, goes +with a request to the AI. + Three buttons answer the one question to decide, **Which is wrong?**: - **The design: ask the AI to fix it**, with this explanation;