Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ All notable changes to Wafersmith are recorded here. The format follows

### Added

- **Try a small fix**: a failing example offers "The design: try a small fix". Without AI, the
server tries the small repairs that undo the most common mistakes (a comparison turned around, a
limit one off in either direction, a condition reversed, a missing or extra `!`/`~`, `&&` for
`||`, `-` for `+`, an assigned value one off), the lines "Why not?" points at first, and keeps
those after which every example passes. Each is shown as a one-line diff that keeps the line as
written (sized numbers too), with what it changes in the examples' outputs, and **Use this
change** commits it. Every mistake "Would your examples catch a mistake?" plants in the starters
and an example notices is repaired back to the exact original line (tested).
`POST /api/projects/{id}/spec/small-fix`.
- **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
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,30 @@ export interface FoundExample {
message: string | null;
}

/** "Try a small fix": small repairs after which every example passes (`POST .../spec/small-fix`). */
export interface SmallFixes {
fixes: SmallFix[];
tried: number;
sites: number;
/** The examples failing before any repair. */
failing: string[];
elapsed_ms: number;
message: string;
}

export interface SmallFix {
file: string;
line: number;
kind: string;
original: string;
replacement: string;
/** "Line 18 of blinker.v: `cnt == N - 1` → `cnt == N`; all 3 examples pass." */
text: string;
/** The line as it is and as it would be, and the whole file changed; null if the change can't
* be placed in the text. */
edit: { before: string; after: string; content: string } | null;
}

