Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mumtool/bin/umtool.mjs | 34++++++++++++++++++++++++----------
Mumtool/components/projects/ClipBench.tsx | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mumtool/components/projects/ClipBenchPage.tsx | 3++-
Mumtool/components/projects/ReportProject.tsx | 21++++++++++++++++++---
Mumtool/lib/projects/report.mjs | 46+++++++++++++++++++++++++++++++---------------
Mumtool/lib/report/manifest.mjs | 81+++++++++++++++++++++++--------------------------------------------------------
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);