oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1package index
2
3import (
4 "fmt"
5 "testing"
6)
7
8// buildTrap fills an index with the fixture that reproduces the ancestry
9// scoring trap, and returns it.
10//
11// Two things about this fixture are load-bearing, and the first version
12// of it had neither.
13//
14// FIRST, the shape. bm25() SUMS per-column contributions, so a row
15// matching in path AND body outranks a row matching only in path under
16// any positive weights — a fixture where the "good" answer also carries
17// the term in its ancestry proves nothing. The discriminating shape is
18// many pages whose ONLY match is an inherited ancestor title, against
19// one page, outside that subtree, whose BODY answers the query.
20//
21// SECOND, the filler, and this is the subtler one. Without the 200
22// unrelated chunks below, "handbook" appears in EVERY indexed row, BM25's
23// IDF term collapses to about zero, every score comes back as -1e-06, and
24// the resulting order is floating-point noise that happens to look like a
25// ranking. Measured: with no filler every score was -0.000001; with it,
26// the path-only rows score -3.196 at PathWeight and -7.218 with the path
27// folded into the heading column, against -4.961 for the body match. Only
28// the second set of numbers can distinguish a working weight from an
29// inert one.
30//
31// pathAsHeading models the mutation: ancestry folded into the heading
32// column at heading weight, which is what a naive implementation does.
33func buildTrap(t *testing.T, pathAsHeading bool) *Index {
34 t.Helper()
35 ix, err := Open()
36 if err != nil {
37 t.Fatal(err)
38 }
39 t.Cleanup(func() { ix.Close() })
40
41 var entries []Entry
42 var id int64
43 add := func(docID int64, heading, path, body string) {
44 id++
45 // Title is the document's own name. Every document in the real
46 // index has one indexed at BodyWeight, so a fixture without one
47 // would be measuring a table this app no longer has — and a
48 // title column is exactly the ingredient that could reintroduce
49 // the trap.
50 e := Entry{ChunkID: id, DocumentID: docID, Ordinal: 0, Heading: heading, Text: body, Title: heading}
51 if pathAsHeading {
52 if path != "" {
53 e.Heading = path + " › " + heading
54 }
55 } else {
56 e.Path = path
57 }
58 entries = append(entries, e)
59 }
60
61 // Filler, so the query term is rare and IDF means something.
62 for i := 0; i < 200; i++ {
63 add(int64(1000+i), fmt.Sprintf("Note %d", i), "Archive › Notes",
64 fmt.Sprintf("Meeting %d discussed procurement, latency budgets and the vendor renewal.", i))
65 }
66 // 20 pages under "Handbook". No body says "handbook"; they match only
67 // through the ancestry they inherited.
68 for i := 0; i < 20; i++ {
69 add(int64(100+i), fmt.Sprintf("Section %d", i), "Handbook › Onboarding",
70 fmt.Sprintf("Week %d covers expenses, travel booking and the equipment request form.", i))
71 }
72 // The Handbook page itself. It matches "handbook" on its own title,
73 // and SHOULD rank — a search for "handbook" returning the handbook is
74 // the right answer. What must not happen is its twenty descendants,
75 // which match only through inherited ancestry, burying real content.
76 add(50, "Handbook", "", "The starting point for everyone new.")
77 // One page elsewhere whose BODY is the actual answer.
78 add(999, "Tooling", "Engineering › Platform",
79 "The handbook is generated from this repository and published nightly.")
80
81 if err := ix.Add(entries...); err != nil {
82 t.Fatal(err)
83 }
84 return ix
85}
86
87// TestAncestryCannotOutrankContent is the gate for the trap described on
88// PathWeight: a document's ancestry repeats into every chunk beneath it,
89// so ancestry scored at heading weight buries real answers under the
90// whole subtree that merely sits below a matching ancestor title.
91//
92// MUTATION-TESTED 2026-09-05: folding the path into the heading column
93// (buildTrap's pathAsHeading branch, which is what a naive implementation
94// does) makes this fail — the top three hits become Sections 0, 1 and 2,
95// and the page that answers the query falls below twenty that do not.
96// TestAncestryTrapFlipIsObserved asserts that flip so this gate cannot
97// quietly stop discriminating. Reverted.
98func TestAncestryCannotOutrankContent(t *testing.T) {
99 ix := buildTrap(t, false)
100 hits, err := ix.Search("handbook", 25)
101 if err != nil {
102 t.Fatal(err)
103 }
104 if len(hits) == 0 {
105 t.Fatal("no hits: the fixture matches nothing, so this gate proves nothing")
106 }
107 // The property is NOT "999 is first" — the Handbook page itself
108 // matching its own title and ranking highly is correct. It is that no
109 // page which matched ONLY through inherited ancestry outranks the
110 // page whose body answers the question.
111 var contentRank = -1
112 for i, h := range hits {
113 if h.DocumentID == 999 {
114 contentRank = i
115 break
116 }
117 }
118 if contentRank < 0 {
119 t.Fatal("the page whose body answers the query did not match at all")
120 }
121 for i, h := range hits {
122 if i >= contentRank {
123 break
124 }
125 if h.DocumentID >= 100 && h.DocumentID < 200 {
126 t.Errorf("ancestry outranked content: document %d matched only through its "+
127 "inherited ancestry and ranks above the page whose body answers the "+
128 "query (position %d vs %d)", h.DocumentID, i, contentRank)
129 }
130 }
131}
132
133// TestAncestryTrapFlipIsObserved fails if the mutation does NOT change the
134// ranking. A gate for a ranking property is worthless unless the ranking
135// has been seen to move, and this codebase has shipped three gates that
136// could not fail for the thing they named.
137func TestAncestryTrapFlipIsObserved(t *testing.T) {
138 // The measurement is the RELATIVE position of two kinds of hit, not
139 // which document is first. Once titles are indexed the Handbook page
140 // itself is top under either weighting — legitimately, it is called
141 // Handbook — so a top-hit comparison stopped discriminating and this
142 // test said so rather than passing.
143 probe := func(pathAsHeading bool) (content int, ancestry int) {
144 ix := buildTrap(t, pathAsHeading)
145 hits, err := ix.Search("handbook", 30)
146 if err != nil {
147 t.Fatal(err)
148 }
149 content, ancestry = -1, -1
150 for i, h := range hits {
151 if h.DocumentID == 999 && content < 0 {
152 content = i
153 }
154 if h.DocumentID >= 100 && h.DocumentID < 200 && ancestry < 0 {
155 ancestry = i
156 }
157 }
158 if content < 0 || ancestry < 0 {
159 t.Fatalf("fixture did not produce both kinds of hit (content=%d ancestry=%d)", content, ancestry)
160 }
161 return content, ancestry
162 }
163
164 goodContent, goodAncestry := probe(false)
165 badContent, badAncestry := probe(true)
166 t.Logf("path column at %.1f : body-match at %d, best ancestry-only at %d", PathWeight, goodContent, goodAncestry)
167 t.Logf("path in heading : body-match at %d, best ancestry-only at %d", badContent, badAncestry)
168
169 if goodContent > goodAncestry {
170 t.Errorf("with the path column, an ancestry-only page already outranks the body match "+
171 "(%d vs %d)", goodAncestry, goodContent)
172 }
173 if badAncestry > badContent {
174 t.Errorf("the mutation did NOT change the ranking: with ancestry at heading weight the "+
175 "body match is still above every ancestry-only page (%d vs %d). This fixture cannot "+
176 "detect the trap, so TestAncestryCannotOutrankContent is a gate written to pass. "+
177 "Strengthen the fixture until the order flips.", badAncestry, badContent)
178 }
179}
180
181// TestTrapFixtureCanDiscriminate guards the FIXTURE, not production code,
182// and it exists because this fixture silently stopped being able to prove
183// anything once already.
184//
185// The first measurement of the trap was taken on a corpus where the query
186// term appeared in every indexed row. BM25's IDF term collapsed to about
187// zero, every score came back as -0.000001, and moving the path weight
188// from 0.5 to 99 changed nothing — yet the rows still came back in an
189// order, and that order looked exactly like a working ranking. A weight
190// that is silently ignored ranks the same as a weight that is wrong, so
191// TestAncestryCannotOutrankContent was passing for a reason unrelated to
192// the property it names.
193//
194// There is no production mutation for this one: it asserts that the
195// corpus and the engine can tell two weightings apart at all. Its
196// mutation is therefore to the fixture.
197//
198// MUTATION-TESTED 2026-09-05: deleting the filler loop from buildTrap
199// makes this fail on the degenerate-IDF assertion. Reverted.
200func TestTrapFixtureCanDiscriminate(t *testing.T) {
201 ix := buildTrap(t, false)
202 score := func(pw float64) float64 {
203 var v float64
204 // Document 100 is one of the ancestry-only matches, so its score
205 // depends on the path weight and nothing else.
206 // FOUR weights, one per column. This probe passed three while the
207 // table had four, which SQLite accepts and which silently made
208 // the path weight inert — caught by this test's own assertion
209 // when the title column landed in a merge.
210 err := ix.db.QueryRow(
211 `SELECT bm25(chunks, ?, ?, ?, ?) FROM chunks WHERE chunks MATCH ? AND document_id = 100`,
212 HeadingWeight, BodyWeight, TitleWeight, pw, MatchQuery("handbook")).Scan(&v)
213 if err != nil {
214 t.Fatal(err)
215 }
216 return v
217 }
218 low, high := score(PathWeight), score(99)
219 t.Logf("ancestry-only row: path weight %.1f → %.4f, path weight 99 → %.4f", PathWeight, low, high)
220 if low == high {
221 t.Fatalf("path weight is inert: %.9f at both %.1f and 99", low, PathWeight)
222 }
223 if low > -1e-4 {
224 t.Fatalf("score %.9f is ~0, so IDF is degenerate and this corpus cannot "+
225 "discriminate a working weight from an inert one", low)
226 }
227}
228