oficina / memoria Public

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

Plain git — no account needed to clone.

Download

Download this file

1package fact
2
3import (
4 "reflect"
5 "sort"
6 "strings"
7 "testing"
8 "time"
9)
10
11// The corpus this package is judged against is index/rank_test.go's,
12// which was built from the live instance where Paul asked for this
13// feature. Quoted here rather than paraphrased: a fixture rewritten from
14// the same model as the code cannot catch a wrong assumption, it records
15// one (AGENTS.md §Review).
16const officeChunk = "Turn the knob. The office phone number is: +353 (0)1 547 7854. " +
17 "We also have the following unused phone numbers: +353 (0)1 598 1234."
18
19const numbersChunk = "Our company number is 566334. Our VAT number is IE3384527RH. " +
20 "Our DUNS/D-U-N-S number is 985632364."
21
22const bankChunk = "The account number is 12345678 and the sort code is 93-00-11."
23
24func TestWantedNamesTheKind(t *testing.T) {
25 for _, c := range []struct {
26 q string
27 want []Kind
28 }{
29 {"phone number", []Kind{Phone}},
30 {"what's the office phone number?", []Kind{Phone}},
31 {"eircode", []Kind{Eircode}},
32 {"post code", []Kind{Eircode}},
33 {"vat number", []Kind{VAT}},
34 {"iban", []Kind{IBAN}},
35 {"bank details", []Kind{IBAN}},
36 {"email address", []Kind{Email}},
37 {"e-mail", []Kind{Email}},
38 {"phone and email", []Kind{Phone, Email}},
39 {"address", []Kind{Address}},
40 {"office address", []Kind{Address}},
41 {"postal address", []Kind{Address}},
42 // The collision the phrase-first lexicon exists to prevent — in
43 // both spellings. "e-mail address" splits to e / mail / address,
44 // and before the three-token phrase existed it primed [email,
45 // address]: an email query sending passages to a model.
46 {"email address", []Kind{Email}},
47 {"e-mail address", []Kind{Email}},
48 {"what is the e-mail address", []Kind{Email}},
49 {"phone number and postal address", []Kind{Phone, Address}},
50 } {
51 if got := Wanted(c.q); !reflect.DeepEqual(got, c.want) {
52 t.Errorf("Wanted(%q) = %v, want %v", c.q, got, c.want)
53 }
54 }
55}
56
57// The words that must NOT prime, and each is here because priming on it
58// would put a box on an ordinary query.
59//
60// "address" is the interesting one: it is ambiguous between a postal
61// address and an email address, and only one of those has anything
62// resembling a validator (§8.8, decision 15).
63func TestWantedRefusesGenericWords(t *testing.T) {
64 for _, q := range []string{"number", "account number", "", "revenue"} {
65 if got := Wanted(q); len(got) != 0 {
66 t.Errorf("Wanted(%q) = %v, want nothing", q, got)
67 }
68 }
69}
70
71// A generous lexicon is affordable BECAUSE of the validator, and this is
72// the test that says so: "mobile app" primes Phone and then finds
73// nothing, so the cost is a box that does not appear.
74func TestAGenerousLexiconCostsNothingWithoutAValidCandidate(t *testing.T) {
75 if got := Wanted("mobile app"); !reflect.DeepEqual(got, []Kind{Phone}) {
76 t.Fatalf("Wanted(mobile app) = %v, want [phone]", got)
77 }
78 if got := Scan("We shipped the mobile app in June.", "Product", "Notes", []Kind{Phone}); len(got) != 0 {
79 t.Errorf("Scan found %v in text with no phone number", got)
80 }
81}
82
83// ---------------------------------------------------------------------
84// Validators, against values checkable outside this repo.
85// ---------------------------------------------------------------------
86
87// The reject rows are the load-bearing ones. An accept-only table passes
88// against a validator that returns true — the guard written to pass, in
89// its purest form.
90func TestVATCheckLetter(t *testing.T) {
91 for _, c := range []struct {
92 in string
93 ok bool
94 }{
95 {"IE6388047V", true}, // Google Ireland
96 {"IE3628739L", true}, // published example
97 {"IE3384527RH", true}, // index/rank_test.go's own corpus
98 {"IE1234567X", false}, // invented: right shape, wrong check letter
99 {"IE6388047W", false}, // one letter off the real one
100 {"IE8Z49289F", false}, // old style, refused deliberately
101 } {
102 if got := vatValid(c.in); got != c.ok {
103 t.Errorf("vatValid(%q) = %v, want %v", c.in, got, c.ok)
104 }
105 }
106}
107
108func TestIBANModulus97(t *testing.T) {
109 for _, c := range []struct {
110 in string
111 ok bool
112 }{
113 {"IE29AIBK93115212345678", true},
114 {"GB82WEST12345698765432", true},
115 {"DE89370400440532013000", true},
116 {"IE29AIBK93115212345679", false}, // one digit changed
117 {"IE29AIBK9311521234567", false}, // one digit short
118 {"XX29AIBK93115212345678", false}, // unknown country
119 } {
120 if got := ibanValid(c.in); got != c.ok {
121 t.Errorf("ibanValid(%q) = %v, want %v", c.in, got, c.ok)
122 }
123 }
124}
125
126// D6W is Dublin 6W and its routing key does not fit [A-Z][0-9][0-9]; B is
127// not in the routing-key alphabet at all. Both are the cases a pattern
128// written from memory gets wrong.
129func TestEircodeAlphabet(t *testing.T) {
130 for _, c := range []struct {
131 in string
132 ok bool
133 }{
134 {"A65 F4E2", true}, // the published example
135 {"D02 X285", true},
136 {"D6W 1234", true},
137 {"D02X285", true},
138 {"B12 3456", false}, // B is not a routing-key letter
139 {"D02 XB85", false}, // B is not in the identifier alphabet
140 } {
141 got := len(Scan("Eircode "+c.in+" here.", "", "T", []Kind{Eircode})) > 0
142 if got != c.ok {
143 t.Errorf("eircode %q recognised = %v, want %v", c.in, got, c.ok)
144 }
145 }
146}
147
148// **A bare run of digits is never a phone number.** This is the whole of
149// what stands between the box and rank_test.go's own first line: the DUNS
150// number there is nine digits and a plausible Irish mobile.
151func TestABareRunOfDigitsIsNotAPhoneNumber(t *testing.T) {
152 for _, c := range []struct {
153 text string
154 want int
155 }{
156 {officeChunk, 2},
157 {numbersChunk, 0}, // 566334, IE3384527RH, 985632364
158 {bankChunk, 0}, // 12345678, 93-00-11
159 {"Virgin Media - login - Office 7: account number 80854641", 0},
160 {"The deadline is 01-02-2024 for everyone.", 0}, // a date leads with 0 and has separators
161 {"Call 0871234567 today.", 0}, // no separators: the stated cost
162 {"Call 087 123 4567 today.", 1},
163 } {
164 if got := len(Scan(c.text, "H", "T", []Kind{Phone})); got != c.want {
165 t.Errorf("Scan(%q) found %d phone numbers, want %d", c.text, got, c.want)
166 }
167 }
168}
169
170// Two spellings of one number are ONE candidate. A dedupe keyed on the
171// span rather than the canonical form passes any fixture that spells it
172// the same way twice, which is why this one does not.
173func TestTwoSpellingsCanonicaliseTogether(t *testing.T) {
174 a := Scan("Office: +353 (0)1 547 7854", "H", "T", []Kind{Phone})
175 b := Scan("Office: 01 547 7854", "H", "T", []Kind{Phone})
176 if len(a) != 1 || len(b) != 1 {
177 t.Fatalf("want one each, got %d and %d", len(a), len(b))
178 }
179 if a[0].Canonical != b[0].Canonical {
180 t.Errorf("canonical %q != %q", a[0].Canonical, b[0].Canonical)
181 }
182 if a[0].Span == b[0].Span {
183 t.Errorf("spans should differ; both %q", a[0].Span)
184 }
185}
186
187// ---------------------------------------------------------------------
188// Spans are the document's bytes.
189// ---------------------------------------------------------------------
190
191func TestSpanIsByteIdenticalToTheDocument(t *testing.T) {
192 for _, c := range []struct{ text, want string }{
193 {"Office: +353 (0)1 547 7854.", "+353 (0)1 547 7854"},
194 {"Office: +353 (0)1 547 7854 ext. 12.", "+353 (0)1 547 7854 ext. 12"},
195 // Pasted from Word or Notion. Go's \s does not match U+00A0, so a
196 // recogniser written with it finds NOTHING here and this case
197 // fails on presence rather than on the bytes — which is the
198 // design demand, not an accident.
199 {"Office: +353 (0)1 547 7854.", "+353 (0)1 547 7854"},
200 } {
201 got := Scan(c.text, "H", "T", []Kind{Phone})
202 if len(got) != 1 {
203 t.Fatalf("Scan(%q) found %d, want 1", c.text, len(got))
204 }
205 if got[0].Span != c.want {
206 t.Errorf("span = %q, want %q", got[0].Span, c.want)
207 }
208 }
209}
210
211// ---------------------------------------------------------------------
212// The label, one case per rule and one per failure the reviews found.
213// ---------------------------------------------------------------------
214
215func TestLabelComesFromTheDocument(t *testing.T) {
216 long := strings.Repeat("x", MaxLabel) // exactly at the cap
217 tooLong := strings.Repeat("x", MaxLabel+1) // one over
218
219 for _, c := range []struct {
220 name string
221 text string
222 head string
223 title string
224 want []string
225 noBox bool
226 }{
227 {
228 name: "a labelled line",
229 text: "Office: +353 1 547 7854", head: "Contact", title: "Tito",
230 want: []string{"Office"},
231 },
232 {
233 // The motivating fixture. Draft 1 of §8.8 required the value
234 // to start its own line, so both numbers fell to the heading
235 // trail, came back labelled identically, and the box would
236 // have shown "unused" and "office" as equals.
237 name: "the office fixture keeps its two sentences apart",
238 text: officeChunk, head: "Office Address", title: "Office",
239 want: []string{"The office phone number is", "We also have the following unused phone numbers"},
240 },
241 {
242 name: "no separator falls to the trail, never the clause",
243 text: "Ring the office on +353 1 547 7854 any weekday.", head: "Contact", title: "Tito",
244 want: []string{"Contact"},
245 },
246 {
247 // chunk.Split keeps lines verbatim, so the run is "-", which
248 // ends in a separator and names nothing. Returning "" here
249 // makes two bullets collide and suppresses the box for a
250 // plain contact list.
251 name: "a markdown bullet falls through rather than yielding an empty label",
252 text: "- +353 1 547 7854\n- +353 1 598 1234", head: "Contact", title: "Tito",
253 want: []string{"Contact", "Contact"},
254 },
255 {
256 // Without the earlier-span rule the second label is
257 // "Phone: 01 547 7854" — one phone number naming another.
258 name: "an adjacent value is not part of the next label",
259 text: "Phone: 01 547 7854 - 01 598 1234", head: "Contact", title: "Tito",
260 want: []string{"Phone", "Contact"},
261 },
262 {
263 // A boundary placed too late composes a label the document
264 // never wrote. Draft 2 claimed this direction was safe.
265 name: "an abbreviation does not end the sentence",
266 text: "Dr. Smith's direct line: 01 547 7854", head: "Contact", title: "Tito",
267 want: []string{"Dr. Smith's direct line"},
268 },
269 {
270 // "1." is not an abbreviation, so the boundary IS taken and
271 // the list marker is not part of the label. The rune check in
272 // isAbbreviation is the only thing holding this.
273 name: "a numbered list marker is not part of the label",
274 text: "1. Office phone: 01 547 7854", head: "Contact", title: "Tito",
275 want: []string{"Office phone"},
276 },
277 {
278 name: "no headings at all falls to the title, never empty",
279 text: "Ring us on +353 1 547 7854 any weekday.", head: "", title: "Pasted contacts",
280 want: []string{"Pasted contacts"},
281 },
282 {
283 name: "a label exactly at the cap renders",
284 text: long + ": 01 547 7854", head: "Contact", title: "Tito",
285 want: []string{long},
286 },
287 {
288 // Substituting the trail would replace the document's own
289 // warning with a vaguer word; dropping just this candidate
290 // would turn an ambiguity into a confident single answer.
291 name: "a label one over the cap renders no box for the kind",
292 text: tooLong + ": 01 547 7854", head: "Contact", title: "Tito",
293 noBox: true,
294 },
295 } {
296 t.Run(c.name, func(t *testing.T) {
297 got := Scan(c.text, c.head, c.title, []Kind{Phone})
298 if c.noBox {
299 if len(got) != 1 || !got[0].NoBox {
300 t.Fatalf("want one NoBox result, got %+v", got)
301 }
302 return
303 }
304 var labels []string
305 for _, f := range got {
306 if f.NoBox {
307 t.Fatalf("unexpected NoBox in %+v", got)
308 }
309 labels = append(labels, f.Label)
310 }
311 if !reflect.DeepEqual(labels, c.want) {
312 t.Errorf("labels = %q, want %q", labels, c.want)
313 }
314 })
315 }
316}
317
318// Ordinary document text that earlier revisions of these recognisers
319// boxed. Every row here was a live false positive found by review, not
320// an invented one, and each names the class it stands for — because the
321// failure they share is that a cited fact which is really an English
322// word reads exactly like a correct answer (§8.7's "cites correctly
323// while being wrong").
324func TestOrdinaryTextIsNotAFact(t *testing.T) {
325 for _, c := range []struct {
326 kind Kind
327 text string
328 why string
329 }{
330 {Eircode, "Take the N11 then exit at Bray.", "a road number and a lowercase word"},
331 {Eircode, "Form P45 then P60.", "a form code and a lowercase word"},
332 {Eircode, "The A65 area near Sligo is quiet.", "a routing key and a lowercase word"},
333 {Phone, "The deadline is 01-02-2024 10:30 sharp.", "a date followed by a time"},
334 {Phone, "Due 01.02.2024 10:30.", "the same with full stops"},
335 {Phone, "Numbers drawn: 01 05 12 23 34", "five two-digit groups"},
336 {Phone, "Growth of +10 000 000 users this year.", "a plus and grouped thousands"},
337 {Phone, "Companies House 12345678 is the reference.", "a bare run of digits"},
338 {Phone, "Card 0123 4567 8901 2345 expires soon.", "sixteen digits in four groups"},
339 // Germany and France really do write more groups than this, and
340 // refusing their numbers is the stated cost of never truncating
341 // one. The alternative was boxing "+49 30 12 34 56".
342 {Phone, "Berlin +49 30 12 34 56 78", "six groups"},
343 {Phone, "Paris +33 1 23 45 67 89", "six groups"},
344 } {
345 if got := Scan(c.text, "Contact", "Notes", []Kind{c.kind}); len(got) != 0 {
346 t.Errorf("%s: %q boxed %q as a %s", c.why, c.text, got[0].Span, c.kind)
347 }
348 }
349}
350
351// The shapes a document actually writes, which must all still be found.
352// Half of these are here because a fix for the table above broke them:
353// a recogniser tightened until nothing false matches is a recogniser
354// that finds nothing at all, and only having both tables makes that
355// visible.
356func TestTheShapesDocumentsActuallyWrite(t *testing.T) {
357 for _, c := range []struct {
358 kind Kind
359 text string
360 want string
361 }{
362 {Phone, "Office phone: +353 (0)1 547 7854", "+353 (0)1 547 7854"},
363 {Phone, "Ring 087 123 4567 today.", "087 123 4567"},
364 {Phone, "Call 01-547-7854 for support.", "01-547-7854"},
365 // The commonest international spelling of an Irish mobile, and
366 // the one a five-digit group cap rendered as "+353 87 12345" — a
367 // different, well-formed, confidently cited number.
368 {Phone, "Mobile: +353 87 1234567", "+353 87 1234567"},
369 {Phone, "Mobile: +353 (0)87 1234567", "+353 (0)87 1234567"},
370 {Phone, "Office phone: +353 1 5477854", "+353 1 5477854"},
371 {Phone, "Call 01 5477854 today.", "01 5477854"},
372 {Phone, "Tel +44 20 7123 4567 in London.", "+44 20 7123 4567"},
373 {Eircode, "Eircode: D02 X285", "D02 X285"},
374 {Eircode, "We are at A65 F4E2.", "A65 F4E2"},
375 // Grouped, and followed by a BIC — which is how bank details are
376 // written, and which found nothing at all until the grouped
377 // alternative was fixed to groups of four.
378 {IBAN, "Our IBAN is IE29 AIBK 9311 5212 3456 78 and our BIC is AIBKIE2D.", "IE29 AIBK 9311 5212 3456 78"},
379 {IBAN, "IBAN: IE29 AIBK 9311 5212 3456 78 BIC AIBKIE2D", "IE29 AIBK 9311 5212 3456 78"},
380 {IBAN, "IBAN IE29AIBK93115212345678.", "IE29AIBK93115212345678"},
381 // Countries whose length is a multiple of four leave no tail for
382 // a following token to hide in, so the tail must be digits only.
383 {IBAN, "IBAN: BE68 5390 0754 7034 BIC: GEBABEBB", "BE68 5390 0754 7034"},
384 {IBAN, "IBAN ES91 2100 0418 4502 0005 1332 BIC CAIXESBB", "ES91 2100 0418 4502 0005 1332"},
385 {IBAN, "Norway: NO93 8601 1117 947.", "NO93 8601 1117 947"},
386 {IBAN, "Poland PL61 1090 1014 0000 0712 1981 2874 please.", "PL61 1090 1014 0000 0712 1981 2874"},
387 {VAT, "Our VAT number is IE3384527RH.", "IE3384527RH"},
388 } {
389 got := Scan(c.text, "Contact", "Notes", []Kind{c.kind})
390 if len(got) != 1 {
391 t.Errorf("%q found %d, want 1", c.text, len(got))
392 continue
393 }
394 if got[0].Span != c.want {
395 t.Errorf("%q span = %q, want %q", c.text, got[0].Span, c.want)
396 }
397 }
398}
399
400// A label must not depend on which kinds the query named.
401//
402// Scan locates spans of EVERY kind and emits only the wanted ones,
403// because the earlier-span rule can only skip a span it can see. Asking
404// for `phone` alone used to yield "Email paul@tito.io or phone" — one
405// fact's value inside another fact's label, which is exactly what that
406// rule exists to prevent.
407func TestALabelDoesNotDependOnWhichKindsWereAskedFor(t *testing.T) {
408 const text = "Email paul@tito.io or phone: 01 547 7854"
409 alone := Scan(text, "Contact", "T", []Kind{Phone})
410 both := Scan(text, "Contact", "T", []Kind{Phone, Email})
411 if len(alone) != 1 {
412 t.Fatalf("phone alone found %d, want 1", len(alone))
413 }
414 if strings.Contains(alone[0].Label, "@") {
415 t.Errorf("label %q carries another fact's value", alone[0].Label)
416 }
417 var phone Found
418 for _, f := range both {
419 if f.Kind == Phone {
420 phone = f
421 }
422 }
423 if phone.Label != alone[0].Label {
424 t.Errorf("label changed with the query: %q alone, %q with email", alone[0].Label, phone.Label)
425 }
426}
427
428// Label derivation was quadratic in the facts a chunk holds, and
429// chunk.Split bounds a chunk by WORDS — so a 2 MiB upload with no
430// whitespace is one chunk, one line, and minutes of search latency
431// bought by one person's file. Measured before the fix: 48.5s at 1 MB.
432//
433// **A DOUBLING RATIO, not a wall-clock limit, and the first two versions
434// of this test were both unreachable.** Version one repeated a single
435// value, so dedupe left one label to derive, at byte zero, where the
436// backward walk returns immediately: a review mutated the lookback bound
437// to 1<<40 and reverted the binary search to a linear scan and both
438// stayed green. Version two used distinct values and a five-second
439// limit, and both mutations stayed green again — 1.5s and 0.5s are
440// slow, and still well inside any limit loose enough not to flake on a
441// loaded machine.
442//
443// So the assertion is the SHAPE of the curve. Quadratic work quadruples
444// when the input doubles; linear work doubles. The minimum of several
445// runs is the robust estimator for CPU-bound work: a loaded machine makes
446// runs slower, not faster, so the minimum discards the noise.
447func TestLabellingIsNotQuadraticInAChunksFacts(t *testing.T) {
448 // **Interleaved, and it flaked before it was.** The second version
449 // timed every small run and then every large one, so load arriving
450 // mid-test landed on one side of the ratio: a reviewer running
451 // mutations in parallel saw it fail intermittently on correct code.
452 // Alternating small and large makes contention hit both, and the
453 // minimum of each discards it. Measured on this fixture, 2026-09-27,
454 // three trials each: pristine 1.90-2.01 idle and 1.50-2.56 with four
455 // CPUs pinned busy; the linear scan 3.54-3.63; the unbounded lookback
456 // 3.89-3.93. Three separates them with about half a point each side.
457 const small, large, rounds = 15000, 30000, 5
458 build := func(n int) string {
459 var b strings.Builder
460 for i := 0; i < n; i++ {
461 b.WriteString(eircodeNo(i))
462 b.WriteByte(',') // a separator, NOT a sentence boundary
463 }
464 return b.String()
465 }
466 a, b := build(small), build(large)
467 bestA, bestB := time.Hour, time.Hour
468 var foundA, foundB int
469 for r := 0; r < rounds; r++ {
470 st := time.Now()
471 foundA = len(Scan(a, "H", "T", []Kind{Eircode}))
472 if d := time.Since(st); d < bestA {
473 bestA = d
474 }
475 st = time.Now()
476 foundB = len(Scan(b, "H", "T", []Kind{Eircode}))
477 if d := time.Since(st); d < bestB {
478 bestB = d
479 }
480 }
481
482 // The presence half: without it this measures the cost of finding
483 // nothing, which is cheap and flat whatever the implementation does.
484 if foundA != small || foundB != large {
485 t.Fatalf("found %d and %d candidates, want %d and %d distinct", foundA, foundB, small, large)
486 }
487 if ratio := float64(bestB) / float64(bestA); ratio > 3 {
488 t.Errorf("doubling the input multiplied the time by %.2f (%v to %v); "+
489 "linear is about 2 and quadratic about 4, so label derivation is quadratic again",
490 ratio, bestA, bestB)
491 }
492}
493
494// eircodeNo builds the i-th distinct valid eircode: a fixed routing key
495// and a base-25 identifier over the published alphabet.
496func eircodeNo(i int) string {
497 const alphabet = "0123456789ACDEFHKNPRTVWXY"
498 id := make([]byte, 4)
499 for j := 3; j >= 0; j-- {
500 id[j] = alphabet[i%len(alphabet)]
501 i /= len(alphabet)
502 }
503 return "D02 " + string(id)
504}
505
506// lastSpanEndBefore searches on a span's END, so Scan sorts by end — and
507// that matters because spans NEST. A phone number sits inside an email
508// address and inside an IBAN, both of which really occur. Sorted by
509// start, the predicate "ends at or before here" is not monotonic, the
510// binary search can walk past the enclosing span, and a recognised value
511// goes back into another fact's label — the thing the earlier-span rule
512// exists to prevent.
513//
514// A direct unit test rather than a corpus fixture, because the timing
515// test above cannot see correctness and the corpus shapes that trigger
516// nesting are contrived enough that nobody would think to write one.
517func TestLastSpanEndBeforeHandlesNestedSpans(t *testing.T) {
518 // A long outer span with two short ones inside it, as an email
519 // address containing phone-shaped runs produces.
520 spans := [][2]int{{12, 20}, {22, 30}, {4, 40}}
521 sort.Slice(spans, func(i, j int) bool { return spans[i][1] < spans[j][1] })
522 for _, c := range []struct{ at, want int }{
523 {3, 0}, // before everything
524 {20, 20}, // exactly on an inner end
525 {21, 20},
526 {30, 30},
527 {45, 40}, // the OUTER span, which a start-sorted search can miss
528 } {
529 if got := lastSpanEndBefore(spans, c.at); got != c.want {
530 t.Errorf("lastSpanEndBefore(%d) = %d, want %d", c.at, got, c.want)
531 }
532 }
533}
534
535// The same rule, asserted through Scan rather than against the helper,
536// because the helper's contract and Scan's obligation to meet it are two
537// different things.
538//
539// TestLastSpanEndBeforeHandlesNestedSpans sorts its own input, so it
540// cannot see Scan sorting by the wrong key — mutating that sort left it
541// green. This is the fixture that catches it: an email address whose
542// local part contains two phone-shaped runs, so a phone span really does
543// nest inside an email span. Sorted by start, the third number comes back
544// labelled "Mail info.087.123.4567" — a recognised value inside another
545// fact's label, which is the exact failure the earlier-span rule exists
546// to prevent, reappearing through the search that implements it.
547func TestScanSortsSpansSoNestingCannotLeakIntoALabel(t *testing.T) {
548 const text = "Tel 01 547 7854. Mail info.087.123.4567-087-123-4568@example.ie"
549 got := Scan(text, "Contact", "Notes", []Kind{Phone, Email})
550 if len(got) < 3 {
551 t.Fatalf("found %d facts, want at least three: this fixture needs the nesting to be wrong about", len(got))
552 }
553 for _, f := range got {
554 for _, other := range got {
555 if other.Span != f.Span && strings.Contains(f.Label, other.Span) {
556 t.Errorf("%s label %q contains another fact's value %q", f.Kind, f.Label, other.Span)
557 }
558 }
559 }
560}
561
562// §8.9: eligibility for a model is a property of the kind, decided by an
563// allowlist — and the allowlist must never contain a kind the
564// deterministic layer can validate. If somebody adds a recogniser for
565// Address, this goes red and forces the decision the spec says has to be
566// made, instead of a validated kind being sent to a model.
567func TestNoModelKindHasARecogniser(t *testing.T) {
568 recognised := map[Kind]bool{}
569 for _, r := range recognisers {
570 recognised[r.kind] = true
571 }
572 for k := range modelKinds {
573 if recognised[k] {
574 t.Errorf("%s is both a model kind and has a recogniser; §8.9 forbids sending a validated kind to a model", k)
575 }
576 }
577 // **The allowlist is pinned exactly.** Which metadata is worth a
578 // model call is Paul's to rule (§13), so adding a kind — even one with
579 // no recogniser, which the check above would let through — has to be
580 // a deliberate edit here, not a one-line change in fact.go that no
581 // test notices. A review added `url` and stayed green.
582 if len(modelKinds) != 1 || !modelKinds[Address] {
583 t.Errorf("modelKinds = %v; §8.9 rules in exactly [address]", modelKinds)
584 }
585 // And the presence half: the allowlist is not empty, and every
586 // validator-backed kind answers false.
587 if !ModelKind(Address) {
588 t.Error("Address is not a model kind")
589 }
590 for _, k := range []Kind{Eircode, Phone, IBAN, VAT, Email} {
591 if ModelKind(k) {
592 t.Errorf("%s is validator-backed and must never be a model kind", k)
593 }
594 }
595}
596
597// The deterministic layer never produces an Address, whatever the text.
598// If it did, §8.8's box would show a postal address no checksum vouched
599// for, without the 🤖 marking §8.9 requires of anything a model chose.
600func TestScanNeverProducesAnAddress(t *testing.T) {
601 text := "Office address: 12 Main Street, Dublin 2, D02 X285. Office phone: 01 547 7854"
602 if got := Scan(text, "Contact", "Tito", []Kind{Address}); len(got) != 0 {
603 t.Errorf("Scan produced %d address facts; only a model may", len(got))
604 }
605 // And in a MIXED request, where other kinds do produce facts: a
606 // review fabricated an Address only when several kinds were asked for
607 // and the single-kind case above stayed green.
608 mixed := Scan(text, "Contact", "Tito", []Kind{Eircode, Phone, Address})
609 if len(mixed) == 0 {
610 t.Fatal("the mixed scan found nothing; it proves nothing")
611 }
612 for _, f := range mixed {
613 if f.Kind == Address {
614 t.Errorf("a mixed scan produced an address: %+v", f)
615 }
616 }
617}
618
619func TestRecognisedSpansCoverEveryKind(t *testing.T) {
620 // All five kinds, because a review dropped IBAN and VAT from the
621 // function and the three-kind version of this fixture stayed green.
622 text := "Mail info@tito.io or ring +353 1 547 7854, at D02 X285. VAT IE3384527RH, IBAN IE29AIBK93115212345678."
623 got := RecognisedSpans(text)
624 var spans []string
625 for _, sp := range got {
626 spans = append(spans, text[sp[0]:sp[1]])
627 }
628 want := []string{"info@tito.io", "+353 1 547 7854", "D02 X285", "IE3384527RH", "IE29AIBK93115212345678"}
629 if !reflect.DeepEqual(spans, want) {
630 t.Errorf("spans = %q, want %q (sorted by end, every kind)", spans, want)
631 }
632}
633
634// LabelAt must agree with Scan for a value Scan found — that is the
635// whole reason it is exported rather than reimplemented.
636func TestLabelAtSpansAgreesWithScan(t *testing.T) {
637 // Including an over-long label, so NoBox is compared too: with only
638 // the office chunk, a LabelAt that always returned NoBox=false agreed
639 // with Scan on every case and stayed green.
640 text := officeChunk + " " + strings.Repeat("x", MaxLabel+1) + ": 01 555 0101"
641 var sawNoBox bool
642 for _, f := range Scan(text, "Office Address", "Office", []Kind{Phone}) {
643 sawNoBox = sawNoBox || f.NoBox
644 start := strings.Index(text, f.Span)
645 got, noBox := LabelAtSpans(text, start, RecognisedSpans(text), "Office Address", "Office")
646 if got != f.Label || noBox != f.NoBox {
647 t.Errorf("LabelAtSpans(%q) = %q/%v, Scan said %q/%v", f.Span, got, noBox, f.Label, f.NoBox)
648 }
649 }
650 if !sawNoBox {
651 t.Fatal("no over-long label in the fixture; the NoBox half proves nothing")
652 }
653}
654
655func TestWordBounded(t *testing.T) {
656 text := "12 Main Street, Streetside"
657 for _, c := range []struct {
658 sub string
659 at int
660 want bool
661 }{
662 {"12 Main Street", 0, true},
663 {"Street", strings.Index(text, "Streetside"), false}, // inside a word
664 {"ain Street", 4, false}, // starts mid-word
665 {"Main", 3, true},
666 } {
667 if got := WordBounded(text, c.at, c.at+len(c.sub)); got != c.want {
668 t.Errorf("WordBounded(%q at %d) = %v, want %v", c.sub, c.at, got, c.want)
669 }
670 }
671 if WordBounded(text, 3, 3) {
672 t.Error("an empty range is not a bounded word")
673 }
674 // Digits are word characters: "2 Main" inside "12 Main" is not a
675 // bounded span. A review stopped treating digits as word runes and
676 // every letter-only case above stayed green.
677 if WordBounded("12 Main Street", 1, len("12 Main")) {
678 t.Error("a span starting after a digit was treated as bounded")
679 }
680 if WordBounded("Unit 42", 0, len("Unit 4")) {
681 t.Error("a span ending before a digit was treated as bounded")
682 }
683}
684
685// Qualifiers on the queries §8.8 pins, which are the realistic shapes a
686// reader types. The first review asked for these by name.
687//
688// MUTATION-TESTED 2026-09-29, each reverted:
689// - lexicon tokens kept → `Qualifiers("support email") = ["support"
690// "email"]`
691// - single-character tokens kept → `["s" "tito"]`
692// - "for" dropped from the stopwords → `["for" "dublin" "office"]`
693// - repeats kept → `["office" "office"]`
694// - generic words kept (Astra's) → `["office" "numbers"]`
695func TestQualifiersOnRealisticQueries(t *testing.T) {
696 for q, want := range map[string][]string{
697 "what is the office phone number?": {"office"},
698 "office phone": {"office"},
699 "office mobile": {"office"},
700 "what's Tito's phone number": {"tito"},
701 "what’s Tito’s phone number": {"tito"},
702 "phone number for the Dublin office": {"dublin", "office"},
703 "support email": {"support"},
704 "phone number": nil,
705 "office office phone": {"office"},
706 "office phone numbers": {"office"},
707 "office phone no": {"office"},
708 "office phone nr": {"office"},
709 } {
710 if got := Qualifiers(q); !reflect.DeepEqual(got, want) {
711 t.Errorf("Qualifiers(%q) = %q, want %q", q, got, want)
712 }
713 }
714}
715
716func TestLabelMatchesWholeTokensAndEveryQualifier(t *testing.T) {
717 for _, c := range []struct {
718 label string
719 quals []string
720 want bool
721 }{
722 {"The office phone number is", []string{"office"}, true},
723 {"Office phone", []string{"office"}, true},
724 {"Offices phone", []string{"office"}, false},
725 {"Backoffice line", []string{"office"}, false},
726 {"Dublin office phone", []string{"dublin", "office"}, true},
727 {"Office phone", []string{"dublin", "office"}, false},
728 {"Office phone", nil, false},
729 } {
730 if got := LabelMatches(c.label, c.quals); got != c.want {
731 t.Errorf("LabelMatches(%q, %q) = %v, want %v", c.label, c.quals, got, c.want)
732 }
733 }
734}
735
736// A leading list marker is markup (§8.8, 2026-09-29), and Own records
737// whether the label is the document's rather than the fallback.
738//
739// MUTATION-TESTED 2026-09-29, reverted: the marker strip disabled →
740// `Scan("- Office: …") label = "- Office"`. A guard for "only when
741// something follows the marker" was also mutated away and stayed green:
742// no input can reach it, so it was deleted rather than kept.
743//
744// Astra's, 2026-09-29, both red now: the pattern's ^ anchor removed →
745// "Never use - Office" loses its warning and becomes "Office"; the +
746// marker dropped → "+ Office".
747func TestListMarkersAreNotPartOfTheLabel(t *testing.T) {
748 for _, c := range []struct {
749 text, label string
750 own bool
751 }{
752 {"- Office: +353 (0)1 547 7854", "Office", true},
753 {" - Office: +353 (0)1 547 7854", "Office", true},
754 {"* Office: +353 (0)1 547 7854", "Office", true},
755 {"+ Office: +353 (0)1 547 7854", "Office", true},
756 // A hyphen INSIDE a label is the document's words, not a marker:
757 // an unanchored pattern would delete the warning and leave
758 // "Office".
759 {"Never use - Office: +353 (0)1 547 7854", "Never use - Office", true},
760 {"• Office: +353 (0)1 547 7854", "Office", true},
761 {"- [x] Office: +353 (0)1 547 7854", "Office", true},
762 {"- [ ] Office: +353 (0)1 547 7854", "Office", true},
763 {"3) Office: +353 (0)1 547 7854", "Office", true},
764 {"3. Office: +353 (0)1 547 7854", "Office", true},
765 {"> Office: +353 (0)1 547 7854", "Office", true},
766 // A bare bullet: nothing follows the marker, so rule 3 sends it to
767 // the trail, which is not the document's own label for the value.
768 {"- +353 (0)1 547 7854", "Contact", false},
769 {"Office: +353 (0)1 547 7854", "Office", true},
770 {"Ring us on +353 (0)1 547 7854 any weekday.", "Contact", false},
771 } {
772 got := Scan(c.text, "Contact", "Tito", []Kind{Phone})
773 if len(got) != 1 {
774 t.Fatalf("Scan(%q) found %d, want 1", c.text, len(got))
775 }
776 if got[0].Label != c.label || got[0].Own != c.own {
777 t.Errorf("Scan(%q) label = %q own = %v, want %q own = %v",
778 c.text, got[0].Label, got[0].Own, c.label, c.own)
779 }
780 }
781}
782