commit 3189cc259ad00aca07f2f3974318d0f88b573813
parent 57bd41f23d9f1394b0495d6278592fcf47240c0b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 03:23:25 -0400
common: quote verification (lib/citations/verify.ts) — token recall of a quote against its cue window or its post, one method string, a drift threshold
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 146 insertions(+), 0 deletions(-)
diff --git a/common/lib/citations/verify.test.ts b/common/lib/citations/verify.test.ts
@@ -0,0 +1,49 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ QUOTE_CHECK_METHOD,
+ QUOTE_DRIFT_THRESHOLD,
+ cueWindowText,
+ quoteDrifted,
+ quoteTokens,
+ quoteVerification,
+ tokenRecall,
+} from "./verify";
+
+test("tokens: lowercased, punctuation stripped, split on whitespace", () => {
+ assert.deepEqual(quoteTokens("Don't stop — the U.S. bridge, OK?"), ["dont", "stop", "the", "us", "bridge", "ok"]);
+ assert.deepEqual(quoteTokens("Café 2019\nnew"), ["café", "2019", "new"]);
+ assert.deepEqual(quoteTokens("…"), []);
+});
+
+test("recall: a verbatim quote scores 1 however much more the window says", () => {
+ assert.equal(tokenRecall("I was there for it.", "The bridge opened in spring, I was there for it. Anyway."), 1);
+});
+
+test("recall: each window token is used once; missing tokens lower the score", () => {
+ assert.equal(tokenRecall("the the the", "the"), 1 / 3);
+ assert.equal(tokenRecall("he opened the bridge himself", "the bridge opened"), 3 / 5);
+ assert.equal(tokenRecall("", "anything"), 0);
+ assert.equal(tokenRecall("words", ""), 0);
+});
+
+test("the cue window takes every cue overlapping the span ± 5 s", () => {
+ const cues = [
+ { start: 0, end: 4, text: "too early" },
+ { start: 4, end: 6, text: "edge before" },
+ { start: 10, end: 20, text: "inside" },
+ { start: 24, end: 26, text: "edge after" },
+ { start: 26, end: 30, text: "too late" },
+ ];
+ assert.equal(cueWindowText(cues, 10, 20), "edge before inside edge after");
+ assert.equal(cueWindowText(cues, 10, 20, 0), "inside");
+});
+
+test("the block: rounded score, the time, the method; drift below the threshold", () => {
+ const v = quoteVerification("he opened the bridge himself", "the bridge opened", "2026-10-05T12:00:00.000Z");
+ assert.deepEqual(v, { quoteScore: 0.6, quoteCheckedAt: "2026-10-05T12:00:00.000Z", method: QUOTE_CHECK_METHOD });
+ assert.equal(quoteDrifted(v), false);
+ assert.equal(quoteDrifted({ quoteScore: QUOTE_DRIFT_THRESHOLD - 0.01 }), true);
+ assert.equal(quoteDrifted({}), true);
+ assert.ok(!("voiceChecked" in v), "a voice is never vouched for");
+});
diff --git a/common/lib/citations/verify.ts b/common/lib/citations/verify.ts
@@ -0,0 +1,97 @@
+// QUOTE VERIFICATION — how compose checks that a citation's `quote` is what
+// the record says at the cited place, and the block it writes.
+//
+// THE METHOD (QUOTE_CHECK_METHOD, written into every block so a reader knows
+// what the number means):
+//
+// 1. The record's text at the cited place: for a span, the text of every cue
+// overlapping [start − 5 s, end + 5 s] (QUOTE_WINDOW_SLACK_SECONDS) — a cue
+// boundary is where a caption line wrapped, so a quote may run a little
+// past the span; for a post, the post's text.
+// 2. Both are normalised the same way: lowercased (Unicode-aware), every
+// character that is not a letter, a digit or whitespace removed (so
+// "don't" is "dont" and "U.S." is "us"), split on whitespace.
+// 3. The score is TOKEN RECALL: the share of the quote's tokens found in the
+// record's tokens, each record token used at most once (a multiset). A
+// verbatim quote scores 1 however much more the window says; a quote
+// with no tokens scores 0.
+//
+// The score is rounded to two decimals. Below QUOTE_DRIFT_THRESHOLD the quote
+// has DRIFTED from the record (a paraphrase, the wrong span, cues that moved
+// since the quote was taken) and compose fails on it — CITATIONS.md promises
+// a quote is verbatim, and this is the check that holds it to that.
+//
+// The block is COMPUTED: compose overwrites whatever a document carried
+// (lib/citations/schema.ts verificationSchema), and nothing here can vouch for
+// a voice, so `voiceChecked` is never written.
+//
+// Pure, no imports but types: the export site and the browser can use it.
+
+import type { CitationVerification } from "./schema";
+
+export const QUOTE_WINDOW_SLACK_SECONDS = 5;
+
+export const QUOTE_DRIFT_THRESHOLD = 0.6;
+
+export const QUOTE_CHECK_METHOD =
+ "token recall v1: quote vs the cues within ±5 s of the span (a post: its text); lowercase, punctuation stripped";
+
+type TimedText = { start: number; end: number; text: string };
+
+// A text's comparison tokens (step 2 above).
+export function quoteTokens(text: string): string[] {
+ return text
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}\s]/gu, "")
+ .split(/\s+/)
+ .filter(Boolean);
+}
+
+// The share of `quote`'s tokens found in `text` (step 3), unrounded.
+export function tokenRecall(quote: string, text: string): number {
+ const want = quoteTokens(quote);
+ if (want.length === 0) return 0;
+ const have = new Map<string, number>();
+ for (const t of quoteTokens(text)) have.set(t, (have.get(t) ?? 0) + 1);
+ let found = 0;
+ for (const t of want) {
+ const n = have.get(t) ?? 0;
+ if (n > 0) {
+ found++;
+ have.set(t, n - 1);
+ }
+ }
+ return found / want.length;
+}
+
+// The text of every cue overlapping [start − slack, end + slack], in order.
+export function cueWindowText(
+ cues: readonly TimedText[],
+ start: number,
+ end: number,
+ slack = QUOTE_WINDOW_SLACK_SECONDS,
+): string {
+ const from = start - slack;
+ const to = end + slack;
+ return cues
+ .filter((c) => c.end > from && c.start < to)
+ .map((c) => c.text)
+ .join(" ");
+}
+
+export function roundScore(score: number): number {
+ return Math.round(score * 100) / 100;
+}
+
+// The verification block for a quote checked against `text` at `checkedAt`.
+export function quoteVerification(quote: string, text: string, checkedAt: string): CitationVerification {
+ return {
+ quoteScore: roundScore(tokenRecall(quote, text)),
+ quoteCheckedAt: checkedAt,
+ method: QUOTE_CHECK_METHOD,
+ };
+}
+
+export function quoteDrifted(v: Pick<CitationVerification, "quoteScore">): boolean {
+ return (v.quoteScore ?? 0) < QUOTE_DRIFT_THRESHOLD;
+}