fix: order a citation run ascending, and name each page once #24

Merged
adrianbonpin merged 1 commits from fix/ascending-citations into prod 2026-10-07 08:29:36 +00:00
4 changed files with 37 additions and 8 deletions
+2
View File
@@ -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.
@@ -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.
+14
View File
@@ -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);
+13 -3
View File
@@ -198,8 +198,8 @@ function stripDefinitions(text: string, known: Set<number>): 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) {