import { describe, expect, test } from "bun:test" import { readFileSync } from "node:fs" import { join } from "node:path" import { valueChecks } from "./fidelity-values.mjs" import { buildContext, checkStatic } from "./gates-static.mjs" import { parseHtml } from "./html-lite.mjs" import { isClaimSegment, numeralKinds, sourcesClaims } from "./sources-citation.mjs" import { type ClassRow, GLUED_LETTER_ROWS, SHAPE_ROWS } from "./tests/numeral-classes" const FIX = join(import.meta.dir, "tests", "fixtures") const EMPTY = { schemaVersion: 1, rows: [], events: [] } const GATED = new Set(["unsourced_number", "untraceable_number", "verification_claim_unmarked"]) /** The codes the static gate, the number trace, and the claim check raise over one document. */ function gated(html: string) { const doc = parseHtml(html) const fidelity = valueChecks(buildContext(doc, {}), EMPTY, null, null) as { code: string }[] return [...(checkStatic(doc).defects as { code: string }[]), ...fidelity].map((d) => d.code).filter((code) => GATED.has(code)) } const underSources = (body: string) => gated(`

Official sources

${body}`) // Round-4 ruling, guard: a digit run glued to a unit, multiplier, or currency stays a numeral, in a // reference-shaped line (EN, KO) and in a Korean sentence where a particle follows the unit. const QUANTITIES = ["50M", "10k", "3x", "5.4T", "12%", "2.5GB", "10ms", "320m", "40percent"] const GUARD = [ ...QUANTITIES.flatMap((q) => [ `
  • Annual filing ${q}, https://example.com/filing
  • `, `
  • \uC5F0\uAC04 \uACF5\uC2DC ${q}, https://example.com/filing
  • `, `

    \uADDC\uBAA8\uB294 ${q}\uB85C \uC9D1\uACC4\uB410\uB2E4.

    `, ]), "
  • Q3 revenue 12%, https://example.com/q3
  • ", "
  • Q3 \uB9E4\uCD9C 12%, https://example.com/q3
  • ", "
  • 3\uBD84\uAE30 \uB9E4\uCD9C 12%, https://example.com/q3
  • ", "
  • Market data, USD5bn in 2025, https://example.com/fx
  • ", ] // Round-4 date ruling: a month/day is a date (a citation field), a day count is not. const DATES = [ "
  • Fed minutes 2026-09-16 (released 10/26), https://example.com/minutes
  • ", "
  • Press release, Oct 26, https://example.com/press
  • ", "
  • Press release, October 26, 2026, https://example.com/press
  • ", "
  • \uC5F0\uC900 \uC758\uC0AC\uB85D (10/26 \uACF5\uAC1C), https://example.com/minutes
  • ", "
  • \uBCF4\uB3C4\uC790\uB8CC 10\uC6D4 26\uC77C, https://example.com/press
  • ", "
  • \uBCF4\uB3C4\uC790\uB8CC 2026. 10. 26., https://example.com/press
  • ", ] const DAY_COUNTS = [ "
  • Trial period 30 days, https://example.com/trial
  • ", "

    The pilot ran for 30 days.

    ", "
  • \uC2DC\uBC94 \uC6B4\uC601 30\uC77C\uAC04, https://example.com/trial
  • ", "

    \uC2DC\uBC94 \uC6B4\uC601\uC740 30\uC77C \uB3D9\uC548 \uC9C4\uD589\uB410\uB2E4.

    ", ] // Round 5: a unit, multiplier, or percent on the right keeps a digit run a numeral even with letters // glued on its left (a Korean particle after the unit allowed), a multiplier glued on the left (x3) is a // numeral, mn / pct / yr(s) / mo are units, and a European digit grouping is a numeral (EN and KO). const GLUED_MEASURES = [ "

    \uB9E4\uCD9C40% \uC99D\uAC00.

    ", "
  • \uC804\uB144\uB300\uBE4430%, https://example.com/yoy
  • ", "

    \uC57D300\uBA85\uC774 \uCC38\uC5EC\uD588\uB2E4.

    ", "

    Model B is x3 faster.

    ", "

    \uC18D\uB3C4\uB294 x3 \uBE68\uB790\uB2E4.

    ", "
  • Churn rate40%, https://example.com/churn
  • ", "

    Grew 5mn users.

    ", "

    Up 40pct.

    ", "

    5yrs of data.

    ", "
  • Retention 18mo, https://example.com/retention
  • ", "
  • \uC5F0\uAC04 \uC131\uC7A5 40pct, https://example.com/growth
  • ", "

    1.234.567 users signed up.

    ", "

    \uAC00\uC785\uC790\uB294 1.234.567\uBA85\uC774\uB2E4.

    ", "
  • Annual report (10-K), https://example.com/filing
  • ", ] // Decision 25: under Sources a sentence is checked unless every numeral in it is a citation-field number. // Receipt cases from review rounds 1-3, EN and KO (Korean as \u escapes). const MUST_FLAG = [ "

    [S4] says revenue rose 40% in 2025.

    ", "

    [S4] Revenue rose 40% in 2025, the highest on record.

    ", "
  • Revenue rose 40% in 2025 according to https://example.com/report
  • ", "

    [S4] Annual report 2024, https://example.com/report
    The check found 42% of spans out of tolerance.

    ", "

    Revenue was 40% higher in 2025.

    ", "

    Revenue totaled 5 billion dollars in 2025.

    ", "

    Note: the market share is 42% as of 2025.

    ", "

    Exports surged 31% to 12 billion in 2025.

    ", "

    [S4] Revenue hit $5 billion in 2025.

    ", "

    [S4] The agency estimates 3.2 million users.

    ", "

    Revenue was 40 percent higher in 2025.

    ", "

    Market share is 42 percent.

    ", "

    Model B is 3x faster than model A.

    ", "

    The survey covered 1,200 firms in 2025.

    ", "

    The dataset includes 3412 reports.

    ", "

    We reviewed the filings. Market share reached 42% in 2025. Source: https://example.com/a

    ", "

    Note. Revenue grew 40% in 2025. Accessed 2025-10-01.

    ", "

    See the notes below. Unemployment was 4.1% in September. 2025.

    ", "
  • [S5] Bloomberg (2025). Revenue grew 40% as AI demand reached record. https://example.com/bloomberg
  • ", "

    [S19] Treasury buyback $94B 2026-10-08: https://example.com/2026/10/08/buybacks

    ", "
  • Llama 3.1 model card, Meta, https://example.com/llama
  • ", "
  • Survey 2025, wave 2, https://example.com/survey
  • ", "

    \uC810\uC720\uC728\uC740 42%\uC774\uB2E4.

    ", "

    \uB9E4\uCD9C\uC740 2025\uB144 40% \uB298\uC5B4 5\uC870\uC6D0\uC744 \uAE30\uB85D\uD588\uB2E4.

    ", "

    \uC9C1\uC6D0 \uC218\uB294 1,250\uBA85\uC774\uB2E4.

    ", "

    We ran the check and it reported 42%.

    ", ...GUARD, ...DAY_COUNTS, ...GLUED_MEASURES, ] // Round-4 ruling, must-pass: a digit run glued to letters that are not a unit is a name (commit hashes, // #issue numbers, identifiers, quarter and filing codes); link text is a citation field; a year range is a date. const NAMES = [ "
  • letta-code @ a75f4d93ef / @ 701f2a53678, https://example.com/letta
  • ", "
  • \uACE0\uC815 \uCEE4\uBC0B 22c5684b983e, 424B4, https://example.com/repo
  • ", "
  • oh-my-openagent PR #6674\u2013#7906, https://example.com/prs
  • ", "
  • \uC774\uC288 #11302 \uB17C\uC758, https://example.com/issue
  • ", "
  • GPT-4 system card v2.3.1 (abc123), https://example.com/gpt
  • ", "
  • GPT-4 \uC2DC\uC2A4\uD15C \uCE74\uB4DC v2.3.1 (abc123), https://example.com/gpt
  • ", "
  • Cohort table Q3-2024, filing 2024-Q3-F1, Oracle Q1 FY27 (10-Q), S-1, https://example.com/cohort
  • ", "
  • \uCF54\uD638\uD2B8 Q3-2024 \uACF5\uC2DC 2024-Q3-F1, YC W26, H1 2026, https://example.com/cohort
  • ", "
  • FRED DGS10/DGS30, KOSPI200, Base44, CC0, 3D icons, https://example.com/data
  • ", "
  • github.com/letta-ai/letta-code @ a75f4d93ef(0.30.14); W3C PROV-DM; https://example.com/letta
  • ", "
  • \uC778\uC6A9 \uADDC\uC57D: \uACBD\uB85C:\uD589@b2105213234ab0c9762c7871611bf336dfb0eb2b (3.1.1), https://example.com/sha
  • ", "
  • \uC5F0\uC900 \uC790\uB8CC (2\uCC28 \uBCF4\uB3C4), https://example.com/fed
  • ", '
  • HN thread (90 points, 37 comments)
  • ', '
  • Spin-off notice (2025-12-02, \uACF5\uB3D9\uCC3D\uC5C5\uC790 20\uBA85 \uC11C\uBA85)
  • ', "
  • Survey archive 2019-2023, 2019~2023, 2019\u20132023, https://example.com/archive
  • ", "
  • 2025\u20132026\uB144 \uADFC\uC6D4\uBB3C \uC790\uB8CC, 2019~2023\uB144, https://example.com/basis
  • ", "

    2019\u20132023\uB144 \uD1B5\uACC4\uB97C 1\uCC28 \uC790\uB8CC\uB85C \uD655\uBCF4\uD558\uC9C0 \uBABB\uD588\uB2E4.

    ", "
  • Build abc123, GPT-4, v2.3.1, #1234, Q3-2024 cohort, 0.30.14, https://example.com/build
  • ", "
  • \uBE4C\uB4DC abc123, GPT-4, v2.3.1, #1234, Q3-2024 \uCF54\uD638\uD2B8, 0.30.14, https://example.com/build
  • ", "
  • \uC81C3\uC790 \uC81C\uACF5 \uC790\uB8CC, https://example.com/third
  • ", "
  • Survey notes [w1:x02], https://example.com/survey
  • ", ] const MUST_PASS = [ '
  • [S3] Reuters, "China\'s exports rose 7% in September", 2025-10-13, https://example.com/reuters
  • ', '
  • [S6] \uC5F0\uD569\uB274\uC2A4, "\uC218\uCD9C 12% \uC99D\uAC00\uD588\uB2E4", 2025-10-01, https://example.com/yna
  • ', "
  • [S7] Nikkei, \u201CSales fell 3.5% in Q2\u201D, https://example.com/nikkei
  • ", "
  • [S9] \uC5F0\uD569\uB274\uC2A4, \u300C\uC218\uCD9C 12% \uC99D\uAC00\u300D, 2025\uB144 10\uC6D4 1\uC77C
  • ", "
  • [S8] LoCoMo arXiv 2402.17753; DOI 10.1145/1571941.1572114
  • ", "
  • Field guide, ISBN 978-0-306-40615-7, 3rd edition, pp. 12-15.
  • ", "
  • Llama 3.1 model card, Meta, https://example.com/llama
  • ", '
  • "Python 3.12 release notes", 2024-10-07, https://example.com/python
  • ', "
  • WCAG 2.2, 2023-10-05, https://example.com/wcag
  • ", "
  • Smith, J. (2024a). Annual report. https://example.com/annual-2024
  • ", "
  • Bridge Review, Vol. 2024/1, No. 3, https://example.com/review. Accessed 25 October 2026.
  • ", "
  • \uAD50\uB7C9 \uC5F0\uAC10 3\uAD8C 2\uD638, 45-52\uCABD, 2024\uB144
  • ", "

    [S4] Annual report, 2024. https://example.com/report

    ", '

    Annual report 2024

    ', "
  • Annual report 2024, https://example.com/report
  • ", '
  • Annual report 2024, source
  • ', "

    2024

    ", "

    Publication cutoff and access date: October 25, 2026. Every linked source was read directly.

    ", ...NAMES, ...DATES, ] describe("under Sources, a sentence with a numeral that is not a citation field is checked", () => { for (const body of MUST_FLAG) test(`#given ${JSON.stringify(body)} #when checked #then untraceable_number`, () => expect(underSources(body)).toContain("untraceable_number")) test("#given a citation, then a claim after
    #when traced #then only the claim sentence's numbers are flagged", () => { expect(underSources("

    [S4] Annual report 2024, https://example.com/report
    Revenue rose 42% in 2025.

    ").filter((code) => code === "untraceable_number")).toHaveLength(2) }) test("#given a first-person cue with a claim number under Sources #when checked #then the claim check warns too", () => { expect(underSources("

    We confirmed that the rate is 42%.

    ")).toEqual(expect.arrayContaining(["unsourced_number", "untraceable_number", "verification_claim_unmarked"])) }) }) describe("an element whose every numeral is a citation field is exempt", () => { for (const body of MUST_PASS) test(`#given ${JSON.stringify(body)} #when checked #then nothing`, () => expect(underSources(body)).toEqual([])) test("#given a report with every reference shape (APA, pages, volume, access dates, year subheading, dl, Sources table) #when checked #then no gated code", () => { expect(gated(readFileSync(join(FIX, "sources-shapes.html"), "utf8"))).toEqual([]) }) }) // Mutation test: each heuristic the literal rule replaced, re-introduced as a gate on the predicate, // must miss at least one must-flag case, so restoring any of them in sources-citation.mjs fails above. const OLD_MEASURE = [/\d\s?(?:%|\u2030|\uD37C\uC13C\uD2B8)/, /[$\u20AC\u00A3\u00A5\u20A9]\s?\d/, /\d\s?(?:thousand|million|billion|trillion|bn)\b|\d(?:k|K|M|B)\b/, /\d\s?(?:\uC6D0|\uB2EC\uB7EC|\uBA85|\uAC74|\uAC1C|\uBC30|[\uCC9C\uB9CC\uC5B5\uC870])/, /\d\s?(?:km|kg|mm|ms|hours?|days?|users?|people|employees|customers?)\b/, /(? /https?:\/\/|\b(?:accessed|retrieved)\b/i.test(text) const root = (body: string) => parseHtml(`
    ${body}
    `) /** The mutants' own sentence split of a case (line breaks, then sentence ends); the real predicate decides each piece. */ const pieces = (body: string) => body .replace(/
    /g, "\n") .replace(/<[^>]+>/g, " ") .split("\n") .flatMap((line) => line.split(/(?<=[.!?])\s+(?=[A-Z\uAC00-\uD7A3])/)) .map((piece) => piece.trim()) .filter(Boolean) const MUTANTS: Record boolean> = { "a measure-word allow-list": (body) => pieces(body).some((s) => isClaimSegment(s) && OLD_MEASURE.some((re) => re.test(s))), "a claim-verb trigger": (body) => pieces(body).some((s) => isClaimSegment(s) && OLD_VERBS.test(s)), "a name rule that ignores units": (body) => pieces(body).some((s) => isClaimSegment(s.replace(/\p{L}+\d[\d.,]*|\d[\d.,]*\p{L}+/gu, " "))), "the middle-segment title heuristic": (body) => { const all = pieces(body) return all.some((s, at) => !(at > 0 && at + 1 < all.length && citationOnly(all[at + 1])) && isClaimSegment(s)) }, } describe("mutation: no word list, verb list, or title heuristic decides a Sources sentence", () => { test("#given the literal rule #when every must-flag case is read #then each yields a checked sentence and no must-pass case does", () => { expect(MUST_FLAG.filter((body) => sourcesClaims(root(body)).length === 0)).toEqual([]) expect(MUST_PASS.filter((body) => sourcesClaims(root(body)).length > 0)).toEqual([]) }) for (const [name, mutant] of Object.entries(MUTANTS)) { test(`#given ${name} re-introduced #when the must-flag cases run #then at least one is missed`, () => { expect(MUST_FLAG.filter((body) => !mutant(body)).length).toBeGreaterThan(0) }) } }) // Lead order (round 5): one table-driven test per class. Each row names the class of every digit run, // and the gates must agree at the real boundary: an element under Sources is checked exactly when a // run is a numeral. function misread(rows: ClassRow[]) { return rows.flatMap(({ input, expected }) => { const kinds = numeralKinds(input).map(({ kind }: { kind: string }) => kind) const want = Array.isArray(expected) ? expected : kinds.map(() => expected) const flagged = underSources(`

    ${input}

    `).includes("untraceable_number") const ok = kinds.length === want.length && kinds.every((kind: string, at: number) => kind === want[at]) && flagged === want.includes("numeral") return ok ? [] : [{ input, expected, kinds, flagged }] }) } describe("numeral classes under Sources, one row per form (EN + KO)", () => { test(`#given ${GLUED_LETTER_ROWS.length} glued-letter forms (every unit, multiplier, percent, and currency token on the right and left, Hangul and Latin words, Korean particles) #when classified #then each reads as its row says`, () => { expect(misread(GLUED_LETTER_ROWS)).toEqual([]) }) test(`#given ${SHAPE_ROWS.length} date, version, and code shapes (slashed, dotted, dashed, ranges, groupings, filing codes, citation markers) #when classified #then each reads as its row says`, () => { expect(misread(SHAPE_ROWS)).toEqual([]) }) })