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 "strings"
6 "testing"
7 "time"
8)
9
10// The query that found this: "phone number" on the real corpus put a
11// passage of Virgin Media ACCOUNT numbers above the passage that states
12// the office phone number. Paul hit it on the live instance, and the
13// keyboard prime shipped the same day sent Enter to the wrong document.
14//
15// The corpus below is what makes the test able to fail, and it took two
16// attempts to build. A two-document version scored every hit at exactly
17// 0.0000: FTS5's bm25 clamps IDF to ~1e-6 for a term that appears in
18// every document, so the scores collapse and the order is rowid. "number"
19// has to be COMMON here and "phone" RARE, as on the real corpus, or this
20// measures nothing.
21//
22// Measured on that corpus before the fix:
23//
24// "phone" OR "number" → chunk 1 at 2.5607, chunk 2 at 1.8670
25// "number" alone → every score 0.0000
26// "phone" alone → identical to "phone" OR "number"
27//
28// The second line is the finding. A common term contributes nothing to
29// ranking, so a two-word query ranked exactly as its rarest word alone,
30// and "phone number" could not be told from "phone".
31func rankCorpus(t *testing.T) *Index {
32 t.Helper()
33 ix, err := Open()
34 if err != nil {
35 t.Fatal(err)
36 }
37 t.Cleanup(func() { ix.Close() })
38
39 var entries []Entry
40 add := func(heading, title, text string) {
41 entries = append(entries, Entry{
42 ChunkID: int64(len(entries) + 1),
43 DocumentID: int64(len(entries) + 1),
44 Ordinal: len(entries),
45 Title: title,
46 Path: "Team Tito Limited",
47 Heading: heading,
48 Text: text,
49 })
50 }
51
52 // Chunk 1: "Phone" in a short, 3×-weighted heading, and no phone
53 // number in the body. Chunk 2: the answer, in the body, twice.
54 add("Utilities › Internet & Phone", "Office",
55 "Virgin Media - login - Office 7: account number 80854641 - Office 2: account number 80753900")
56 add("Office Address", "Office",
57 "Turn the knob. The office phone number is: +353 (0)1 547 7854. We also have the following unused phone numbers: +353 (0)1 598 1234.")
58
59 // "number" common across the corpus, as on the real one.
60 add("Business & VAT Numbers", "Business & VAT Numbers",
61 "Our company number is 566334. Our VAT number is IE3384527RH. Our DUNS/D-U-N-S number is 985632364.")
62 add("Bank", "Banking", "The account number is 12345678 and the sort code is 93-00-11.")
63 add("Payroll", "Payroll", "Each employee number is assigned on their first day.")
64 add("Insurance", "Insurance", "The policy number is renewed every January.")
65 add("Revenue", "Q3 board pack", "June revenue was 1,350,000 across all regions.")
66 add("Costs", "Q3 board pack", "Hosting costs fell after the migration.")
67 add("Employee Offboarding", "Processes", "Collect the laptop and the serial number of any device issued.")
68 add("Company Registration", "Registration", "The registration number appears on every invoice.")
69 add("Invoices", "Finance", "Every invoice carries a sequential number for the auditor.")
70 add("Passport numbers", "Travel", "Each passport number is stored in the safe, never in a document.")
71 add("Suppliers", "Finance", "Each supplier has a reference number in the ledger.")
72 add("Meeting, 12 August", "Minutes", "We will not build a suite index app.")
73 add("Sealing notes", "Sealing", "A sheet's contents are sealed at rest.")
74
75 if err := ix.Add(entries...); err != nil {
76 t.Fatal(err)
77 }
78 return ix
79}
80
81// Broken by removing the NEAR operand from MatchQuery — red, then
82// reverted. Broken again by ANDing it rather than ORing it, which
83// returns the one passage and drops the other fourteen — red on the
84// recall assertion below, then reverted. 2026-09-05.
85func TestAdjacentTermsOutrankAScatteredCommonWord(t *testing.T) {
86 ix := rankCorpus(t)
87 hits, err := ix.Search("phone number", 25)
88 if err != nil {
89 t.Fatal(err)
90 }
91 if len(hits) == 0 {
92 t.Fatal("no hits at all")
93 }
94 if hits[0].ChunkID != 2 {
95 t.Errorf("top hit is chunk %d (%q), want chunk 2 — the passage that states the number",
96 hits[0].ChunkID, hits[0].Heading)
97 }
98 // Recall is the other half, and the half a NEAR-only query loses.
99 // Every passage carrying either word must still come back: requiring
100 // the words together would turn a working search into a one-result
101 // page, which is the plausible-empty-list failure with a better score.
102 if len(hits) < 10 {
103 t.Errorf("only %d hits; the boost has narrowed recall instead of reordering it", len(hits))
104 }
105}
106
107// The boost must be earned by the words being TOGETHER, not by both
108// being present — which is what AND already gave, and what did not help.
109//
110// The first version of this test could not fail. It used two documents
111// and no filler, so both terms appeared in every document, FTS5 clamped
112// both IDFs to ~1e-6, every score collapsed to 0 and the order fell back
113// to rowid — which happened to be the order it asserted. Removing the fix
114// entirely left it green. It needed the filler corpus for the same reason
115// the test above does, and it needed the scattered passage to be WINNING
116// beforehand: a test where the right answer is already first proves
117// nothing about what put it there.
118//
119// So the scattered passage says both words three times each and the
120// adjacent one says them once. On term frequency alone the scattered one
121// wins; only togetherness turns it over.
122//
123// Broken by removing the NEAR operand — red, then reverted. 2026-09-05.
124func TestBoostNeedsTheWordsTogether(t *testing.T) {
125 ix := rankCorpus(t)
126 if err := ix.Add(
127 Entry{ChunkID: 90, DocumentID: 90, Ordinal: 0, Heading: "Scattered", Title: "Desk",
128 Text: "The phone, the phone, the phone. Later, ask for a number, a number, another number."},
129 Entry{ChunkID: 91, DocumentID: 91, Ordinal: 0, Heading: "Adjacent", Title: "Desk",
130 Text: "The phone number is on the desk."},
131 ); err != nil {
132 t.Fatal(err)
133 }
134 hits, err := ix.Search("phone number", 25)
135 if err != nil {
136 t.Fatal(err)
137 }
138 var scattered, adjacent int
139 for i, h := range hits {
140 switch h.ChunkID {
141 case 90:
142 scattered = i + 1
143 case 91:
144 adjacent = i + 1
145 }
146 }
147 if scattered == 0 || adjacent == 0 {
148 t.Fatalf("both passages must be returned; scattered=%d adjacent=%d of %d hits",
149 scattered, adjacent, len(hits))
150 }
151 if adjacent > scattered {
152 t.Errorf("the scattered passage ranked %d and the adjacent one %d — "+
153 "adjacency is not being rewarded, only presence", scattered, adjacent)
154 }
155}
156
157// A single-word query has nothing to be near, and must not grow a
158// spurious operand.
159func TestSingleTermQueryIsUnchanged(t *testing.T) {
160 if got, want := MatchQuery("revenue"), `"revenue"`; got != want {
161 t.Errorf("MatchQuery(%q) = %q, want %q", "revenue", got, want)
162 }
163}
164
165// Typing the same two words the other way round must not change what
166// comes back first.
167//
168// This is a regression the FIRST version of the togetherness boost
169// introduced, found by review. It used an ordered phrase, so "revenue
170// june" scored a coincidental adjacency across a full stop — "…helped
171// revenue. June also saw…" — above the passage whose heading IS Revenue,
172// and the keyboard prime then sent Enter there. Measured before the fix:
173// "june revenue" put the right passage at 5.563 and the coincidence at
174// 2.994; "revenue june" put the coincidence FIRST at 5.520.
175//
176// Before any boost existed the two spellings ranked identically, because
177// order could not matter. NEAR restores that.
178//
179// Broken by replacing NEAR with the ordered phrase — red, then reverted.
180// 2026-09-05.
181func TestWordOrderDoesNotChangeTheRanking(t *testing.T) {
182 ix := rankCorpus(t)
183 if err := ix.Add(
184 Entry{ChunkID: 80, DocumentID: 80, Ordinal: 0, Heading: "Revenue", Title: "Q3 board pack",
185 Text: "June revenue was 1,350,000 across all regions, up from 1,180,000."},
186 Entry{ChunkID: 81, DocumentID: 81, Ordinal: 0, Heading: "Costs", Title: "Q3 board pack",
187 Text: "Hosting fell, which helped revenue. June also saw the migration finish."},
188 ); err != nil {
189 t.Fatal(err)
190 }
191
192 rank := func(q string) []int64 {
193 hits, err := ix.Search(q, 25)
194 if err != nil {
195 t.Fatal(err)
196 }
197 var ids []int64
198 for _, h := range hits {
199 if h.ChunkID == 80 || h.ChunkID == 81 {
200 ids = append(ids, h.ChunkID)
201 }
202 }
203 return ids
204 }
205 forward, reversed := rank("june revenue"), rank("revenue june")
206 if len(forward) != 2 || len(reversed) != 2 {
207 t.Fatalf("both passages must come back either way round: forward=%v reversed=%v", forward, reversed)
208 }
209 if forward[0] != 80 {
210 t.Errorf(`"june revenue" ranked chunk %d first, want 80`, forward[0])
211 }
212 if reversed[0] != forward[0] {
213 t.Errorf(`"revenue june" ranked chunk %d first but "june revenue" ranked chunk %d — `+
214 `word order is changing the answer`, reversed[0], forward[0])
215 }
216}
217
218// A search box is not a document. Without a cap the OR chain is
219// quadratic: 2.8ms at 100 terms, 97ms at 1000, 2.25s at 5000 — all of it
220// under the index's read lock, so one pasted essay stalls ingest.
221//
222// Broken by removing the cap — red on the timing assertion, then
223// reverted. 2026-09-05.
224func TestAbsurdlyLongQueryIsCapped(t *testing.T) {
225 var sb strings.Builder
226 for i := 0; i < 5000; i++ {
227 fmt.Fprintf(&sb, "term%d ", i)
228 }
229 q := MatchQuery(sb.String())
230 if n := strings.Count(q, " OR ") + 1; n > 66 {
231 t.Errorf("a 5000-term query produced %d operands; the cap is not holding", n)
232 }
233
234 // And it must still run quickly against a real index, which is the
235 // property the cap exists for rather than the operand count.
236 ix := rankCorpus(t)
237 start := time.Now()
238 if _, err := ix.Search(sb.String(), 25); err != nil {
239 t.Fatalf("a very long query errored: %v", err)
240 }
241 if d := time.Since(start); d > 250*time.Millisecond {
242 t.Errorf("a 5000-term search took %s; it holds the index read lock throughout", d)
243 }
244}
245