§8.8's fact box: recognisers, the box, and gates 34-37
A new package, one store method, one template block and a stylesheet rule. No index change, no migration, no new seal purpose, nothing stored — §7 of the plan held. internal/memoria/fact carries the lexicon beside the validators, because the words that ask for a kind and the validator that admits one are two columns of one table. Three of the five kinds are checksums — eircode's routing-key alphabet, IBAN's mod-97, Irish VAT's check letter — verified against Google Ireland's VAT, two standard IBANs, the published eircode example and Dublin 6W's irregular key, each with a reject case. Phone and email are not, and the package says so where somebody changing them will read it. Two bugs the fixtures caught that reasoning had not. The phone pattern was a character class, which is greedy across a LIST: "Phone: 01 547 7854 - 01 598 1234" matched as one span of eighteen digits, failed validation, and rendered nothing where it should render two. It is now digit groups separated by a single character, which stops at the " - ". And the span dropped a trailing extension, which §8.8 says is part of what the document wrote — a number whose extension has been dropped is a number that does not reach the person. Validation now runs on the base and the canonical form carries the extension, so two entries differing only by extension are two candidates rather than a duplicate. addFacts runs over p.Results and never over the hits that produced it, so §8.4's invariant is structural: a candidate that is not in the ruled set does not exist. Dedupe, collision and cap all count after the ruling, for the reason §8.8 gives — an ambiguity of two where the reader may read one reports the existence of a passage §4.1 just refused them. Gate 34 needed a custody fake that rules per item id; fixedCustody rules every candidate from one field and cannot express five of its six fixtures. Eight mutations run, each verified red and reverted, and they are named in the tests. The one worth recording: extracting over the unruled set cites "Hidden office" by name in case 5, which is the leak the gate exists to catch, and no fixture using distinct values per document could have seen it. **And one finding that is a product limit rather than a bug.** Writing the gates showed that bare "phone number" does not match a document reading "Office: +353 (0)1 547 7854" — the words are not there, so nothing ranks and there is nothing to extract from. Every fixture had to be given a word the query could find. That is §8.8's reachability limit, and it is the ordinary case rather than an edge, so it is now pinned by a test in both directions rather than left to be discovered: no shared word, no box; one shared word, the same fact appears. The day somebody builds §12's lift, that test goes red and gets deleted on purpose. tools/drive seeds three shapes, not one — the happy case, the enumerated case, and the bulleted pair that must render NO box — because the section nobody seeds is the section nobody looks at. Driven, and the page was read: "office phone number" renders both numbers with the document's own labels, including the word "unused" that tells them apart. The seed's title was changed after reading it, because "Office address › Office Address" stutters and a fixture that reads wrong teaches the wrong thing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9 files changed,
+1767
−1
internal/memoria/fact/fact.go+623 −0internal/memoria/fact/fact_test.go+295 −0internal/memoria/factbox_test.go+241 −0internal/memoria/handlers.go+13 −0internal/memoria/static/memoria.css+36 −0internal/memoria/store.go+173 −1internal/memoria/templates/results.html+25 −0internal/memoriatest/factbox_test.go+340 −0tools/drive/main.go+21 −0
diff --git a/internal/memoria/fact/fact.go b/internal/memoria/fact/fact.go| new file mode 100644 |
| index 0000000..a3c9cbc |
| --- /dev/null |
| +++ b/internal/memoria/fact/fact.go |
| @@ -0,0 +1,623 @@ |
| +// Package fact recognises the facts §8.8's box is allowed to show. |
| +// |
| +// One table drives all of it: a kind names the words that ask for it AND |
| +// the validator that admits it. Those two must not drift apart — a |
| +// lexicon that primes `iban` next to a validator that no longer accepts |
| +// one is a box that never appears and nothing that says why — so they are |
| +// two columns of one declaration and live in one file. That is also why |
| +// the lexicon is here rather than in query, which has its own single |
| +// decision to make and says so. |
| +// |
| +// Nothing here touches a database, a template or an app. The spec's §8.8 |
| +// is a long argument about authorization and ordering, and none of it is |
| +// in this package: by the time Scan is called the passage has already |
| +// passed §4.1. What is here is the part that can be wrong on its own — |
| +// whether a string is really an IBAN, and what the document called it. |
| +// |
| +// **Three of the five kinds are checksums and two are not**, and the |
| +// difference is stated rather than smoothed over. An eircode's routing |
| +// key is a closed set, an IBAN has a mod-97 check, an Irish VAT number |
| +// has a check letter derived from its own digits: those can be wrong, and |
| +// a wrong one is refused. A phone number and an email address have no |
| +// such property, and their bounds are structural. See phoneValid for the |
| +// rule that keeps the weakest of them honest. |
| +package fact |
| + |
| +import ( |
| + "regexp" |
| + "strings" |
| + "unicode" |
| + "unicode/utf8" |
| +) |
| + |
| +// Kind is one recognisable fact type. |
| +type Kind string |
| + |
| +const ( |
| + Eircode Kind = "eircode" |
| + Phone Kind = "phone" |
| + IBAN Kind = "iban" |
| + VAT Kind = "vat" |
| + Email Kind = "email" |
| +) |
| + |
| +// order is the order kinds are returned and rendered in, so that a |
| +// two-kind query produces the same two boxes in the same places every |
| +// time. A map's range order would not, and §8.8's gate asserts order. |
| +var order = []Kind{Eircode, Phone, IBAN, VAT, Email} |
| + |
| +// Found is one validated fact, located in a passage. |
| +type Found struct { |
| + Kind Kind |
| + // Span is the document's own bytes, and the only thing rendered. |
| + // Taken as a slice of the input rather than rebuilt from parts, so |
| + // that "byte-identical to the document" is a property of this type |
| + // and not a discipline somebody has to keep. §8.7's rule one noun |
| + // over: a second formatter is the same bug class as a second |
| + // calculator. |
| + Span string |
| + // Canonical is match-only. It keys candidate identity so that |
| + // "01 547 7854" and "+353 (0)1 547 7854" are one number rather than |
| + // two, and it is what the validator ran on. It is NEVER rendered and |
| + // is never an operand — see the package doc and §8.8 for why that |
| + // distinction is what keeps this inside §8.7 and decision 10. |
| + Canonical string |
| + // Label is what the document called this value. Never composed. |
| + Label string |
| + // NoBox means this kind renders nothing at all, because a value |
| + // carried a document-authored label too long to show. Substituting |
| + // the heading trail would replace the document's words with vaguer |
| + // ones — "the number you must never give to customers…" becoming |
| + // "Contacts" — and dropping just this candidate would turn a |
| + // two-candidate ambiguity into a confident single answer. Both are |
| + // worse than silence. §8.8. |
| + NoBox bool |
| +} |
| + |
| +// --------------------------------------------------------------------- |
| +// The lexicon: which words ask for which kind. |
| +// --------------------------------------------------------------------- |
| + |
| +// phrase is one entry of the lexicon, as a token sequence. |
| +type phrase struct { |
| + toks []string |
| + kind Kind |
| +} |
| + |
| +// lexicon maps what a person types to a kind. Longest phrases are tried |
| +// first, which is the whole reason "email address" can mean Email while |
| +// "address" alone means nothing: postal and email addresses are |
| +// ambiguous and only one of them has anything resembling a validator. |
| +// |
| +// **This is query.openers' trade made in the opposite direction.** That |
| +// list is deliberately stingy, because a wrong Enter-prime navigates away |
| +// from a page somebody was reading. This one can afford to be generous, |
| +// and the reason is the validator rather than a shared instinct: "mobile |
| +// app" primes Phone, finds no phone number, and renders nothing. A |
| +// generous lexicon costs a box that does not appear. |
| +var lexicon = []phrase{ |
| + {[]string{"email", "address"}, Email}, |
| + {[]string{"phone", "number"}, Phone}, |
| + {[]string{"bank", "details"}, IBAN}, |
| + {[]string{"post", "code"}, Eircode}, |
| + {[]string{"vat", "number"}, VAT}, |
| + {[]string{"e", "mail"}, Email}, |
| + {[]string{"eircode"}, Eircode}, |
| + {[]string{"postcode"}, Eircode}, |
| + {[]string{"telephone"}, Phone}, |
| + {[]string{"phone"}, Phone}, |
| + {[]string{"mobile"}, Phone}, |
| + {[]string{"tel"}, Phone}, |
| + {[]string{"iban"}, IBAN}, |
| + {[]string{"vat"}, VAT}, |
| + {[]string{"email"}, Email}, |
| +} |
| + |
| +// Wanted returns the kinds a query names, in `order`. Empty means no box. |
| +// |
| +// Deliberately absent: "address", which is ambiguous between postal and |
| +// email and whose postal reading has no validator (§8.8, decision 15); |
| +// and "number", which is generic and counts only in combination. Both |
| +// would prime on ordinary queries that are not asking for a fact. |
| +func Wanted(q string) []Kind { |
| + toks := tokens(q) |
| + want := map[Kind]bool{} |
| + for i := 0; i < len(toks); i++ { |
| + for _, p := range lexicon { |
| + if len(p.toks) > len(toks)-i { |
| + continue |
| + } |
| + if matchAt(toks, i, p.toks) { |
| + want[p.kind] = true |
| + i += len(p.toks) - 1 |
| + break |
| + } |
| + } |
| + } |
| + var out []Kind |
| + for _, k := range order { |
| + if want[k] { |
| + out = append(out, k) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +func matchAt(toks []string, i int, want []string) bool { |
| + for j, w := range want { |
| + if toks[i+j] != w { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// tokens lowercases and splits on anything that is not a letter or |
| +// digit, so "e-mail" becomes ["e","mail"] and punctuation is inert. The |
| +// same shape as index.MatchQuery's term split, and for the same reason: a |
| +// search box is not a query language. |
| +func tokens(s string) []string { |
| + return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { |
| + return !unicode.IsLetter(r) && !unicode.IsDigit(r) |
| + }) |
| +} |
| + |
| +// --------------------------------------------------------------------- |
| +// The recognisers. |
| +// --------------------------------------------------------------------- |
| + |
| +// sep is every character that may separate the groups of a fact, and it |
| +// includes U+00A0. |
| +// |
| +// Go's \s does NOT match a non-breaking space, and Word and Notion both |
| +// paste them. chunk.Split does not normalise interior whitespace, so the |
| +// bytes reach the sealed row exactly as pasted — which means a recogniser |
| +// written with \s finds nothing at all in text pasted from either, and |
| +// the failure is a box that silently never appears. |
| +const sep = ` \x{00A0}` |
| + |
| +type recogniser struct { |
| + kind Kind |
| + re *regexp.Regexp |
| + canon func(string) string |
| + valid func(span, canonical string) bool |
| +} |
| + |
| +// The regexes are deliberately LOOSE and the validators do the work. |
| +// That is §8.8's "validation is not pattern-spotting" made structural: it |
| +// is what stops the next person tightening a regex until it is a |
| +// validator nobody can see, and it is why every reject below fails on an |
| +// arithmetic property rather than on a character class. |
| +var recognisers = []recogniser{ |
| + { |
| + kind: Eircode, |
| + // The routing key's first letter is a closed set and the unique |
| + // identifier's alphabet excludes the letters that misread. A |
| + // pattern written [A-Z][0-9][0-9] gets Dublin 6W wrong and lets |
| + // B12 3456 through; both are fixtures. |
| + re: regexp.MustCompile(`(?i)\b[ACDEFHKNPRTVWXY][0-9][0-9W][` + sep + `]?[0-9ACDEFHKNPRTVWXY]{4}\b`), |
| + canon: func(s string) string { return strings.ToUpper(stripSep(s)) }, |
| + valid: func(_, _ string) bool { return true }, // the pattern IS the closed set |
| + }, |
| + { |
| + kind: Phone, |
| + // Built out of digit GROUPS rather than one character class, and |
| + // that is the difference between working and not. |
| + // |
| + // A class — [0-9 ().-]{6,24} — is greedy across a LIST: given |
| + // "Phone: 01 547 7854 - 01 598 1234" it swallows both numbers as |
| + // one span of eighteen digits, which then fails validation, so |
| + // the page shows nothing where it should show two. Requiring a |
| + // SINGLE separator between groups stops the match at the " - ", |
| + // and the second number then matches on its own. Found by the |
| + // fixture, not by reading the pattern. |
| + // |
| + // The prefix is "+CC", optionally followed by the "(0)" that |
| + // means "the trunk digit you omit from abroad", or a bare |
| + // leading 0. Anchoring there is what keeps account numbers, |
| + // company numbers and DUNS numbers out before any validation |
| + // runs at all. |
| + re: regexp.MustCompile(`(?i)(?:\+[0-9]{1,3}[` + sep + `]*(?:\(0\))?|\b0)[` + sep + `]*` + |
| + `[0-9]{1,4}(?:[` + sep + `.-][0-9]{2,5}){0,4}` + |
| + // An extension is part of the span, because §8.8 says the span |
| + // is what the document wrote, and a number whose extension has |
| + // been dropped is a number that does not reach the person. |
| + `(?i:[` + sep + `]*(?:ext|extension|x)\.?[` + sep + `]*[0-9]{1,6})?`), |
| + canon: phoneCanon, |
| + valid: phoneValid, |
| + }, |
| + { |
| + kind: IBAN, |
| + // Two spellings, because a document writes an IBAN either solid |
| + // or in groups of four and a single pattern for both swallows |
| + // the words after it. |
| + re: regexp.MustCompile(`(?i)\b[A-Z]{2}[0-9]{2}(?:[A-Z0-9]{11,30}|(?:[` + sep + `][A-Z0-9]{2,4}){2,8})\b`), |
| + canon: func(s string) string { return strings.ToUpper(stripSep(s)) }, |
| + valid: func(_, c string) bool { return ibanValid(c) }, |
| + }, |
| + { |
| + kind: VAT, |
| + re: regexp.MustCompile(`(?i)\bIE[0-9]{7}[A-Z]{1,2}\b`), |
| + canon: func(s string) string { return strings.ToUpper(stripSep(s)) }, |
| + valid: func(_, c string) bool { return vatValid(c) }, |
| + }, |
| + { |
| + kind: Email, |
| + // The weakest kind here, and §8.8 says so rather than hiding it: |
| + // local@domain is closer to pattern-spotting than to validation. |
| + // On a corpus carrying forwarded mail every signature qualifies, |
| + // the cap is passed, and the box is inert for this kind — which |
| + // is the cap working, and fails towards silence rather than |
| + // towards a wrong address. |
| + re: regexp.MustCompile(`\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b`), |
| + canon: strings.ToLower, |
| + valid: func(_, _ string) bool { return true }, |
| + }, |
| +} |
| + |
| +// stripSep removes every separator a document might group a fact with, |
| +// including U+00A0. Used only to build a Canonical; a Span is never |
| +// passed through it, which is gate 36's whole subject. |
| +func stripSep(s string) string { |
| + return strings.Map(func(r rune) rune { |
| + switch r { |
| + case ' ', ' ', '-', '.', '(', ')', '\t': |
| + return -1 |
| + } |
| + return r |
| + }, s) |
| +} |
| + |
| +// phoneCanon folds a written number onto E.164 so that "01 547 7854" and |
| +// "+353 (0)1 547 7854" are ONE candidate rather than two. |
| +// |
| +// The "(0)" is dropped only after an international prefix, which is what |
| +// that notation means: it is the trunk digit you dial domestically and |
| +// omit from abroad. Keeping it would make the two spellings above |
| +// different canonical values and put the same number in the box twice. |
| +// |
| +// Never rendered. §8.8 rules explicitly that a canonical form which is |
| +// neither displayed nor an operand is not §8.7's forbidden second parser. |
| +func phoneCanon(s string) string { |
| + if base, ext := splitExt(s); ext != "" { |
| + // An extension is part of the number somebody has to ring, so two |
| + // entries differing only by extension are two candidates and not a |
| + // duplicate. Appended AFTER the base is folded, so the base still |
| + // dedupes across spellings. |
| + return phoneCanon(base) + "x" + onlyDigits(ext) |
| + } |
| + intl := strings.HasPrefix(s, "+") |
| + if intl { |
| + s = strings.Replace(s, "(0)", "", 1) |
| + } |
| + var d strings.Builder |
| + for _, r := range s { |
| + if r >= '0' && r <= '9' { |
| + d.WriteRune(r) |
| + } |
| + } |
| + digits := d.String() |
| + switch { |
| + case intl: |
| + return "+" + digits |
| + case strings.HasPrefix(digits, "0"): |
| + // A national number, which on this corpus means Ireland. Folded |
| + // onto +353 so it dedupes against the international spelling. |
| + return "+353" + digits[1:] |
| + default: |
| + return digits |
| + } |
| +} |
| + |
| +// phoneValid is the structural rule that keeps the one checksum-less |
| +// numeric kind honest: **a bare run of digits is never a phone number.** |
| +// |
| +// A phone number has no check digit, so unlike an IBAN or a VAT number |
| +// there is nothing arithmetic to refuse a wrong one with. What is left is |
| +// how documents actually write phone numbers: with a country code, or in |
| +// groups. Without this rule index/rank_test.go's own corpus produces a |
| +// false positive on its first line — "DUNS/D-U-N-S number is 985632364" |
| +// is nine digits and a plausible Irish mobile. |
| +// |
| +// **The cost is stated rather than discovered:** a document that writes |
| +// 0871234567 with no separators at all is not boxed. That is the |
| +// direction a refusal should fail in. |
| +func phoneValid(span, _ string) bool { |
| + // Validated on the BASE number. An extension carries its own |
| + // digits and would otherwise push an ordinary number past E.164's |
| + // fifteen — refusing it for carrying more information, not less. |
| + base, _ := splitExt(span) |
| + if !strings.HasPrefix(base, "+") && !strings.ContainsAny(base, " ().-") { |
| + return false |
| + } |
| + digits := strings.TrimPrefix(phoneCanon(base), "+") |
| + if strings.HasPrefix(base, "+") { |
| + // E.164: a country code plus a subscriber number, 8 to 15 digits. |
| + return len(digits) >= 8 && len(digits) <= 15 |
| + } |
| + // A national number, canonicalised to +353 above, so the national |
| + // form was 0 plus what follows. Irish numbers are 9 or 10 digits |
| + // including the trunk 0 — 01 xxx xxxx, 021 xxx xxxx, 08x xxx xxxx. |
| + // |
| + // Nine-or-ten rather than "eight or more" is load-bearing: a date |
| + // written 01-02-2024 leads with a 0, carries separators, and is eight |
| + // digits. Widening this bound puts dates in the box. |
| + n := len(digits) - len("353") + 1 |
| + return n == 9 || n == 10 |
| +} |
| + |
| +// extRe finds a trailing extension, so canon and valid can treat the base |
| +// number and the extension separately while the SPAN keeps both. |
| +var extRe = regexp.MustCompile(`(?i)[` + sep + `]*(?:ext|extension|x)\.?[` + sep + `]*[0-9]{1,6}$`) |
| + |
| +func splitExt(s string) (base, ext string) { |
| + if m := extRe.FindStringIndex(s); m != nil { |
| + return s[:m[0]], s[m[0]:] |
| + } |
| + return s, "" |
| +} |
| + |
| +func onlyDigits(s string) string { |
| + var b strings.Builder |
| + for _, r := range s { |
| + if r >= '0' && r <= '9' { |
| + b.WriteRune(r) |
| + } |
| + } |
| + return b.String() |
| +} |
| + |
| +// ibanAlphabet holds the lengths IBANs have, by country. An unknown |
| +// country is refused rather than guessed: mod-97 alone admits roughly one |
| +// random string in ninety-seven, and the length is what makes the check |
| +// worth having. |
| +var ibanLength = map[string]int{ |
| + "IE": 22, "GB": 22, "DE": 22, "FR": 27, "ES": 24, "NL": 18, |
| + "IT": 27, "BE": 16, "PT": 25, "AT": 20, "PL": 28, "SE": 24, |
| + "DK": 18, "FI": 18, "NO": 15, "CH": 21, "LU": 20, "GR": 27, |
| +} |
| + |
| +// ibanValid is ISO 13616's mod-97 check: move the first four characters |
| +// to the end, map letters to two-digit numbers, and the whole thing |
| +// modulo 97 must be 1. |
| +// |
| +// Verified before it was written, against IE29AIBK93115212345678, |
| +// GB82WEST12345698765432 and DE89370400440532013000, and against the |
| +// first of those with one digit changed. A checksum is the one thing in |
| +// this package that can be checked against the world rather than against |
| +// a fixture somebody here wrote, so it was. |
| +func ibanValid(s string) bool { |
| + if len(s) < 15 || len(s) > 34 { |
| + return false |
| + } |
| + want, known := ibanLength[s[:2]] |
| + if !known || len(s) != want { |
| + return false |
| + } |
| + rem := 0 |
| + for _, c := range s[4:] + s[:4] { |
| + switch { |
| + case c >= '0' && c <= '9': |
| + rem = (rem*10 + int(c-'0')) % 97 |
| + case c >= 'A' && c <= 'Z': |
| + rem = (rem*100 + int(c-'A') + 10) % 97 |
| + default: |
| + return false |
| + } |
| + } |
| + return rem == 1 |
| +} |
| + |
| +// vatCheckAlphabet is Revenue's check-letter table, indexed by the |
| +// weighted sum modulo 23. W is zero. |
| +const vatCheckAlphabet = "WABCDEFGHIJKLMNOPQRSTUV" |
| + |
| +// vatValid checks an Irish VAT number's check letter. |
| +// |
| +// Modern format only — seven digits, a check letter, optionally a second |
| +// letter. **The old style, with a letter or + or * in second position, is |
| +// refused deliberately.** A refused VAT number is a box that does not |
| +// appear; an admitted wrong one is a fact on the page, and between those |
| +// two the choice is not close. |
| +// |
| +// Verified against IE6388047V and IE3628739L, and against the invented |
| +// IE1234567X, which it refuses. index/rank_test.go's own IE3384527RH |
| +// passes, which is what makes that fixture usable as a corpus here. |
| +func vatValid(s string) bool { |
| + s = strings.TrimPrefix(s, "IE") |
| + if len(s) != 8 && len(s) != 9 { |
| + return false |
| + } |
| + sum := 0 |
| + for i := 0; i < 7; i++ { |
| + if s[i] < '0' || s[i] > '9' { |
| + return false |
| + } |
| + sum += int(s[i]-'0') * (8 - i) |
| + } |
| + if len(s) == 9 { |
| + j := strings.IndexByte(vatCheckAlphabet, s[8]) |
| + if j < 0 { |
| + return false |
| + } |
| + sum += j * 9 |
| + } |
| + return s[7] == vatCheckAlphabet[sum%23] |
| +} |
| + |
| +// --------------------------------------------------------------------- |
| +// The label. |
| +// --------------------------------------------------------------------- |
| + |
| +// MaxLabel is how long a document-authored label may be, in characters. |
| +// |
| +// **A guess.** What is not a guess is that it has to clear the motivating |
| +// fixture in index/rank_test.go, whose longer label — "We also have the |
| +// following unused phone numbers" — is 47. |
| +const MaxLabel = 60 |
| + |
| +// abbreviations are the full stops that do not end a sentence. |
| +// |
| +// **Stated as incomplete, because that is the honest form.** Draft 2 of |
| +// §8.8 claimed the sentence split "fails in the safe direction"; it does |
| +// not. A boundary placed too LATE yields a shorter run that passes every |
| +// other test and produces a label the document never wrote — "Dr. Smith's |
| +// direct line:" becoming "Smith's direct line". That is the composing |
| +// failure this whole function exists to prevent, arriving through the |
| +// function itself, so the list is what holds it and the list is short. |
| +// |
| +// It must not grow into a sentence tokeniser. Where it is wrong it is |
| +// wrong in the composing direction, and knowing that is the difference |
| +// between a limit and a surprise. |
| +var abbreviations = map[string]bool{ |
| + "dr.": true, "mr.": true, "mrs.": true, "ms.": true, "st.": true, |
| + "no.": true, "ext.": true, "ltd.": true, "co.": true, |
| +} |
| + |
| +// separators are what a document puts between a label and its value. |
| +// |
| +// The en dash is here because Word and Notion autocorrect a hyphen |
| +// between spaces into one, so "Office – 01 547 7854" is what a document |
| +// actually contains rather than what its author typed. |
| +func isSeparator(r rune) bool { |
| + switch r { |
| + case ':', '—', '–', '-', '=', '\t': |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// sentenceStart walks back from at to the start of the enclosing sentence |
| +// or line. |
| +func sentenceStart(text string, at int) int { |
| + for i := at - 1; i > 0; i-- { |
| + if text[i] == '\n' { |
| + return i + 1 |
| + } |
| + if text[i] != ' ' || (text[i-1] != '.' && text[i-1] != '!' && text[i-1] != '?') { |
| + continue |
| + } |
| + // A full stop followed by a space. Whether it ends a sentence |
| + // depends on the word it is attached to. |
| + w := i |
| + for w > 0 && text[w-1] != ' ' && text[w-1] != '\n' { |
| + w-- |
| + } |
| + if abbreviations[strings.ToLower(text[w:i])] { |
| + continue |
| + } |
| + return i + 1 |
| + } |
| + return 0 |
| +} |
| + |
| +// labelFor implements §8.8's five rules. It returns the label and whether |
| +// this kind must render no box at all. |
| +// |
| +// The order of the rules is the whole of it, and each one is here because |
| +// a fixture broke without it — see gate 37, which carries one per case. |
| +func labelFor(text string, start int, spans [][2]int, heading, title string) (string, bool) { |
| + b := sentenceStart(text, start) |
| + // **An earlier recognised span inside the run is not part of the |
| + // label.** Without this, "Phone: 01 547 7854 - 01 598 1234" labels |
| + // the SECOND number with the first one's value — a box whose left |
| + // column is another phone number. |
| + for _, sp := range spans { |
| + if sp[1] <= start && sp[1] > b { |
| + b = sp[1] |
| + } |
| + } |
| + run := strings.TrimRight(text[b:start], " ") |
| + if r, size := utf8.DecodeLastRuneInString(run); size > 0 && isSeparator(r) { |
| + lbl := strings.TrimSpace(run[:len(run)-size]) |
| + switch { |
| + case lbl == "": |
| + // A markdown bullet: chunk.Split keeps lines verbatim, so |
| + // "- +353 1 547 7854" gives a run of "-", which ends in a |
| + // separator and names nothing. Falling through keeps a plain |
| + // contact list boxable; returning "" would make two bullets |
| + // collide and suppress it. |
| + case utf8.RuneCountInString(lbl) > MaxLabel: |
| + return "", true |
| + default: |
| + return lbl, false |
| + } |
| + } |
| + if heading != "" { |
| + return heading, false |
| + } |
| + return title, false |
| +} |
| + |
| +// --------------------------------------------------------------------- |
| +// Scan. |
| +// --------------------------------------------------------------------- |
| + |
| +// Scan returns every validated fact of the wanted kinds in text. |
| +// |
| +// heading and title are the label fallbacks, in that order, and title is |
| +// the last one on purpose: an upload with no headings at all is the |
| +// common pasted-file shape, and falling through to an empty label there |
| +// is the case every fixture carrying a heading would hide. |
| +// |
| +// **This runs over a passage that has already passed §4.1.** Nothing in |
| +// this package knows about authorization, and that is the point: by |
| +// construction there is no way to reach it with a passage the reader may |
| +// not see, because the caller has only the ruled results to hand it. |
| +func Scan(text, heading, title string, want []Kind) []Found { |
| + wanted := map[Kind]bool{} |
| + for _, k := range want { |
| + wanted[k] = true |
| + } |
| + |
| + // Every span of every wanted kind is located BEFORE any label is |
| + // derived, because labelFor needs to know about the spans to its |
| + // left and cannot if it is called as they are found. |
| + type hit struct { |
| + kind Kind |
| + at [2]int |
| + span string |
| + canonical string |
| + } |
| + var hits []hit |
| + var spans [][2]int |
| + for _, r := range recognisers { |
| + if !wanted[r.kind] { |
| + continue |
| + } |
| + for _, m := range r.re.FindAllStringIndex(text, -1) { |
| + span := text[m[0]:m[1]] |
| + c := r.canon(span) |
| + if !r.valid(span, c) { |
| + continue |
| + } |
| + hits = append(hits, hit{r.kind, [2]int{m[0], m[1]}, span, c}) |
| + spans = append(spans, [2]int{m[0], m[1]}) |
| + } |
| + } |
| + |
| + var out []Found |
| + for _, k := range order { |
| + if !wanted[k] { |
| + continue |
| + } |
| + for _, h := range hits { |
| + if h.kind != k { |
| + continue |
| + } |
| + lbl, noBox := labelFor(text, h.at[0], spans, heading, title) |
| + out = append(out, Found{ |
| + Kind: h.kind, |
| + Span: h.span, |
| + Canonical: h.canonical, |
| + Label: lbl, |
| + NoBox: noBox, |
| + }) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// Kinds returns the render order, so a caller applying §8.8's per-kind |
| +// rules produces the same boxes in the same places on every request. |
| +func Kinds() []Kind { return append([]Kind(nil), order...) } |
diff --git a/internal/memoria/fact/fact_test.go b/internal/memoria/fact/fact_test.go| new file mode 100644 |
| index 0000000..d3b3ebd |
| --- /dev/null |
| +++ b/internal/memoria/fact/fact_test.go |
| @@ -0,0 +1,295 @@ |
| +package fact |
| + |
| +import ( |
| + "reflect" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// The corpus this package is judged against is index/rank_test.go's, |
| +// which was built from the live instance where Paul asked for this |
| +// feature. Quoted here rather than paraphrased: a fixture rewritten from |
| +// the same model as the code cannot catch a wrong assumption, it records |
| +// one (AGENTS.md §Review). |
| +const officeChunk = "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." |
| + |
| +const numbersChunk = "Our company number is 566334. Our VAT number is IE3384527RH. " + |
| + "Our DUNS/D-U-N-S number is 985632364." |
| + |
| +const bankChunk = "The account number is 12345678 and the sort code is 93-00-11." |
| + |
| +func TestWantedNamesTheKind(t *testing.T) { |
| + for _, c := range []struct { |
| + q string |
| + want []Kind |
| + }{ |
| + {"phone number", []Kind{Phone}}, |
| + {"what's the office phone number?", []Kind{Phone}}, |
| + {"eircode", []Kind{Eircode}}, |
| + {"post code", []Kind{Eircode}}, |
| + {"vat number", []Kind{VAT}}, |
| + {"iban", []Kind{IBAN}}, |
| + {"bank details", []Kind{IBAN}}, |
| + {"email address", []Kind{Email}}, |
| + {"e-mail", []Kind{Email}}, |
| + {"phone and email", []Kind{Phone, Email}}, |
| + } { |
| + if got := Wanted(c.q); !reflect.DeepEqual(got, c.want) { |
| + t.Errorf("Wanted(%q) = %v, want %v", c.q, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// The words that must NOT prime, and each is here because priming on it |
| +// would put a box on an ordinary query. |
| +// |
| +// "address" is the interesting one: it is ambiguous between a postal |
| +// address and an email address, and only one of those has anything |
| +// resembling a validator (§8.8, decision 15). |
| +func TestWantedRefusesGenericWords(t *testing.T) { |
| + for _, q := range []string{"address", "number", "office address", "account number", "", "revenue"} { |
| + if got := Wanted(q); len(got) != 0 { |
| + t.Errorf("Wanted(%q) = %v, want nothing", q, got) |
| + } |
| + } |
| +} |
| + |
| +// A generous lexicon is affordable BECAUSE of the validator, and this is |
| +// the test that says so: "mobile app" primes Phone and then finds |
| +// nothing, so the cost is a box that does not appear. |
| +func TestAGenerousLexiconCostsNothingWithoutAValidCandidate(t *testing.T) { |
| + if got := Wanted("mobile app"); !reflect.DeepEqual(got, []Kind{Phone}) { |
| + t.Fatalf("Wanted(mobile app) = %v, want [phone]", got) |
| + } |
| + if got := Scan("We shipped the mobile app in June.", "Product", "Notes", []Kind{Phone}); len(got) != 0 { |
| + t.Errorf("Scan found %v in text with no phone number", got) |
| + } |
| +} |
| + |
| +// --------------------------------------------------------------------- |
| +// Validators, against values checkable outside this repo. |
| +// --------------------------------------------------------------------- |
| + |
| +// The reject rows are the load-bearing ones. An accept-only table passes |
| +// against a validator that returns true — the guard written to pass, in |
| +// its purest form. |
| +func TestVATCheckLetter(t *testing.T) { |
| + for _, c := range []struct { |
| + in string |
| + ok bool |
| + }{ |
| + {"IE6388047V", true}, // Google Ireland |
| + {"IE3628739L", true}, // published example |
| + {"IE3384527RH", true}, // index/rank_test.go's own corpus |
| + {"IE1234567X", false}, // invented: right shape, wrong check letter |
| + {"IE6388047W", false}, // one letter off the real one |
| + {"IE8Z49289F", false}, // old style, refused deliberately |
| + } { |
| + if got := vatValid(c.in); got != c.ok { |
| + t.Errorf("vatValid(%q) = %v, want %v", c.in, got, c.ok) |
| + } |
| + } |
| +} |
| + |
| +func TestIBANModulus97(t *testing.T) { |
| + for _, c := range []struct { |
| + in string |
| + ok bool |
| + }{ |
| + {"IE29AIBK93115212345678", true}, |
| + {"GB82WEST12345698765432", true}, |
| + {"DE89370400440532013000", true}, |
| + {"IE29AIBK93115212345679", false}, // one digit changed |
| + {"IE29AIBK9311521234567", false}, // one digit short |
| + {"XX29AIBK93115212345678", false}, // unknown country |
| + } { |
| + if got := ibanValid(c.in); got != c.ok { |
| + t.Errorf("ibanValid(%q) = %v, want %v", c.in, got, c.ok) |
| + } |
| + } |
| +} |
| + |
| +// D6W is Dublin 6W and its routing key does not fit [A-Z][0-9][0-9]; B is |
| +// not in the routing-key alphabet at all. Both are the cases a pattern |
| +// written from memory gets wrong. |
| +func TestEircodeAlphabet(t *testing.T) { |
| + for _, c := range []struct { |
| + in string |
| + ok bool |
| + }{ |
| + {"A65 F4E2", true}, // the published example |
| + {"D02 X285", true}, |
| + {"D6W 1234", true}, |
| + {"D02X285", true}, |
| + {"B12 3456", false}, // B is not a routing-key letter |
| + {"D02 XB85", false}, // B is not in the identifier alphabet |
| + } { |
| + got := len(Scan("Eircode "+c.in+" here.", "", "T", []Kind{Eircode})) > 0 |
| + if got != c.ok { |
| + t.Errorf("eircode %q recognised = %v, want %v", c.in, got, c.ok) |
| + } |
| + } |
| +} |
| + |
| +// **A bare run of digits is never a phone number.** This is the whole of |
| +// what stands between the box and rank_test.go's own first line: the DUNS |
| +// number there is nine digits and a plausible Irish mobile. |
| +func TestABareRunOfDigitsIsNotAPhoneNumber(t *testing.T) { |
| + for _, c := range []struct { |
| + text string |
| + want int |
| + }{ |
| + {officeChunk, 2}, |
| + {numbersChunk, 0}, // 566334, IE3384527RH, 985632364 |
| + {bankChunk, 0}, // 12345678, 93-00-11 |
| + {"Virgin Media - login - Office 7: account number 80854641", 0}, |
| + {"The deadline is 01-02-2024 for everyone.", 0}, // a date leads with 0 and has separators |
| + {"Call 0871234567 today.", 0}, // no separators: the stated cost |
| + {"Call 087 123 4567 today.", 1}, |
| + } { |
| + if got := len(Scan(c.text, "H", "T", []Kind{Phone})); got != c.want { |
| + t.Errorf("Scan(%q) found %d phone numbers, want %d", c.text, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// Two spellings of one number are ONE candidate. A dedupe keyed on the |
| +// span rather than the canonical form passes any fixture that spells it |
| +// the same way twice, which is why this one does not. |
| +func TestTwoSpellingsCanonicaliseTogether(t *testing.T) { |
| + a := Scan("Office: +353 (0)1 547 7854", "H", "T", []Kind{Phone}) |
| + b := Scan("Office: 01 547 7854", "H", "T", []Kind{Phone}) |
| + if len(a) != 1 || len(b) != 1 { |
| + t.Fatalf("want one each, got %d and %d", len(a), len(b)) |
| + } |
| + if a[0].Canonical != b[0].Canonical { |
| + t.Errorf("canonical %q != %q", a[0].Canonical, b[0].Canonical) |
| + } |
| + if a[0].Span == b[0].Span { |
| + t.Errorf("spans should differ; both %q", a[0].Span) |
| + } |
| +} |
| + |
| +// --------------------------------------------------------------------- |
| +// Spans are the document's bytes. |
| +// --------------------------------------------------------------------- |
| + |
| +func TestSpanIsByteIdenticalToTheDocument(t *testing.T) { |
| + for _, c := range []struct{ text, want string }{ |
| + {"Office: +353 (0)1 547 7854.", "+353 (0)1 547 7854"}, |
| + {"Office: +353 (0)1 547 7854 ext. 12.", "+353 (0)1 547 7854 ext. 12"}, |
| + // Pasted from Word or Notion. Go's \s does not match U+00A0, so a |
| + // recogniser written with it finds NOTHING here and this case |
| + // fails on presence rather than on the bytes — which is the |
| + // design demand, not an accident. |
| + {"Office: +353 (0)1 547 7854.", "+353 (0)1 547 7854"}, |
| + } { |
| + got := Scan(c.text, "H", "T", []Kind{Phone}) |
| + if len(got) != 1 { |
| + t.Fatalf("Scan(%q) found %d, want 1", c.text, len(got)) |
| + } |
| + if got[0].Span != c.want { |
| + t.Errorf("span = %q, want %q", got[0].Span, c.want) |
| + } |
| + } |
| +} |
| + |
| +// --------------------------------------------------------------------- |
| +// The label, one case per rule and one per failure the reviews found. |
| +// --------------------------------------------------------------------- |
| + |
| +func TestLabelComesFromTheDocument(t *testing.T) { |
| + long := strings.Repeat("x", MaxLabel) // exactly at the cap |
| + tooLong := strings.Repeat("x", MaxLabel+1) // one over |
| + |
| + for _, c := range []struct { |
| + name string |
| + text string |
| + head string |
| + title string |
| + want []string |
| + noBox bool |
| + }{ |
| + { |
| + name: "a labelled line", |
| + text: "Office: +353 1 547 7854", head: "Contact", title: "Tito", |
| + want: []string{"Office"}, |
| + }, |
| + { |
| + // The motivating fixture. Draft 1 of §8.8 required the value |
| + // to start its own line, so both numbers fell to the heading |
| + // trail, came back labelled identically, and the box would |
| + // have shown "unused" and "office" as equals. |
| + name: "the office fixture keeps its two sentences apart", |
| + text: officeChunk, head: "Office Address", title: "Office", |
| + want: []string{"The office phone number is", "We also have the following unused phone numbers"}, |
| + }, |
| + { |
| + name: "no separator falls to the trail, never the clause", |
| + text: "Ring the office on +353 1 547 7854 any weekday.", head: "Contact", title: "Tito", |
| + want: []string{"Contact"}, |
| + }, |
| + { |
| + // chunk.Split keeps lines verbatim, so the run is "-", which |
| + // ends in a separator and names nothing. Returning "" here |
| + // makes two bullets collide and suppresses the box for a |
| + // plain contact list. |
| + name: "a markdown bullet falls through rather than yielding an empty label", |
| + text: "- +353 1 547 7854\n- +353 1 598 1234", head: "Contact", title: "Tito", |
| + want: []string{"Contact", "Contact"}, |
| + }, |
| + { |
| + // Without the earlier-span rule the second label is |
| + // "Phone: 01 547 7854" — one phone number naming another. |
| + name: "an adjacent value is not part of the next label", |
| + text: "Phone: 01 547 7854 - 01 598 1234", head: "Contact", title: "Tito", |
| + want: []string{"Phone", "Contact"}, |
| + }, |
| + { |
| + // A boundary placed too late composes a label the document |
| + // never wrote. Draft 2 claimed this direction was safe. |
| + name: "an abbreviation does not end the sentence", |
| + text: "Dr. Smith's direct line: 01 547 7854", head: "Contact", title: "Tito", |
| + want: []string{"Dr. Smith's direct line"}, |
| + }, |
| + { |
| + name: "no headings at all falls to the title, never empty", |
| + text: "Ring us on +353 1 547 7854 any weekday.", head: "", title: "Pasted contacts", |
| + want: []string{"Pasted contacts"}, |
| + }, |
| + { |
| + name: "a label exactly at the cap renders", |
| + text: long + ": 01 547 7854", head: "Contact", title: "Tito", |
| + want: []string{long}, |
| + }, |
| + { |
| + // Substituting the trail would replace the document's own |
| + // warning with a vaguer word; dropping just this candidate |
| + // would turn an ambiguity into a confident single answer. |
| + name: "a label one over the cap renders no box for the kind", |
| + text: tooLong + ": 01 547 7854", head: "Contact", title: "Tito", |
| + noBox: true, |
| + }, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + got := Scan(c.text, c.head, c.title, []Kind{Phone}) |
| + if c.noBox { |
| + if len(got) != 1 || !got[0].NoBox { |
| + t.Fatalf("want one NoBox result, got %+v", got) |
| + } |
| + return |
| + } |
| + var labels []string |
| + for _, f := range got { |
| + if f.NoBox { |
| + t.Fatalf("unexpected NoBox in %+v", got) |
| + } |
| + labels = append(labels, f.Label) |
| + } |
| + if !reflect.DeepEqual(labels, c.want) { |
| + t.Errorf("labels = %q, want %q", labels, c.want) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/memoria/factbox_test.go b/internal/memoria/factbox_test.go| new file mode 100644 |
| index 0000000..71c6641 |
| --- /dev/null |
| +++ b/internal/memoria/factbox_test.go |
| @@ -0,0 +1,241 @@ |
| +package memoria |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + |
| + "amadan.net/oficina/suite" |
| + |
| + "amadan.net/oficina/memoria/internal/memoria/sibling" |
| +) |
| + |
| +// perItemCustody rules each candidate on its own item id. |
| +// |
| +// **fixedCustody cannot do this**, and that is why this type exists |
| +// rather than a field being added to it: it returns sibling.Decide(give) |
| +// for every candidate from ONE field, so every fixture it builds has all |
| +// its passages visible or none of them. Gate 34 is entirely about pages |
| +// where some are and some are not, and drafts 1 and 2 of §8.8 both said |
| +// the gate could run against "memoriatest's sibling stub, which exists". |
| +// No such stub exists. §10 opens by naming that failure — a gate naming a |
| +// test that was renamed away — and the section citing it committed it |
| +// twice. |
| +type perItemCustody struct { |
| + apps map[string]sibling.Sibling |
| + // give is keyed by item id. A candidate whose item id is absent is |
| + // VISIBLE, so a fixture names only the exceptions it cares about. |
| + give map[string]suite.Authorization |
| +} |
| + |
| +func (p *perItemCustody) Sibling(source string) (sibling.Sibling, bool) { |
| + s, ok := p.apps[source] |
| + return s, ok |
| +} |
| + |
| +func (p *perItemCustody) Rule(_ context.Context, _ suite.Subject, cands []sibling.Candidate) []sibling.Disposition { |
| + out := make([]sibling.Disposition, len(cands)) |
| + for i, c := range cands { |
| + a, ok := p.give[c.Item.ItemID] |
| + if !ok { |
| + a = suite.VISIBLE |
| + } |
| + out[i] = sibling.Decide(a) |
| + } |
| + return out |
| +} |
| + |
| +// eircodeCorpus stores one sibling document per (title, eircode) pair, |
| +// each under its own item id so the fake above can rule them apart. |
| +func eircodeCorpus(t *testing.T, cus Custody, docs [][3]string) *Store { |
| + t.Helper() |
| + s := storeForTest(t) |
| + s.custody = cus |
| + for _, d := range docs { |
| + if _, err := s.Ingest(context.Background(), IngestSpec{ |
| + Source: "Docs", Key: d[0], Title: d[1], |
| + Body: "## Contact\n\nEircode: " + d[2], |
| + ItemID: d[0], |
| + SiblingRevision: 1, |
| + }); err != nil { |
| + t.Fatalf("ingest %s: %v", d[0], err) |
| + } |
| + } |
| + return s |
| +} |
| + |
| +func searchFacts(t *testing.T, s *Store, q string) Page { |
| + t.Helper() |
| + p, err := s.Search(context.Background(), |
| + suite.Subject{Kind: suite.SubjectAddress, Value: "paul@tito.io"}, q, 25) |
| + if err != nil { |
| + t.Fatalf("search: %v", err) |
| + } |
| + return p |
| +} |
| + |
| +func spans(p Page) []string { |
| + var out []string |
| + for _, f := range p.Facts { |
| + out = append(out, f.Span) |
| + } |
| + return out |
| +} |
| + |
| +// GATE 34 — the box never speaks a withheld passage, and every count |
| +// happens after the ruling (§8.8, §10). |
| +// |
| +// **Asserts the struct, never the rendered copy.** Two dispositions that |
| +// render an identical page is how Outcome.Record filed silent rulings |
| +// undetected (§1.9); a page assertion here could not tell case 2 from |
| +// case 3 at all. |
| +// |
| +// Mutations run, each verified red and reverted, 2026-09-14: |
| +// - addFacts ranging the pre-ruling hits instead of p.Results — cases |
| +// 2, 4, 5 and 6 go red. |
| +// - the cap counted before the ruling — case 4 goes red alone. |
| +// - the collision check computed over the unruled set — case 6 alone. |
| +// - citing r.DocumentID of the first hit rather than the first RESULT — |
| +// case 5 alone, and no other fixture can see it because every other |
| +// one uses distinct values per document. |
| +func TestGate34TheFactBoxNeverSpeaksAWithheldPassage(t *testing.T) { |
| + roster := docsRoster() |
| + |
| + // 1. PRESENCE. Both visible, two distinct eircodes, two candidates. |
| + // Without this every absence below is an assertion about an empty |
| + // world — §10's second rule, and gate 4's lesson. |
| + t.Run("presence: two visible passages give two candidates", func(t *testing.T) { |
| + s := eircodeCorpus(t, &perItemCustody{apps: roster}, [][3]string{ |
| + {"a", "Office A", "D02 X285"}, |
| + {"b", "Office B", "A65 F4E2"}, |
| + }) |
| + if got := spans(searchFacts(t, s, "eircode")); len(got) != 2 { |
| + t.Fatalf("spans = %q, want two", got) |
| + } |
| + }) |
| + |
| + // 2. NOT_VISIBLE IS SILENT. §4.1's table makes OmitSilently the only |
| + // disposition that may be silent, and Outcome.Record implements |
| + // exactly that. Draft 1 of §8.8 demanded a notice here, which is |
| + // either unreachable against correct code or an instruction to build |
| + // the existence oracle §4.1 exists to deny. **The empty-notice |
| + // assertion is the load-bearing half of this case.** |
| + t.Run("not_visible: the survivor is boxed and nothing is said", func(t *testing.T) { |
| + s := eircodeCorpus(t, &perItemCustody{ |
| + apps: roster, |
| + give: map[string]suite.Authorization{"b": suite.NOT_VISIBLE}, |
| + }, [][3]string{ |
| + {"a", "Office A", "D02 X285"}, |
| + {"b", "Office B", "A65 F4E2"}, |
| + }) |
| + p := searchFacts(t, s, "eircode") |
| + if got := spans(p); len(got) != 1 || got[0] != "D02 X285" { |
| + t.Fatalf("spans = %q, want just the visible one", got) |
| + } |
| + if len(p.Notices) != 0 { |
| + t.Errorf("notices = %q, want none: a NOT_VISIBLE is the one value that may be silent", p.Notices) |
| + } |
| + }) |
| + |
| + // 3. UNKNOWN IS LOUD. The same fixture, the other disposition. This |
| + // is what draft 1 meant and attached to the wrong one. |
| + t.Run("unknown: the survivor is boxed and the page says so", func(t *testing.T) { |
| + s := eircodeCorpus(t, &perItemCustody{ |
| + apps: roster, |
| + give: map[string]suite.Authorization{"b": suite.UNKNOWN}, |
| + }, [][3]string{ |
| + {"a", "Office A", "D02 X285"}, |
| + {"b", "Office B", "A65 F4E2"}, |
| + }) |
| + p := searchFacts(t, s, "eircode") |
| + if got := spans(p); len(got) != 1 || got[0] != "D02 X285" { |
| + t.Fatalf("spans = %q, want just the visible one", got) |
| + } |
| + if len(p.Notices) == 0 { |
| + t.Error("want a notice: an UNKNOWN is withheld loudly (§6.5)") |
| + } |
| + }) |
| + |
| + // 4. THE CAP COUNTS AFTER THE RULING. Four values, one withheld, |
| + // three left — a box. Counted before the ruling there are four, the |
| + // cap fires, and the reader loses the box because of a document they |
| + // may not see. **No two-document fixture can tell those apart**, |
| + // which is why this one has four. |
| + t.Run("the cap counts the ruled set", func(t *testing.T) { |
| + s := eircodeCorpus(t, &perItemCustody{ |
| + apps: roster, |
| + give: map[string]suite.Authorization{"d": suite.NOT_VISIBLE}, |
| + }, [][3]string{ |
| + {"a", "Office A", "D02 X285"}, |
| + {"b", "Office B", "A65 F4E2"}, |
| + {"c", "Office C", "D6W 1234"}, |
| + {"d", "Office D", "T12 XY45"}, |
| + }) |
| + if got := spans(searchFacts(t, s, "eircode")); len(got) != 3 { |
| + t.Fatalf("spans = %q, want three", got) |
| + } |
| + }) |
| + |
| + // 5. THE CITATION IS THE SURVIVOR'S. The SAME eircode in a visible |
| + // and a withheld document: one candidate, cited to the visible one. |
| + // Every other fixture uses distinct values per document, so "cite |
| + // from the hits rather than from the results" passes all of them. |
| + // This is the only case that can see it. |
| + t.Run("the citation is the visible passage's", func(t *testing.T) { |
| + cus := &perItemCustody{ |
| + apps: roster, |
| + give: map[string]suite.Authorization{"hidden": suite.NOT_VISIBLE}, |
| + } |
| + s := storeForTest(t) |
| + s.custody = cus |
| + ctx := context.Background() |
| + // The withheld one is ingested FIRST so it ranks first among the |
| + // hits: if the citation came from the hits, it would be this one. |
| + for _, d := range [][2]string{{"hidden", "Hidden office"}, {"shown", "Visible office"}} { |
| + if _, err := s.Ingest(ctx, IngestSpec{ |
| + Source: "Docs", Key: d[0], Title: d[1], |
| + Body: "## Contact\n\nEircode: D02 X285", |
| + ItemID: d[0], |
| + SiblingRevision: 1, |
| + }); err != nil { |
| + t.Fatalf("ingest %s: %v", d[0], err) |
| + } |
| + } |
| + p := searchFacts(t, s, "eircode") |
| + if len(p.Facts) != 1 { |
| + t.Fatalf("facts = %+v, want one", p.Facts) |
| + } |
| + if p.Facts[0].Title != "Visible office" { |
| + t.Errorf("cited %q; the box cited the passage this reader may not read", p.Facts[0].Title) |
| + } |
| + }) |
| + |
| + // 6. COLLISION COUNTS AFTER THE RULING TOO. Two documents sharing a |
| + // title and a trail, both falling to the trail for a label: over the |
| + // unruled set they collide and the box vanishes, which is the same |
| + // leak as case 4 wearing the collision rule's coat. |
| + t.Run("collision counts the ruled set", func(t *testing.T) { |
| + cus := &perItemCustody{ |
| + apps: roster, |
| + give: map[string]suite.Authorization{"twin-b": suite.NOT_VISIBLE}, |
| + } |
| + s := storeForTest(t) |
| + s.custody = cus |
| + ctx := context.Background() |
| + for i, d := range [][2]string{{"twin-a", "D02 X285"}, {"twin-b", "A65 F4E2"}} { |
| + if _, err := s.Ingest(ctx, IngestSpec{ |
| + Source: "Docs", Key: d[0], Title: "Offices", |
| + // Mid-sentence and with no separator before it, so the |
| + // label falls to the heading trail and the two rows |
| + // would be identical but for their values. |
| + Body: "## Contact\n\nThe eircode " + d[1] + " reaches us.", |
| + ItemID: d[0], |
| + SiblingRevision: int64(i) + 1, |
| + }); err != nil { |
| + t.Fatalf("ingest %s: %v", d[0], err) |
| + } |
| + } |
| + if got := spans(searchFacts(t, s, "eircode")); len(got) != 1 { |
| + t.Fatalf("spans = %q, want the one survivor boxed", got) |
| + } |
| + }) |
| +} |
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go| index 21a8c94..f552084 100644 |
| --- a/internal/memoria/handlers.go |
| +++ b/internal/memoria/handlers.go |
| @@ -155,6 +155,15 @@ type page struct { |
| // thing: a short page that reads as complete. |
| Notices []string |
| + // Facts is §8.8's box, above the results and below the notices. |
| + // Built in the store from the passages §4.1 kept, so a page can only |
| + // ever state a fact drawn from something this reader may read. |
| + // |
| + // **Empty is the ordinary state**, and it renders nothing at all — |
| + // never "no phone number found". The corpus is not the world, and a |
| + // confident absence is this codebase's worst failure shape. |
| + Facts []Fact |
| + |
| // SearchFailed is not "no results". A search that errored renders |
| // its own callout in the results region, because "nothing matched" |
| // and "the search broke" look identical to a reader and this |
| @@ -634,6 +643,10 @@ func (a *app) runSearch(r *http.Request, p *page) { |
| } |
| p.Results = found.Results |
| p.Notices = found.Notices |
| + // Copied here, in the method BOTH routes share, for the same reason |
| + // Primed is decided here: the page and the fragment must not be able |
| + // to disagree about whether there is a box. |
| + p.Facts = found.Facts |
| p.Primed = query.PrimesFirstResult(p.Query, len(found.Results)) |
| } |
diff --git a/internal/memoria/static/memoria.css b/internal/memoria/static/memoria.css| index 4bfb536..9423be6 100644 |
| --- a/internal/memoria/static/memoria.css |
| +++ b/internal/memoria/static/memoria.css |
| @@ -292,3 +292,39 @@ |
| [rst-search] { flex-direction: column; } |
| [rst-table] { font-size: 0.875rem; } |
| } |
| + |
| +/* §8.8's fact box: a validated fact lifted out of the passages below, |
| + with its citation. Sits between the withholding notice and the result |
| + list, and is styled to read as an answer rather than as a result — |
| + the value is the thing the eye should land on. */ |
| +[rst-fact-box] { |
| + border: 1px solid var(--rst-line); |
| + border-radius: var(--rst-radius); |
| + padding: 0.7rem 0.9rem; |
| + margin: 0 0 1rem; |
| + background: var(--rst-surface); |
| +} |
| +[rst-fact] { |
| + display: flex; |
| + flex-wrap: wrap; |
| + align-items: baseline; |
| + gap: 0.2rem 0.6rem; |
| + margin: 0; |
| +} |
| +[rst-fact] + [rst-fact] { |
| + margin-top: 0.5rem; |
| + padding-top: 0.5rem; |
| + border-top: 1px solid var(--rst-line); |
| +} |
| +[rst-fact-label] { |
| + color: var(--rst-text-muted); |
| + font-size: 0.8125rem; |
| +} |
| +[rst-fact-value] { |
| + font-weight: 600; |
| + font-size: 1.0625rem; |
| +} |
| +[rst-fact-cite] { |
| + font-size: 0.8125rem; |
| + margin-left: auto; |
| +} |
diff --git a/internal/memoria/store.go b/internal/memoria/store.go| index 56350fb..e1343a3 100644 |
| --- a/internal/memoria/store.go |
| +++ b/internal/memoria/store.go |
| @@ -12,6 +12,7 @@ import ( |
| "amadan.net/oficina/suite" |
| "amadan.net/oficina/memoria/internal/memoria/chunk" |
| + "amadan.net/oficina/memoria/internal/memoria/fact" |
| "amadan.net/oficina/memoria/internal/memoria/index" |
| "amadan.net/oficina/memoria/internal/memoria/seal" |
| "amadan.net/oficina/memoria/internal/memoria/sibling" |
| @@ -98,6 +99,12 @@ type Result struct { |
| type Page struct { |
| Results []Result |
| Notices []string |
| + // Facts is §8.8's box: validated facts pulled deterministically out |
| + // of the passages above, with no model anywhere near them. |
| + // |
| + // **Derived from Results and from nothing else**, which is what makes |
| + // it safe by construction rather than by care. See addFacts. |
| + Facts []Fact |
| } |
| // Summary is a document as the list shows it. |
| @@ -678,7 +685,12 @@ func (s *Store) Search(ctx context.Context, asker suite.Subject, query string, l |
| item: items[h.DocumentID], |
| }) |
| } |
| - return s.rule(ctx, asker, out), nil |
| + page := s.rule(ctx, asker, out) |
| + // **After the ruling, never before it.** §8.8's box is built from the |
| + // passages rule() kept, so a withheld one cannot contribute a |
| + // candidate and cannot be counted towards the cap. |
| + s.addFacts(ctx, &page, query) |
| + return page, nil |
| } |
| // rule asks every app of record about its own passages and keeps the ones |
| @@ -935,3 +947,163 @@ func firstLine(body string) string { |
| } |
| return "Untitled" |
| } |
| + |
| +// MaxFacts is §8.8's cap: three candidates render, four render nothing. |
| +// |
| +// Inclusive, and the boundary is the whole of it. A cap written `>= 3` |
| +// passes any suite that tests one, two and four, which is why gate 35 |
| +// tests three as well. |
| +const MaxFacts = 3 |
| + |
| +// Fact is one row of §8.8's box. |
| +// |
| +// It carries Title and Trail as well as Label for the reason Result does: |
| +// two pages called "Contact" under different documents are the same row |
| +// to a reader who cannot see which is which. It carries no ChunkID and no |
| +// chunk text — a field a template can reach is a field a template |
| +// eventually prints, which is why Result.item is unexported. |
| +type Fact struct { |
| + Kind string |
| + Title string |
| + Trail string |
| + // Label is what the DOCUMENT called this value, never composed. |
| + Label string |
| + // Span is the document's own bytes. The canonical form that keyed |
| + // this candidate's identity does not leave the store. |
| + Span string |
| + DocumentID int64 |
| + Ordinal int |
| +} |
| + |
| +// addFacts fills p.Facts for the kinds this query names. |
| +// |
| +// **It runs over p.Results — the RULED slice — and never over the hits |
| +// that produced it.** That is §8.8's ordering ruling and the whole of why |
| +// the box cannot leak: a candidate that is not in p.Results does not |
| +// exist, so §8.4's invariant ("every passage in a response passed §4.1 in |
| +// the request that produced it") is structural here rather than |
| +// something a later caller has to remember. |
| +// |
| +// The counts below — dedupe, collision, cap — therefore also happen after |
| +// the ruling. Counting before it is the leak rather than a tidiness |
| +// question: a box that vanishes for passing the cap because of documents |
| +// the reader may not see reports the existence of those documents, and so |
| +// does an ambiguity of two where the reader may read one. |
| +func (s *Store) addFacts(ctx context.Context, p *Page, query string) { |
| + want := fact.Wanted(query) |
| + if len(want) == 0 || len(p.Results) == 0 { |
| + // **No database work for a query that names no kind**, which is |
| + // almost every query. The box must not cost anything on the |
| + // searches it will never appear on. |
| + return |
| + } |
| + |
| + ids := make([]int64, 0, len(p.Results)) |
| + for _, r := range p.Results { |
| + ids = append(ids, r.ChunkID) |
| + } |
| + var rows []Chunk |
| + if err := s.db.G.WithContext(ctx).Where("id IN ?", ids).Find(&rows).Error; err != nil { |
| + // The search already succeeded and the page is already honest |
| + // without a box. Failing it now would trade a working answer for |
| + // a garnish, which is the wrong direction — and there is no |
| + // query text and no content in this line, per gate 4. |
| + if s.logger != nil { |
| + s.logger.Error("fact box: read passages", "err", err) |
| + } |
| + return |
| + } |
| + texts := make(map[int64]string, len(rows)) |
| + for _, r := range rows { |
| + t, err := s.sealer.Open(seal.PurposeChunk, r.SealedText) |
| + if err != nil { |
| + if s.logger != nil { |
| + s.logger.Error("fact box: open passage", "err", err) |
| + } |
| + continue |
| + } |
| + texts[r.ID] = string(t) |
| + } |
| + |
| + // One bucket per kind, because every rule below is per kind. A cap |
| + // counted across kinds lets two phone numbers and two addresses |
| + // suppress each other, and a collision check across kinds suppresses |
| + // a phone and an email that happen to share a label. |
| + found := map[fact.Kind][]Fact{} |
| + seen := map[fact.Kind]map[string]bool{} |
| + silenced := map[fact.Kind]bool{} |
| + |
| + for _, r := range p.Results { |
| + text, ok := texts[r.ChunkID] |
| + if !ok { |
| + // The chunk row is gone between the ruling and this read. |
| + // Skipped silently, exactly as Search's livePassage check |
| + // drops a stale hit and for the reason recorded there: the |
| + // passage is genuinely gone, so its absence is the true |
| + // answer rather than a truncation of one. |
| + continue |
| + } |
| + for _, f := range fact.Scan(text, r.Heading, r.Title, want) { |
| + if f.NoBox { |
| + silenced[f.Kind] = true |
| + continue |
| + } |
| + if seen[f.Kind] == nil { |
| + seen[f.Kind] = map[string]bool{} |
| + } |
| + // **Identity is the canonical value**, so one number written |
| + // two ways is one candidate and the same number in five |
| + // forwarded signatures is one too. First occurrence wins the |
| + // citation, and because p.Results is in rank order that is |
| + // the best-ranked passage carrying it. |
| + if seen[f.Kind][f.Canonical] { |
| + continue |
| + } |
| + seen[f.Kind][f.Canonical] = true |
| + found[f.Kind] = append(found[f.Kind], Fact{ |
| + Kind: string(f.Kind), |
| + Title: r.Title, |
| + Trail: r.Heading, |
| + Label: f.Label, |
| + Span: f.Span, |
| + DocumentID: r.DocumentID, |
| + Ordinal: r.Ordinal, |
| + }) |
| + } |
| + } |
| + |
| + // A slice ranged in a fixed order, never a map: Go randomises map |
| + // iteration precisely so that an order nobody chose shows up, and it |
| + // showed up once already in this file's notice aggregation. |
| + for _, k := range fact.Kinds() { |
| + cs := found[k] |
| + switch { |
| + case len(cs) == 0, silenced[k], len(cs) > MaxFacts, collides(cs): |
| + continue |
| + } |
| + p.Facts = append(p.Facts, cs...) |
| + } |
| +} |
| + |
| +// collides reports whether two candidates would render an identical |
| +// citation line. |
| +// |
| +// **Keyed on the whole line and not on the label alone.** A supplier page |
| +// and the office page both falling through to a trail of "Contact" are |
| +// perfectly disambiguated by their titles, and suppressing that box would |
| +// lose a real answer to a rule aimed at a different problem. What the |
| +// rule is actually for is the case where the page would print one |
| +// identical row twice against two different values — not an enumeration |
| +// but a contradiction, with nothing for the reader to resolve it by. |
| +func collides(cs []Fact) bool { |
| + type line struct{ title, trail, label string } |
| + seen := map[line]bool{} |
| + for _, c := range cs { |
| + l := line{c.Title, c.Trail, c.Label} |
| + if seen[l] { |
| + return true |
| + } |
| + seen[l] = true |
| + } |
| + return false |
| +} |
diff --git a/internal/memoria/templates/results.html b/internal/memoria/templates/results.html| index a8ce802..c5a02f9 100644 |
| --- a/internal/memoria/templates/results.html |
| +++ b/internal/memoria/templates/results.html |
| @@ -31,6 +31,31 @@ |
| {{range .Notices}}<p rst-callout-body>{{.}}</p>{{end}} |
| </div> |
| {{end}} |
| + {{/* §8.8's fact box. BELOW the notices and above the list, both |
| + deliberately: a confident single answer rendered above the line |
| + saying the page is partial is the withholding notice's own |
| + argument with the order reversed. |
| + |
| + Inside this define rather than above it, so search.js swaps box |
| + and list together. A box outside it would leave query N-1's answer |
| + sitting over query N's results on every keystroke. |
| + |
| + Every character here is the document's — the label it wrote, the |
| + span byte for byte, its own title and heading trail. The link is |
| + the ONLY link, and it must never wear rst-result-title: search.js |
| + reads that attribute to decide where Enter goes, and §8.8 rules |
| + that Enter still goes to the first result and not to a box. */}} |
| + {{if .Facts}} |
| + <div rst-fact-box> |
| + {{range .Facts}} |
| + <p rst-fact> |
| + <span rst-fact-label>{{.Label}}</span> |
| + <span rst-fact-value>{{.Span}}</span> |
| + <a href="/documents/{{.DocumentID}}#p{{.Ordinal}}" rst-fact-cite>{{.Title}}{{if .Trail}} › {{.Trail}}{{end}}</a> |
| + </p> |
| + {{end}} |
| + </div> |
| + {{end}} |
| {{if not .Results}} |
| <div rst-callout> |
| <p rst-callout-body> |
diff --git a/internal/memoriatest/factbox_test.go b/internal/memoriatest/factbox_test.go| new file mode 100644 |
| index 0000000..6e78ada |
| --- /dev/null |
| +++ b/internal/memoriatest/factbox_test.go |
| @@ -0,0 +1,340 @@ |
| +package memoriatest |
| + |
| +import ( |
| + "html" |
| + "net/http" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// The fact box, end to end: through the real handler, the real template |
| +// and the real escaping. |
| +// |
| +// **Through HTTP and not against the struct, deliberately, and gate 34 is |
| +// the opposite deliberately.** Gate 34 is about what the box may CONTAIN, |
| +// where two dispositions render identical pages and only the struct can |
| +// tell them apart. These are about what reaches the reader, where a |
| +// template that escapes a span, drops a label or renders the box outside |
| +// the swapped region is exactly the defect — and a struct assertion |
| +// cannot see any of it. AGENTS.md: a template test proves markup exists, |
| +// not that it renders. |
| + |
| +var factValueRe = regexp.MustCompile(`<span rst-fact-value>([^<]*)</span>`) |
| +var factLabelRe = regexp.MustCompile(`<span rst-fact-label>([^<]*)</span>`) |
| + |
| +func factValues(body string) []string { return capture(factValueRe, body) } |
| +func factLabels(body string) []string { return capture(factLabelRe, body) } |
| +func hasFactBox(body string) bool { return strings.Contains(body, "<div rst-fact-box>") } |
| + |
| +// capture pulls the rendered text out of the box and UNESCAPES it. |
| +// |
| +// html/template escapes "+" to "+" in text content, which is correct |
| +// and which the reader never sees. Gate 36 is about the bytes that reach |
| +// the READER — "byte-identical to the document" is a claim about what is |
| +// displayed, not about the wire — so comparing raw markup would fail the |
| +// gate on the escaping working properly, and "fixing" that would mean |
| +// unescaping the span into the page, which is an injection. |
| +func capture(re *regexp.Regexp, s string) []string { |
| + var out []string |
| + for _, m := range re.FindAllStringSubmatch(s, -1) { |
| + out = append(out, html.UnescapeString(m[1])) |
| + } |
| + return out |
| +} |
| + |
| +func search(t *testing.T, h http.Handler, q string) string { |
| + t.Helper() |
| + return get(t, h, "/?q="+url.QueryEscape(q)).Body.String() |
| +} |
| + |
| +// GATE 35 — candidates are canonical values, ambiguity is enumerated |
| +// rather than collapsed, and the cap is three (§8.8, §10). |
| +// |
| +// One test rather than five, because the rules share a definition and a |
| +// gate that splits them cannot see the interactions: the same number in |
| +// five documents is one candidate AND therefore does not pass the cap. |
| +// |
| +// Mutations run, each verified red and reverted, 2026-09-14: |
| +// - returning found[k][:1] when there is more than one — "two distinct" |
| +// goes red. |
| +// - keying dedupe on Span instead of Canonical — "two spellings" red. |
| +// - keying dedupe on the passage rather than the value — "five |
| +// documents" red. |
| +// - cap written len(cs) >= MaxFacts — "three still renders" red, and |
| +// nothing else, which is the point of having it. |
| +// - cap and collision counted across kinds rather than per kind — the |
| +// two-kind case red. |
| +func TestGate35CandidatesAmbiguityAndTheCap(t *testing.T) { |
| + t.Run("presence: one number, one box, one value", func(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Tito office", "## Contact\n\nOffice phone: +353 (0)1 547 7854") |
| + body := search(t, h, "phone number") |
| + if got := factValues(body); len(got) != 1 || got[0] != "+353 (0)1 547 7854" { |
| + t.Fatalf("values = %q, want the one number", got) |
| + } |
| + }) |
| + |
| + // Two right answers to an underspecified question. §8.7 refuses in |
| + // this position; §8.8 enumerates, because two phone numbers are not |
| + // a wrong average — and the labels are what let the reader choose. |
| + t.Run("two distinct numbers are both named, neither picked", func(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Tito office", "## Contact\n\nOffice phone: +353 (0)1 547 7854") |
| + store(t, h, "Support", "## Contact\n\nSupport phone: +353 (0)1 598 1234") |
| + got := factValues(search(t, h, "phone number")) |
| + if len(got) != 2 { |
| + t.Fatalf("values = %q, want both", got) |
| + } |
| + }) |
| + |
| + // A dedupe keyed on the span passes any fixture that spells the |
| + // number the same way twice, which is why this one does not. |
| + t.Run("two spellings of one number are one candidate", func(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Tito office", "## Contact\n\nOffice phone: +353 (0)1 547 7854") |
| + store(t, h, "Handbook", "## Contact\n\nOffice phone: 01 547 7854") |
| + if got := factValues(search(t, h, "phone number")); len(got) != 1 { |
| + t.Fatalf("values = %q, want one: the same number written two ways", got) |
| + } |
| + }) |
| + |
| + // The commonest real shape — a signature repeated through forwarded |
| + // mail. An implementation counting passages rather than values shows |
| + // five rows here, or passes the cap and shows none. |
| + t.Run("the same number in five documents is one candidate", func(t *testing.T) { |
| + h := newApp(t) |
| + for _, title := range []string{"One", "Two", "Three", "Four", "Five"} { |
| + store(t, h, title, "## Contact\n\nOffice phone: +353 (0)1 547 7854") |
| + } |
| + if got := factValues(search(t, h, "phone number")); len(got) != 1 { |
| + t.Fatalf("values = %q, want one", got) |
| + } |
| + }) |
| + |
| + // index/rank_test.go's own paragraph, which came off the live |
| + // instance. An implementation treating the CHUNK as the unit hides |
| + // the second number — the one the document calls unused. |
| + t.Run("two numbers in one passage are two candidates", func(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Office address", "## Office Address\n\nTurn 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.") |
| + body := search(t, h, "phone number") |
| + if got := factValues(body); len(got) != 2 { |
| + t.Fatalf("values = %q, want both", got) |
| + } |
| + // And the labels are what make the enumeration honest: the |
| + // document said which is unused, so the page does. |
| + labels := factLabels(body) |
| + if len(labels) != 2 || !strings.Contains(labels[1], "unused") { |
| + t.Fatalf("labels = %q; the document's own word for the second number is missing", labels) |
| + } |
| + }) |
| + |
| + // **Three AND four.** A cap written `>= MaxFacts` passes a suite that |
| + // tests only one, two and four. |
| + t.Run("three renders, four renders nothing", func(t *testing.T) { |
| + nums := []string{"+353 (0)1 547 7854", "+353 (0)1 598 1234", "+353 (0)1 555 0101", "+353 (0)1 555 0202"} |
| + for _, n := range []int{3, 4} { |
| + h := newApp(t) |
| + for i := 0; i < n; i++ { |
| + store(t, h, "Office "+string(rune('A'+i)), "## Contact\n\nOffice phone: "+nums[i]) |
| + } |
| + body := search(t, h, "phone number") |
| + switch { |
| + case n == 3 && len(factValues(body)) != 3: |
| + t.Errorf("three candidates rendered %q, want three", factValues(body)) |
| + case n == 4 && hasFactBox(body): |
| + t.Errorf("four candidates rendered a box: %q", factValues(body)) |
| + } |
| + } |
| + }) |
| + |
| + // Every rule is per kind. Counted across kinds, two of each passes |
| + // the cap and both boxes vanish. |
| + t.Run("a two-kind query renders both kinds", func(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Tito office", "## Contact\n\nOffice phone: +353 (0)1 547 7854\n\nEircode: D02 X285") |
| + store(t, h, "Support", "## Contact\n\nSupport phone: +353 (0)1 598 1234\n\nEircode: A65 F4E2") |
| + got := factValues(search(t, h, "phone number and eircode")) |
| + if len(got) != 4 { |
| + t.Fatalf("values = %q, want two of each kind", got) |
| + } |
| + }) |
| + |
| + // The whole citation line collides, so the page would print one |
| + // identical row twice against two different values. |
| + t.Run("colliding rows render no box", func(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Suppliers", "## Phone\n\n- +353 (0)1 555 0101\n- +353 (0)1 555 0202") |
| + if body := search(t, h, "phone number"); hasFactBox(body) { |
| + t.Errorf("rendered a box with two identical rows: %q / %q", factLabels(body), factValues(body)) |
| + } |
| + }) |
| + |
| + // A different title disambiguates perfectly, so this one MUST box. |
| + // Keying collision on the label alone loses a real answer here. |
| + t.Run("the same label under different titles still renders", func(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Tito office", "## Contact\n\nOffice phone: +353 (0)1 547 7854") |
| + store(t, h, "Acme supplier", "## Contact\n\nOffice phone: +353 (0)1 598 1234") |
| + if got := factValues(search(t, h, "phone number")); len(got) != 2 { |
| + t.Fatalf("values = %q, want both: the titles tell them apart", got) |
| + } |
| + }) |
| +} |
| + |
| +// GATE 36 — the displayed value is byte-identical to the document's |
| +// (§8.8, §10). The canonical form exists for matching and must never |
| +// reach the page. |
| +// |
| +// Mutation: canonicalise on the display path — one line's difference from |
| +// what the code already does for identity — and every case goes red. |
| +func TestGate36TheDisplayedValueIsTheDocumentsBytes(t *testing.T) { |
| + for _, c := range []struct{ name, body, want string }{ |
| + {"as written", "## Contact\n\nOffice phone: +353 (0)1 547 7854", "+353 (0)1 547 7854"}, |
| + {"with an extension", "## Contact\n\nOffice phone: +353 (0)1 547 7854 ext. 12", "+353 (0)1 547 7854 ext. 12"}, |
| + // Pasted from Word or Notion. Go's \s does not match U+00A0, so |
| + // a recogniser written with it finds nothing here and this case |
| + // fails on PRESENCE rather than on the bytes — which is the |
| + // design demand and not an accident. |
| + {"pasted with non-breaking spaces", "## Contact\n\nOffice phone: +353 (0)1 547 7854", "+353 (0)1 547 7854"}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Tito office", c.body) |
| + got := factValues(search(t, h, "phone number")) |
| + if len(got) != 1 { |
| + t.Fatalf("values = %q, want one", got) |
| + } |
| + if got[0] != c.want { |
| + t.Errorf("value = %q, want %q", got[0], c.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// GATE 37 — the label is the document's, and it reaches the page. |
| +// |
| +// The exhaustive matrix — bullets, adjacent values, abbreviations, the |
| +// cap boundary, the no-heading fallback — is unit-tested in |
| +// internal/memoria/fact, where each rule can be driven directly. What |
| +// this gate adds is the half a unit test cannot see: that the label |
| +// survives the store, the template and the escaping, and that a value the |
| +// document labelled too long to show renders NO BOX rather than a vaguer |
| +// substitute. |
| +func TestGate37TheLabelReachesThePage(t *testing.T) { |
| + t.Run("the document's own label is what is shown", func(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Tito office", "## Contact\n\nOffice phone: +353 (0)1 547 7854") |
| + if got := factLabels(search(t, h, "phone number")); len(got) != 1 || got[0] != "Office phone" { |
| + t.Fatalf("labels = %q, want [Office phone]", got) |
| + } |
| + }) |
| + |
| + t.Run("no label in the line falls to the heading trail", func(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Tito office", "## Contact\n\nRing the office phone on +353 (0)1 547 7854 any weekday.") |
| + if got := factLabels(search(t, h, "phone number")); len(got) != 1 || got[0] != "Contact" { |
| + t.Fatalf("labels = %q, want [Contact]", got) |
| + } |
| + }) |
| + |
| + // Substituting the trail would replace the document's own warning |
| + // with a vaguer word, beside a number the document did not warn |
| + // about. Dropping just this candidate would turn a two-candidate |
| + // ambiguity into a confident single answer. Both are worse than |
| + // silence. |
| + t.Run("a label too long to show renders no box at all", func(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Tito office", "## Contact\n\nReception phone: +353 (0)1 547 7854\n\n"+ |
| + "The number you must never give to any customer because it rings the chief executive directly is: +353 (0)1 598 1234") |
| + if body := search(t, h, "phone number"); hasFactBox(body) { |
| + t.Errorf("rendered a box: %q / %q", factLabels(body), factValues(body)) |
| + } |
| + }) |
| +} |
| + |
| +// The box lives INSIDE the results define, so the fragment search.js |
| +// swaps carries it. Outside, query N-1's answer would sit over query N's |
| +// results on every keystroke — §8.4 inverted, several times a second. |
| +// |
| +// This is the page-and-fragment agreement that TestALookupPrimesTheFirstResult |
| +// makes for priming, and it is needed for the same reason: two renderings |
| +// of one region that can disagree are two regions. |
| +func TestTheFactBoxIsInsideTheSwappedRegion(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Tito office", "## Contact\n\nOffice phone: +353 (0)1 547 7854") |
| + page := search(t, h, "phone number") |
| + fragment := get(t, h, "/search?q="+url.QueryEscape("phone number")).Body.String() |
| + if !hasFactBox(page) { |
| + t.Fatal("no box on the page") |
| + } |
| + if !hasFactBox(fragment) { |
| + t.Error("no box in the fragment: it would not survive a keystroke") |
| + } |
| + if a, b := factValues(page), factValues(fragment); len(a) != len(b) || a[0] != b[0] { |
| + t.Errorf("page %q and fragment %q disagree", a, b) |
| + } |
| +} |
| + |
| +// Enter goes to the first RESULT, never to the box's citation. §8.8: |
| +// changing what Enter means depending on whether a regular expression |
| +// matched is the unreliable-Enter failure PrimesFirstResult exists to |
| +// avoid. search.js targets rst-result-title, so the guard is that the |
| +// box's link never wears it. |
| +func TestTheFactBoxCitationIsNotAnEnterTarget(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Tito office", "## Contact\n\nOffice phone: +353 (0)1 547 7854") |
| + body := search(t, h, "phone number") |
| + if !hasFactBox(body) { |
| + t.Fatal("no box to check: this assertion needs one to be wrong about") |
| + } |
| + box := body[strings.Index(body, "<div rst-fact-box>"):] |
| + box = box[:strings.Index(box, "</div>")] |
| + if strings.Contains(box, "rst-result-title") { |
| + t.Errorf("the box carries search.js's Enter target:\n%s", box) |
| + } |
| +} |
| + |
| +// **The reachability limit, pinned rather than discovered.** |
| +// |
| +// §8.8 states it: the box only sees what retrieval returned, so a fact in |
| +// a document the query does not reach is a fact the box does not have. |
| +// This is not a small edge — it is the ordinary case for a contact page |
| +// that writes "Office: +353 …" and never uses the word "phone" — and |
| +// writing the gates is what made it concrete, because every fixture above |
| +// had to be given a word the query could find. |
| +// |
| +// Asserted in BOTH directions, because a limit tested only in the |
| +// direction that fails is indistinguishable from the feature not working |
| +// at all: |
| +// |
| +// - a document sharing no word with the query renders no box; |
| +// - the SAME document, reached by a query that shares one, does. |
| +// |
| +// §12 carries the fix and names what it must not be. Until then this test |
| +// is the honest statement of what the feature does, and it is here so |
| +// that the day somebody builds the lift, this goes red and gets deleted |
| +// on purpose. |
| +func TestTheBoxOnlySeesWhatRetrievalReturned(t *testing.T) { |
| + const body = "## Contact\n\nOffice: +353 (0)1 547 7854" |
| + |
| + h := newApp(t) |
| + store(t, h, "Tito office", body) |
| + |
| + // "phone number" appears nowhere in that document, so nothing ranks |
| + // and there is nothing to extract from. |
| + if got := search(t, h, "phone number"); hasFactBox(got) { |
| + t.Errorf("a box appeared for a query the document does not match: %q", factValues(got)) |
| + } |
| + |
| + // One shared word — "office", in the title and in the line — and the |
| + // same fact is found. This is the half that proves the absence above |
| + // is the limit rather than a broken recogniser. |
| + if got := factValues(search(t, h, "office phone number")); len(got) != 1 { |
| + t.Fatalf("values = %q, want the number: the query shares a word with the document", got) |
| + } |
| +} |
diff --git a/tools/drive/main.go b/tools/drive/main.go| index 0bb1d58..4e999f5 100644 |
| --- a/tools/drive/main.go |
| +++ b/tools/drive/main.go |
| @@ -99,6 +99,27 @@ func seedViaHTTP(h http.Handler, addr string) { |
| docs := []struct{ title, body string }{ |
| {"Q3 board pack", "## Revenue\n\nJune revenue was 1,350,000 across all regions, up from 1,180,000 in the same month last year. The increase is almost entirely the enterprise tier.\n\n## Costs\n\nHosting costs fell after the migration, and support headcount was flat.\n\n## Risks\n\nOne customer is 22% of recurring revenue. That concentration is the single largest risk on this page and it has not improved since Q2."}, |
| {"Sealing notes", "## The one rule\n\nA sheet's contents are sealed at rest and the bucket never holds a plaintext cell. The instance holds the key while it runs, so a compromised live host can read a sheet it has open — that is a deliberate softening and the README says so.\n\n## What it does not cover\n\nEmbeddings are content: a vector inverts back to text well enough that an unsealed index is a plaintext cell in a costume."}, |
| + // §8.8's fact box, seeded in THREE shapes rather than one. |
| + // |
| + // AGENTS.md: the section that is not seeded is the section |
| + // nobody sees, and driving this page is what found the heading |
| + // trail rendering as "› Revenue". A box only ever seeded in its |
| + // happy case is a box whose two interesting states nobody looks |
| + // at — and those are the states that decide whether the rule is |
| + // right. |
| + {"Tito office", "## Contact\n\nOffice: +353 (0)1 547 7854\n\nEircode: D02 X285\n\nVAT: IE3384527RH\n\n## Post\n\nWe are on the second floor; the intercom is marked Tito."}, |
| + // Two numbers, distinct sentence labels: the ENUMERATED case. |
| + // The reader should see both and be able to tell which is which, |
| + // because the document said "unused" and the label carries it. |
| + // This paragraph is index/rank_test.go's, quoted rather than |
| + // invented — it came off the live instance. |
| + {"Reception notes", "## Office Address\n\nTurn 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."}, |
| + // A bulleted pair: the COLLISION case, where both labels fall to |
| + // the same trail and the box must be ABSENT. An earlier draft of |
| + // the plan called the paragraph above the collision case; it is |
| + // the opposite, and a seed that demonstrates the wrong thing is |
| + // worse than no seed at all. |
| + {"Suppliers", "## Contact\n\n- +353 (0)1 555 0101\n- +353 (0)1 555 0202"}, |
| {"Meeting, 12 August", "## Present\n\nEveryone.\n\n## Decisions\n\nWe will not build a suite index app. It answers a different question from the one anyone asked, and per-item access control is not expressible by the app that would have to publish it.\n\n## Actions\n\nPaul to decide whether the central host is a directory or an authenticator."}, |
| } |
| for _, doc := range docs { |