/** How well a version of the design is tested (`POST /agent/{id}/compare`). */
export interface Quality {
examples: number;
Expand Down Expand Up @@ -644,6 +668,8 @@ export const api = {
request<{ commit: string; changed: boolean }>("PUT", `/projects/${enc(id)}/spec`, body),
runSpec: (id: string) => request<SpecRun>("POST", `/projects/${enc(id)}/spec/run`),
plantMistakes: (id: string) => request<MistakesReport>("POST", `/projects/${enc(id)}/spec/mistakes`),
smallFix: (id: string, focus: { file: string; line: number }[]) =>
request<SmallFixes>("POST", `/projects/${enc(id)}/spec/small-fix`, { focus }),
findExample: (id: string, target: FindTarget) => request<FoundExample>("POST", `/projects/${enc(id)}/spec/find-example`, target),
exampleTestbench: (id: string, body: { spec: Spec; example: SpecExample }) =>
request<{ path: string; top: string; source: string }>("POST", `/projects/${enc(id)}/spec/testbench`, body),
Expand Down
111 changes: 110 additions & 1 deletion apps/web/src/components/Design.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
type FindTarget,
type Job,
type MistakesReport,
type SmallFix,
type SmallFixes,
type Spec,
type SpecExample,
type SpecRun,
Expand All @@ -20,6 +22,7 @@ import { decided, missed, why, whyNot, whyNotText, whyText, type Because, type R
import { StateMachine } from "./StateMachine";
import { TryIt } from "./TryIt";
import { SimClient } from "../sim/client";
import { compareExamples } from "../lib/compare";
import { BlocksStep } from "./Blocks";

type Step = "describe" | "examples" | "blocks" | "health";
Expand Down Expand Up @@ -982,6 +985,57 @@ function Happened({
};
// "Why not?": why the first failing check didn't see the value the example expected.
const [notWhy, setNotWhy] = useState<WhyNot | null>(null);
// "Try a small fix": repairs the server found, what each does to the outputs, and the commit
// they apply to.
const [small, setSmall] = useState<{ result: SmallFixes; effects: string[][]; parent: string | null } | "working" | null>(null);
const [smallError, setSmallError] = useState<string | null>(null);
async function trySmallFix() {
if (!rep) return;
setSmall("working");
setSmallError(null);
try {
// The lines "Why not?" and "Why?" point at are tried first.
const f = rep.firstFail;
const w = notWhy ?? (f && f.failCycle !== undefined ? whyNot(rep, `${rep.top}.${f.signal}`, f.failCycle, f.expected) : null);
const focus = [
...(w?.candidates ?? []).flatMap((c) => [c.line, ...c.passed.map((p) => p.line), ...(c.blocked ? [c.blocked.line] : [])].map((line) => ({ file: c.file, line }))),
...trail.flatMap((b) => (b.file && b.line ? [b.line, ...b.conds.map((c) => c.line)].map((line) => ({ file: b.file!, line })) : [])),
];
const result = await api.smallFix(projectId, focus);
const snap = await api.snapshot(projectId);
const client = new SimClient();
const effects: string[][] = [];
try {
for (const fx of result.fixes) {
if (!fx.edit) {
effects.push([]);
continue;
}
const changes = await compareExamples(client, baseSpec, null, snap.files, { ...snap.files, [fx.file]: fx.edit.content });
effects.push(changes.flatMap((c) => c.differences.map((d) => `${c.title}: ${d}`)));
}
} finally {
client.dispose();
}
setSmall({ result, effects, parent: snap.commit });
} catch (e) {
setSmallError((e as Error).message);
setSmall(null);
}
}
async function applySmallFix(fx: SmallFix, parent: string | null) {
if (!fx.edit) return;
try {
await api.commit(projectId, {
message: `Small fix: line ${fx.line} of ${fx.file.split("/").pop()}, ${fx.original} → ${fx.replacement}`,
parent,
changes: [{ op: "write", path: fx.file, content: fx.edit.content }],
});
setSmall(null);
} catch (e) {
setSmallError((e as Error).message);
}
}
// "The design is right": a change to the example that makes it pass, worked out and re-checked.
const [fix, setFix] = useState<Fix | "working" | "none" | null>(null);
async function acceptDesign() {
Expand Down Expand Up @@ -1048,7 +1102,11 @@ 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 traced = [notWhy && whyNotText(notWhy), ...trail.map(whyText)].filter(Boolean);
const traced = [
notWhy && whyNotText(notWhy),
...trail.map(whyText),
small && typeof small === "object" && !small.result.fixes.length && `Small changes tried without the AI: ${small.result.message}`,
].filter(Boolean);
const story = traced.length ? `${rep.story} Why, traced in the simulation: ${traced.join(" ")}` : rep.story;
const picked = trail[0];

Expand Down Expand Up @@ -1141,6 +1199,57 @@ function Happened({
<button disabled={busy} onClick={() => onFixExample(story)}>
The example: ask the assistant to correct it
</button>
<button
disabled={busy || small === "working"}
onClick={trySmallFix}
title="Try small changes to the design (a comparison, a constant one off, a condition the other way), checking every example after each; no AI"
>
The design: try a small fix
</button>
</div>
)}
{small === "working" && <p className="muted small">Trying small changes to the design, running every example after each…</p>}
{smallError && <p className="error small">Couldn't try small fixes: {smallError}</p>}
{small && typeof small === "object" && (
<div className="design-card fix-preview">
<div className="design-row" style={{ justifyContent: "space-between" }}>
<strong className="small">{small.result.fixes.length ? "Small changes to the design that make every example pass" : "No small fix"}</strong>
<button className="link small" onClick={() => setSmall(null)}>
Close
</button>
</div>
<p className="muted small" style={{ margin: 0 }}>
{small.result.message}
{!small.result.fixes.length && " Ask the AI to fix the design; it is told what was tried."}
</p>
{small.result.fixes.map((fx, i) => (
<div key={i} className="small-fix">
<div className="small">{withCode(fx.text)}</div>
{fx.edit && (
<pre className="small-fix-diff mono">
<span className="del">- {fx.edit.before.trim()}</span>
{"\n"}
<span className="add">+ {fx.edit.after.trim()}</span>
</pre>
)}
{small.effects[i]?.length > 0 && <span className="muted small">What it changes in the examples:</span>}
{small.effects[i]?.length > 0 && (
<ul className="small">
{small.effects[i].map((d) => (
<li key={d}>{withCode(d)}</li>
))}
</ul>
)}
<button
className={i === 0 ? "primary small" : "small"}
disabled={!fx.edit || busy}
title={fx.edit ? undefined : "This change spans lines or is written differently; make it in the code"}
onClick={() => applySmallFix(fx, small.parent)}
>
Use this change
</button>
</div>
))}
</div>
)}
{fix === "working" && <p className="muted small">Working out the change and checking it…</p>}
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2682,3 +2682,27 @@ a.button {
.why-chain button.link {
padding: 0;
}

/* "Try a small fix": each repair found, as a one-line diff. */
.small-fix {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding-top: 8px;
border-top: 1px solid var(--border);
}

.small-fix-diff {
margin: 0;
white-space: pre-wrap;
font-size: 12px;
}

.small-fix-diff .del {
color: var(--danger);
}

.small-fix-diff .add {
color: var(--ok);
}
34 changes: 34 additions & 0 deletions crates/wafer-api/src/design.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,40 @@ pub async fn find_example(
found.map(Json).map_err(ApiError::Conflict)
}

/// How long "Try a small fix" may search.
const FIX_BUDGET: std::time::Duration = std::time::Duration::from_secs(20);

#[derive(Debug, Deserialize, Default)]
pub struct SmallFixReq {
/// Lines to try first (those "Why not?" pointed at).
#[serde(default)]
pub focus: Vec<crate::fixes::Focus>,
}

/// "Try a small fix" (`crate::fixes`) at the branch tip: small repairs after which every example
/// passes, each with the edit to make. Nothing is saved. Editors: it costs server time.
pub async fn small_fix(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<SmallFixReq>,
) -> ApiResult<Json<crate::fixes::Fixes>> {
authorize(&state, &user, id, Role::Editor).await?;
let commit = head(&state, id).await?;
let files = files_at(&state, id, &commit).await?;
let spec = match files.get(SPEC_FILE) {
Some(t) => parse_spec(t)?,
None => return Err(ApiError::NotFound("this project has no spec yet".into())),
};
let tops: Vec<String> = spec.examples.iter().map(|e| spec::tb_name(&e.id)).collect();
let focus: Vec<crate::fixes::Focus> = req.focus.into_iter().take(64).collect();
let found = tokio::task::spawn_blocking(move || {
gate::with_slot(|| wafer_sim::on_big_stack(|| crate::fixes::find(&files, &tops, &focus, FIX_BUDGET)))
})
.await?;
found.map(Json).map_err(ApiError::Conflict)
}

#[derive(Debug, Deserialize)]
pub struct TestbenchReq {
/// The spec being edited (unsaved edits included).
Expand Down
Loading