File size: 5,956 Bytes
6c3af4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import { describe, expect, it } from "vitest";
import { goldenQueries } from "./goldenSet.ts";
import { ndcgAtK, recallAtK } from "./metrics.ts";
import {
  K,
  MAX_NOOP_MEAN_NDCG,
  MAX_NOOP_MEAN_RECALL,
  MIN_MEAN_NDCG,
  MIN_MEAN_RECALL,
} from "./thresholds.ts";

/**
 * Bumped deliberately whenever the golden set grows, so an edit to the set
 * forces the author to re-read (and re-tune if needed) the thresholds in
 * thresholds.ts.
 */
const EXPECTED_QUERY_COUNT = 26;

// Sequences that String.replace treats as special. The app's getSystemPrompt
// substitutes the search results into the prompt via String.replace, and
// getFormattedSearchResults puts the title, url, and snippet of every result
// into that replacement string. A field containing one of these would silently
// corrupt the prompt (a pre-existing bug in client/modules/systemPrompt.ts).
// Rejecting them here keeps the eval from ever grading a corrupted prompt.
const REPLACE_SPECIAL_SEQUENCES = ["$&", "$`", "$'", "$$"];

/** Structural checks on the golden set so a bad entry fails loudly. */
function validateGoldenSet() {
  const seenIds = new Set<string>();
  for (const g of goldenQueries) {
    const urls = g.results.map((r) => r.url);
    const duplicate = urls.find((u, i) => urls.indexOf(u) !== i);
    expect(
      duplicate,
      `${g.id}: duplicate result url ${duplicate} (breaks the nDCG <= 1 bound)`,
    ).toBeUndefined();
    expect(g.query.length, `${g.id}: empty query`).toBeGreaterThan(0);
    expect(g.rubric.length, `${g.id}: empty rubric`).toBeGreaterThan(0);
    expect(
      g.referenceAnswer.length,
      `${g.id}: empty referenceAnswer (weakens the judge)`,
    ).toBeGreaterThan(0);
    expect(g.relevant.length, `${g.id}: no relevant labels`).toBeGreaterThan(0);
    expect(
      new Set(g.relevant).size === g.relevant.length,
      `${g.id}: duplicate relevant index (dedupes to a trivial entry)`,
    ).toBe(true);
    for (const i of g.relevant) {
      expect(
        Number.isInteger(i),
        `${g.id}: relevant index ${i} is not an integer`,
      ).toBe(true);
      expect(
        i >= 0 && i < g.results.length,
        `${g.id}: relevant index ${i} out of range (0..${g.results.length - 1})`,
      ).toBe(true);
    }
    // Retrieval falsifiability, per entry: the input top-K must not already
    // hold every relevant result it can fit, or a no-op reranker (which
    // preserves input order) scores this entry as well as a working one.
    // Comparing against min(K, relevant.length) rather than "some index >= K"
    // also covers entries with more than K labels, where the input top-K can
    // be saturated while a label still sits outside it. This is the per-entry
    // form of the "stays falsifiable" aggregate below; the aggregate alone
    // would let a single degenerate entry pass silently.
    const relevantInTopK = g.relevant.filter((i) => i < K).length;
    expect(
      relevantInTopK < Math.min(K, g.relevant.length),
      `${g.id}: the input top-${K} already holds every relevant result it can fit, so a no-op reranker already scores it as well as a working one`,
    ).toBe(true);
    // The answer eval builds the prompt from these results, but the app slices
    // to searchResultsToConsider (6) before sending it. A longer entry would
    // grade a prompt the app can never send.
    expect(
      g.results.length <= 6,
      `${g.id}: ${g.results.length} results exceeds the app's 6-result prompt budget`,
    ).toBe(true);
    for (const r of g.results) {
      expect(r.title.length, `${g.id}: empty result title`).toBeGreaterThan(0);
      expect(r.snippet.length, `${g.id}: empty result snippet`).toBeGreaterThan(
        0,
      );
      expect(r.url.length, `${g.id}: empty result url`).toBeGreaterThan(0);
    }
    for (const special of REPLACE_SPECIAL_SEQUENCES) {
      for (const r of g.results) {
        for (const field of [r.title, r.snippet, r.url]) {
          expect(
            field.includes(special),
            `${g.id}: a result field contains ${JSON.stringify(special)} (String.replace would corrupt the prompt)`,
          ).toBe(false);
        }
      }
    }
    expect(seenIds.has(g.id), `${g.id}: duplicate golden id`).toBe(false);
    seenIds.add(g.id);
  }
}

describe("golden set", () => {
  it("is well-formed", () => {
    validateGoldenSet();
  });

  it("has the expected number of queries", () => {
    expect(goldenQueries).toHaveLength(EXPECTED_QUERY_COUNT);
  });

  it("keeps the no-op ceilings below the eval's floors", () => {
    // If a ceiling reached or passed its floor, a no-op reranker would clear
    // the floor and the retrieval eval would stop being falsifiable.
    expect(MAX_NOOP_MEAN_NDCG).toBeLessThan(MIN_MEAN_NDCG);
    expect(MAX_NOOP_MEAN_RECALL).toBeLessThan(MIN_MEAN_RECALL);
  });

  it("stays falsifiable: a no-op reranker scores below the eval's floors", () => {
    // Score the golden set as a reranker that just preserves the input order
    // (the failure mode the retrieval eval exists to catch). If this mean
    // climbs to the eval's floors, the set no longer distinguishes a working
    // reranker from a dead one - usually because an entry keeps its relevant
    // results in the input top-K.
    const mean = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length;
    const scored = goldenQueries.map((g) => {
      const urls = g.results.map((r) => r.url);
      const relevant = g.relevant.map((i) => urls[i]);
      return {
        ndcg: ndcgAtK(urls, relevant, K),
        recall: recallAtK(urls, relevant, K),
      };
    });

    expect(
      mean(scored.map((s) => s.ndcg)),
      `a golden entry keeps its relevant results in the input top-${K}`,
    ).toBeLessThanOrEqual(MAX_NOOP_MEAN_NDCG);
    expect(
      mean(scored.map((s) => s.recall)),
      `a golden entry keeps its relevant results in the input top-${K}`,
    ).toBeLessThanOrEqual(MAX_NOOP_MEAN_RECALL);
  });
});