commit 71772a5621457f1b5a85aae8aa3c2377f07d6acb
parent 7cb67429095ae4c2df3ddbe4e35c552ce2706195
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 19 Sep 2026 02:22:01 -0400
umtool: `incorrect` is a verdict, and a note is not always a complaint
The mutual exclusion was the wrong shape. A correction on a GOOD clip is a
real thing to want to write -- why its window moved, what a reader should know
-- and refusing it forced the operator to choose between the note and the
verdict.
So there are two values. `confirmed` takes an optional note; `incorrect`
REQUIRES one, because an incorrect clip with nothing written is a complaint
nobody can act on. The requirement is a property of the entry AFTER the patch,
which makes both halves one check: setting `incorrect` with no note and
clearing the note off a clip that is already `incorrect` are the same error and
get the same sentence.
Legacy is read, not migrated: a `correction` with no `verdict` was written by
`x` before `incorrect` existed (two of them are in a live manifest) and means
exactly that. Keying the requirement on the explicit value also means those
entries can still be cleared -- a rule that refused to let somebody undo a note
they wrote before the rule existed would be a trap.
In the bench the note carries the verdict with it: writing one on a clip
nobody has confirmed sends `{correction, verdict: "incorrect"}` as ONE patch,
so the manifest never holds a complaint with no verdict. On a confirmed clip it
is just a note and the verdict is left alone. `y` keeps the note and the state
reads "confirmed · with note". `x` arms the box as required and saving stays
put, because the note is there to be read back against the clip.
`umtool corrections` splits on the verdict rather than on "has text": the
defects under the existing heading, the notes on confirmed clips under their
own, so the next pass is not sent to fix a clip nobody complained about.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
6 files changed, 170 insertions(+), 113 deletions(-)
diff --git a/umtool/bin/umtool.mjs b/umtool/bin/umtool.mjs
@@ -19,7 +19,7 @@
// umtool kinds [--json]
// umtool window <project> <clip> [--start S] [--end E] [--lock] [--lock-end]
// [--title T] [--date YYYY-MM-DD] [--cite S] [--cite-url U] [--quote Q]
-// [--correction TEXT] [--verdict confirmed|'']
+// [--correction TEXT] [--verdict confirmed|incorrect|'']
// umtool corrections <project> what the report got wrong, as markdown for the next pass
// umtool build <project> [--preset preview|fast|final] [--only ID] [--dry]
// umtool index [--rebuild] [--prune] [--since MS] [--json]
@@ -444,7 +444,7 @@ function usage() {
" umtool window <project> <clip> [--start S] [--end E] [--lock|--lock-end|…]",
" attribution too: --title, --date YYYY-MM-DD, --cite, --cite-url, --quote",
" --correction TEXT what the REPORT got wrong here (never rendered)",
- " --verdict confirmed the walk agrees this clip is what it says it is",
+ " --verdict confirmed|incorrect what the walk said; incorrect needs --correction",
" an empty value (--title '') deletes the field",
" umtool corrections <project> what the report got wrong + the walk's coverage",
" umtool build <project> [--preset preview|fast|final] [--only ID]",
@@ -526,8 +526,8 @@ async function cmdWindow() {
["--cite-url", "citeUrl"],
["--quote", "quote"],
["--correction", "correction"],
- // Not attribution: whether the walk has looked at this clip. "confirmed",
- // or '' to clear it. A non-empty correction is the other answer.
+ // Not attribution: what the walk said about this clip. "confirmed" or
+ // "incorrect" ('' clears it); the writer refuses `incorrect` with no note.
["--verdict", "verdict"],
]) {
if (val(flag) !== undefined) patch[key] = val(flag);
@@ -586,16 +586,30 @@ async function cmdCorrections() {
if (json) return out({ project: p.id, corrections: rows, review });
const summary =
- `${review.total} clips: ${review.corrected} corrected, ` +
- `${review.confirmed} confirmed, ${review.unreviewed} not yet reviewed` +
+ `${review.total} clips: ${review.incorrect} incorrect, ` +
+ `${review.confirmed} confirmed (${review.confirmedWithNote} with a note), ` +
+ `${review.unreviewed} not yet reviewed` +
(review.unreviewedIds.length ? ` (ids: ${review.unreviewedIds.join(", ")})` : "");
- console.log(`# Corrections for the next pass — ${manifest.title ?? p.id}\n`);
- console.log(`${summary}\n`);
- if (!rows.length) return console.log("No corrections: nobody has said the report got anything wrong here.");
- for (const c of rows) {
+ // Split by VERDICT, not by "has text". A note on a confirmed clip says why a
+ // good clip's window moved; printing it under the same heading as a defect
+ // sends the next pass off to fix something nobody complained about.
+ const wrong = rows.filter((c) => c.verdict === "incorrect");
+ const notes = rows.filter((c) => c.verdict !== "incorrect");
+ const bullet = (c) => {
console.log(`- **${c.id}** · ${c.channel}/${c.video} @ ${hms(c.at)} · <${c.href}>`);
console.log(` ${c.text}`);
+ };
+
+ console.log(`# Corrections for the next pass — ${manifest.title ?? p.id}\n`);
+ console.log(`${summary}\n`);
+ if (!wrong.length) console.log("No corrections: nobody has said the report got anything wrong here.");
+ for (const c of wrong) bullet(c);
+ if (notes.length) {
+ console.log(`\n## Notes on confirmed clips\n`);
+ console.log(`These clips are RIGHT. The note says why the window moved, or what a`);
+ console.log(`reader should know -- not what to fix.\n`);
+ for (const c of notes) bullet(c);
}
}
diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx
@@ -56,25 +56,35 @@ type Clip = {
lock: boolean;
lockStart: boolean;
lockEnd: boolean;
- /** "confirmed", or null for "nobody has looked at this one yet". */
- verdict: "confirmed" | null;
+ /** "confirmed" / "incorrect", or null for "nobody has looked at this yet". */
+ verdict: "confirmed" | "incorrect" | null;
};
/**
* Has anybody looked at this clip, and did they agree with the description?
*
* The same three answers lib/projects/report.mjs's clipVerdict() reads out of
- * the manifest, from the copy this component holds. A `correction` IS the "no"
- * answer, so it wins on its own -- the writer keeps the two exclusive.
+ * the manifest, from the copy this component holds -- LEGACY entries included:
+ * a `correction` with no verdict was written before `incorrect` existed and
+ * means exactly that.
*/
-const verdictOf = (c: Clip): "corrected" | "confirmed" | "unreviewed" =>
- (c.correction ?? "").trim() ? "corrected" : c.verdict === "confirmed" ? "confirmed" : "unreviewed";
+const verdictOf = (c: Clip): "incorrect" | "confirmed" | "unreviewed" => {
+ if (c.verdict === "confirmed" || c.verdict === "incorrect") return c.verdict;
+ return (c.correction ?? "").trim() ? "incorrect" : "unreviewed";
+};
-const VERDICT_LABEL = {
- corrected: "corrected",
- confirmed: "confirmed",
- unreviewed: "not yet reviewed",
-} as const;
+/**
+ * What the state line says.
+ *
+ * "confirmed · with note" is its own reading rather than a badge somewhere
+ * else: a note on a good clip -- why its window moved, a caveat for the
+ * writers -- must never be mistaken for a complaint about the clip.
+ */
+const verdictLabel = (c: Clip): string => {
+ const v = verdictOf(c);
+ if (v === "confirmed") return (c.correction ?? "").trim() ? "confirmed · with note" : "confirmed";
+ return v === "incorrect" ? "incorrect" : "not yet reviewed";
+};
/** The five fields that decide the burned-in header and the QR. */
const ATTRIB = [
@@ -83,7 +93,7 @@ const ATTRIB = [
["cite", "cite", "The second the header prints, in absolute source seconds. Empty uses the clip's start."],
["citeUrl", "citeUrl", "Where the QR points. Empty derives it from the archive. Set it when the clip is cut from a mirror that reads better."],
["quote", "quote", "The words this clip exists for. Not drawn on screen — it is what the cut is checked against."],
- ["correction", "correction", "What the report got wrong here — wrong speaker, wrong addressee, wrong date. Read by the next report pass, never rendered."],
+ ["correction", "correction", "On an incorrect clip: what the report got wrong — wrong speaker, wrong addressee, wrong date, read by the next report pass. On a confirmed one it is just a note: why the window moved, or something a reader should know. Never rendered."],
] as const;
type AttribKey = (typeof ATTRIB)[number][0];
@@ -116,7 +126,7 @@ const fromEntry = (prev: Clip, e: Record<string, unknown>): Clip => ({
lock: !!e.lock,
lockStart: !!e.lockStart,
lockEnd: !!e.lockEnd,
- verdict: e.verdict === "confirmed" ? "confirmed" : null,
+ verdict: e.verdict === "confirmed" || e.verdict === "incorrect" ? e.verdict : null,
});
export type ClipBenchData = {
@@ -180,6 +190,10 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
const [busy, setBusy] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const [draft, setDraft] = useState<Record<AttribKey, string>>(() => emptyDraft(data.clip));
+ // `x` was pressed and nothing has been typed yet: the box is REQUIRED until
+ // it has something in it. An incorrect verdict with no note is a complaint
+ // nobody can act on, so the bench asks for the note before it writes one.
+ const [needNote, setNeedNote] = useState(false);
const [segment, setSegment] = useState(data.segment);
const [segmentMtime, setSegmentMtime] = useState(data.segmentMtime);
@@ -333,13 +347,33 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
[doSave],
);
- /** Persist one attribution field, on blur or Enter, if it actually changed. */
+ /**
+ * Persist one attribution field, on blur or Enter, if it actually changed.
+ *
+ * `correction` is the exception, and it is the whole "no" answer: writing a
+ * note on a clip nobody has confirmed IS saying the clip is wrong, so the
+ * verdict rides along in the SAME patch -- one write, one token, and never a
+ * moment where the manifest holds a complaint with no verdict. On a clip
+ * already confirmed it is just a note and the verdict is left alone.
+ */
const commit = useCallback(
(k: AttribKey) => {
if (draft[k] === attribValue(clip, k)) return;
+ if (k === "correction") {
+ const text = draft.correction.trim();
+ // Armed and still empty: keep the cue and write nothing. Blurring out
+ // of a box you were asked to fill is not an answer.
+ if (!text && needNote && clip.verdict !== "incorrect") return;
+ if (text && clip.verdict !== "confirmed") {
+ void save({ correction: draft.correction, verdict: "incorrect" }).then((ok) => {
+ if (ok) setNeedNote(false);
+ });
+ return;
+ }
+ }
void save({ [k]: draft[k] });
},
- [draft, clip, save],
+ [draft, clip, save, needNote],
);
// ---- the walk's verdict ---------------------------------------------------
@@ -347,19 +381,18 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
// "Is this clip what the report says it is" is the question the walk exists
// to answer, and it has two answers: yes, or a note saying what is wrong.
// Confirming ADVANCES, because the next clip is what you want in the yes
- // case; saying no stays put, because the note has to be typed.
+ // case; saying no stays put, because the note has to be typed -- and then
+ // re-read, which is why it still stays put once it is saved.
const confirmClip = useCallback(async () => {
- if ((clip.correction ?? "").trim()) {
- // A clip cannot be both. Refused here as well as in the writer, so the
- // answer arrives before the round trip rather than as a 400.
- setNote("this clip has a correction; clear it first if it is actually fine");
- return;
- }
+ // A note survives a confirmation. It stops being a complaint and becomes
+ // what it now says it is: why this clip is here in the shape it is in.
const ok = await save({ verdict: "confirmed" });
+ if (ok) setNeedNote(false);
if (ok && data.next) router.push(`/browse/${data.project}/clip/${data.next}`);
- }, [clip.correction, save, router, data.project, data.next]);
+ }, [save, router, data.project, data.next]);
const rejectClip = useCallback(() => {
+ setNeedNote(true);
const el = correctionBox.current;
if (!el) return;
el.focus();
@@ -945,13 +978,19 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
className={
verdict === "confirmed"
? "text-[var(--color-good)]"
- : verdict === "corrected"
+ : verdict === "incorrect"
? "text-[var(--color-dirty)]"
: "text-[var(--color-dim)]"
}
>
- {VERDICT_LABEL[verdict]}
+ {verdictLabel(clip)}
</span>
+ {needNote && !draft.correction.trim() && (
+ <span className="text-[11px] text-[var(--color-dirty)]">
+ the note is the answer — type it below, then blur or <kbd>Enter</kbd>. Saving
+ stays here, so you can read it back against the clip.
+ </span>
+ )}
</div>
{data.sourceTitle == null && (
@@ -977,6 +1016,9 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
return (
<label key={k} className="block" title={why}>
<span className="font-mono text-[11px] text-[var(--color-text)]">{label}</span>
+ {k === "correction" && needNote && !draft.correction.trim() && (
+ <span className="ml-2 text-[11px] text-[var(--color-dirty)]">required</span>
+ )}
{changed && (
<span className="ml-2 text-[11px] text-[var(--color-dirty)]">
unsaved — was {attribValue(clip, k) || "(empty)"}
@@ -996,7 +1038,11 @@ export default function ClipBench({ data }: { data: ClipBenchData }) {
}
onChange={(e) => setDraft((d) => ({ ...d, [k]: e.target.value }))}
onBlur={() => commit(k)}
- className="mt-0.5 w-full rounded border border-[var(--color-line)] bg-[var(--color-panel-2)] px-2 py-1 text-[12px]"
+ className={`mt-0.5 w-full rounded border bg-[var(--color-panel-2)] px-2 py-1 text-[12px] ${
+ k === "correction" && needNote && !draft.correction.trim()
+ ? "border-[var(--color-dirty)]"
+ : "border-[var(--color-line)]"
+ }`}
/>
) : (
<input
diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx
@@ -60,7 +60,8 @@ export default async function ClipBenchPage({
lock: !!entry.lock,
lockStart: !!entry.lockStart,
lockEnd: !!entry.lockEnd,
- verdict: entry.verdict === "confirmed" ? "confirmed" : null,
+ verdict:
+ entry.verdict === "confirmed" || entry.verdict === "incorrect" ? entry.verdict : null,
},
view,
windows: windows.map((w: { name: string; from: number; to: number }) => ({
diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx
@@ -79,6 +79,7 @@ export default async function ReportProject({
at: number;
href: string;
text: string;
+ verdict: "confirmed" | "incorrect" | "unreviewed";
}[];
const clips = entries.filter((e) => e.kind === "clip");
// How much of the cut has been walked. One definition, shared with the bench
@@ -192,7 +193,7 @@ export default async function ReportProject({
</Link>
<span className="text-[11px] text-[var(--color-dim)]" data-walk-progress="">
<kbd>p</kbd> / <kbd>n</kbd> move between clips · {review.reviewed} of {review.total}{" "}
- reviewed{review.corrected > 0 ? ` · ${review.corrected} corrected` : ""}
+ reviewed{review.incorrect > 0 ? ` · ${review.incorrect} incorrect` : ""}
</span>
</div>
)}
@@ -511,10 +512,24 @@ export default async function ReportProject({
data-corrections=""
className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2"
>
- <h2 className="micro mb-1.5">corrections for the next pass — {corrections.length}</h2>
+ <h2 className="micro mb-1.5">
+ corrections for the next pass —{" "}
+ {corrections.filter((c) => c.verdict === "incorrect").length}
+ {corrections.some((c) => c.verdict !== "incorrect") &&
+ ` · ${corrections.filter((c) => c.verdict !== "incorrect").length} notes on confirmed clips`}
+ </h2>
<ul className="space-y-1.5">
{corrections.map((c) => (
- <li key={c.id} data-correction={c.id} className="text-[12px] leading-snug">
+ <li
+ key={c.id}
+ data-correction={c.id}
+ data-correction-verdict={c.verdict}
+ className="text-[12px] leading-snug"
+ >
+ {/* A note on a CONFIRMED clip is not a defect, and a reader
+ who cannot tell the two apart goes off to fix a clip
+ nobody complained about. */}
+ {c.verdict !== "incorrect" && <Pill>note · confirmed</Pill>}{" "}
<Link
href={`/browse/${project.id}/clip/${c.id}`}
className="font-mono text-[11px] text-[var(--color-sel)] hover:underline"
diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs
@@ -125,26 +125,36 @@ export function correctionsOf(m) {
at: e.cite ?? e.start ?? 0,
href: citeUrlFor(m, e),
text: String(e.correction).trim(),
+ // What the walk said about the clip this text is attached to. A note on a
+ // CONFIRMED clip is not a defect in the report, and a reader who cannot
+ // tell the two apart will go and "fix" a clip nobody complained about.
+ verdict: clipVerdict(e),
}));
}
/**
* Has anybody looked at this clip, and did they agree with the description?
*
- * Three answers out of two keys, and the absent key is the point: a manifest
- * nobody has walked says so by carrying neither, rather than by carrying
- * `"verdict": "unreviewed"` on every entry -- which would read like a decision
- * and would have to be written before the walk could start.
+ * "confirmed" -> the description is accurate. A `correction` here is a NOTE:
+ * why a good clip's window moved, a caveat for the writers.
+ * "incorrect" -> it is not, and the `correction` says how. The writer refuses
+ * one without the other.
+ * absent -> nobody has been here yet.
*
- * A `correction` IS the "no" answer, so it wins on its own. The writer keeps the
- * two mutually exclusive (see updateClip), so this never has to guess.
+ * LEGACY: a clip carrying a `correction` and no `verdict` was written by the
+ * bench's `x` before `incorrect` existed -- two of them are in a live manifest
+ * -- and it means exactly what `incorrect` means. Read that way rather than
+ * migrated: a rewrite of somebody's manifest to teach this file a value it can
+ * already infer is a worse trade than one branch.
*/
-export const clipVerdict = (e) =>
- String(e?.correction ?? "").trim()
- ? "corrected"
- : e?.verdict === "confirmed"
- ? "confirmed"
- : "unreviewed";
+export const clipVerdict = (e) => {
+ const v = e?.verdict;
+ if (v === "confirmed" || v === "incorrect") return v;
+ return String(e?.correction ?? "").trim() ? "incorrect" : "unreviewed";
+};
+
+/** Does this clip carry a written note, whatever the verdict says? */
+export const clipHasNote = (e) => !!String(e?.correction ?? "").trim();
/**
* The walk's coverage: how much of the cut has been looked at, and what is left.
@@ -153,16 +163,22 @@ export const clipVerdict = (e) =>
* corrections` -- the same reason correctionsOf() is one definition.
*/
export function reviewOf(m) {
- const rows = clipsOf(m).map((e) => ({ id: e.id, verdict: clipVerdict(e) }));
+ const rows = clipsOf(m).map((e) => ({
+ id: e.id,
+ verdict: clipVerdict(e),
+ note: clipHasNote(e),
+ }));
const of = (v) => rows.filter((r) => r.verdict === v);
return {
total: rows.length,
clips: rows,
confirmed: of("confirmed").length,
- corrected: of("corrected").length,
+ /** Confirmed AND annotated: a note on a good clip is not a defect. */
+ confirmedWithNote: of("confirmed").filter((r) => r.note).length,
+ incorrect: of("incorrect").length,
unreviewed: of("unreviewed").length,
unreviewedIds: of("unreviewed").map((r) => r.id),
- /** Confirmed or corrected: somebody has been here and said something. */
+ /** Confirmed or incorrect: somebody has been here and said something. */
reviewed: rows.length - of("unreviewed").length,
};
}
diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs
@@ -193,73 +193,38 @@ export async function updateClip(dir, clipId, patch, { token = null } = {}) {
// ---- the walk's verdict -------------------------------------------------
//
// Whether somebody has LOOKED at this clip and said the description is what
- // the clip actually is. Three states and only two keys, because an absent
- // key is the honest way to say "nobody has been here":
+ // the clip actually is. Three states, and an absent key is the honest way
+ // to say "nobody has been here":
//
- // correction non-empty -> corrected (the no answer IS the note)
- // verdict === "confirmed" -> confirmed
- // neither -> not yet reviewed
+ // verdict: "confirmed" -> the description is accurate
+ // verdict: "incorrect" -> it is not, and `correction` says how
+ // neither -> not yet reviewed
//
- // A clip cannot be both, so the two keys are kept mutually exclusive HERE
- // rather than by asking every reader to pick a winner: writing a correction
- // drops a stale confirmation, and confirming a clip that still carries one
- // is refused rather than quietly overwriting the note.
- if (String(entry.correction ?? "").trim()) delete entry.verdict;
-
+ // `correction` is OPTIONAL under a confirmed verdict and REQUIRED under an
+ // incorrect one. The two are not the same sentence: a note on a good clip
+ // is why its window moved or a caveat for the writers, while an incorrect
+ // clip with no note is a complaint nobody can act on. So the rule is a
+ // property of the ENTRY AFTER the patch, which makes both halves of it one
+ // check -- setting `incorrect` with no note, and clearing the note off a
+ // clip that is already `incorrect`, are the same error.
+ //
+ // LEGACY, and deliberately not migrated: a clip carrying a `correction` and
+ // no `verdict` predates `incorrect` and READS as incorrect (clipVerdict()).
+ // The requirement above is keyed on the explicit value, so those entries can
+ // still be cleared -- a rule that refused to let somebody undo a note they
+ // wrote before the rule existed would be a trap.
if (patch.verdict !== undefined) {
const v = String(patch.verdict ?? "").trim();
if (!v) delete entry.verdict;
- else if (v !== "confirmed") {
- throw new Error(`verdict must be "confirmed", or empty to clear it (got \`${v}\`)`);
- } else if (String(entry.correction ?? "").trim()) {
- throw new Error(
- "this clip has a correction; clear it first if the description is actually accurate",
- );
- } else entry.verdict = "confirmed";
- }
-
- if (patch.date !== undefined) {
- const v = String(patch.date ?? "").trim();
- if (!v) delete entry.date;
- else if (!isCalendarDate(v)) {
+ else if (v !== "confirmed" && v !== "incorrect") {
throw new Error(
- `date must be a real calendar date written YYYY-MM-DD (got \`${v}\`)`,
+ `verdict must be "confirmed" or "incorrect", or empty to clear it (got \`${v}\`)`,
);
- } else entry.date = v;
+ } else entry.verdict = v;
}
- if (patch.cite !== undefined) {
- // null or empty means "no cite", and the header then falls back to the
- // clip's own start -- which is what `entry.cite ?? entry.start` has always
- // done. Rounded like a window, for the same reason.
- if (patch.cite === null || patch.cite === "") delete entry.cite;
- else {
- const v = Number(patch.cite);
- if (!Number.isFinite(v) || v < 0) {
- throw new Error("cite must be a number of seconds ≥ 0, or empty to use the clip's start");
- }
- entry.cite = round2(v);
- }
- }
-
- if (patch.citeUrl !== undefined) {
- const v = String(patch.citeUrl ?? "").trim();
- if (!v) delete entry.citeUrl;
- else {
- // The QR target. A value that is not an http(s) URL encodes to something
- // a phone camera opens and nothing answers -- the same class of defect
- // as the 19 codes that shipped reading `undefined/?v=…`.
- let u = null;
- try {
- u = new URL(v);
- } catch {
- /* handled below */
- }
- if (!u || (u.protocol !== "http:" && u.protocol !== "https:")) {
- throw new Error(`citeUrl must be an http:// or https:// URL (got \`${v}\`)`);
- }
- entry.citeUrl = v;
- }
+ if (entry.verdict === "incorrect" && !String(entry.correction ?? "").trim()) {
+ throw new Error("an incorrect verdict needs its note: say what the report got wrong");
}
const nextToken = await writeManifestAtomic(dir, manifest);