diff --git a/src/components/features/NumberPreservationWarning.tsx b/src/components/features/NumberPreservationWarning.tsx
new file mode 100644
index 00000000..3895514c
--- /dev/null
+++ b/src/components/features/NumberPreservationWarning.tsx
@@ -0,0 +1,102 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 The offlinecv Authors
+
+/**
+ * Number-preservation UI vocabulary (#778), shared by every surface that
+ * shows the result of `checkNumbersPreserved` / `applyNumberPreservation`
+ * (`src/lib/webllm/preserve-numbers.ts`, `post-process.ts`): the per-role
+ * `SectionRewrite` panel, the whole-résumé `ProposedPanel`
+ * (`ResumeRewriteProposed.tsx`), and the in-flight `CompletedList`
+ * (`ResumeRewrite.tsx`). Split out of `SectionRewrite.tsx` (#874 review) so a
+ * file already flagged as known debt over CLAUDE.md's ~200 LOC guidance
+ * doesn't keep absorbing every future change to this vocabulary, and so the
+ * three call sites read one classification instead of three independently
+ * written ones that could drift out of sync.
+ *
+ * Pure presentation + one pure classifier — no component state, no hooks.
+ */
+
+/**
+ * The one classification every number-preservation tone/badge decision in
+ * the tree reduces to. `"reverted"` must be checked before `"drift"`:
+ * `numbersPreserved` is true on a reverted rewrite by construction (#778),
+ * so testing it first would read a reverted section as a clean pass.
+ */
+export type NumberDriftStatus = "reverted" | "drift" | "clean";
+
+export function numberDriftStatus(result: {
+ numbersPreserved: boolean;
+ reverted: boolean;
+}): NumberDriftStatus {
+ if (result.reverted) return "reverted";
+ if (!result.numbersPreserved) return "drift";
+ return "clean";
+}
+
+/**
+ * Caption the diff carries when a rewrite was rejected (#778). The two sides
+ * are identical in that case, so without it the panel reads as "the model
+ * looked at your bullets and changed nothing" — which is a different, and
+ * false, story about what happened.
+ */
+export const REVERTED_DIFF_LABEL =
+ "No changes applied — your original bullets.";
+
+export function NumberPreservationWarning({
+ dropped,
+ added,
+ reverted = false,
+}: {
+ dropped: readonly string[];
+ added: readonly string[];
+ /**
+ * The rewrite was rejected and the original kept (#778). Changes the copy
+ * from "check what the AI changed" to "nothing changed, and here's why" —
+ * the delivered bullets are the user's own, so telling them to review a
+ * metric they never lost would be wrong.
+ */
+ reverted?: boolean;
+}) {
+ const detail = describeNumberDrift(dropped, added);
+ if (reverted) {
+ return (
+
+ ⚠
+ Kept your original — the rewrite {detail}, so I didn’t apply it. Try
+ again for a different attempt.
+
+ );
+ }
+ return (
+
+ ⚠
+ AI altered a metric — {detail}. Review before saving.
+
+ );
+}
+
+/**
+ * The one phrasing of "what the model did to the numbers", shared by the
+ * revert notice, the drift warning, and the whole-résumé per-section label.
+ * Both halves are named because the gate reverts on either since the #778
+ * widening — quoting only the dropped ones left an invention-only revert
+ * saying the rewrite dropped nothing at all.
+ */
+export function describeNumberDrift(
+ dropped: readonly string[],
+ added: readonly string[],
+): string {
+ const parts: string[] = [];
+ if (dropped.length > 0) parts.push(`removed ${formatTokens(dropped)}`);
+ if (added.length > 0) parts.push(`invented ${formatTokens(added)}`);
+ return parts.join(" and ");
+}
+
+export function formatTokens(tokens: readonly string[]): string {
+ if (tokens.length === 1) return tokens[0]!;
+ if (tokens.length === 2) return `${tokens[0]} and ${tokens[1]}`;
+ return `${tokens.slice(0, -1).join(", ")}, and ${tokens[tokens.length - 1]}`;
+}
diff --git a/src/components/features/ResumeRewrite.test.ts b/src/components/features/ResumeRewrite.test.ts
index 13d29390..914aa704 100644
--- a/src/components/features/ResumeRewrite.test.ts
+++ b/src/components/features/ResumeRewrite.test.ts
@@ -45,6 +45,7 @@ const okResult: ResumeRewriteResult = {
data: {
text: "Senior engineer.",
numbersPreserved: true,
+ reverted: false,
droppedNumbers: [],
addedNumbers: [],
},
@@ -55,6 +56,7 @@ const okResult: ResumeRewriteResult = {
data: {
bullets: ["Shipped Foo to 10M users."],
numbersPreserved: true,
+ reverted: false,
droppedNumbers: [],
addedNumbers: [],
},
@@ -71,6 +73,7 @@ const driftResult: ResumeRewriteResult = {
data: {
bullets: ["Saved money."],
numbersPreserved: false,
+ reverted: false,
droppedNumbers: ["$5K"],
addedNumbers: [],
},
@@ -81,6 +84,7 @@ const driftResult: ResumeRewriteResult = {
data: {
text: "Senior engineer with 99.9% availability.",
numbersPreserved: false,
+ reverted: false,
droppedNumbers: [],
addedNumbers: ["99.9%"],
},
@@ -164,15 +168,25 @@ describe("sectionsEqual", () => {
describe("aggregateDrift", () => {
it("returns empty arrays for an all-clean result", () => {
- expect(aggregateDrift(okResult)).toEqual({ dropped: [], added: [] });
+ expect(aggregateDrift(okResult, () => true)).toEqual({
+ dropped: [],
+ added: [],
+ });
});
- it("collects dropped and added tokens across every section regardless of kind", () => {
- expect(aggregateDrift(driftResult)).toEqual({
+ it("collects dropped and added tokens across every included section regardless of kind", () => {
+ expect(aggregateDrift(driftResult, () => true)).toEqual({
dropped: ["$5K"],
added: ["99.9%"],
});
});
+
+ it("only collects tokens from sections `include` selects (#874 review)", () => {
+ expect(aggregateDrift(driftResult, () => false)).toEqual({
+ dropped: [],
+ added: [],
+ });
+ });
});
describe("StepIndicator", () => {
@@ -330,3 +344,92 @@ describe("ResumeRewritePanel", () => {
expect(html).toContain("99.9%");
});
});
+
+// ── #778: reverted sections must not read as clean passes ───────────────────
+
+describe("reverted sections (#778)", () => {
+ /** One reverted experience section + one clean one. */
+ const revertedResult: ResumeRewriteResult = {
+ allNumbersPreserved: true,
+ sections: [
+ {
+ kind: "experience",
+ input: {
+ kind: "experience",
+ id: "experience:0",
+ label: "Engineer — Acme",
+ bullets: ["Grew ARR to $4.2M in FY24."],
+ },
+ data: {
+ bullets: ["Grew ARR to $4.2M in FY24."],
+ numbersPreserved: true,
+ reverted: true,
+ droppedNumbers: ["$4.2M"],
+ addedNumbers: [],
+ },
+ },
+ {
+ kind: "experience",
+ input: {
+ kind: "experience",
+ id: "experience:1",
+ label: "Engineer — Beta",
+ bullets: ["Owned the write path."],
+ },
+ data: {
+ bullets: ["Owned and hardened the write path."],
+ numbersPreserved: true,
+ reverted: false,
+ droppedNumbers: [],
+ addedNumbers: [],
+ },
+ },
+ ],
+ };
+
+ it("badges a reverted section in the in-flight list instead of showing a clean tick", () => {
+ // `numbersPreserved` is true on a reverted section by construction, so
+ // without an explicit branch the user would never learn their section
+ // went unrewritten.
+ const status: ResumeRewriteStatus = {
+ kind: "running",
+ progress: {
+ currentIndex: 2,
+ totalSections: 2,
+ currentLabel: null,
+ completed: revertedResult.sections,
+ },
+ };
+ const html = renderToStaticMarkup(
+ createElement(ResumeRewritePanel, {
+ status,
+ onDismiss: () => {},
+ onApplied: () => {},
+ onUndo: () => {},
+ }),
+ );
+ expect(html).toContain("kept original");
+ // The clean section must not pick up the badge.
+ expect(html.match(/kept original/g)).toHaveLength(1);
+ expect(html).not.toContain("metric drift");
+ });
+
+ it("captions the reverted section's empty diff in the proposed view", () => {
+ const status: ResumeRewriteStatus = {
+ kind: "proposed",
+ result: revertedResult,
+ snapshot: [],
+ };
+ const html = renderToStaticMarkup(
+ createElement(ResumeRewritePanel, {
+ status,
+ onDismiss: () => {},
+ onApplied: () => {},
+ onUndo: () => {},
+ }),
+ );
+ expect(html).toContain("Kept unchanged");
+ expect(html).toContain("$4.2M");
+ });
+});
+
diff --git a/src/components/features/ResumeRewrite.tsx b/src/components/features/ResumeRewrite.tsx
index 6682e69b..8534e556 100644
--- a/src/components/features/ResumeRewrite.tsx
+++ b/src/components/features/ResumeRewrite.tsx
@@ -52,6 +52,7 @@ import {
ProposedPanel,
type ResumeRewriteApply,
} from "./ResumeRewriteProposed.tsx";
+import { numberDriftStatus } from "./NumberPreservationWarning.tsx";
import { RewritePromptDisclosure } from "./RewritePromptDisclosure.tsx";
export interface ResumeRewriteParts {
@@ -425,7 +426,18 @@ function CompletedList({
✓
{outcome.input.label}
- {!outcome.data.numbersPreserved && (
+ {/* #778: a reverted section preserves every number by construction,
+ so `numbersPreserved` alone would render it as a clean pass and
+ the user would never learn their section went unrewritten. */}
+ {numberDriftStatus(outcome.data) === "reverted" && (
+
+ kept original
+
+ )}
+ {numberDriftStatus(outcome.data) === "drift" && (
{
).toHaveLength(0);
});
});
+
+describe("ProposedPanel — rejected rewrites (#778)", () => {
+ const REVERTED: ResumeRewriteResult = {
+ allNumbersPreserved: true,
+ sections: [
+ {
+ kind: "experience",
+ input: {
+ kind: "experience",
+ id: "experience:0",
+ label: "Senior Engineer — Acme",
+ bullets: ["Grew ARR to $4.2M in FY24."],
+ },
+ data: {
+ bullets: ["Grew ARR to $4.2M in FY24."],
+ numbersPreserved: true,
+ reverted: true,
+ droppedNumbers: ["$4.2M"],
+ addedNumbers: [],
+ },
+ },
+ ],
+ };
+
+ it("falls through to the read-only redline instead of offering accept rows", () => {
+ // The gate returned the input verbatim, so a review row would ask the user
+ // to "accept" their own bullet.
+ const handlers: SectionRewriteApply = {
+ obsIds: ["0|a"],
+ onReplace: vi.fn(),
+ onRemove: vi.fn(),
+ onAdd: vi.fn(),
+ };
+ const el = render(
+ createElement(ProposedPanel, {
+ result: REVERTED,
+ onDismiss: vi.fn(),
+ onApplied: vi.fn(),
+ applyBySection: new Map([["experience:0", handlers]]),
+ }),
+ );
+ const acceptButtons = [...el.querySelectorAll("button")].filter((b) =>
+ b.getAttribute("aria-label")?.startsWith("Accept this"),
+ );
+ expect(acceptButtons.length).toBe(0);
+ expect(el.textContent).toContain("Kept unchanged");
+ expect(el.textContent).toContain("$4.2M");
+ });
+
+ it("still shows the revert caption when the input carried a blank bullet", () => {
+ // The revert hands the input back verbatim, blanks included, while the
+ // original side has always filtered them. Filtering only one side made the
+ // two strings differ, so `InlineDiff` saw a change and suppressed the
+ // caption — leaving a revert looking like a silent no-op, the exact failure
+ // the caption exists to prevent.
+ const withBlank: ResumeRewriteResult = {
+ allNumbersPreserved: true,
+ sections: [
+ {
+ kind: "experience",
+ input: {
+ kind: "experience",
+ id: "experience:0",
+ label: "Senior Engineer — Acme",
+ bullets: ["Grew ARR to $4.2M in FY24.", " "],
+ },
+ data: {
+ bullets: ["Grew ARR to $4.2M in FY24.", " "],
+ numbersPreserved: true,
+ reverted: true,
+ droppedNumbers: ["$4.2M"],
+ addedNumbers: [],
+ },
+ },
+ ],
+ };
+ const el = render(
+ createElement(ProposedPanel, {
+ result: withBlank,
+ onDismiss: vi.fn(),
+ onApplied: vi.fn(),
+ }),
+ );
+ expect(el.textContent).toContain("Kept unchanged");
+ });
+
+ it("does not dress a fully-reverted run as a clean success", () => {
+ // `allNumbersPreserved` is true here by construction; the tone has to come
+ // from `reverted` or the panel reads green on a run that changed nothing.
+ const el = render(
+ createElement(ProposedPanel, {
+ result: REVERTED,
+ onDismiss: vi.fn(),
+ onApplied: vi.fn(),
+ }),
+ );
+ expect(el.innerHTML).toContain("border-feedback-warning-border");
+ expect(el.innerHTML).not.toContain("border-feedback-success-border");
+ });
+
+ it("explains the revert in the résumé-level alert, not just in the border colour", () => {
+ // The warning used to be guarded on `!allNumbersPreserved` alone, which is
+ // true-by-construction false here: a revert delivers the ORIGINAL bullets,
+ // and those preserve their own numbers. So the panel turned warning-toned
+ // and said nothing — no text, no `role="alert"` for a screen reader. Pinned
+ // by content, because a border class is not an explanation.
+ const el = render(
+ createElement(ProposedPanel, {
+ result: REVERTED,
+ onDismiss: vi.fn(),
+ onApplied: vi.fn(),
+ }),
+ );
+ const alert = el.querySelector('[role="alert"]');
+ expect(alert).not.toBeNull();
+ // The reverted copy, not the "AI altered a metric — review before saving"
+ // variant: the delivered bullets are the user's own.
+ expect(alert!.textContent).toContain("Kept your original");
+ expect(alert!.textContent).toContain("removed $4.2M");
+ expect(alert!.textContent).not.toContain("Review before saving");
+ });
+
+ it("names the invented figure when a section was reverted for inventing one", () => {
+ const invented: ResumeRewriteResult = {
+ allNumbersPreserved: true,
+ sections: [
+ {
+ kind: "experience",
+ input: {
+ kind: "experience",
+ id: "experience:0",
+ label: "Senior Engineer — Acme",
+ bullets: ["Completed phase 5 of the migration."],
+ },
+ data: {
+ bullets: ["Completed phase 5 of the migration."],
+ numbersPreserved: true,
+ reverted: true,
+ droppedNumbers: [],
+ addedNumbers: ["5"],
+ },
+ },
+ ],
+ };
+ const el = render(
+ createElement(ProposedPanel, {
+ result: invented,
+ onDismiss: vi.fn(),
+ onApplied: vi.fn(),
+ }),
+ );
+ const alert = el.querySelector('[role="alert"]');
+ expect(alert?.textContent).toContain("invented 5");
+ });
+});
+
diff --git a/src/components/features/ResumeRewriteProposed.tsx b/src/components/features/ResumeRewriteProposed.tsx
index 1445e6b2..3c09cb9e 100644
--- a/src/components/features/ResumeRewriteProposed.tsx
+++ b/src/components/features/ResumeRewriteProposed.tsx
@@ -35,10 +35,12 @@ import {
} from "../../lib/rewrite-review/align-bullets.ts";
import { resolveSectionWrites } from "../../lib/rewrite-review/apply-accepted.ts";
import { useRewriteReview, type RewriteReview } from "../../hooks/useRewriteReview.ts";
+import type { SectionRewriteApply } from "./SectionRewrite.tsx";
import {
+ describeNumberDrift,
+ numberDriftStatus,
NumberPreservationWarning,
- type SectionRewriteApply,
-} from "./SectionRewrite.tsx";
+} from "./NumberPreservationWarning.tsx";
import { BulletReviewRow } from "./RewriteReviewList.tsx";
/** Per-section apply wiring for the whole-résumé review, keyed by the
@@ -95,7 +97,18 @@ export function ProposedPanel({
* Absent → every section renders read-only (graceful fallback). */
applyBySection?: ResumeRewriteApply;
}) {
- const aggregated = useMemo(() => aggregateDrift(result), [result]);
+ const revertedDrift = useMemo(
+ () => aggregateDrift(result, (o) => o.data.reverted),
+ [result],
+ );
+ const emptiedDrift = useMemo(
+ () =>
+ aggregateDrift(
+ result,
+ (o) => !o.data.reverted && !o.data.numbersPreserved,
+ ),
+ [result],
+ );
// Experience sections with an apply wiring become per-bullet reviewable;
// pair ids are namespaced by section id so the one combined decision map
@@ -106,6 +119,10 @@ export function ProposedPanel({
for (const outcome of result.sections) {
const apply = applyBySection?.get(outcome.input.id);
if (!apply) continue;
+ // A rejected rewrite (#778) returns the input verbatim, so aligning it
+ // would offer the user an "accept" on their own text. Fall through to
+ // the read-only redline, which captions why there is nothing to see.
+ if (outcome.data.reverted) continue;
const pairs =
outcome.kind === "summary"
? summaryPairs(
@@ -202,15 +219,43 @@ export function ProposedPanel({
const accepted = review.acceptedCount;
+ // #778: `allNumbersPreserved` is true when every section reverted — the
+ // originals preserve themselves — so it cannot carry the tone on its own, and
+ // it cannot decide whether the warning renders either. Guarding the alert on
+ // it alone turned the panel warning-coloured on a revert while saying nothing:
+ // a border colour, no text, no `role="alert"`. Same condition as
+ // `ProposedSection`'s in SectionRewrite.tsx, one level up.
+ const anyReverted = result.sections.some((o) => o.data.reverted);
+ // A generation that came back empty is NOT reverted — `applyNumberPreservation`
+ // deliberately leaves it blank rather than restoring the original (#778,
+ // `post-process.ts`) — so its drift can't share the "Kept your original"
+ // copy with a genuinely reverted section without misdescribing what
+ // happened to it. Split by outcome rather than one aggregate boolean+list
+ // covering every section (#874 review).
+ const anyEmptiedDrift =
+ emptiedDrift.dropped.length > 0 || emptiedDrift.added.length > 0;
+
+ const tone =
+ numberDriftStatus({
+ numbersPreserved: result.allNumbersPreserved,
+ reverted: anyReverted,
+ }) === "clean"
+ ? "success"
+ : "warning";
+
return (
-
- {!result.allNumbersPreserved && (
+
+ {anyReverted && (
+ )}
+ {anyEmptiedDrift && (
+
)}
@@ -345,13 +390,20 @@ function SectionResult({ outcome }: { outcome: SectionOutcome }) {
outcome.input.text,
outcome.data.text || "",
)}
+ noChangeLabel={
+ outcome.data.reverted ? revertedLabel(outcome.data) : undefined
+ }
/>
);
}
- const originalBullets = outcome.input.bullets.filter(
- (b) => b.trim().length > 0,
- );
+ // Both sides get the SAME blank filter. A revert hands back the input verbatim
+ // (blanks included) while this side has always dropped them, so filtering only
+ // the original made a section with one blank bullet diff as "changed" — which
+ // suppressed the `noChangeLabel` that exists to stop a revert reading as a
+ // silent no-op (#778).
+ const originalBullets = withoutBlanks(outcome.input.bullets);
+ const proposedBullets = withoutBlanks(outcome.data.bullets);
return (
@@ -360,31 +412,62 @@ function SectionResult({ outcome }: { outcome: SectionOutcome }) {
`• ${b}`).join("\n"),
- outcome.data.bullets.map((b) => `• ${b}`).join("\n"),
+ proposedBullets.map((b) => `• ${b}`).join("\n"),
)}
+ noChangeLabel={
+ outcome.data.reverted ? revertedLabel(outcome.data) : undefined
+ }
/>
);
}
+function withoutBlanks(bullets: readonly string[]): string[] {
+ return bullets.filter((b) => b.trim().length > 0);
+}
+
+/**
+ * Why a section in the whole-résumé review shows no redline (#778). Named per
+ * section rather than aggregated into the résumé-level warning because the
+ * chain rewrites each section independently — one reverting says nothing about
+ * the others, and a single banner would leave the reader guessing which.
+ */
+function revertedLabel(data: {
+ droppedNumbers: readonly string[];
+ addedNumbers: readonly string[];
+}): string {
+ const detail = describeNumberDrift(data.droppedNumbers, data.addedNumbers);
+ return detail === ""
+ ? "Kept unchanged — the rewrite was rejected."
+ : `Kept unchanged — the rewrite ${detail}.`;
+}
+
export interface AggregateDrift {
dropped: string[];
added: string[];
}
/**
- * Concatenate every section's dropped/added numeric tokens in encounter
- * order so the whole-résumé warning quotes the same specific metrics that
- * each per-section panel would have shown individually.
+ * Concatenate the dropped/added numeric tokens of every section `include`
+ * selects, in encounter order, so a warning built from the result quotes the
+ * same specific metrics each per-section panel would have shown individually.
*
- * Both `SectionOutcome` variants store the diff in `.data.droppedNumbers`
- * / `.data.addedNumbers`, so the kind discriminator doesn't change the
- * lookup — one shared loop covers both.
+ * `include` exists because "reverted" and "emptied-but-not-reverted" (#778's
+ * deliberate no-revert-on-empty-generation exemption) are different stories
+ * about what happened to a section's content, and a caller aggregating both
+ * into one list would hand a warning copy that's accurate for one and false
+ * for the other (#874 review). Both `SectionOutcome` variants store the diff
+ * in `.data.droppedNumbers` / `.data.addedNumbers`, so the kind discriminator
+ * doesn't change the lookup — one shared loop covers both.
*/
-export function aggregateDrift(result: ResumeRewriteResult): AggregateDrift {
+export function aggregateDrift(
+ result: ResumeRewriteResult,
+ include: (outcome: SectionOutcome) => boolean,
+): AggregateDrift {
const dropped: string[] = [];
const added: string[] = [];
for (const outcome of result.sections) {
+ if (!include(outcome)) continue;
dropped.push(...outcome.data.droppedNumbers);
added.push(...outcome.data.addedNumbers);
}
diff --git a/src/components/features/RewriteReviewList.tsx b/src/components/features/RewriteReviewList.tsx
index 0619c5a4..4ac12624 100644
--- a/src/components/features/RewriteReviewList.tsx
+++ b/src/components/features/RewriteReviewList.tsx
@@ -236,35 +236,44 @@ export function RewriteReviewList({
{warning}
-
-
- {total} change{total === 1 ? "" : "s"} proposed — review each below.
-
-
- review.acceptMany(ids)}
- className="rounded-md px-2 py-0.5 text-2xs"
- >
- Accept all
-
- review.rejectMany(ids)}
- className="rounded-md px-2 py-0.5 text-2xs text-content-tertiary"
- >
- Reject all
-
-
-
+ {/* Nothing to review renders no review chrome. Reachable since #778,
+ where a rejected rewrite deliberately yields zero pairs — "0 changes
+ proposed — review each below" over an empty list would be the
+ surface contradicting itself, with the `warning` above already
+ carrying the real outcome. */}
+ {total > 0 && (
+ <>
+
+
+ {total} change{total === 1 ? "" : "s"} proposed — review each below.
+
+
+ review.acceptMany(ids)}
+ className="rounded-md px-2 py-0.5 text-2xs"
+ >
+ Accept all
+
+ review.rejectMany(ids)}
+ className="rounded-md px-2 py-0.5 text-2xs text-content-tertiary"
+ >
+ Reject all
+
+
+
-
- {pairs.map((pair) => (
-
- ))}
-
+
+ {pairs.map((pair) => (
+
+ ))}
+
+ >
+ )}
{
const result: SectionRewriteResult = {
bullets: ["x"],
numbersPreserved: true,
+ reverted: false,
droppedNumbers: [],
addedNumbers: [],
};
@@ -118,6 +117,48 @@ describe("NumberPreservationWarning", () => {
// ── ProposedSection ─────────────────────────────────────────────────────────
+describe("NumberPreservationWarning — reverted copy (#778)", () => {
+ function render(props: {
+ dropped: string[];
+ added: string[];
+ reverted?: boolean;
+ }): string {
+ return renderToStaticMarkup(
+ createElement(NumberPreservationWarning, props),
+ );
+ }
+
+ it("tells the user nothing was applied and names what would have been lost", () => {
+ const html = render({ dropped: ["$4.2M"], added: [], reverted: true });
+ expect(html).toContain("Kept your original");
+ expect(html).toContain("$4.2M");
+ // Never the "review before saving" copy — there is nothing new to review.
+ expect(html).not.toContain("Review before saving");
+ });
+
+ it("names the INVENTED token when the gate reverted on invention alone", () => {
+ // The widened gate reverts on pure invention, where `dropped` is empty —
+ // copy that quoted only the dropped list rendered an empty token list.
+ const html = render({ dropped: [], added: ["30%"], reverted: true });
+ expect(html).toContain("Kept your original");
+ expect(html).toContain("invented 30%");
+ expect(html).not.toContain("removed");
+ expect(html).not.toContain("Review before saving");
+ });
+
+ it("keeps the drift copy when the rewrite was applied", () => {
+ const html = render({ dropped: [], added: ["99.9%"], reverted: false });
+ expect(html).toContain("invented 99.9%");
+ expect(html).toContain("Review before saving");
+ expect(html).not.toContain("Kept your original");
+ });
+
+ it("defaults to the drift copy when `reverted` is omitted", () => {
+ const html = render({ dropped: ["40%"], added: [] });
+ expect(html).toContain("removed 40%");
+ });
+});
+
describe("ProposedSection", () => {
function render(result: SectionRewriteResult): string {
return renderToStaticMarkup(
@@ -133,6 +174,7 @@ describe("ProposedSection", () => {
const html = render({
bullets: ["Reduced p99 latency by 40%.", "Drove $1.2M ARR."],
numbersPreserved: true,
+ reverted: false,
droppedNumbers: [],
addedNumbers: [],
});
@@ -146,6 +188,7 @@ describe("ProposedSection", () => {
const html = render({
bullets: ["Reduced p99 latency."],
numbersPreserved: false,
+ reverted: false,
droppedNumbers: ["40%"],
addedNumbers: [],
});
@@ -163,6 +206,7 @@ describe("ProposedSection", () => {
const html = render({
bullets: ["one", "two", "three"],
numbersPreserved: true,
+ reverted: false,
droppedNumbers: [],
addedNumbers: [],
});
@@ -174,6 +218,30 @@ describe("ProposedSection", () => {
expect(html).toContain("two");
});
+ it("says the rewrite was rejected instead of showing a bare no-op diff (#778)", () => {
+ // The gate returns the ORIGINAL bullets, so the diff is all-equal. Without
+ // the notice the panel would read as "the model looked at your bullets and
+ // changed nothing", which is a different — and false — story.
+ const html = render({
+ bullets: ["Original bullet 1.", "Original bullet 2."],
+ numbersPreserved: true,
+ reverted: true,
+ droppedNumbers: ["$4.2M", "14%"],
+ addedNumbers: [],
+ });
+ expect(html).toContain('role="alert"');
+ expect(html).toContain("Kept your original");
+ expect(html).toContain("$4.2M and 14%");
+ // The diff itself carries the caption, so the surface is never silent even
+ // if the alert is scrolled past.
+ expect(html).toContain("No changes applied");
+ // Warning chrome, not success — the user asked for a rewrite and did not
+ // get one, even though every number survived.
+ expect(html).toContain("border-feedback-warning-border");
+ // And no "Use this" CTA offering the user their own bullets back.
+ expect(html).not.toContain("Use this — copy all bullets");
+ });
+
// The label swap after a successful copy moved into the shared `CopyButton`
// with #609 — it is covered against a real stubbed clipboard in
// `design-system/primitives/CopyButton.test.tsx`, which this static-markup
@@ -183,6 +251,7 @@ describe("ProposedSection", () => {
const html = render({
bullets: ["one"],
numbersPreserved: true,
+ reverted: false,
droppedNumbers: [],
addedNumbers: [],
});
diff --git a/src/components/features/SectionRewrite.tsx b/src/components/features/SectionRewrite.tsx
index c47537b5..04e47a75 100644
--- a/src/components/features/SectionRewrite.tsx
+++ b/src/components/features/SectionRewrite.tsx
@@ -16,9 +16,10 @@
* means clicking section-rewrite after per-bullet (or in a sibling role)
* reuses the same engine — no second multi-GB download.
* - Number-preservation guardrail runs deterministically on every output.
- * When a numeric token is dropped or invented, an inline warning names
- * the specific token (non-blocking) so the user can still accept the
- * rewrite knowingly.
+ * Since #778 a dropped or invented numeric token is a hard block: the
+ * rewrite is reverted to the user's original bullets, and an inline
+ * warning names the specific token so the user knows why nothing
+ * changed.
*
* Reuse analysis (CLAUDE.md 3-tier rule):
* - Primitive: `Button` from `@design-system` for every interactive
@@ -70,6 +71,11 @@ import {
UndoBatchButton,
UNDO_HOLD_MS,
} from "./ApplyConfirmation.tsx";
+import {
+ numberDriftStatus,
+ NumberPreservationWarning,
+ REVERTED_DIFF_LABEL,
+} from "./NumberPreservationWarning.tsx";
import { RewriteReviewList } from "./RewriteReviewList.tsx";
/**
@@ -237,9 +243,14 @@ export function useSectionRewrite(
// Per-bullet review state (#211). Aligned against the snapshot the model
// actually saw (stable per proposal) — not the live bullets — so the rows and
// the decision map don't churn under unrelated re-renders.
+ // A rejected rewrite (#778) has nothing to review: `result.bullets` IS the
+ // snapshot, so aligning them would build a row per bullet offering to
+ // "accept" the user's own text. The warning above the list carries the
+ // outcome instead — same precedent as the whole-résumé path, which drops a
+ // section with no reviewable pairs to its read-only redline.
const pairs = useMemo(
() =>
- status.kind === "proposed"
+ status.kind === "proposed" && !status.result.reverted
? alignBullets(status.snapshot, status.result.bullets)
: NO_PAIRS,
[status],
@@ -421,7 +432,7 @@ export function useSectionRewrite(
{status.kind === "proposed" &&
(apply ? (
- )
+ ) : undefined
}
/>
@@ -499,8 +512,9 @@ export function useSectionRewrite(
// Helpers exported for unit tests (see SectionRewrite.test.ts). The component
// itself is harder to render in non-idle states from a smoke test (status is
// internal + driven by async work), so the testable surface is the helpers
-// that own the branching: `labelFor`, `formatTokens`, `ProposedSection`,
-// `NumberPreservationWarning`.
+// that own the branching: `labelFor` and `ProposedSection` here, plus
+// `formatTokens` / `NumberPreservationWarning` in the sibling file they now
+// live in.
export function labelFor(status: Status, lockedByOther: boolean): string {
if (lockedByOther) return "Another rewrite running…";
switch (status.kind) {
@@ -537,14 +551,12 @@ export function ProposedSection({
onReject: () => void;
}) {
return (
-
- {!result.numbersPreserved && (
+
+ {(result.reverted || !result.numbersPreserved) && (
)}
@@ -553,18 +565,24 @@ export function ProposedSection({
original.map((b) => `• ${b}`).join("\n"),
result.bullets.map((b) => `• ${b}`).join("\n"),
)}
+ noChangeLabel={result.reverted ? REVERTED_DIFF_LABEL : undefined}
/>
-
- Use this — copy all bullets
-
+ {/* No copy CTA on a rejected rewrite (#778): `result.bullets` is the
+ user's own text, so "Use this" would offer them what they already
+ have — and read as a contradiction of the notice above it. */}
+ {!result.reverted && (
+
+ Use this — copy all bullets
+
+ )}
0) parts.push(`removed ${formatTokens(dropped)}`);
- if (added.length > 0) parts.push(`invented ${formatTokens(added)}`);
- const detail = parts.join(" and ");
- return (
-
- ⚠
- AI altered a metric — {detail}. Review before saving.
-
- );
-}
-
-export function formatTokens(tokens: readonly string[]): string {
- if (tokens.length === 1) return tokens[0]!;
- if (tokens.length === 2) return `${tokens[0]} and ${tokens[1]}`;
- return `${tokens.slice(0, -1).join(", ")}, and ${tokens[tokens.length - 1]}`;
+/**
+ * A reject/revert is a warning, not a success, even though the delivered
+ * bullets preserve every number: the user asked for a rewrite and did not get
+ * one, and the panel has to read that way. `numbersPreserved` alone can't
+ * decide the tone after #778 — it is true on a reverted rewrite by
+ * construction.
+ */
+function resultTone(result: {
+ numbersPreserved: boolean;
+ reverted: boolean;
+}): "success" | "warning" {
+ return numberDriftStatus(result) === "clean" ? "success" : "warning";
}
diff --git a/src/design-system/shared/InlineDiff.test.tsx b/src/design-system/shared/InlineDiff.test.tsx
new file mode 100644
index 00000000..1ac8314a
--- /dev/null
+++ b/src/design-system/shared/InlineDiff.test.tsx
@@ -0,0 +1,87 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 The offlinecv Authors
+
+// @vitest-environment jsdom
+
+/**
+ * `noChangeLabel` (#778) is the only branch in `InlineDiff` — everything else
+ * is a straight map over segments — and it is the branch that decides whether
+ * a rejected rewrite reads as "nothing happened" or as "this was declined".
+ *
+ * jsdom via the pragma, raw `createRoot`, matching the feature-component tests.
+ */
+
+import { describe, expect, it, afterEach } from "vitest";
+import { createElement } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { act } from "react";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
+ true;
+
+import { InlineDiff } from "./InlineDiff.tsx";
+import { computeTextDiff } from "../../lib/diff/text-diff.ts";
+
+let root: Root | null = null;
+let host: HTMLDivElement | null = null;
+
+function render(node: React.ReactElement): HTMLDivElement {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ act(() => {
+ root!.render(node);
+ });
+ return host;
+}
+
+afterEach(() => {
+ act(() => root?.unmount());
+ host?.remove();
+ root = null;
+ host = null;
+});
+
+describe("InlineDiff", () => {
+ it("renders every segment in order", () => {
+ const el = render(
+ createElement(InlineDiff, {
+ segments: computeTextDiff("Cut latency 40%.", "Cut p99 latency 40%."),
+ }),
+ );
+ expect(el.textContent).toContain("40%");
+ expect(el.querySelectorAll("span").length).toBeGreaterThan(1);
+ });
+
+ it("shows `noChangeLabel` when the two sides are identical", () => {
+ const el = render(
+ createElement(InlineDiff, {
+ segments: computeTextDiff("Cut latency 40%.", "Cut latency 40%."),
+ noChangeLabel: "No changes applied — your original bullets.",
+ }),
+ );
+ expect(el.textContent).toContain("No changes applied");
+ });
+
+ it("hides `noChangeLabel` the moment there is a real change to show", () => {
+ // The redline speaks for itself when there is one; the caption exists only
+ // for the case where there is nothing on screen to explain the outcome.
+ const el = render(
+ createElement(InlineDiff, {
+ segments: computeTextDiff("Cut latency 40%.", "Cut latency."),
+ noChangeLabel: "No changes applied — your original bullets.",
+ }),
+ );
+ expect(el.textContent).not.toContain("No changes applied");
+ });
+
+ it("renders an unchanged diff exactly as before when no label is passed", () => {
+ const el = render(
+ createElement(InlineDiff, {
+ segments: computeTextDiff("Cut latency 40%.", "Cut latency 40%."),
+ }),
+ );
+ expect(el.querySelector("div")).toBeNull();
+ expect(el.textContent).toBe("Cut latency 40%.");
+ });
+});
diff --git a/src/design-system/shared/InlineDiff.tsx b/src/design-system/shared/InlineDiff.tsx
index d7e2aa2a..f8e5f262 100644
--- a/src/design-system/shared/InlineDiff.tsx
+++ b/src/design-system/shared/InlineDiff.tsx
@@ -10,8 +10,19 @@
* two-column "Original | Proposed" grid.
*
* Props:
- * `segments` — output of `computeTextDiff` from `src/lib/diff/text-diff.ts`
- * `className` — extra classes for the outer block (width, margin, etc.)
+ * `segments` — output of `computeTextDiff` from `src/lib/diff/text-diff.ts`
+ * `className` — extra classes for the outer block (width, margin, etc.)
+ * `noChangeLabel` — caption shown ONLY when the diff has no added/removed
+ * run, i.e. when the two sides are identical
+ *
+ * `noChangeLabel` exists because an all-equal diff renders as plain prose that
+ * is indistinguishable from any other block of text: the reader cannot tell
+ * "these two are the same" from "this is just some text". Whenever a caller
+ * produced the two sides by a process that could have changed them and did
+ * not, that outcome is information, and the component that owns the redline is
+ * the one place that can tell there is nothing to draw. Domain-agnostic on
+ * purpose — the caller supplies the sentence (#778's rewrite-rejected notice
+ * is the first one), this only decides when it is shown.
*
* Rendering notes:
* - Outer element is a `` (inline text content, not a structural section).
@@ -35,13 +46,23 @@ interface InlineDiffProps {
segments: DiffSegment[];
/** Extra classes applied to the outer block — width, overflow, etc. */
className?: string;
+ /**
+ * Caption rendered above the text when the diff contains no `added` or
+ * `removed` segment. Omitted → an unchanged diff renders exactly as before.
+ */
+ noChangeLabel?: React.ReactNode;
}
-export function InlineDiff({ segments, className }: InlineDiffProps) {
+export function InlineDiff({
+ segments,
+ className,
+ noChangeLabel,
+}: InlineDiffProps) {
const base =
"whitespace-pre-wrap break-words text-sm leading-snug";
const cls = className ? `${base} ${className}` : base;
- return (
+ const unchanged = segments.every((seg) => seg.type === "equal");
+ const body = (
{segments.map((seg, i) => (
@@ -50,4 +71,15 @@ export function InlineDiff({ segments, className }: InlineDiffProps) {
))}
);
+
+ if (noChangeLabel === undefined || !unchanged) return body;
+
+ return (
+
+
+ {noChangeLabel}
+
+ {body}
+
+ );
}
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
index dadf03b1..8e3b326d 100644
--- a/src/lib/analytics.ts
+++ b/src/lib/analytics.ts
@@ -463,12 +463,21 @@ export function trackWebllmSectionRewriteCompleted(args: {
inputBulletCount: number;
outputBulletCount: number;
numbersPreserved: boolean;
+ /**
+ * The #778 gate rejected the rewrite because it dropped or invented a
+ * number, so the user kept their original bullets. Reported alongside
+ * `numbersPreserved` rather than folded into it — that one keeps measuring
+ * the model's raw output, so its series stays comparable across the release,
+ * and this one says whether the drift reached the user.
+ */
+ reverted: boolean;
}): void {
track("webllm_section_rewrite_completed", {
model: args.model,
input_bullet_count: args.inputBulletCount,
output_bullet_count: args.outputBulletCount,
numbers_preserved: args.numbersPreserved,
+ reverted: args.reverted,
});
}
@@ -506,7 +515,16 @@ export function trackWebllmResumeRewriteSectionCompleted(args: {
inputUnitCount: number;
/** Bullets out for "experience"; 0 or 1 for "summary" (empty model output → 0). */
outputUnitCount: number;
+ /**
+ * Measures the MODEL'S raw output, exactly as the section-level
+ * `webllm_section_rewrite_completed` does — not what the user received. The
+ * #778 gate makes the delivered units number-clean by construction, so
+ * reporting the delivered property here would silently flip this series'
+ * meaning mid-release and break comparability with everything logged before.
+ */
numbersPreserved: boolean;
+ /** The #778 gate rejected this section's rewrite; the user kept the original. */
+ reverted: boolean;
}): void {
track("webllm_resume_rewrite_section_completed", {
model: args.model,
@@ -515,18 +533,23 @@ export function trackWebllmResumeRewriteSectionCompleted(args: {
input_unit_count: args.inputUnitCount,
output_unit_count: args.outputUnitCount,
numbers_preserved: args.numbersPreserved,
+ reverted: args.reverted,
});
}
export function trackWebllmResumeRewriteCompleted(args: {
model: string;
sectionCount: number;
+ /** True iff every section's raw model output preserved its numbers. */
allNumbersPreserved: boolean;
+ /** True iff the #778 gate rejected at least one section in the run. */
+ anyReverted: boolean;
}): void {
track("webllm_resume_rewrite_completed", {
model: args.model,
section_count: args.sectionCount,
all_numbers_preserved: args.allNumbersPreserved,
+ any_reverted: args.anyReverted,
});
}
diff --git a/src/lib/webllm/eval/README.md b/src/lib/webllm/eval/README.md
index 04ad5b00..247b9820 100644
--- a/src/lib/webllm/eval/README.md
+++ b/src/lib/webllm/eval/README.md
@@ -80,6 +80,20 @@ doesn't apply); the aggregate's dedup rate is computed over `redundant`
fixtures only. The **Steering** column behaves the same way for fixtures
that carry a steering probe — see below.
+The **Reverted** column (#778) is a diagnostic, not a criterion, and is the
+one column that must be read *with* another. The harness runs the product's
+number-preservation gate (`applyNumberPreservation`) before scoring, so a cell
+whose rewrite dropped **or invented** a number is scored on the fixture's own
+bullets and passes `Numbers` by construction — `Reverted` is what tells you the
+model did not earn that pass. Because the gate covers both halves `Numbers`
+measures, `Numbers` now reads ~100% on any run where every cell produced
+output; treat `Reverted` as the column that describes the models, and `Numbers`
+as a check that the gate ran. It is excluded from `Aggregate` on purpose: a
+revert is the guardrail working, so counting it as either a pass or a fail
+would misstate the run. Per-cell it prints the tokens that triggered the
+rejection — dropped first, then invented — because the rubric cannot re-derive
+them once the scored bullets are the input.
+
The judge column is `—` until the optional LLM-judge gate is enabled.
That path is flag-plumbed (`runEval({ judgeEnabled })`) but the
implementation is intentionally stubbed — coherence judging is a follow-up.
diff --git a/src/lib/webllm/eval/report.test.ts b/src/lib/webllm/eval/report.test.ts
index 5c8df9cf..0da31533 100644
--- a/src/lib/webllm/eval/report.test.ts
+++ b/src/lib/webllm/eval/report.test.ts
@@ -27,6 +27,8 @@ function passingRecord(modelId: string, variantId: string, fixtureId: string): R
droppedNumbers: [],
addedNumbers: [],
},
+ reverted: false,
+ revertedNumbers: [],
rewriteDurationMs: 1200,
error: null,
};
@@ -49,6 +51,7 @@ const sampleReport: EvalReport = {
variantId: "baseline",
scoredFixtures: 2,
numbersPreservedRate: 1,
+ revertedRate: 0,
oneLineRate: 1,
actionVerbRate: 1,
lengthSanityRate: 1,
@@ -93,6 +96,33 @@ describe("renderMarkdownReport", () => {
expect(md).toMatch(/\| — \| — \| \*\*100%\*\* \|/);
});
+ it("renders the Reverted column with the tokens the gate refused to lose (#778)", () => {
+ // The rubric re-derives its diff from the scored bullets, which after a
+ // revert ARE the input — so the report has to carry the evidence itself or
+ // the cell reads as a clean pass with no trace of what the model lost.
+ const revertReport: EvalReport = {
+ ...sampleReport,
+ records: [
+ {
+ ...passingRecord("Qwen2.5-1.5B-Instruct-q4f16_1-MLC", "baseline", "fx-weak"),
+ reverted: true,
+ revertedNumbers: ["$4.2M", "14%"],
+ },
+ ],
+ aggregates: [{ ...sampleReport.aggregates[0]!, revertedRate: 0.5 }],
+ };
+ const md = renderMarkdownReport(revertReport);
+ expect(md).toContain("| Numbers | Reverted |");
+ expect(md).toContain("REVERTED: $4.2M, 14%");
+ expect(md).toContain("| 100% | 50% |");
+ });
+
+ it("leaves the Reverted cell empty when the gate did not fire", () => {
+ const md = renderMarkdownReport(sampleReport);
+ expect(md).toContain("| Numbers | Reverted |");
+ expect(md).not.toContain("REVERTED");
+ });
+
it("renders an error column for errored cells", () => {
const errReport: EvalReport = {
...sampleReport,
diff --git a/src/lib/webllm/eval/report.ts b/src/lib/webllm/eval/report.ts
index adf28c8c..29f244b2 100644
--- a/src/lib/webllm/eval/report.ts
+++ b/src/lib/webllm/eval/report.ts
@@ -3,7 +3,7 @@
import { getModelById } from "../models.ts";
import { getVariantById } from "./prompt-variants.ts";
-import type { EvalReport } from "./types.ts";
+import type { EvalReport, RunRecord } from "./types.ts";
/**
* Render the eval report in two flavors:
@@ -40,16 +40,16 @@ export function renderMarkdownReport(report: EvalReport): string {
lines.push("## Aggregate (per model × variant)");
lines.push("");
lines.push(
- "| Model | Variant | Numbers | One-line | Verb | Length | No-preamble | Dedup | Steering | Judge | **Aggregate** |",
+ "| Model | Variant | Numbers | Reverted | One-line | Verb | Length | No-preamble | Dedup | Steering | Judge | **Aggregate** |",
);
lines.push(
- "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
);
for (const row of report.aggregates) {
const modelLabel = getModelById(row.modelId)?.name ?? row.modelId;
const variantLabel = getVariantById(row.variantId)?.label ?? row.variantId;
lines.push(
- `| ${modelLabel} | ${variantLabel} | ${pct(row.numbersPreservedRate)} | ${pct(row.oneLineRate)} | ${pct(row.actionVerbRate)} | ${pct(row.lengthSanityRate)} | ${pct(row.noPreambleLeakRate)} | ${pctOrDash(row.dedupEffectiveRate)} | ${pctOrDash(row.steeringAdherenceRate)} | ${numOrDash(row.judgeMean)} | **${pct(row.aggregateScore)}** |`,
+ `| ${modelLabel} | ${variantLabel} | ${pct(row.numbersPreservedRate)} | ${pct(row.revertedRate)} | ${pct(row.oneLineRate)} | ${pct(row.actionVerbRate)} | ${pct(row.lengthSanityRate)} | ${pct(row.noPreambleLeakRate)} | ${pctOrDash(row.dedupEffectiveRate)} | ${pctOrDash(row.steeringAdherenceRate)} | ${numOrDash(row.judgeMean)} | **${pct(row.aggregateScore)}** |`,
);
}
lines.push("");
@@ -65,15 +65,15 @@ export function renderMarkdownReport(report: EvalReport): string {
lines.push(`#### ${variantLabel}`);
lines.push("");
lines.push(
- "| Fixture | Kind | In → Out | Numbers | Verb | Length | Preamble | Dedup | Steering | Error |",
+ "| Fixture | Kind | In → Out | Numbers | Reverted | Verb | Length | Preamble | Dedup | Steering | Error |",
);
lines.push(
- "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
);
for (const r of report.records) {
if (r.modelId !== modelId || r.variantId !== variantId) continue;
lines.push(
- `| ${r.fixtureId} | ${r.fixtureKind} | ${r.inputBulletCount} → ${r.outputBulletCount} | ${tick(r.rubric.numbersPreserved)} | ${tick(r.rubric.actionVerbLead)} | ${tick(r.rubric.lengthSanity)} | ${tick(r.rubric.noPreambleLeak)} | ${tickOrDash(r.rubric.dedupEffective)} | ${tickOrDash(r.rubric.steeringAdherence)} | ${r.error ? `\`${r.error}\`` : ""} |`,
+ `| ${r.fixtureId} | ${r.fixtureKind} | ${r.inputBulletCount} → ${r.outputBulletCount} | ${tick(r.rubric.numbersPreserved)} | ${revertCell(r)} | ${tick(r.rubric.actionVerbLead)} | ${tick(r.rubric.lengthSanity)} | ${tick(r.rubric.noPreambleLeak)} | ${tickOrDash(r.rubric.dedupEffective)} | ${tickOrDash(r.rubric.steeringAdherence)} | ${r.error ? `\`${r.error}\`` : ""} |`,
);
}
lines.push("");
@@ -95,6 +95,19 @@ function numOrDash(v: number | null): string {
return v === null ? "—" : v.toFixed(2);
}
+/**
+ * The #778 revert cell: not a PASS/fail, because a revert is neither. It reads
+ * as the tokens the gate refused to lose or to let through (`REVERTED: $4.2M,
+ * 14%`) so the committed report keeps the evidence the rubric can no longer
+ * re-derive — the scored bullets ARE the input once a cell reverts.
+ */
+function revertCell(r: RunRecord): string {
+ if (!r.reverted) return "";
+ return r.revertedNumbers.length === 0
+ ? "REVERTED"
+ : `REVERTED: ${r.revertedNumbers.join(", ")}`;
+}
+
function tick(v: boolean): string {
return v ? "PASS" : "fail";
}
diff --git a/src/lib/webllm/eval/rubric.ts b/src/lib/webllm/eval/rubric.ts
index ca32d3ef..9f3c06d3 100644
--- a/src/lib/webllm/eval/rubric.ts
+++ b/src/lib/webllm/eval/rubric.ts
@@ -18,7 +18,7 @@ import { startsWithActionVerb } from "./verbs.ts";
*
* The six criteria are the issue #65 AC list:
*
- * 1. numbersPreserved — multiset of numeric tokens unchanged
+ * 1. numbersPreserved — set of numeric tokens unchanged
* 2. oneLinePerBullet — no embedded `\n` after the runner's split
* 3. actionVerbLead — first token of each bullet in the curated set
* 4. lengthSanity — each bullet in a sane char band
diff --git a/src/lib/webllm/eval/run-eval-browser.ts b/src/lib/webllm/eval/run-eval-browser.ts
index e1299b4d..72a6b66b 100644
--- a/src/lib/webllm/eval/run-eval-browser.ts
+++ b/src/lib/webllm/eval/run-eval-browser.ts
@@ -31,7 +31,10 @@
* benchmarking.
*/
-import { cleanRewriteLine } from "../post-process.ts";
+import {
+ applyNumberPreservation,
+ cleanRewriteLine,
+} from "../post-process.ts";
import {
buildSectionUserPrompt,
sectionMaxTokens,
@@ -91,11 +94,30 @@ function makeRealRewriteFn(engine: WebLlmEngine): RewriteFn {
});
const raw = response.choices[0]?.message?.content ?? "";
- const bullets = raw
+ const cleaned = raw
.split("\n")
.map((line) => cleanRewriteLine(line))
.filter((line) => line.length > 0);
- return { bullets, raw } satisfies RawRewriteOutput;
+
+ // #778: the harness runs the SAME reject gate the product does, so the
+ // rubric scores what a user would actually have received. A reverted cell
+ // therefore scores `numbersPreserved: true` — no number reached the user
+ // wrong — and `reverted` is what keeps that from reading as a model that
+ // got it right. Every other criterion is scored on the reverted output too,
+ // which is the point: `dedupEffective` on a reverted redundant fixture is
+ // honestly false, because the user's bullets were not deduped.
+ //
+ // Both halves of the diff go into `revertedNumbers`, not just the dropped
+ // half: the gate now fires on invention as well, and an invention-only
+ // cell would otherwise print a bare `REVERTED` with no trace of the figure
+ // the model made up — the one thing the rubric cannot re-derive.
+ const outcome = applyNumberPreservation(fixture.bullets, cleaned);
+ return {
+ bullets: outcome.bullets,
+ raw,
+ reverted: outcome.reverted,
+ revertedNumbers: [...outcome.droppedNumbers, ...outcome.addedNumbers],
+ } satisfies RawRewriteOutput;
};
}
diff --git a/src/lib/webllm/eval/runner.test.ts b/src/lib/webllm/eval/runner.test.ts
index 2b87f7bf..79421366 100644
--- a/src/lib/webllm/eval/runner.test.ts
+++ b/src/lib/webllm/eval/runner.test.ts
@@ -153,6 +153,123 @@ describe("runEval", () => {
expect(r.fixtureIds).toEqual(["f"]);
});
+ it("carries the #778 revert flag onto the record and into revertedRate", async () => {
+ // The harness runs the product's reject gate before scoring, so a reverted
+ // cell arrives already scoring `numbersPreserved: true` — the input IS the
+ // output. `revertedRate` is what keeps that from reading as a model that
+ // preserved everything on its own.
+ const fx = fixture("numeric", "numeric", ["Grew ARR to $4.2M in FY24."]);
+ const clean = fixture("clean", "strong", [
+ "Owned the write path end to end.",
+ ]);
+ const report = await runEval({
+ modelIds: ["M"],
+ variantIds: ["V"],
+ fixtures: [fx, clean],
+ rewriteFn: dispatchFn({
+ "M|V|numeric": {
+ bullets: ["Grew ARR to $4.2M in FY24."],
+ raw: "Grew ARR substantially.",
+ reverted: true,
+ revertedNumbers: ["$4.2M"],
+ },
+ "M|V|clean": {
+ bullets: ["Owned the write path end to end."],
+ raw: "Owned the write path end to end.",
+ },
+ }),
+ });
+ const reverted = report.records.find((r) => r.fixtureId === "numeric")!;
+ expect(reverted.reverted).toBe(true);
+ expect(reverted.revertedNumbers).toEqual(["$4.2M"]);
+ expect(reverted.rubric.numbersPreserved).toBe(true);
+ expect(report.records.find((r) => r.fixtureId === "clean")!.reverted).toBe(
+ false,
+ );
+ expect(report.aggregates[0]!.revertedRate).toBe(0.5);
+ expect(report.aggregates[0]!.numbersPreservedRate).toBe(1);
+ });
+
+ it("keeps revertedRate out of the composite aggregateScore", async () => {
+ // A revert is the guardrail working, so scoring it as a criterion in
+ // either direction would misstate the run the default model is picked from.
+ const fx = fixture("f", "strong", ["Cut p99 latency 40% on the write path."]);
+ const output: RawRewriteOutput = {
+ bullets: ["Cut p99 latency 40% on the write path."],
+ raw: "Cut p99 latency 40% on the write path.",
+ reverted: true,
+ revertedNumbers: ["40%"],
+ };
+ const withRevert = await runEval({
+ modelIds: ["M"],
+ variantIds: ["V"],
+ fixtures: [fx],
+ rewriteFn: dispatchFn({ "M|V|f": output }),
+ });
+ const withoutRevert = await runEval({
+ modelIds: ["M"],
+ variantIds: ["V"],
+ fixtures: [fx],
+ rewriteFn: dispatchFn({
+ "M|V|f": { bullets: output.bullets, raw: output.raw },
+ }),
+ });
+ expect(withRevert.aggregates[0]!.revertedRate).toBe(1);
+ expect(withoutRevert.aggregates[0]!.revertedRate).toBe(0);
+ expect(withRevert.aggregates[0]!.aggregateScore).toBe(
+ withoutRevert.aggregates[0]!.aggregateScore,
+ );
+ });
+
+ it("keeps numbersPreservedRate out of the composite aggregateScore too", async () => {
+ // Post-#778 the gate makes this rate ~1.0 for every model, so leaving it in
+ // the composite added a constant term to every row and displaced a
+ // criterion that actually discriminates. Same treatment as `revertedRate`.
+ const fx = fixture("f", "strong", ["Cut p99 latency 40% on the write path."]);
+ const preserved = await runEval({
+ modelIds: ["M"],
+ variantIds: ["V"],
+ fixtures: [fx],
+ rewriteFn: dispatchFn({
+ "M|V|f": {
+ bullets: ["Cut p99 latency 40% on the write path."],
+ raw: "Cut p99 latency 40% on the write path.",
+ },
+ }),
+ });
+ const dropped = await runEval({
+ modelIds: ["M"],
+ variantIds: ["V"],
+ fixtures: [fx],
+ rewriteFn: dispatchFn({
+ "M|V|f": {
+ bullets: ["Cut p99 latency on the write path."],
+ raw: "Cut p99 latency on the write path.",
+ },
+ }),
+ });
+ expect(preserved.aggregates[0]!.numbersPreservedRate).toBe(1);
+ expect(dropped.aggregates[0]!.numbersPreservedRate).toBe(0);
+ // Still reported in the row — only its contribution to the score is gone.
+ expect(dropped.aggregates[0]!.aggregateScore).toBe(
+ preserved.aggregates[0]!.aggregateScore,
+ );
+ });
+
+ it("defaults reverted to false for a stub that never ran the gate", async () => {
+ const fx = fixture("f", "weak", ["W"]);
+ const report = await runEval({
+ modelIds: ["M"],
+ variantIds: ["V"],
+ fixtures: [fx],
+ rewriteFn: dispatchFn({
+ "M|V|f": { bullets: ["Owned the write path end to end."], raw: "" },
+ }),
+ });
+ expect(report.records[0]!.reverted).toBe(false);
+ expect(report.records[0]!.revertedNumbers).toEqual([]);
+ });
+
it("invokes onProgress once per cell with running counts", async () => {
const fx = fixture("f", "weak", ["W"]);
const good: RawRewriteOutput = {
diff --git a/src/lib/webllm/eval/runner.ts b/src/lib/webllm/eval/runner.ts
index 2ba1435a..1b6e3e19 100644
--- a/src/lib/webllm/eval/runner.ts
+++ b/src/lib/webllm/eval/runner.ts
@@ -109,6 +109,8 @@ export async function runEval({
inputBulletCount: fixture.bullets.length,
outputBulletCount: output.bullets.length,
rubric,
+ reverted: output.reverted ?? false,
+ revertedNumbers: [...(output.revertedNumbers ?? [])],
rewriteDurationMs: now() - cellStart,
error: null,
};
@@ -121,6 +123,8 @@ export async function runEval({
inputBulletCount: fixture.bullets.length,
outputBulletCount: 0,
rubric: emptyRubricForError(),
+ reverted: false,
+ revertedNumbers: [],
rewriteDurationMs: now() - cellStart,
error: err instanceof Error ? err.message : String(err),
};
@@ -154,6 +158,11 @@ export async function runEval({
* and the report renders `—`. The composite `aggregateScore` is the
* equal-weight mean of the deterministic rates (judge excluded) so
* choosing a default-model from the report is one column.
+ *
+ * "Deterministic rate" is narrower than "every rate in the row": a column has
+ * to DISCRIMINATE between models to earn a place in the composite. The two
+ * #778 columns (`numbersPreservedRate`, `revertedRate`) are reported but not
+ * summed for that reason — see the comment at their computation.
*/
function aggregateRecords(
records: readonly RunRecord[],
@@ -168,7 +177,18 @@ function aggregateRecords(
);
const scored = cell.filter((r) => r.error === null);
+ // Both #778 diagnostics, and BOTH kept out of `deterministicRates` below
+ // for the same reason: neither discriminates between models any more.
+ // The gate makes every cell that produced output number-clean, so
+ // `numbersPreservedRate` is ~1.0 by construction and would add a constant
+ // term that inflates every composite equally while displacing a real
+ // criterion; and a revert is the guardrail working, so scoring it in
+ // either direction would misstate the run. They stay reported side by
+ // side — 100%/0% is a model that kept every number, 100%/60% is the gate
+ // carrying it — just not summed into the score the default model is
+ // chosen from.
const numbersPreservedRate = rate(scored, (r) => r.rubric.numbersPreserved);
+ const revertedRate = rate(scored, (r) => r.reverted);
const oneLineRate = rate(scored, (r) => r.rubric.oneLinePerBullet);
const actionVerbRate = rate(scored, (r) => r.rubric.actionVerbLead);
const lengthSanityRate = rate(scored, (r) => r.rubric.lengthSanity);
@@ -203,7 +223,6 @@ function aggregateRecords(
: judgeScores.reduce((s, v) => s + v, 0) / judgeScores.length;
const deterministicRates = [
- numbersPreservedRate,
oneLineRate,
actionVerbRate,
lengthSanityRate,
@@ -219,6 +238,7 @@ function aggregateRecords(
variantId,
scoredFixtures: scored.length,
numbersPreservedRate,
+ revertedRate,
oneLineRate,
actionVerbRate,
lengthSanityRate,
diff --git a/src/lib/webllm/eval/types.ts b/src/lib/webllm/eval/types.ts
index 45262ad3..a9288027 100644
--- a/src/lib/webllm/eval/types.ts
+++ b/src/lib/webllm/eval/types.ts
@@ -110,12 +110,12 @@ export interface RubricResult {
/** Per-bullet diagnostic detail surfaced in the report. */
perBullet: PerBulletDiagnostic[];
/**
- * Numbers that the model dropped from input (multiset diff). Empty when
+ * Numbers that the model dropped from input (set diff). Empty when
* numbersPreserved is true.
*/
droppedNumbers: string[];
/**
- * Numbers that appeared in output but not input (multiset diff). Empty
+ * Numbers that appeared in output but not input (set diff). Empty
* when numbersPreserved is true.
*/
addedNumbers: string[];
@@ -146,6 +146,26 @@ export interface RawRewriteOutput {
* preamble leakage across the whole response (not just per-bullet).
*/
raw: string;
+ /**
+ * The #778 reject gate fired: the model dropped or invented a number, so
+ * `bullets` is the fixture's input rather than the model's rewrite. Optional
+ * because the Node test stubs produce output directly and never run the
+ * gate; absent is read as `false`.
+ *
+ * Diagnostic only — it is deliberately NOT a rubric criterion. A revert is a
+ * *success* of the guardrail and a *failure* of the model, and folding those
+ * into one pass/fail would make the composite score unreadable.
+ */
+ reverted?: boolean;
+ /**
+ * The numeric tokens that triggered the revert — dropped ones first, then
+ * invented ones, undifferentiated because the cell's verdict is the same
+ * either way. Recorded because the rubric re-derives its diff from
+ * `bullets`, which after a revert equals the input — so without this the
+ * committed report would show a clean cell with no trace of what the model
+ * actually lost or made up.
+ */
+ revertedNumbers?: readonly string[];
}
/**
@@ -179,6 +199,18 @@ export interface RunRecord {
inputBulletCount: number;
outputBulletCount: number;
rubric: RubricResult;
+ /**
+ * The #778 gate rejected this cell's rewrite and the input bullets were
+ * scored instead. `numbersPreserved` on a reverted row is true by
+ * construction, so this is the field that keeps the rate honest — read the
+ * two together, never `Numbers` alone.
+ */
+ reverted: boolean;
+ /**
+ * The numeric tokens that triggered the revert — dropped then invented,
+ * undifferentiated; empty when the gate did not fire.
+ */
+ revertedNumbers: string[];
/** Wall-clock ms spent inside the `RewriteFn` (browser-leg only). */
rewriteDurationMs: number | null;
/**
@@ -215,8 +247,29 @@ export interface AggregateRow {
variantId: string;
/** Number of fixtures that produced a usable rubric (i.e. not errored). */
scoredFixtures: number;
- /** 0..1 per-criterion pass rate across scored fixtures. */
+ /**
+ * 0..1 per-criterion pass rate across scored fixtures.
+ *
+ * Since #778 this is a rate over the DELIVERED output, so a reverted cell
+ * counts as preserved. Read it with `revertedRate`, which is the only column
+ * that separates the two ways to score 100% here: 100%/0% is a model that
+ * kept every number, 100%/60% is the gate carrying it. Now that the gate
+ * covers invention as well as dropping, every cell that produced any output
+ * at all scores true — the rate is ~100% BY CONSTRUCTION, and reading it
+ * without `revertedRate` says nothing about the model.
+ *
+ * A diagnostic, NOT a criterion — excluded from `aggregateScore` for exactly
+ * that reason. A term that is ~1.0 for every model adds no signal to the
+ * composite and dilutes the criteria that do discriminate.
+ */
numbersPreservedRate: number;
+ /**
+ * 0..1 share of scored fixtures whose rewrite the #778 gate rejected.
+ * A diagnostic, NOT a criterion — deliberately excluded from
+ * `aggregateScore`, since a revert is the guardrail working and scoring it
+ * as either a pass or a fail would misstate the run.
+ */
+ revertedRate: number;
oneLineRate: number;
actionVerbRate: number;
lengthSanityRate: number;
diff --git a/src/lib/webllm/post-process.test.ts b/src/lib/webllm/post-process.test.ts
index 74ba571a..39beb164 100644
--- a/src/lib/webllm/post-process.test.ts
+++ b/src/lib/webllm/post-process.test.ts
@@ -3,7 +3,10 @@
import { describe, expect, it } from "vitest";
-import { cleanRewriteLine } from "./post-process.ts";
+import {
+ applyNumberPreservation,
+ cleanRewriteLine,
+} from "./post-process.ts";
describe("cleanRewriteLine", () => {
it("returns empty for whitespace-only input", () => {
@@ -271,3 +274,125 @@ describe("cleanRewriteLine", () => {
});
});
});
+
+describe("applyNumberPreservation (#778)", () => {
+ it("keeps a rewrite that carries every number through", () => {
+ const out = applyNumberPreservation(
+ ["Grew ARR to $4.2M across 2 regions."],
+ ["Grew ARR to $4.2M in 2 regions."],
+ );
+ expect(out.bullets).toEqual(["Grew ARR to $4.2M in 2 regions."]);
+ expect(out.reverted).toBe(false);
+ expect(out.numbersPreserved).toBe(true);
+ expect(out.droppedNumbers).toEqual([]);
+ });
+
+ it("rejects a rewrite that drops a number and returns the ORIGINAL", () => {
+ const original = ["Grew ARR to $4.2M in FY24."];
+ const out = applyNumberPreservation(original, ["Grew ARR substantially."]);
+ expect(out.bullets).toEqual(original);
+ expect(out.reverted).toBe(true);
+ expect(out.droppedNumbers).toEqual(["$4.2M"]);
+ });
+
+ it("counts a reverted rewrite as number-preserving — that is the user-facing outcome", () => {
+ // The metric measures what reached the user, and what reached the user is
+ // their own bullet with every figure intact.
+ const out = applyNumberPreservation(
+ ["Cut latency 40%."],
+ ["Cut latency."],
+ );
+ expect(out.reverted).toBe(true);
+ expect(out.numbersPreserved).toBe(true);
+ });
+
+ it("does not copy the caller's arrays into the result", () => {
+ const original = ["Cut latency 40%."];
+ const out = applyNumberPreservation(original, ["Cut latency."]);
+ expect(out.bullets).not.toBe(original);
+ out.bullets.push("mutated");
+ expect(original).toEqual(["Cut latency 40%."]);
+ });
+
+ it("allows a merge that moves a number into another bullet", () => {
+ // The gate is a whole-section set diff, not a per-line one, precisely
+ // so `MERGE_AND_PRUNE_RULE` stays usable.
+ const out = applyNumberPreservation(
+ ["Cut latency 40%.", "Owned the write path."],
+ ["Owned the write path, cutting latency 40%."],
+ );
+ expect(out.reverted).toBe(false);
+ expect(out.bullets).toHaveLength(1);
+ });
+
+ it("rejects a rewrite that INVENTS a number even though it dropped none", () => {
+ // The widening on top of #778: an invented figure is a false claim in a
+ // document the user hands an employer, so it is gated exactly like a drop.
+ const original = ["Improved availability."];
+ const out = applyNumberPreservation(
+ original,
+ ["Improved availability to 99.9%."],
+ );
+ expect(out.bullets).toEqual(original);
+ expect(out.reverted).toBe(true);
+ expect(out.droppedNumbers).toEqual([]);
+ expect(out.addedNumbers).toEqual(["99.9%"]);
+ // Same reasoning as the drop case: the original is what reached the user.
+ expect(out.numbersPreserved).toBe(true);
+ });
+
+ it("rejects a rewrite that both drops and invents, and reports both lists", () => {
+ const original = ["Cut latency 40%."];
+ const out = applyNumberPreservation(original, ["Cut latency 4%."]);
+ expect(out.bullets).toEqual(original);
+ expect(out.reverted).toBe(true);
+ expect(out.droppedNumbers).toEqual(["40%"]);
+ expect(out.addedNumbers).toEqual(["4%"]);
+ });
+
+ it("leaves a rewrite that changes no numeric fact alone", () => {
+ // The gate's whole job is to be invisible on a clean rewrite — no drop,
+ // no invention, so the model's text is what ships.
+ const out = applyNumberPreservation(
+ ["Cut p99 latency 40% for 1,200 users."],
+ ["Cut p99 latency 40%, serving 1,200 users."],
+ );
+ expect(out.reverted).toBe(false);
+ expect(out.numbersPreserved).toBe(true);
+ expect(out.droppedNumbers).toEqual([]);
+ expect(out.addedNumbers).toEqual([]);
+ expect(out.bullets).toEqual(["Cut p99 latency 40%, serving 1,200 users."]);
+ });
+
+ it("leaves an empty rewrite empty — a failed generation is not a rejected one", () => {
+ const out = applyNumberPreservation(["Cut latency 40%."], []);
+ expect(out.bullets).toEqual([]);
+ expect(out.reverted).toBe(false);
+ });
+
+ it("is a no-op when the input carries no numbers at all", () => {
+ const out = applyNumberPreservation(
+ ["Owned the write path."],
+ ["Owned and hardened the write path."],
+ );
+ expect(out.reverted).toBe(false);
+ expect(out.bullets).toEqual(["Owned and hardened the write path."]);
+ });
+
+ it("rejects on the extraction gaps #778 closed — `~50` and `50-100`", () => {
+ // The gate is only as good as the detector under it; these two would have
+ // sailed through before the extraction fix.
+ expect(
+ applyNumberPreservation(
+ ["Triaged ~50 tickets a week."],
+ ["Triaged tickets each week."],
+ ).reverted,
+ ).toBe(true);
+ expect(
+ applyNumberPreservation(
+ ["Handled 50-100 tickets a week."],
+ ["Handled a high volume of tickets."],
+ ).reverted,
+ ).toBe(true);
+ });
+});
diff --git a/src/lib/webllm/post-process.ts b/src/lib/webllm/post-process.ts
index b422fe89..b8acd6a5 100644
--- a/src/lib/webllm/post-process.ts
+++ b/src/lib/webllm/post-process.ts
@@ -2,7 +2,24 @@
// Copyright 2026 The offlinecv Authors
/**
- * Per-line cleanup shared by the per-bullet and section rewrite paths.
+ * Post-processing shared by the per-bullet and section rewrite paths. Two
+ * stages, in order:
+ *
+ * 1. {@link cleanRewriteLine} — per-line cleanup of the wrappers small
+ * instruct models put around a bullet.
+ * 2. {@link applyNumberPreservation} — the #778 reject gate: a rewrite that
+ * drops a concrete number from the input, or invents one that was never
+ * there, never reaches the user at all.
+ *
+ * Stage 2 is a whole-rewrite decision, not a per-line one, and that is not a
+ * convenience — section rewrite is explicitly allowed to merge, drop and
+ * reorder bullets (`MERGE_AND_PRUNE_RULE`), so output line *i* has no owning
+ * input bullet to check it against. `$4.2M` moving from bullet 2 to a merged
+ * bullet 1 is a correct rewrite; only the whole-section set diff can tell
+ * that apart from a drop. Hence a second exported function rather than an
+ * extra argument to `cleanRewriteLine`, which stays a pure `string → string`.
+ *
+ * ---
*
* Small instruct models emit a small but persistent set of wrappers
* around each bullet, even when the system prompt says "no preamble,
@@ -25,6 +42,8 @@
* drops them.
*/
+import { checkNumbersPreserved } from "./preserve-numbers.ts";
+
/**
* Exact-match scaffolding lines (post-cleanup). Cheap set lookup for the
* common case where the model echoes the prompt's section headers.
@@ -195,3 +214,80 @@ export function cleanRewriteLine(line: string): string {
return withoutQuotes;
}
+
+/** What {@link applyNumberPreservation} decided, and why. */
+export interface NumberPreservationOutcome {
+ /**
+ * The units that may reach the user: the model's rewrite, or — when the
+ * rewrite dropped or invented a number — the caller's own `original`,
+ * unchanged.
+ */
+ bullets: string[];
+ /**
+ * True iff the rewrite was rejected and `bullets` is the original. The UI
+ * MUST surface this: a revert and a model that chose to change nothing
+ * produce the same empty diff, and letting them look alike is exactly the
+ * silent failure #778 exists to remove.
+ */
+ reverted: boolean;
+ /**
+ * Property of `bullets` — of what the user actually gets. True whenever
+ * `reverted` is true, because the original trivially preserves itself. This
+ * is the field the eval rubric re-derives, which is why a reverted rewrite
+ * counts toward `numbersPreservedRate`: no number reached the user wrong.
+ */
+ numbersPreserved: boolean;
+ /**
+ * Diagnostics about the MODEL'S raw output, kept whether or not it was
+ * applied — a non-empty list alongside `numbersPreserved: true` is the
+ * revert case, and it is what the revert notice quotes back ("kept your
+ * original — the rewrite would have removed $4.2M"). Reading either list as
+ * a property of `bullets` is the one misreading to avoid.
+ */
+ droppedNumbers: string[];
+ addedNumbers: string[];
+}
+
+/**
+ * The #778 reject gate: refuse a rewrite that changes the numeric facts —
+ * either by dropping a concrete number or by inventing one.
+ *
+ * `PRESERVE_NUMBERS_RULE` has been in every rewrite prompt since #609, and the
+ * 2026-08-07 eval reports measured every model in the registry breaking it —
+ * `numbersPreservedRate` between 0% and 80%, with whole figures (`$4.2M`,
+ * `120K`, `14%`) vanishing from rewritten bullets. A prompt cannot be the only
+ * enforcement of a rule this load-bearing: a fluent bullet missing its
+ * quantification is one the user accepts without re-checking, and
+ * quantification is what the score's `Specificity` dimension (weight 0.4)
+ * rewards them for having. So the guardrail becomes deterministic here —
+ * declining to rewrite beats rewriting away a fact.
+ *
+ * Gated on the full `ok`, so invention reverts too. #778 shipped drop-only on
+ * the reasoning that an invented number arrives *visible* with a warning on
+ * it; the eval data retired that. Invention is the worse half for a résumé
+ * tool — a dropped figure costs the user a true claim they can restore, an
+ * invented one puts a false claim in the document they hand an employer — and
+ * a drop-only gate could not reach the ~100% `numbersPreservedRate` #778 asked
+ * for, because whole failure cells (Qwen/baseline inventing `30%` on a fixture
+ * with zero drops) never triggered it.
+ *
+ * An empty rewrite is deliberately NOT reverted. Every caller already treats
+ * "the model returned nothing" as a failed generation with its own handling;
+ * turning it into a silent "kept your original" would hide the failure behind
+ * a success, and there is no rewrite there to reject in the first place.
+ */
+export function applyNumberPreservation(
+ original: readonly string[],
+ rewritten: readonly string[],
+): NumberPreservationOutcome {
+ const preservation = checkNumbersPreserved(original, rewritten);
+ const shouldRevert = rewritten.length > 0 && !preservation.ok;
+
+ return {
+ bullets: shouldRevert ? [...original] : [...rewritten],
+ reverted: shouldRevert,
+ numbersPreserved: shouldRevert ? true : preservation.ok,
+ droppedNumbers: preservation.dropped,
+ addedNumbers: preservation.added,
+ };
+}
diff --git a/src/lib/webllm/preserve-numbers.test.ts b/src/lib/webllm/preserve-numbers.test.ts
index 07a1e2e7..537b307e 100644
--- a/src/lib/webllm/preserve-numbers.test.ts
+++ b/src/lib/webllm/preserve-numbers.test.ts
@@ -95,16 +95,16 @@ describe("checkNumbersPreserved", () => {
expect(checkNumbersPreserved(input, output).ok).toBe(true);
});
- it("counts numbers as a multiset — two distinct 5%s must both survive", () => {
+ it("counts numbers as a set — a repeated 5% survives as one mention", () => {
+ // The deliberate trade recorded in the module docblock: collapsing two
+ // separate 5% claims into one scores clean, because counting occurrences
+ // instead would reject the licensed merge the test below pins.
const input = ["Lifted CTR 5% in Q1 and another 5% in Q2."];
- // Rewrite collapses to one mention of 5%.
const output = ["Lifted CTR 5% over Q1 and Q2."];
- const result = checkNumbersPreserved(input, output);
- expect(result.ok).toBe(false);
- expect(result.dropped).toEqual(["5%"]);
+ expect(checkNumbersPreserved(input, output).ok).toBe(true);
});
- it("strips the headcount: / year: namespace for display tokens", () => {
+ it("strips the num: namespace for display tokens", () => {
const dropped = checkNumbersPreserved(
["Led 5 engineers in 2021."],
["Drove delivery."],
@@ -188,4 +188,557 @@ describe("checkNumbersPreserved", () => {
expect(result.ok).toBe(false);
expect(result.dropped).toEqual(["12"]);
});
+
+ // ── #874 review: a management verb alone must not claim a headcount when a
+ // non-people noun follows the digit (Samhit21) ─────────────────────────────
+
+ describe("a management verb needs a people noun or a bare context, not any noun (#874 review)", () => {
+ it("does NOT flag a headcount when the verb's object is not a person", () => {
+ // `projects` isn't a people noun, so `Managed 5 projects` is a verb
+ // pointing at a non-people object, not a headcount claim.
+ expect(
+ checkNumbersPreserved(
+ ["Owned 5 projects."],
+ ["Managed 5 projects."],
+ ),
+ ).toEqual({ ok: true, dropped: [], added: [] });
+ expect(
+ checkNumbersPreserved(["Built 8 features."], ["Led 8 features."]),
+ ).toEqual({ ok: true, dropped: [], added: [] });
+ expect(
+ checkNumbersPreserved(
+ ["Delivered 4 programs."],
+ ["Ran 4 programs."],
+ ),
+ ).toEqual({ ok: true, dropped: [], added: [] });
+ expect(
+ checkNumbersPreserved(
+ ["Shipped 6 releases."],
+ ["Directed 6 releases."],
+ ),
+ ).toEqual({ ok: true, dropped: [], added: [] });
+ });
+
+ it("still claims a headcount when the verb has no noun to run into", () => {
+ // `Managed 8 across the data platform` elides the noun — the verb alone
+ // has to be enough, or a real headcount rewording (Managed 8 people →
+ // Managed 8 across the platform) would score as an invention.
+ const result = checkNumbersPreserved(
+ ["Managed 8 people across the data platform."],
+ ["Managed 8 across the data platform."],
+ );
+ expect(result.ok).toBe(true);
+ });
+
+ it("still claims a headcount when a verb-object noun IS a person", () => {
+ expect(
+ checkNumbersPreserved(
+ ["Managed 5 engineers."],
+ ["Led 5 engineers."],
+ ),
+ ).toEqual({ ok: true, dropped: [], added: [] });
+ });
+
+ it("still catches the genuine invention this fix must not blunt — phase 5 to 5 engineers", () => {
+ const result = checkNumbersPreserved(
+ ["Completed phase 5 of the migration"],
+ ["Managed 5 engineers through the migration"],
+ );
+ expect(result.ok).toBe(false);
+ expect(result.added).toEqual(["5"]);
+ });
+
+ it("still catches a dropped $ figure — the fix is scoped to bare-integer headcount context", () => {
+ const result = checkNumbersPreserved(
+ ["Saved the team $4.2M last quarter."],
+ ["Saved the team money last quarter."],
+ );
+ expect(result.ok).toBe(false);
+ expect(result.dropped).toEqual(["$4.2M"]);
+ });
+ });
+
+ // ── #778: extraction gaps that let a real drop score clean ────────────────
+ //
+ // Every case below was INVISIBLE before #778: the token produced no atom at
+ // all, so a rewrite could delete it and `ok` stayed true. Each one is
+ // asserted twice — once that the token round-trips (no false reject) and
+ // once that deleting it is caught (no false pass) — because a detector that
+ // only does the first is indistinguishable from one that tracks nothing.
+
+ describe("tilde-prefixed approximations (#778)", () => {
+ it("catches a dropped `~50`", () => {
+ const result = checkNumbersPreserved(
+ ["Triaged ~50 support tickets a week."],
+ ["Triaged support tickets each week."],
+ );
+ expect(result.ok).toBe(false);
+ expect(result.dropped).toEqual(["~50"]);
+ });
+
+ it("round-trips `~50` unchanged", () => {
+ const input = ["Triaged ~50 support tickets a week."];
+ expect(checkNumbersPreserved(input, input).ok).toBe(true);
+ });
+
+ it("treats `~50` and `50` as different claims, like `-15%` and `15%`", () => {
+ const result = checkNumbersPreserved(
+ ["Triaged ~50 tickets a week."],
+ ["Triaged 50 tickets a week."],
+ );
+ expect(result.ok).toBe(false);
+ expect(result.dropped).toEqual(["~50"]);
+ });
+
+ it("keeps the approximation on a decorated token — `~$4.2M`", () => {
+ const result = checkNumbersPreserved(
+ ["Grew ARR to ~$4.2M."],
+ ["Grew ARR."],
+ );
+ expect(result.dropped).toEqual(["~$4.2M"]);
+ });
+
+ it("accepts the ≈ and ∼ spellings a PDF extractor may emit", () => {
+ const input = ["Lifted retention ≈30% and cut churn ∼12%."];
+ expect(checkNumbersPreserved(input, input).ok).toBe(true);
+ expect(
+ checkNumbersPreserved(input, ["Lifted retention and cut churn."])
+ .dropped,
+ ).toEqual(["≈30%", "∼12%"]);
+ });
+ });
+
+ describe("hyphenated ranges (#778)", () => {
+ it("catches a range collapsed to one endpoint", () => {
+ const result = checkNumbersPreserved(
+ ["Handled 50-100 tickets per week."],
+ ["Handled up to 100 tickets per week."],
+ );
+ expect(result.ok).toBe(false);
+ expect(result.dropped).toContain("50");
+ });
+
+ it("catches a range dropped entirely", () => {
+ const result = checkNumbersPreserved(
+ ["Handled 50-100 tickets per week."],
+ ["Handled inbound tickets each week."],
+ );
+ expect(result.dropped).toEqual(["50", "100"]);
+ });
+
+ it("round-trips a range unchanged", () => {
+ const input = ["Handled 50-100 tickets per week."];
+ expect(checkNumbersPreserved(input, input).ok).toBe(true);
+ });
+
+ it("matches across dash spellings — a hyphen range survives as an en dash", () => {
+ // The dash is detection context, never part of the key, so re-spelling
+ // it is not a drop. A PDF extractor picks the dash, not the author.
+ expect(
+ checkNumbersPreserved(
+ ["Handled 50-100 tickets per week."],
+ ["Handled 50–100 tickets per week."],
+ ).ok,
+ ).toBe(true);
+ });
+
+ it("tracks the bare endpoint of a mixed range — `10-15%`", () => {
+ const result = checkNumbersPreserved(
+ ["Lifted conversion 10-15% across the funnel."],
+ ["Lifted conversion 15% across the funnel."],
+ );
+ expect(result.ok).toBe(false);
+ expect(result.dropped).toEqual(["10"]);
+ });
+
+ it("does NOT read `6-month` or `day-7` as a range — the dash is followed by a letter", () => {
+ // Both bare integers stay untracked exactly as before #778, so dropping
+ // them is not flagged. This is the false-reject guard for the change.
+ const result = checkNumbersPreserved(
+ ["Ran a 6-month pilot that lifted day-7 retention."],
+ ["Ran a pilot that lifted retention."],
+ );
+ expect(result.ok).toBe(true);
+ });
+
+ it("still scores a date range as its pair of years, not as a range", () => {
+ // Pinned because reading `2019-2021` as a range instead would make the
+ // legitimate rewording below a drop.
+ expect(
+ checkNumbersPreserved(
+ ["Owned the platform 2019-2021."],
+ ["Owned the platform between 2019 and 2021."],
+ ).ok,
+ ).toBe(true);
+ });
+ });
+
+ describe("multiplier and at-least suffixes (#778)", () => {
+ it("catches a dropped `10x`", () => {
+ const result = checkNumbersPreserved(
+ ["Scaled ingestion throughput 10x in one quarter."],
+ ["Scaled ingestion throughput substantially in one quarter."],
+ );
+ expect(result.ok).toBe(false);
+ expect(result.dropped).toEqual(["10x"]);
+ });
+
+ it("catches a dropped decimal multiplier `3.5x`", () => {
+ // Before #778 the atom matched a stranded `3` here and classified it as
+ // noise, so the whole figure was untracked.
+ const result = checkNumbersPreserved(
+ ["Grew revenue 3.5x year over year."],
+ ["Grew revenue year over year."],
+ );
+ expect(result.dropped).toEqual(["3.5x"]);
+ });
+
+ it("is case-insensitive on the multiplier — `10X` matches `10x`", () => {
+ expect(
+ checkNumbersPreserved(["Scaled 10X."], ["Scaled 10x."]).ok,
+ ).toBe(true);
+ });
+
+ it("catches a dropped `10+`", () => {
+ const result = checkNumbersPreserved(
+ ["Brings 10+ years of platform experience."],
+ ["Brings years of platform experience."],
+ );
+ expect(result.dropped).toEqual(["10+"]);
+ });
+
+ it("treats `10+` and `10` as different claims", () => {
+ const result = checkNumbersPreserved(
+ ["Brings 10+ years of experience."],
+ ["Brings 10 years of experience."],
+ );
+ expect(result.ok).toBe(false);
+ expect(result.dropped).toEqual(["10+"]);
+ });
+
+ it("does NOT match either side of a dimension pair — `1920x1080`, `2x2`", () => {
+ // The `(?!\w)` boundary is what keeps these out; if it ever loosened,
+ // dropping a resolution would start rejecting rewrites.
+ const result = checkNumbersPreserved(
+ ["Shipped 1920x1080 assets on a 2x2 grid."],
+ ["Shipped assets on a grid."],
+ );
+ expect(result.ok).toBe(true);
+ });
+ });
+
+ // ── Formatting must not decide whether a number is "present" ──────────────
+ //
+ // Whether a BARE integer is tracked depends on the prose around it, so the
+ // same digits classify differently in `50 to 100` vs `50-100` and in
+ // `12 people` vs `12-person team`. Comparing tracked-against-tracked made a
+ // re-spelling look like a drop (or an invention) and reverted the section,
+ // telling the user the model removed a number they typed themselves.
+
+ describe("re-spelling a number is not a change (#778 review)", () => {
+ it("accepts a spelled-out range tightened to a hyphen", () => {
+ expect(
+ checkNumbersPreserved(
+ ["Handled 50 to 100 tickets."],
+ ["Handled 50-100 tickets."],
+ ),
+ ).toEqual({ ok: true, dropped: [], added: [] });
+ });
+
+ it("accepts a hyphenated range spelled out with `to`", () => {
+ expect(
+ checkNumbersPreserved(
+ ["Handled 50-100 tickets."],
+ ["Handled 50 to 100 tickets."],
+ ),
+ ).toEqual({ ok: true, dropped: [], added: [] });
+ });
+
+ it("accepts a headcount re-attached as a hyphenated word suffix", () => {
+ expect(
+ checkNumbersPreserved(
+ ["Managed 12 people."],
+ ["Managed a 12-person team."],
+ ),
+ ).toEqual({ ok: true, dropped: [], added: [] });
+ });
+
+ it("accepts the same move in reverse — `12-person team` back to `12 people`", () => {
+ expect(
+ checkNumbersPreserved(
+ ["Managed a 12-person team."],
+ ["Managed 12 people."],
+ ),
+ ).toEqual({ ok: true, dropped: [], added: [] });
+ });
+
+ it("still catches a real drop from a re-spelled range", () => {
+ // The fix must not blunt detection: `50` is gone from the output
+ // entirely, in any formatting, so it is still a drop.
+ const result = checkNumbersPreserved(
+ ["Handled 50-100 tickets."],
+ ["Handled up to 100 tickets."],
+ );
+ expect(result.ok).toBe(false);
+ expect(result.dropped).toEqual(["50"]);
+ });
+
+ it("still catches a real drop of a re-spelled headcount", () => {
+ const result = checkNumbersPreserved(
+ ["Managed 12 people."],
+ ["Managed a small team."],
+ );
+ expect(result.ok).toBe(false);
+ expect(result.dropped).toEqual(["12"]);
+ });
+
+ it("still catches an invented headcount that appears in no formatting before", () => {
+ const result = checkNumbersPreserved(
+ ["Managed the platform team."],
+ ["Managed 12 engineers on the platform team."],
+ );
+ expect(result.ok).toBe(false);
+ expect(result.added).toEqual(["12"]);
+ });
+ });
+
+ describe("grouping commas are formatting, not value (#778 review)", () => {
+ // Same digits, same claim, different spelling. Keying on the literal form
+ // made `1,200` and `1200` two different numbers, so a rewrite that only
+ // regrouped the figure was reported as dropping (or inventing) it and the
+ // whole section reverted — the same false-revert class as `50 to 100` vs
+ // `50-100`, on the formatting axis instead of the prose one.
+
+ it("accepts a grouped integer written without its commas", () => {
+ expect(
+ checkNumbersPreserved(
+ ["Processed 1,200 orders"],
+ ["Processed 1200 orders"],
+ ),
+ ).toEqual({ ok: true, dropped: [], added: [] });
+ });
+
+ it("accepts the same move in reverse — `1200` regrouped as `1,200`", () => {
+ expect(
+ checkNumbersPreserved(
+ ["Processed 1200 orders"],
+ ["Processed 1,200 orders"],
+ ),
+ ).toEqual({ ok: true, dropped: [], added: [] });
+ });
+
+ it("accepts a six-figure count losing its commas", () => {
+ expect(
+ checkNumbersPreserved(
+ ["Served 100,000 users"],
+ ["Served 100000 users"],
+ ),
+ ).toEqual({ ok: true, dropped: [], added: [] });
+ });
+
+ it("accepts a regrouped headcount — the people context still matches", () => {
+ // Crosses both axes at once: the input figure is claimed by its grouping,
+ // the output figure by the verb in front of it.
+ expect(
+ checkNumbersPreserved(
+ ["Managed 1,200 employees"],
+ ["Managed 1200 employees"],
+ ),
+ ).toEqual({ ok: true, dropped: [], added: [] });
+ });
+
+ it("normalises grouping inside a decorated token too — `$1,200` ≡ `$1200`", () => {
+ expect(
+ checkNumbersPreserved(["Saved $1,200 a month"], ["Saved $1200 a month"])
+ .ok,
+ ).toBe(true);
+ });
+
+ it("still catches a grouped figure that disappears entirely", () => {
+ const result = checkNumbersPreserved(
+ ["Processed 1,200 orders"],
+ ["Processed the order backlog"],
+ );
+ expect(result.ok).toBe(false);
+ // Quoted back in the spelling the user wrote, commas included.
+ expect(result.dropped).toEqual(["1,200"]);
+ });
+
+ it("still catches a grouped figure swapped for a different value", () => {
+ const result = checkNumbersPreserved(
+ ["Processed 1,200 orders"],
+ ["Processed 2,400 orders"],
+ );
+ expect(result.ok).toBe(false);
+ expect(result.dropped).toEqual(["1,200"]);
+ expect(result.added).toEqual(["2,400"]);
+ });
+ });
+
+ describe("an invented headcount is not excused by an unrelated digit (#778 review)", () => {
+ // The add direction cannot use the drop direction's lenient lookup: that a
+ // digit appears SOMEWHERE in the input says nothing about whether the claim
+ // the output makes with it was ever made. Merging every bare integer into
+ // one `num:` namespace (the fix for the drop-side false positives) is what
+ // opened this — before it, a headcount lived in its own namespace and the
+ // invention below was caught.
+
+ it("flags `phase 5` rewritten as `5 engineers`", () => {
+ const result = checkNumbersPreserved(
+ ["Completed phase 5 of the migration"],
+ ["Managed 5 engineers through the migration"],
+ );
+ expect(result.ok).toBe(false);
+ expect(result.added).toEqual(["5"]);
+ expect(result.dropped).toEqual([]);
+ });
+
+ it("flags a headcount minted from a section number", () => {
+ const result = checkNumbersPreserved(
+ ["Wrote section 8 of the runbook"],
+ ["Led 8 developers on the runbook"],
+ );
+ expect(result.ok).toBe(false);
+ expect(result.added).toEqual(["8"]);
+ });
+
+ it("does NOT flag a headcount the input already asserted, re-spelled", () => {
+ // The false-positive guard for the rule above, both directions: `12` is a
+ // people claim on both sides, so the hyphenated attachment is a rewording
+ // and not a new assertion.
+ expect(
+ checkNumbersPreserved(
+ ["Managed a 12-person team."],
+ ["Managed 12 people."],
+ ),
+ ).toEqual({ ok: true, dropped: [], added: [] });
+ expect(
+ checkNumbersPreserved(
+ ["Managed 12 people."],
+ ["Managed a 12-person team."],
+ ),
+ ).toEqual({ ok: true, dropped: [], added: [] });
+ });
+
+ it("does NOT flag a range endpoint that only gained its dash", () => {
+ // Range and grouping stay on the lenient lookup: an untracked→tracked
+ // move there is the re-spelling rule 1 protects, not a new claim.
+ expect(
+ checkNumbersPreserved(
+ ["Handled 50 to 100 tickets."],
+ ["Handled 50-100 tickets."],
+ ).added,
+ ).toEqual([]);
+ expect(
+ checkNumbersPreserved(
+ ["Processed 1200 orders"],
+ ["Processed 1,200 orders"],
+ ).added,
+ ).toEqual([]);
+ });
+
+ it("keeps the drop direction lenient — a headcount demoted to a plain digit is not a drop", () => {
+ // The asymmetry, pinned from the other side. Rule 1 asks only whether the
+ // VALUE survived, because the prose that decides tracking is exactly what
+ // a rewrite is licensed to move; tightening this direction is what made
+ // the gate fire on numbers the user typed themselves.
+ expect(
+ checkNumbersPreserved(
+ ["Managed 5 engineers on the migration"],
+ ["Completed phase 5 of the migration"],
+ ).dropped,
+ ).toEqual([]);
+ });
+ });
+
+ describe("a dropped headcount is not excused by an unrelated pre-existing unclaimed digit (#874 review)", () => {
+ // The drop side used to test presence against outputKeys — every output
+ // atom, claimed or not — so a genuinely-dropped headcount scored clean
+ // whenever an unrelated digit of the same value survived unclaimed,
+ // unchanged, elsewhere in the output. The fix is count-aware, not a flat
+ // "must be claimed": a NEW unclaimed occurrence still counts as a
+ // legitimate reword (the "phase 5" case below, pinned since #778), only
+ // a pre-existing, unrelated one doesn't.
+
+ it("catches a dropped headcount masked by an unrelated same-value digit that was already there", () => {
+ const result = checkNumbersPreserved(
+ [
+ "Managed a team of 12 engineers.",
+ "Completed certification module 12 of the curriculum.",
+ ],
+ [
+ "Led the engineering department.",
+ "Completed certification module 12 of the curriculum.",
+ ],
+ );
+ expect(result.ok).toBe(false);
+ expect(result.dropped).toEqual(["12"]);
+ });
+
+ it("does NOT extend to year — a year has no unclaimed state to compare against (documented residual)", () => {
+ // Every 4-digit number in 1900-2099 auto-claims as a year regardless of
+ // context (see `bareIntegerClaim`), so "suite 1900" is itself always
+ // claimed — there is no unclaimed bucket for the count-aware guard to
+ // compare against, and this masking case survives. Catching it needs a
+ // context gate on year classification itself (mirroring headcount's
+ // verb/noun check), which is a separate, larger change than this fix;
+ // tracked as a follow-up rather than silently left unmentioned.
+ const result = checkNumbersPreserved(
+ ["Founded the program in 1900.", "Operated out of suite 1900."],
+ ["Founded the program.", "Operated out of suite 1900."],
+ );
+ expect(result.ok).toBe(true);
+ });
+
+ it("keeps a range/form drop lenient — an unclaimed same-value digit elsewhere still counts as present", () => {
+ // The deliberate trade rule 1 keeps for form/range: a re-spelling that
+ // our heuristic fails to classify identically on the other side must
+ // not false-revert a legitimate reword.
+ const result = checkNumbersPreserved(
+ ["Handled 50-100 tickets per week."],
+ ["Handled up to 100 tickets. Reference: ticket #50 opened the queue."],
+ );
+ expect(result.ok).toBe(true);
+ });
+ });
+
+ describe("set semantics for a licensed merge (#778 review)", () => {
+ it("accepts a merge that de-duplicates a repeated metric", () => {
+ // MERGE_AND_PRUNE_RULE licenses this. Under multiset semantics the
+ // output's single `5%` looked like one of the two inputs being dropped,
+ // and the gate discarded the whole section's rewrite over it.
+ expect(
+ checkNumbersPreserved(
+ ["Cut 5% cost", "Cut 5% churn"],
+ ["Cut 5% cost and churn"],
+ ),
+ ).toEqual({ ok: true, dropped: [], added: [] });
+ });
+
+ it("still accepts the merge that moves two distinct metrics into one bullet", () => {
+ expect(
+ checkNumbersPreserved(
+ ["Cut latency 40%", "Saved $4.2M"],
+ ["Cut latency 40% and saved $4.2M"],
+ ).ok,
+ ).toBe(true);
+ });
+
+ it("still catches a genuine drop when the only instance disappears", () => {
+ const result = checkNumbersPreserved(
+ ["Cut 5% cost", "Cut 12% churn"],
+ ["Cut 5% cost and churn"],
+ );
+ expect(result.ok).toBe(false);
+ expect(result.dropped).toEqual(["12%"]);
+ });
+
+ it("reports a repeated dropped token once, not once per occurrence", () => {
+ const result = checkNumbersPreserved(
+ ["Cut 5% cost", "Cut 5% churn"],
+ ["Cut cost and churn"],
+ );
+ expect(result.dropped).toEqual(["5%"]);
+ });
+ });
});
diff --git a/src/lib/webllm/preserve-numbers.ts b/src/lib/webllm/preserve-numbers.ts
index caad9a1c..a2512656 100644
--- a/src/lib/webllm/preserve-numbers.ts
+++ b/src/lib/webllm/preserve-numbers.ts
@@ -10,15 +10,24 @@
* the input bullets, extract the same set from the rewritten bullets, and
* report any token that disappeared or appeared from nowhere.
*
- * Trust signal, not a hard block. The UI surfaces the diff inline so the
- * user can decide whether the rewrite is still acceptable.
+ * Since #778 this is a hard block, not a trust signal: `applyNumberPreservation`
+ * in `post-process.ts` reads the result and, on any drop or invention, throws
+ * the rewrite away and hands the user their original bullets back. The diff
+ * lists below are what the revert notice quotes ("kept your original — the
+ * rewrite would have removed $4.2M"); they are not an accept/reject choice
+ * offered to the user. A false positive here therefore costs a whole section's
+ * rewrite, which is what drives the rules under "Comparison semantics".
*
- * Tokens covered (per issue #63 decision #3):
+ * Tokens covered (per issue #63 decision #3, extended by #778):
* - Money with $, €, £, or ¥: `$5`, `€500K`, `£1.2M`, `¥1,000`
* - Percent: `40%`, `12.5%`, `-15%`
* - Magnitude: `5K`, `10M`, `1.2B`, `10MB`, `2GB`
+ * - Multipliers: `10x`, `3.5x` (#778)
+ * - Approximations: `~50`, `~$4.2M`, `≈30%` (#778)
+ * - At-least markers: `10+`, `500+`, `$1M+` (#778)
* - Plain numbers with commas/decimals: `1,200`, `3.14`
* - Years (1900-2099) and date ranges: `2019`, `2019-2021`
+ * - Both endpoints of a numeric range: `50-100`, `10–15%` (#778)
* - Headcounts in people-management context: `led 5`, `managed 8`,
* `team of 12`, `5 engineers`
*
@@ -26,26 +35,134 @@
* ATOM regex pass, which is what prevents the date-range/year and the
* verb-prefix/noun-suffix overlaps from emitting two tokens for one digit.
*
+ * ## Comparison semantics
+ *
+ * Every atom carries a `claim` — why it is a fact worth defending, or `"none"`.
+ * A number decorated by its own written shape (`$4.2M`, `40%`, `10x`, `1,200`)
+ * always claims; a BARE integer claims only when its context supplies one — a
+ * headcount, a year, or a range endpoint. `phase 2` and `1 of 5 applicants`
+ * stay unclaimed, because defending them would reject a legitimate rewording
+ * rather than catch a lost figure.
+ *
+ * Three rules follow from that. The first two exist to stop a false revert;
+ * the third is what keeps the invention half of the gate from being defeated
+ * by them:
+ *
+ * 1. **The claim decides what we look FOR; presence decides whether we found
+ * it — except for a headcount, where a same-value digit that was ALREADY
+ * sitting unclaimed on both sides before the rewrite doesn't count.** For
+ * `form`/`range`/`year`, a claimed input atom is a drop only if its key is
+ * absent from *every* atom on the other side — claimed or not. Whether a
+ * bare integer reads as a range endpoint or a grouped figure depends on the
+ * surrounding prose (`50-100` vs `50 to 100`) or punctuation (`1,200` vs
+ * `1200`), so requiring the other side to re-produce the exact same reading
+ * would make the gate fire on numbers the user typed themselves, purely
+ * because the model re-spelled the phrasing around them; `year` has no
+ * unclaimed state to compare against in the first place (a 4-digit number
+ * in range is always a year, see `bareIntegerClaim`), so the lenient lookup
+ * is also the only one available to it. A headcount is different: its
+ * context (a management verb, a people noun) is common enough to
+ * reproduce by accident on a digit that means something else entirely —
+ * `"Managed a team of 12 engineers"` dropped down to `"Led the
+ * department"`, sitting next to an unrelated, UNCHANGED `"Completed module
+ * 12"`, would score clean under a blanket `outputKeys.has`, because the
+ * coincidental `12` was already there before the rewrite touched anything.
+ * So a headcount counts as present only if the output claims it too, OR a
+ * *NEW* unclaimed occurrence of the key appears that the input didn't
+ * already carry — `outputClaimedKeys` and the unclaimed-occurrence counts
+ * below implement exactly that, and the "new" qualifier is what keeps
+ * `"Managed 5 engineers"` → `"Completed phase 5"` (the digit's ONLY
+ * occurrence, reworded away from headcount context) reading as a
+ * legitimate reword rather than a drop. Undecorated keys still share one
+ * `num:` namespace across all four bare-integer readings, because a
+ * headcount, a year, a range endpoint and a comma-grouped figure holding
+ * the same digits are the same value; the presence rule is what differs
+ * per claim kind, not the key they share.
+ * 2. **Set semantics, not multiset.** A number is dropped only when it is gone
+ * entirely, never when its count fell. `MERGE_AND_PRUNE_RULE` licenses the
+ * model to fold two bullets into one, and `["Cut 5% cost", "Cut 5% churn"] →
+ * ["Cut 5% cost and churn"]` is exactly that licence being used well. Counting
+ * occurrences reads the dedup as a drop and discards the whole section. The
+ * cost is a genuine "5% in Q1 and another 5% in Q2" collapse scoring clean;
+ * that trade is deliberate, because the gate reverts silently and the merge
+ * case is the one the prompt actively asks for.
+ * 3. **The two directions are asymmetric, because they ask different
+ * questions.** Rule 1's lenient half (`form`/`range`) is right for a DROP —
+ * a figure the user typed is only lost if its value is gone from the
+ * output in every spelling. That same leniency is wrong for an INVENTION:
+ * that a digit happens to appear somewhere in the input is
+ * not evidence that a claim the output newly asserts was ever made. `phase 5`
+ * → `5 engineers` reuses the digit and invents the headcount, and under a
+ * plain rule-1 lookup it scored clean. So an output atom the surrounding
+ * prose reads as a HEADCOUNT counts as present only when the same value is a
+ * claimed fact on the input side too. Every other claim kind keeps the
+ * lenient rule-1 lookup, because for those the unclaimed→claimed move is
+ * exactly the re-spelling rule 1 protects: `50 to 100` → `50-100` (unclaimed
+ * → range) and `1200` → `1,200` (unclaimed → grouped figure) are the same
+ * claim written differently. `12-person` is read as people context for the
+ * same reason — the hyphen is how English attaches the noun, not a different
+ * claim from `12 people`. The residual cost is a people phrasing our lexicon
+ * misses on the input side but recognises on the output side
+ * (`a team comprising 5` → `5 engineers`) reverting as an invention; that is
+ * the deliberate trade for catching a headcount the model made up.
+ *
* Sign sensitivity: a leading `-` (between a word boundary and the digit) is
* captured into the token. This is what catches "Reduced costs 15%" being
- * rewritten as "Reduced costs -15%" — same magnitude, inverted meaning.
+ * rewritten as "Reduced costs -15%" — same magnitude, inverted meaning. The
+ * approximation marker (`~`) and the at-least marker (`+`) are captured on the
+ * same reasoning: `~50` and `50` are different claims, as are `10+` and `10`.
+ *
+ * ## What this deliberately does NOT do
+ *
+ * **No cross-form value normalisation.** A DECORATED token is matched as
+ * written, so `120K` does not match `120,000` and `$4.2M` does not match
+ * `$4.2 million`. Those are value-equivalence features, not extraction gaps.
+ * What IS normalised is surface formatting that leaves the digits themselves
+ * alone: the prose around a bare integer — the part that decides tracking, per
+ * rule 1 above — and the grouping commas inside the digit run, so `1,200` and
+ * `1200` share a key. Both are the same claim with the phrasing moved, and
+ * `120K` vs `120,000` is not.
+ *
+ * **No ordinals** (`1st`, `3rd`). They are the one common numeric idiom with a
+ * fluent word form — a model rewriting `3rd` as `third` is a legitimate
+ * rewrite, so tracking them would buy false rejects rather than caught drops.
+ *
+ * **No spaced ranges** (`50 - 100`). Only a tight `50-100` is read as a range;
+ * a spaced hyphen is ambiguous with negation (`50 -100`) and with prose dashes.
*/
/**
* Atom regex: one numeric occurrence with all its optional decorations.
- * 1. optional leading `-` (preceded by start, whitespace, or punctuation —
+ * 1. optional approximation marker (`~`, `∼`, `≈`)
+ * 2. optional leading `-` (preceded by start, whitespace, or punctuation —
* not by another digit, which would make it a date-range hyphen)
- * 2. optional currency symbol ($, €, £, ¥)
- * 3. digit body (comma-grouped, decimal, or bare integer)
- * 4. optional magnitude suffix (k/m/b/g/t with optional b/B for data
- * sizes like MB / GB)
- * 5. optional trailing `%`
+ * 3. optional currency symbol ($, €, £, ¥)
+ * 4. digit body (comma-grouped, decimal, or bare integer)
+ * 5. optional magnitude suffix (k/m/b/g/t with optional b/B for data
+ * sizes like MB / GB) OR an `x` multiplier — alternatives, never both
+ * 6. optional trailing `%`
+ * 7. optional trailing `+` ("at least this much")
*
* The `(?[~\u223C\u2248])?(?-)?(?[$€£¥])?(?\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+\.\d+|\d+)(?:(?[kKmMbBgGtT][bB]?)|(?[xX]))?(?%)?(?\+)?(?!\w)/g;
+
+/**
+ * Dash characters that can join the two endpoints of a numeric range:
+ * ASCII hyphen-minus, the U+2010–U+2015 dash block (hyphen, non-breaking
+ * hyphen, figure dash, en dash, em dash, horizontal bar) and U+2212 MINUS
+ * SIGN. A PDF extractor emits any of these where the author typed one dash,
+ * so recognising only `-` would make range detection depend on the font.
+ */
+const RANGE_DASH = /[\u002D\u2010-\u2015\u2212]/;
/**
* Verbs/phrasing that signal a bare integer is a headcount when they appear
@@ -59,56 +176,135 @@ const PEOPLE_VERB_PREFIX =
/**
* Nouns that signal a bare integer is a headcount when they appear just
- * after the digit. Anchored to `^\s*` so the noun has to be the first token
- * of the suffix slice.
+ * after the digit. Anchored to the start of the suffix slice, so the noun has
+ * to be its first token — separated by whitespace OR by one of `RANGE_DASH`'s
+ * dashes, because `12-person team` is the same claim as `12 people`, only
+ * attributively attached. Reading the hyphenated form as no claim at all is
+ * what made the legitimate reverse rewording (`a 12-person team` →
+ * `12 people`) look like an invented headcount under rule 3.
*/
-const PEOPLE_NOUN_FOLLOW =
- /^\s*(?:engineers?|developers?|designers?|analysts?|interns?|reports?|people|persons?|members?|employees?|contractors?|consultants?|staff|hires?|recruits?)\b/i;
+const PEOPLE_NOUN_FOLLOW = new RegExp(
+ `^(?:\\s|${RANGE_DASH.source})*(?:engineers?|developers?|designers?|analysts?|interns?|reports?|people|persons?|members?|employees?|contractors?|consultants?|staff|hires?|recruits?)\\b`,
+ "i",
+);
+
+/**
+ * What a `PEOPLE_VERB_PREFIX` match is allowed to run into when there is no
+ * people noun to confirm it: end-of-string, punctuation, or a closed list of
+ * prepositions/conjunctions. Anchored to the start of the suffix slice, same
+ * as `PEOPLE_NOUN_FOLLOW`.
+ *
+ * This is what stops the verb alone from claiming a headcount when a
+ * NON-people noun follows it — `Managed 5 projects` — where the verb is
+ * pointing at that noun, not at a headcount. `Managed 8 across the data
+ * platform` (noun elided) still qualifies, because `across` is in the list.
+ */
+const FUNCTION_WORD_FOLLOWS = new RegExp(
+ "^(?:\\s*$|\\s*[.,;:!?)]|\\s+(?:across|in|on|at|for|to|from|over|within|during|and|with|through|by|per)\\b)",
+ "i",
+);
/** How many characters of context to inspect on each side of a bare integer. */
const PEOPLE_CONTEXT_WINDOW = 30;
+/**
+ * WHY an atom is a numeric fact the guardrail defends — `"none"` when it is
+ * just a digit that happened to be in the text (`phase 2`, `1 of 5
+ * applicants`).
+ *
+ * The reason, not only the boolean, is load-bearing on the add side (rule 3):
+ * a value the OUTPUT reads as a headcount while the INPUT read it as nothing
+ * is a claim the model minted, whereas the same value merely re-grouped
+ * (`"form"`: `1200` → `1,200`) or re-spelled as a range (`"range"`: `50 to 100`
+ * → `50-100`) is the claim the user already made. `"form"` is the number's own
+ * written shape ($, %, x, ~, +, a magnitude suffix, grouping commas, a decimal
+ * point); the other three are the prose around a bare integer.
+ */
+type NumericClaim = "none" | "form" | "headcount" | "year" | "range";
+
interface ClassifiedAtom {
/** Match key used for set equality (lowercased so `$5K` ≡ `$5k`). */
key: string;
/** Human-readable form used in the UI warning (preserves original case). */
display: string;
+ /**
+ * Why this counts as a fact worth defending, or `"none"`. Unclaimed atoms are
+ * never reported as dropped or added, but they still count as PRESENT for the
+ * other side's lookup — see rules 1 and 3 in the module docblock.
+ */
+ claim: NumericClaim;
}
-function classifyAtom(
+function isClaimed(atom: ClassifiedAtom): boolean {
+ return atom.claim !== "none";
+}
+
+/**
+ * Is this match one endpoint of a tight numeric range (`50-100`)?
+ *
+ * Both directions have to be checked, because a range contributes two separate
+ * atoms and each has to be recognised from its own position: the left endpoint
+ * is followed by `dash digit`, the right endpoint is preceded by `digit dash`.
+ *
+ * "Tight" is load-bearing — `digit dash digit` with no whitespace. It is what
+ * separates a range from the two things that look like one:
+ * - `6-month window` / `day-7 retention` — the dash is followed by a letter
+ * that is not a people noun, so neither is a range and both bare integers
+ * stay unclaimed, as before.
+ * - `50 -100` — the ATOM already reads `-100` as a signed number, and it
+ * never reaches this check because a sign claims the atom verbatim.
+ */
+function isRangeEndpoint(match: RegExpExecArray, bullet: string): boolean {
+ const start = match.index;
+ const end = start + match[0].length;
+ const leftEndpoint =
+ RANGE_DASH.test(bullet[end] ?? "") && /\d/.test(bullet[end + 1] ?? "");
+ const rightEndpoint =
+ RANGE_DASH.test(bullet[start - 1] ?? "") &&
+ /\d/.test(bullet[start - 2] ?? "");
+ return leftEndpoint || rightEndpoint;
+}
+
+/**
+ * Does this atom carry a decoration that makes it a numeric fact on its own?
+ * Approximation, sign, currency, magnitude, multiplier, `%` and `+` all change
+ * what the number claims, so any of them is enough — no context needed.
+ */
+function isDecorated(groups: Record): boolean {
+ return (
+ groups.approx !== undefined ||
+ groups.sign !== undefined ||
+ groups.currency !== undefined ||
+ groups.magnitude !== undefined ||
+ groups.multiplier !== undefined ||
+ groups.percent !== undefined ||
+ groups.plus !== undefined
+ );
+}
+
+/**
+ * What, if anything, makes this bare integer worth defending, given the words
+ * around it?
+ *
+ * Three ways to qualify — a headcount, a year, or one endpoint of a tight
+ * range. Everything else ("the 3 of us", "phase 2", "section 4") is noise: it
+ * still produces an atom, so the other side can match against it, but it is
+ * never itself reported as dropped or added.
+ *
+ * The match index IS the digit index on this branch: every prefix decoration
+ * (approximation, sign, currency) implies `isDecorated`, so a caller that
+ * reaches here has nothing between `match.index` and the first digit.
+ *
+ * Years are checked before ranges, which is what keeps `2019-2021` scoring as a
+ * pair of years rather than a range — the two classifications now share the
+ * `num:` key namespace, but a 4-digit year qualifies without needing a dash, so
+ * `2019` in "between 2019 and 2021" stays claimed.
+ */
+function bareIntegerClaim(
match: RegExpExecArray,
bullet: string,
-): ClassifiedAtom | null {
- const [, sign, currency, digits, magnitude, percent] = match;
- const hasSign = Boolean(sign);
- const hasCurrency = Boolean(currency);
- const hasMagnitude = Boolean(magnitude);
- const hasPercent = Boolean(percent);
-
- const display =
- (hasSign ? "-" : "") +
- (hasCurrency ? currency! : "") +
- digits +
- (hasMagnitude ? magnitude! : "") +
- (hasPercent ? "%" : "");
- const key = display.toLowerCase();
-
- // Decorated tokens (currency / magnitude / % / comma groups / decimals
- // / explicit sign) are always tracked verbatim — they are unambiguously
- // meaningful numeric facts.
- if (
- hasCurrency ||
- hasMagnitude ||
- hasPercent ||
- hasSign ||
- digits.includes(",") ||
- digits.includes(".")
- ) {
- return { key, display };
- }
-
- // Bare integer. Inspect surrounding context to decide whether it's a
- // headcount, a year, or noise we should ignore.
+ digits: string,
+): NumericClaim {
const matchStart = match.index;
const before = bullet.slice(
Math.max(0, matchStart - PEOPLE_CONTEXT_WINDOW),
@@ -119,20 +315,73 @@ function classifyAtom(
matchStart + digits.length + PEOPLE_CONTEXT_WINDOW,
);
- if (PEOPLE_VERB_PREFIX.test(before) || PEOPLE_NOUN_FOLLOW.test(after)) {
- return { key: `headcount:${digits}`, display: digits };
+ // A people noun after the digit is decisive on its own.
+ if (PEOPLE_NOUN_FOLLOW.test(after)) {
+ return "headcount";
+ }
+ // The verb alone qualifies only when NO noun follows — the digit runs into
+ // a preposition, punctuation, or the end of the bullet. A non-people noun
+ // right after the digit (`Managed 5 projects`) means the verb is modifying
+ // that noun, not asserting a headcount.
+ if (PEOPLE_VERB_PREFIX.test(before) && FUNCTION_WORD_FOLLOWS.test(after)) {
+ return "headcount";
}
if (digits.length === 4) {
const year = Number(digits);
- if (year >= 1900 && year <= 2099) {
- return { key: `year:${digits}`, display: digits };
- }
+ if (year >= 1900 && year <= 2099) return "year";
}
- // Plain integer without people context or a year shape — too noisy to
- // track. Examples: "the 3 of us", "phase 2", "section 4".
- return null;
+ // A range endpoint (#778). `50-100 tickets` used to track NEITHER number:
+ // both are bare integers with no people context and no year shape, so a
+ // rewrite could drop the whole range and the guardrail scored it clean. The
+ // two endpoints are two independent atoms rather than one `50-100` atom, so a
+ // rewrite that re-spells the dash (`50–100`) or the whole range (`50 to 100`)
+ // still matches — the dash is detection context, never part of the key.
+ return isRangeEndpoint(match, bullet) ? "range" : "none";
+}
+
+function classifyAtom(match: RegExpExecArray, bullet: string): ClassifiedAtom {
+ const g = match.groups!;
+ const digits = g.digits!;
+
+ const prefix = (g.approx ?? "") + (g.sign ?? "") + (g.currency ?? "");
+ const suffix =
+ (g.magnitude ?? g.multiplier ?? "") + (g.percent ?? "") + (g.plus ?? "");
+ const display = prefix + digits + suffix;
+
+ // Grouping commas are presentation, not value: `1,200` and `1200` are the
+ // same figure spelled two ways. The KEY drops them so the two spellings
+ // match; `display` keeps whichever the author (or the model) wrote, because
+ // that is what the warning copy quotes back. Keying on the literal spelling
+ // is what made "Processed 1,200 orders" → "Processed 1200 orders" report a
+ // dropped `1,200` and revert a rewrite that changed nothing but the comma.
+ const value = digits.replace(/,/g, "");
+
+ if (isDecorated(g)) {
+ return {
+ key: (prefix + value + suffix).toLowerCase(),
+ display,
+ claim: "form",
+ };
+ }
+
+ // Undecorated. ONE key namespace regardless of what (if anything) qualified
+ // it — a headcount `12`, a year `2019`, a range endpoint `50` and a grouped
+ // `1,200` are the same value as the same digits written with no context and
+ // no commas, and splitting them by namespace is what made `Managed 12 people`
+ // → `Managed a 12-person team` report a dropped `12`.
+ return {
+ key: `num:${value}`,
+ display,
+ // Grouping and a decimal point are the figure's own written shape, so they
+ // claim it without needing context: `1,200` and `3.14` were written as
+ // figures on purpose.
+ claim:
+ digits.includes(",") || digits.includes(".")
+ ? "form"
+ : bareIntegerClaim(match, bullet, digits),
+ };
}
function extractNumbers(bullets: readonly string[]): ClassifiedAtom[] {
@@ -141,8 +390,7 @@ function extractNumbers(bullets: readonly string[]): ClassifiedAtom[] {
ATOM.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = ATOM.exec(bullet)) !== null) {
- const token = classifyAtom(match, bullet);
- if (token !== null) tokens.push(token);
+ tokens.push(classifyAtom(match, bullet));
}
}
return tokens;
@@ -156,49 +404,106 @@ export interface PreservationResult {
added: string[];
}
+/**
+ * Every claimed atom in `atoms` that `isPresent` cannot find on the other side,
+ * reported by its display form in first-encounter order, at most once per key.
+ *
+ * The presence test is a parameter rather than a set lookup because the two
+ * directions ask different questions of the other side (rule 3): a drop asks
+ * only whether the value survived anywhere, an invention asks whether the claim
+ * was there to begin with. Membership is set-shaped, not counted (rule 2).
+ */
+function missingFrom(
+ atoms: readonly ClassifiedAtom[],
+ isPresent: (atom: ClassifiedAtom) => boolean,
+): string[] {
+ const reported = new Set();
+ const missing: string[] = [];
+ for (const atom of atoms) {
+ if (!isClaimed(atom)) continue;
+ if (reported.has(atom.key) || isPresent(atom)) continue;
+ reported.add(atom.key);
+ missing.push(atom.display);
+ }
+ return missing;
+}
+
+/** How many UNCLAIMED atoms share each key — the drop side's masking guard. */
+function countUnclaimedByKey(
+ atoms: readonly ClassifiedAtom[],
+): Map {
+ const counts = new Map();
+ for (const atom of atoms) {
+ if (isClaimed(atom)) continue;
+ counts.set(atom.key, (counts.get(atom.key) ?? 0) + 1);
+ }
+ return counts;
+}
+
/**
* Check that every numeric fact from the input bullets survives into the
* rewritten bullets, and that no new numeric fact was invented.
*
- * Multiset semantics: two `5%`s in the input must both appear in the output.
- * Diff lists preserve the order tokens were encountered, so the UI can quote
- * them back to the user without sorting noise. Tokens are returned in their
- * original casing (`$5K`, not `$5k`).
+ * Set semantics: a number counts as preserved while it appears at all, so a
+ * licensed merge that de-duplicates a repeated metric is not a drop. Diff lists
+ * preserve the order tokens were encountered, so the UI can quote them back to
+ * the user without sorting noise. Tokens are returned in their original casing
+ * (`$5K`, not `$5k`).
*/
export function checkNumbersPreserved(
input: readonly string[],
output: readonly string[],
): PreservationResult {
- const inputTokens = extractNumbers(input);
- const outputTokens = extractNumbers(output);
+ const inputAtoms = extractNumbers(input);
+ const outputAtoms = extractNumbers(output);
- const outputCounts = new Map();
- for (const t of outputTokens) {
- outputCounts.set(t.key, (outputCounts.get(t.key) ?? 0) + 1);
- }
- const dropped: string[] = [];
- for (const t of inputTokens) {
- const remaining = outputCounts.get(t.key) ?? 0;
- if (remaining === 0) {
- dropped.push(t.display);
- } else {
- outputCounts.set(t.key, remaining - 1);
- }
- }
+ const inputKeys = new Set(inputAtoms.map((a) => a.key));
+ const outputKeys = new Set(outputAtoms.map((a) => a.key));
+ // Only the values the INPUT itself asserted as facts. A headcount the output
+ // asserts has to land in here, not merely in `inputKeys` — see rule 3.
+ const inputClaimedKeys = new Set(
+ inputAtoms.filter(isClaimed).map((a) => a.key),
+ );
+ const outputClaimedKeys = new Set(
+ outputAtoms.filter(isClaimed).map((a) => a.key),
+ );
+ // Per-key counts of UNCLAIMED occurrences on each side — the baseline of
+ // "this digit shows up elsewhere for unrelated reasons" that already
+ // existed before the rewrite touched anything. Only headcount needs this:
+ // it is the one claim kind with a real unclaimed state on the same key
+ // (`num:12` from "team of 12" vs `num:12` from "module 12" of a curriculum).
+ const inputUnclaimedCounts = countUnclaimedByKey(inputAtoms);
+ const outputUnclaimedCounts = countUnclaimedByKey(outputAtoms);
- const inputCounts = new Map();
- for (const t of inputTokens) {
- inputCounts.set(t.key, (inputCounts.get(t.key) ?? 0) + 1);
- }
- const added: string[] = [];
- for (const t of outputTokens) {
- const remaining = inputCounts.get(t.key) ?? 0;
- if (remaining === 0) {
- added.push(t.display);
- } else {
- inputCounts.set(t.key, remaining - 1);
- }
- }
+ // Drop: the value is gone from the output in every spelling (rule 1) —
+ // except for a headcount, which counts as present only if the output
+ // claims it too, OR a NEW unclaimed occurrence of the key appears that
+ // wasn't already there before the rewrite (the "phase 5" masking case
+ // below, which rule 1 is right to treat as a reword, not a loss). Without
+ // the "new" qualifier, an unrelated digit the input ALREADY carried
+ // unclaimed (e.g. "module 12" sitting next to a genuinely-dropped
+ // "12 engineers") would mask the drop just by surviving unchanged — the
+ // masking bug rule 1's blanket `outputKeys.has` used to have. `form`/
+ // `range`/`year` keep the fully lenient lookup: those claims genuinely
+ // depend on prose a rewrite is licensed to move (or, for `year`, have no
+ // unclaimed state to compare against at all), so tightening them risks a
+ // false revert on a legitimate reword rule 1 exists to allow.
+ const dropped = missingFrom(inputAtoms, (atom) => {
+ if (atom.claim !== "headcount") return outputKeys.has(atom.key);
+ if (outputClaimedKeys.has(atom.key)) return true;
+ return (
+ (outputUnclaimedCounts.get(atom.key) ?? 0) >
+ (inputUnclaimedCounts.get(atom.key) ?? 0)
+ );
+ });
+ // Invention: the same lookup, except that a headcount the output asserts is
+ // "present" only if the input asserted that value too (rule 3). `phase 5` →
+ // `5 engineers` reuses the digit while making a claim the résumé never made.
+ const added = missingFrom(outputAtoms, (atom) =>
+ atom.claim === "headcount"
+ ? inputClaimedKeys.has(atom.key)
+ : inputKeys.has(atom.key),
+ );
return {
ok: dropped.length === 0 && added.length === 0,
diff --git a/src/lib/webllm/rewrite-guardrails.ts b/src/lib/webllm/rewrite-guardrails.ts
index cebb6ae5..f7263e44 100644
--- a/src/lib/webllm/rewrite-guardrails.ts
+++ b/src/lib/webllm/rewrite-guardrails.ts
@@ -33,7 +33,7 @@
* The single most important rule in the tree, and the one #609 required to
* have exactly one definition.
*
- * The deterministic `checkNumbersPreserved` multiset diff is what actually
+ * The deterministic `checkNumbersPreserved` set diff is what actually
* catches a violation after the fact; this sentence is what stops the model
* producing one. Both halves are needed — the check can only warn, it cannot
* repair — so neither is redundant with the other.
diff --git a/src/lib/webllm/rewrite-resume.test.ts b/src/lib/webllm/rewrite-resume.test.ts
index d5d5425d..0beba8f7 100644
--- a/src/lib/webllm/rewrite-resume.test.ts
+++ b/src/lib/webllm/rewrite-resume.test.ts
@@ -113,6 +113,7 @@ describe("buildResumeContext", () => {
data: {
bullets: ["Shipped Foo to 10M users."],
numbersPreserved: true,
+ reverted: false,
droppedNumbers: [],
addedNumbers: [],
},
@@ -132,6 +133,7 @@ describe("buildResumeContext", () => {
data: {
bullets: [long],
numbersPreserved: true,
+ reverted: false,
droppedNumbers: [],
addedNumbers: [],
},
@@ -249,7 +251,7 @@ describe("rewriteResumeWithLlm", () => {
);
});
- it("aggregates allNumbersPreserved across sections", async () => {
+ it("reverts the one section that dropped a metric, leaving the others rewritten (#778)", async () => {
const { engine } = makeEngine(async (req: ChatCompletionRequest) => {
// Drop a metric on the second call only.
const ord = req.messages[1]!.content;
@@ -266,13 +268,45 @@ describe("rewriteResumeWithLlm", () => {
TEST_MODEL,
() => {},
);
- expect(result.allNumbersPreserved).toBe(false);
- expect(result.sections[0]!.kind === "experience" && result.sections[0]!.data.numbersPreserved).toBe(
- true,
+ const [first, second] = result.sections;
+ expect(first!.kind === "experience" && first!.data.reverted).toBe(false);
+ expect(first!.kind === "experience" && first!.data.bullets).toEqual([
+ "Drove $1.2M ARR.",
+ ]);
+ // The dropping section keeps the user's own bullet — one bad section does
+ // not cost them the rest of the run.
+ expect(second!.kind === "experience" && second!.data.reverted).toBe(true);
+ expect(second!.kind === "experience" && second!.data.bullets).toEqual([
+ "Saved $5K per quarter",
+ ]);
+ // Every number reached the user, so the aggregate is true — which is why
+ // the UI reads `reverted` and not this flag alone.
+ expect(result.allNumbersPreserved).toBe(true);
+ });
+
+ it("reverts a section that invents a metric, and still reports the token", async () => {
+ // Invention is gated too since the #778 widening, so the aggregate reads
+ // true (the original invents nothing) and `reverted` is what carries the
+ // story — the same pairing the drop case already had.
+ const { engine } = makeEngine(async () =>
+ reply("Drove revenue with 99.9% availability."),
);
- expect(result.sections[1]!.kind === "experience" && result.sections[1]!.data.numbersPreserved).toBe(
- false,
+ const sections: SectionInput[] = [
+ experienceSection("experience:0", "Acme", ["Drove revenue"]),
+ ];
+ const result = await rewriteResumeWithLlm(
+ sections,
+ engine,
+ TEST_MODEL,
+ () => {},
);
+ expect(result.sections[0]!.data.reverted).toBe(true);
+ const section = result.sections[0]!;
+ expect(section.kind === "experience" && section.data.bullets).toEqual([
+ "Drove revenue",
+ ]);
+ expect(result.sections[0]!.data.addedNumbers).toEqual(["99.9%"]);
+ expect(result.allNumbersPreserved).toBe(true);
});
it("fires webllm_resume_rewrite_started and _completed exactly once per run", async () => {
@@ -291,6 +325,7 @@ describe("rewriteResumeWithLlm", () => {
model: TEST_MODEL,
sectionCount: 1,
allNumbersPreserved: true,
+ anyReverted: false,
});
});
@@ -309,6 +344,7 @@ describe("rewriteResumeWithLlm", () => {
inputUnitCount: 1,
outputUnitCount: 1,
numbersPreserved: true,
+ reverted: false,
});
expect(sectionCompletedMock).toHaveBeenNthCalledWith(2, {
model: TEST_MODEL,
@@ -317,9 +353,40 @@ describe("rewriteResumeWithLlm", () => {
inputUnitCount: 1,
outputUnitCount: 1,
numbersPreserved: true,
+ reverted: false,
});
});
+ it("reports the MODEL's number preservation, not the delivered outcome, on a revert", async () => {
+ // The delivered bullets are the user's own after a revert, so
+ // `data.numbersPreserved` is true by construction. Passing that through
+ // would flip what `numbers_preserved` measures mid-release; the series has
+ // to keep describing the model. `reverted` is the new dimension that says
+ // the gate fired. Mirrors `rewrite-section.ts`.
+ const { engine } = makeEngine(async () => reply("Drove revenue."));
+ const sections: SectionInput[] = [
+ experienceSection("experience:0", "Acme", ["Drove $1.2M in ARR"]),
+ ];
+ const result = await rewriteResumeWithLlm(
+ sections,
+ engine,
+ TEST_MODEL,
+ () => {},
+ );
+ expect(result.sections[0]!.data.reverted).toBe(true);
+ expect(sectionCompletedMock).toHaveBeenCalledWith(
+ expect.objectContaining({ numbersPreserved: false, reverted: true }),
+ );
+ expect(completedMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ allNumbersPreserved: false,
+ anyReverted: true,
+ }),
+ );
+ // The value the UI reads is unchanged — it is a property of what shipped.
+ expect(result.allNumbersPreserved).toBe(true);
+ });
+
it("fires webllm_first_resume_rewrite exactly once per model", async () => {
const { engine } = makeEngine(async () => reply("Built X."));
const sections: SectionInput[] = [
@@ -490,8 +557,8 @@ describe("app findings reach the rewrite prompt (#608)", () => {
it("still catches a fabricated number when a finding names one", async () => {
// A finding is allowed to mention a number (a critique suggestion routinely
- // does). If the model copies it into the résumé, `allNumbersPreserved` must
- // still report the invention — a finding must not become a fabrication
+ // does). If the model copies it into the résumé, the gate must still see
+ // and reject the invention — a finding must not become a fabrication
// vector that the deterministic checker stops seeing.
const numeric = new Map([
[
@@ -510,10 +577,15 @@ describe("app findings reach the rewrite prompt (#608)", () => {
() => {},
{ findings: numeric },
);
- expect(result.allNumbersPreserved).toBe(false);
+ expect(result.sections[0]!.data.reverted).toBe(true);
// `checkNumbersPreserved` tokenizes the percent with its unit, so the
// invented token is "40%" — the point is that it is reported as ADDED.
expect(result.sections[0]!.data.addedNumbers).toContain("40%");
+ // And the fabrication never reaches the résumé.
+ const section = result.sections[0]!;
+ expect(section.kind === "experience" && section.data.bullets).toEqual([
+ "Worked on the payments API",
+ ]);
});
});
diff --git a/src/lib/webllm/rewrite-resume.ts b/src/lib/webllm/rewrite-resume.ts
index 27f46d23..004c84f3 100644
--- a/src/lib/webllm/rewrite-resume.ts
+++ b/src/lib/webllm/rewrite-resume.ts
@@ -108,6 +108,14 @@ export interface ResumeRewriteResult {
* True iff every section's `numbersPreserved` was true. The UI aggregates
* each section's `dropped` / `added` tokens for the warning copy, so the
* orchestrator only needs the boolean.
+ *
+ * Since #778 this is a property of what the user RECEIVED, so a section
+ * whose rewrite was rejected counts as preserved — the original preserves
+ * itself. It is therefore no longer sufficient to drive the panel's tone:
+ * read `sections[].data.reverted` alongside it, or a run where every section
+ * was rejected renders as a clean success. Now that invention reverts too,
+ * the only way this reads false is a section whose generation came back
+ * empty — there is no rewrite there to reject.
*/
allNumbersPreserved: boolean;
}
@@ -239,13 +247,21 @@ export async function rewriteResumeWithLlm(
completed.push(outcome);
accumulateOutcomeSignals(outcome, usedVerbs, usedPhrases);
+ // Telemetry measures the MODEL, not the delivered outcome — the same rule
+ // `rewrite-section.ts` follows. `outcome.data.numbersPreserved` is a
+ // property of what the user RECEIVED and is therefore true on every revert,
+ // so passing it through would flip `numbers_preserved` from "the model kept
+ // the numbers" to "the user got clean numbers" with no rename and no way to
+ // compare against anything logged before #778. Re-derive it from the raw
+ // diff lists and report the gate separately as `reverted`.
trackWebllmResumeRewriteSectionCompleted({
model: modelId,
sectionIndex: i,
sectionKind: outcome.kind,
inputUnitCount: inputUnitCountOf(section),
outputUnitCount: outputUnitCountOf(outcome),
- numbersPreserved: outcome.data.numbersPreserved,
+ numbersPreserved: modelPreservedNumbers(outcome),
+ reverted: outcome.data.reverted,
});
}
@@ -254,7 +270,10 @@ export async function rewriteResumeWithLlm(
trackWebllmResumeRewriteCompleted({
model: modelId,
sectionCount: totalSections,
- allNumbersPreserved,
+ // Aggregate of the same MODEL-facing property, not of the delivered
+ // `allNumbersPreserved` the function returns to the UI.
+ allNumbersPreserved: completed.every(modelPreservedNumbers),
+ anyReverted: completed.some((o) => o.data.reverted),
});
// Per-model one-shot — only counts a run that produced at least one
@@ -278,6 +297,19 @@ export async function rewriteResumeWithLlm(
return { sections: completed, allNumbersPreserved };
}
+/**
+ * Did the MODEL'S raw output preserve the section's numbers? Re-derived from
+ * the diff lists rather than read off `data.numbersPreserved`, which since #778
+ * describes the delivered units and is true on every revert by construction.
+ * This is the property the telemetry series has always carried.
+ */
+function modelPreservedNumbers(outcome: SectionOutcome): boolean {
+ return (
+ outcome.data.droppedNumbers.length === 0 &&
+ outcome.data.addedNumbers.length === 0
+ );
+}
+
function isNonEmptySection(section: SectionInput): boolean {
if (section.kind === "summary") return section.text.trim().length > 0;
return section.bullets.some((b) => b.trim().length > 0);
diff --git a/src/lib/webllm/rewrite-section.test.ts b/src/lib/webllm/rewrite-section.test.ts
index b50c1c47..52106cfc 100644
--- a/src/lib/webllm/rewrite-section.test.ts
+++ b/src/lib/webllm/rewrite-section.test.ts
@@ -166,8 +166,17 @@ describe("rewriteSectionWithLlm", () => {
);
// Three input bullets, three output bullets — but the model could
// legitimately return 2 or 4, and the function must handle it.
+ //
+ // The inputs carry the same figures the stub returns so the number gate is
+ // a no-op here: this test is about line splitting, and a stub that invents
+ // `10M` against a numberless input would revert and measure the gate
+ // instead.
const out = await rewriteSectionWithLlm(
- ["worked on Foo", "led the team", "sold things"],
+ [
+ "worked on Foo for 10M users",
+ "led 5 engineers, p99 latency 40%",
+ "sold things, $1.2M ARR",
+ ],
engine,
TEST_MODEL,
);
@@ -208,7 +217,7 @@ describe("rewriteSectionWithLlm", () => {
expect(out.addedNumbers).toEqual([]);
});
- it("flags numbersPreserved=false with the specific dropped token", async () => {
+ it("rejects a rewrite that drops a number and returns the input bullets (#778)", async () => {
const { engine } = makeEngine(async () =>
reply("Saved the team some money each quarter."),
);
@@ -217,21 +226,72 @@ describe("rewriteSectionWithLlm", () => {
engine,
TEST_MODEL,
);
- expect(out.numbersPreserved).toBe(false);
+ // The user gets their own bullet back, verbatim — the model's fluent but
+ // figure-less rewrite never reaches them.
+ expect(out.bullets).toEqual(["Saved the team $5K per quarter."]);
+ expect(out.reverted).toBe(true);
+ // `numbersPreserved` describes what was DELIVERED, so it is true here:
+ // nothing reached the user missing. `droppedNumbers` still names what the
+ // model lost, which is what the revert notice quotes.
+ expect(out.numbersPreserved).toBe(true);
expect(out.droppedNumbers).toEqual(["$5K"]);
});
- it("flags an invented number in added", async () => {
+ it("keeps a rewrite that carries every number through (#778)", async () => {
const { engine } = makeEngine(async () =>
- reply("Improved availability to 99.9%."),
+ reply("Cut $5K per quarter from the team's cloud spend."),
+ );
+ const out = await rewriteSectionWithLlm(
+ ["Saved the team $5K per quarter."],
+ engine,
+ TEST_MODEL,
+ );
+ expect(out.bullets).toEqual([
+ "Cut $5K per quarter from the team's cloud spend.",
+ ]);
+ expect(out.reverted).toBe(false);
+ expect(out.numbersPreserved).toBe(true);
+ });
+
+ it("does NOT revert a merge that moves a number to another bullet (#778)", async () => {
+ // Section rewrite is allowed to merge; the gate is a whole-section
+ // set diff precisely so a number changing bullets is not a drop.
+ const { engine } = makeEngine(async () =>
+ reply("Cut cloud spend $5K per quarter while lifting uptime 40%."),
+ );
+ const out = await rewriteSectionWithLlm(
+ ["Saved the team $5K per quarter.", "Lifted uptime 40%."],
+ engine,
+ TEST_MODEL,
);
+ expect(out.reverted).toBe(false);
+ expect(out.bullets).toHaveLength(1);
+ });
+
+ it("does NOT revert an empty generation — that is a failure, not a drop (#778)", async () => {
+ const { engine } = makeEngine(async () => reply(" "));
const out = await rewriteSectionWithLlm(
- ["Improved availability."],
+ ["Saved the team $5K per quarter."],
engine,
TEST_MODEL,
);
- expect(out.numbersPreserved).toBe(false);
+ expect(out.bullets).toEqual([]);
+ expect(out.reverted).toBe(false);
+ });
+
+ it("reverts an invented number and reports it in added", async () => {
+ // The widened #778 gate: invention reverts exactly like a drop, so the
+ // user's own bullet comes back and `addedNumbers` carries the evidence.
+ const original = ["Improved availability."];
+ const { engine } = makeEngine(async () =>
+ reply("Improved availability to 99.9%."),
+ );
+ const out = await rewriteSectionWithLlm(original, engine, TEST_MODEL);
+ expect(out.bullets).toEqual(original);
+ expect(out.reverted).toBe(true);
expect(out.addedNumbers).toEqual(["99.9%"]);
+ // Property of what was DELIVERED — the original invents nothing.
+ expect(out.numbersPreserved).toBe(true);
});
it("fires webllm_section_rewrite_started and _completed with counts and preservation flag", async () => {
@@ -248,6 +308,27 @@ describe("rewriteSectionWithLlm", () => {
inputBulletCount: 3,
outputBulletCount: 2,
numbersPreserved: true,
+ reverted: false,
+ });
+ });
+
+ it("reports the MODEL's preservation plus a reverted flag in telemetry (#778)", async () => {
+ const { engine } = makeEngine(async () =>
+ reply("Saved the team some money each quarter."),
+ );
+ await rewriteSectionWithLlm(
+ ["Saved the team $5K per quarter."],
+ engine,
+ TEST_MODEL,
+ );
+ // `numbers_preserved` keeps measuring the raw generation so the series is
+ // comparable across the release; `reverted` is what says the gate fired.
+ expect(trackCompletedMock).toHaveBeenCalledWith({
+ model: TEST_MODEL,
+ inputBulletCount: 1,
+ outputBulletCount: 1,
+ numbersPreserved: false,
+ reverted: true,
});
});
diff --git a/src/lib/webllm/rewrite-section.ts b/src/lib/webllm/rewrite-section.ts
index 2e413e8f..73224f5e 100644
--- a/src/lib/webllm/rewrite-section.ts
+++ b/src/lib/webllm/rewrite-section.ts
@@ -6,8 +6,10 @@ import {
trackWebllmSectionRewriteCompleted,
trackWebllmSectionRewriteStarted,
} from "../analytics.ts";
-import { cleanRewriteLine } from "./post-process.ts";
-import { checkNumbersPreserved } from "./preserve-numbers.ts";
+import {
+ applyNumberPreservation,
+ cleanRewriteLine,
+} from "./post-process.ts";
import {
composeRulesPrompt,
keepIfAlreadyStrongRule,
@@ -143,11 +145,19 @@ export interface SectionRewriteOptions {
}
export interface SectionRewriteResult {
- /** The rewritten bullets (M may differ from N). */
+ /**
+ * What the user gets (M may differ from N) — the model's rewrite, or the
+ * input bullets unchanged when the #778 gate rejected it.
+ */
bullets: string[];
- /** True iff every input number survived and none were invented. */
+ /** True iff `bullets` carries every input number and invents none. */
numbersPreserved: boolean;
- /** Numeric tokens that did not survive (UI surfaces these inline). */
+ /**
+ * The rewrite dropped a number and was rejected; `bullets` is the input
+ * (#778). The panel must say so — see `NumberPreservationWarning`.
+ */
+ reverted: boolean;
+ /** Numeric tokens the model dropped (UI surfaces these inline). */
droppedNumbers: string[];
/** Numeric tokens that appeared from nowhere (UI surfaces these inline). */
addedNumbers: string[];
@@ -220,13 +230,24 @@ export async function rewriteSectionWithLlm(
.map((line) => cleanRewriteLine(line))
.filter((line) => line.length > 0);
- const preservation = checkNumbersPreserved(bullets, rewrittenBullets);
+ // #778: a rewrite that drops OR invents a number is rejected here and the
+ // input bullets are returned instead.
+ const outcome = applyNumberPreservation(bullets, rewrittenBullets);
+ // Telemetry measures the MODEL, not the delivered outcome: every property
+ // below describes `rewrittenBullets`, so `numbers_preserved` means the
+ // same thing after #778 as before it and the series stays comparable
+ // across the release. `reverted` is what says whether the gate fired: the
+ // gate now covers both halves, so `numbers_preserved: false` with
+ // `reverted: false` narrows to a generation that came back empty.
trackWebllmSectionRewriteCompleted({
model: modelId,
inputBulletCount: bullets.length,
outputBulletCount: rewrittenBullets.length,
- numbersPreserved: preservation.ok,
+ numbersPreserved:
+ outcome.droppedNumbers.length === 0 &&
+ outcome.addedNumbers.length === 0,
+ reverted: outcome.reverted,
});
// Same gating as the per-bullet path: only count the first *successful*
@@ -241,10 +262,11 @@ export async function rewriteSectionWithLlm(
}
return {
- bullets: rewrittenBullets,
- numbersPreserved: preservation.ok,
- droppedNumbers: preservation.dropped,
- addedNumbers: preservation.added,
+ bullets: outcome.bullets,
+ numbersPreserved: outcome.numbersPreserved,
+ reverted: outcome.reverted,
+ droppedNumbers: outcome.droppedNumbers,
+ addedNumbers: outcome.addedNumbers,
};
} finally {
releaseInference(modelId);
diff --git a/src/lib/webllm/rewrite-summary.test.ts b/src/lib/webllm/rewrite-summary.test.ts
index 65356056..0aad955b 100644
--- a/src/lib/webllm/rewrite-summary.test.ts
+++ b/src/lib/webllm/rewrite-summary.test.ts
@@ -98,7 +98,13 @@ describe("rewriteSummaryWithLlm", () => {
const { engine } = makeEngine(async () =>
reply("Senior engineer.\nLed a team of 5.\nShipped 3 products."),
);
- const out = await rewriteSummaryWithLlm("Engineer.", engine, TEST_MODEL);
+ // Input carries the headcount the stub returns so the number gate stays a
+ // no-op — this test is about line collapsing, not about the gate.
+ const out = await rewriteSummaryWithLlm(
+ "Engineer with a team of 5.",
+ engine,
+ TEST_MODEL,
+ );
expect(out.text).toBe("Senior engineer. Led a team of 5. Shipped 3 products.");
});
@@ -122,7 +128,7 @@ describe("rewriteSummaryWithLlm", () => {
expect(out.numbersPreserved).toBe(true);
});
- it("flags numbersPreserved=false when a metric is dropped", async () => {
+ it("rejects a rewrite that drops a metric and returns the original paragraph (#778)", async () => {
const { engine } = makeEngine(async () =>
reply("Senior engineer with a decade of experience."),
);
@@ -131,10 +137,25 @@ describe("rewriteSummaryWithLlm", () => {
engine,
TEST_MODEL,
);
- expect(out.numbersPreserved).toBe(false);
+ expect(out.text).toBe("I drove $5K in revenue per quarter.");
+ expect(out.reverted).toBe(true);
+ expect(out.numbersPreserved).toBe(true);
expect(out.droppedNumbers).toEqual(["$5K"]);
});
+ it("leaves an empty generation empty rather than reverting it (#778)", async () => {
+ // A blank response is a failed generation the caller already handles; the
+ // gate must not dress it up as "kept your original".
+ const { engine } = makeEngine(async () => reply(" "));
+ const out = await rewriteSummaryWithLlm(
+ "I drove $5K in revenue per quarter.",
+ engine,
+ TEST_MODEL,
+ );
+ expect(out.text).toBe("");
+ expect(out.reverted).toBe(false);
+ });
+
it("returns an empty text on null model content without throwing", async () => {
const { engine } = makeEngine(async () => reply(null));
const out = await rewriteSummaryWithLlm("Engineer.", engine, TEST_MODEL);
diff --git a/src/lib/webllm/rewrite-summary.ts b/src/lib/webllm/rewrite-summary.ts
index 6d24b379..d7dfbd77 100644
--- a/src/lib/webllm/rewrite-summary.ts
+++ b/src/lib/webllm/rewrite-summary.ts
@@ -1,8 +1,10 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 The offlinecv Authors
-import { cleanRewriteLine } from "./post-process.ts";
-import { checkNumbersPreserved } from "./preserve-numbers.ts";
+import {
+ applyNumberPreservation,
+ cleanRewriteLine,
+} from "./post-process.ts";
import {
composeRulesPrompt,
DROP_FILLER_RULE,
@@ -29,10 +31,11 @@ import { acquireInference, releaseInference } from "./web-llm.ts";
* the cross-model `acquireInference` lock can defer `.unload()` while
* this call is in flight.
* - Runs the deterministic number-preservation check after the model
- * responds (same `checkNumbersPreserved` used by the section path —
- * the multiset diff is shape-agnostic).
- * - Returns `numbersPreserved` + `dropped/added` so the UI can surface
- * the same inline warning shape as the section path.
+ * responds (same `applyNumberPreservation` used by the section path —
+ * the set diff is shape-agnostic), including the #778 reject gate:
+ * a rewrite that drops or invents a number returns the ORIGINAL paragraph.
+ * - Returns `numbersPreserved` + `reverted` + `dropped/added` so the UI can
+ * surface the same inline warning shape as the section path.
*
* Output handling: paragraphs sometimes come back across multiple lines if
* the model adds a wrap (or hallucinates a `Rewritten:` echo). We run each
@@ -112,11 +115,20 @@ export interface SummaryRewriteOptions {
}
export interface SummaryRewriteResult {
- /** The rewritten summary paragraph. Empty string when the model returned nothing. */
+ /**
+ * What the user gets. Empty string when the model returned nothing; the
+ * ORIGINAL paragraph when the #778 gate rejected the rewrite.
+ */
text: string;
- /** True iff every input number survived and none were invented. */
+ /** True iff `text` carries every input number and invents none. */
numbersPreserved: boolean;
- /** Numeric tokens that did not survive (UI surfaces these inline). */
+ /**
+ * The rewrite dropped or invented a number and was rejected; `text` is the
+ * original paragraph (#778). The panel must say so rather than showing an
+ * empty diff.
+ */
+ reverted: boolean;
+ /** Numeric tokens the model dropped (UI surfaces these inline). */
droppedNumbers: string[];
/** Numeric tokens that appeared from nowhere (UI surfaces these inline). */
addedNumbers: string[];
@@ -166,13 +178,21 @@ export async function rewriteSummaryWithLlm(
.join(" ")
.trim();
- const preservation = checkNumbersPreserved([summary], text ? [text] : []);
+ // #778, applied to the one-unit paragraph shape: `applyNumberPreservation`
+ // works on arrays, so wrap and unwrap. A blank generation stays blank —
+ // the gate deliberately leaves an empty rewrite to the caller's
+ // failed-generation handling rather than dressing it up as "kept yours".
+ const outcome = applyNumberPreservation(
+ [summary],
+ text ? [text] : [],
+ );
return {
- text,
- numbersPreserved: preservation.ok,
- droppedNumbers: preservation.dropped,
- addedNumbers: preservation.added,
+ text: outcome.bullets[0] ?? "",
+ numbersPreserved: outcome.numbersPreserved,
+ reverted: outcome.reverted,
+ droppedNumbers: outcome.droppedNumbers,
+ addedNumbers: outcome.addedNumbers,
};
} finally {
releaseInference(modelId);
diff --git a/tests/fixtures/rewrite/reports/README.md b/tests/fixtures/rewrite/reports/README.md
index 0eacf089..d99f1723 100644
--- a/tests/fixtures/rewrite/reports/README.md
+++ b/tests/fixtures/rewrite/reports/README.md
@@ -97,6 +97,74 @@ runs show `Numbers` between 0% and 80% (#778) and `Dedup` at 0% in eight of
nine cells — unrelated to #608, but visible in these files and worth its own
look rather than being read as noise.
+> **The `Numbers` reading above describes the 2026-08-07 runs and the code as
+> it stood then.** #778 has since changed what the criterion measures — see
+> "Reading `Numbers` after #778" below before comparing a new run to these.
+
+## Reading `Numbers` after #778
+
+#778 made the number-preservation guardrail binding rather than advisory: a
+rewrite that would drop a concrete number **or invent one that was not in the
+input** is **rejected**, and the user keeps their original bullets
+(`applyNumberPreservation` in `src/lib/webllm/post-process.ts`). The eval
+harness runs the same gate before scoring, so `Numbers` is now a rate over what
+a user would have *received*.
+
+Three consequences for anyone reading a report:
+
+1. **A high `Numbers` no longer means the model kept the figures.** Read it
+ with the new **`Reverted`** column, which is the share of cells the gate
+ rejected. `100% / 0%` is a model that got it right; `100% / 60%` is the gate
+ carrying it. `Reverted` is deliberately excluded from `Aggregate` — a revert
+ is the guardrail working, and scoring it as a criterion in either direction
+ would corrupt the number a default-model choice is made from.
+2. **`Numbers` is now ~100% by construction, so it carries almost no signal
+ on its own.** The gate covers both halves the criterion measures — dropping
+ and inventing — so every cell that produced any output at all is either a
+ clean rewrite or a revert, and both score `PASS`. The only route to a `fail`
+ left is a generation that came back empty, which the gate deliberately does
+ not touch. `Reverted` is the column that carries the model's actual
+ behaviour; the earlier drop-only gate is why the invention-only cells (the
+ `weak-marketing-generalist` cells, most obviously) used to score `fail`
+ instead.
+3. **Every other criterion is scored on the reverted output too.** That is
+ intentional, not a bug: a reverted `redundant` fixture honestly scores
+ `Dedup: fail`, because the user's bullets were not deduped.
+
+The per-cell `Reverted` column carries the tokens that triggered each rejection
+(`REVERTED: $4.2M, 14%`), because the rubric can no longer re-derive them — the
+scored bullets *are* the input once a cell reverts. Dropped tokens are listed
+first and invented ones after, undifferentiated: the cell's verdict is the same
+either way, and if anything the invented half is the worse one — a dropped
+figure costs the user a true claim they can put back, an invented one would
+have put a false claim in the document they hand an employer.
+
+**The 2026-08-07 reports predate all of this.** Their `Numbers` column is the
+old measurement (did the raw generation keep every figure) and they carry no
+`Reverted` column at all, exactly as the 2026-06-23 pair carries no `Steering`
+column. Do not read the two generations of the column as one series.
+
+**No post-#778 run is committed yet.** The gate and the extraction fixes it
+rests on are unit-tested (`post-process.test.ts`, `preserve-numbers.test.ts`),
+but a fresh `npm run eval:rewrite` on a WebGPU machine is still owed here
+before any claim about the shipped model's post-fix rate is made from this
+directory. #778's ~100% `Numbers` target is now reachable *by construction* —
+both halves of the criterion revert — but "by construction" is an argument, not
+a measurement, and the run is what would show whether `Reverted` lands at 10% or
+at 90%. That share, not `Numbers`, is the number worth waiting for.
+
+### Why prompt tuning was not tried first
+
+Recorded because it is the obvious question a reader of a future run will ask.
+`PRESERVE_NUMBERS_RULE` has been in every rewrite prompt since #609 and the
+2026-08-07 runs measured all three registry models breaking it, including under
+the `terse` variant that strips every competing instruction. The models in
+`MODEL_REGISTRY` (1.5B–3B) are not reliable enough at "carry these tokens
+through verbatim" for a wording change to be expected to move the number, so
+#778 built the deterministic backstop instead. Prompt tuning is worth
+revisiting when a future model generation makes small-model instruction
+adherence trustworthy — not before.
+
A third anomaly is in these files and no criterion catches it: **19 of the
24 Gemma 2 `terse` bullets ship literal markdown bold** — `"**Led** the
migration of the billing platform…"`. Those are `perBullet[].text`, i.e.