From 34c43834286e54772d3e9d00f55a8dccae01679e Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 7 Oct 2026 08:29:33 +0000 Subject: [PATCH] fix: order a citation run ascending, and name each page once (#24) --- README.md | 2 ++ .../specs/2026-10-07-source-citations-design.md | 13 ++++++++----- src/__tests__/citations.test.ts | 14 ++++++++++++++ src/lib/citations.ts | 16 +++++++++++++--- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7cd034b..def7236 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,8 @@ In a sentence the marker is raised, so a claim resting on a search result reads Markers the model wrote touching are separated as they are drawn, because `¹²` reads as twelve, or as twenty one, and either way as one number. The prompt asks for exactly that touching shape, so `[1][2]` and the same numbers written apart both arrive as two raised digits with a space between them. A space rather than a comma: a comma sits at the baseline, so between raised digits it drops away from them and reads as a stray mark. +The pair is ordered ascending and each page is named once, because a model lists sources by rank rather than by number. `[2][1]` is what a real answer sent for a sentence resting on both, and drawn as `¹ ²` it reads as the answer's first two sources rather than as "two, one". The numbers themselves are never renumbered: `[3]` is still the third source the turn found. + Numbers are handed out where the tools run, once per turn, and printed in the tool result the model reads. So `[3]` means one page for the model and for the reader, three searches in one turn do not each start again at `1`, and a page found by a search and read again later keeps the number it already had. A marker stays as written unless every number in it is a source with a destination this window should open: half a link reads as a citation that works, and a `javascript:` address out of a search result is a real vulnerability rather than a rendering quirk. The list shows the sources the answer cited, falling back to everything the turn consulted when it cited nothing, since that is the one case where the list is the only evidence of what was read. It waits for a citation before appearing at all, so it never arrives in full and then shrinks. diff --git a/docs/superpowers/specs/2026-10-07-source-citations-design.md b/docs/superpowers/specs/2026-10-07-source-citations-design.md index 5d37e72..d4a7643 100644 --- a/docs/superpowers/specs/2026-10-07-source-citations-design.md +++ b/docs/superpowers/specs/2026-10-07-source-citations-design.md @@ -57,11 +57,14 @@ would have been asked are recorded here instead. answer cites nothing while sources exist, the list shows all of them, because then it is the only evidence of what was consulted. Rejected alternatives are in "Approaches considered". -- **A2. Numbers are never renumbered.** Contiguous numbering from 1 in citation - order is the academic convention, but it would make the transcript disagree - with the model's own reasoning text and with the tool output a curious reader - can inspect. Keeping the model's numbers means one number means one thing - everywhere in the turn. +- **A2. Numbers are never renumbered, though a run is ordered ascending.** Contiguous + numbering from 1 in citation order is the academic convention, but renumbering the + whole answer would make the transcript disagree with the model's own reasoning text + and with the tool output a curious reader can inspect. Order inside a single run is + normalised instead, because a model lists sources by rank rather than by number, so + `[2][1]` reads as "two, one" once the digits sit next to each other. `[3]` therefore + still means the third source the turn found, whether or not the answer cited the two + before it. - **A3. The list is answer content, not a tool log.** It is shown whether or not the Show Tool Calls preference is on. Hiding tool calls is about the activity line; a citation list is part of the answer's credibility. diff --git a/src/__tests__/citations.test.ts b/src/__tests__/citations.test.ts index ad7654f..0b43daa 100644 --- a/src/__tests__/citations.test.ts +++ b/src/__tests__/citations.test.ts @@ -170,6 +170,20 @@ describe("linkCitations", () => { ); }); + it("orders a run ascending, because a model lists by rank and a reader reads by number", () => { + // A real answer sent [2][1] for a sentence resting on both, which drew as + // "two, one" and left the reader to work out that it meant the first two sources. + expect(linkCitations("Both [3][1] agree.", sources).text).toBe( + "Both [¹](https://first.example) [³](https://third.example) agree.", + ); + }); + + it("cites one page once, however often a run names it", () => { + expect(linkCitations("Both [3][1][3] agree.", sources).text).toBe( + "Both [¹](https://first.example) [³](https://third.example) agree.", + ); + }); + it("leaves a marker alone when the destination is not one this window should open", () => { const unsafe = [{ index: 1, title: "Nasty", url: "javascript:alert(1)" }]; const result = linkCitations("See [1].", unsafe); diff --git a/src/lib/citations.ts b/src/lib/citations.ts index 5dd6cf1..fb407a1 100644 --- a/src/lib/citations.ts +++ b/src/lib/citations.ts @@ -198,8 +198,8 @@ function stripDefinitions(text: string, known: Set): string { } /** - * `[1]`, `[^1]`, `[1, 3]` and `[1][2]` as superscript links, plus the numbers that - * became links. + * `[1]`, `[^1]`, `[1, 3]` and `[1][2]` as superscript links, ordered ascending and + * each page once, plus the numbers that became links. * * One run of prose only: fences are the caller's business, because the caller * already walks the document in segments for the maths pass and there is no @@ -214,7 +214,17 @@ export function linkCitations(text: string, sources: Source[]): CitationResult { const linked = mapOutsideCodeSpans(prose, (chunk) => chunk.replace(CITATION_RUN, (whole: string) => { - const wanted = [...whole.matchAll(RUN_NUMBERS)].map((match) => Number(match[0])); + /** + * Ascending, and each page once. + * + * A run is a set of sources, and a model lists them in the order it thought of + * them rather than by number: a real answer sent `[2][1]` for a sentence resting + * on both, which drew as "two, one", and `[8][4]` for another. The numbers + * themselves are never renumbered, so `[3]` is still the third source the turn + * found and the reader can find it in the list. + */ + const named = [...whole.matchAll(RUN_NUMBERS)].map((match) => Number(match[0])); + const wanted = [...new Set(named)].sort((left, right) => left - right); const links: string[] = []; for (const index of wanted) {