Archilyzer · Source

archilyzer

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

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:
Acommon/lib/citations/verify.test.ts | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/citations/verify.ts | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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; +}