| index 904a104..4b2ae80 100644 |
| --- a/internal/memoria/fact/fact.go |
| +++ b/internal/memoria/fact/fact.go |
| @@ -40,12 +40,32 @@ const ( |
| IBAN Kind = "iban" |
| VAT Kind = "vat" |
| Email Kind = "email" |
| + // Address is a postal address, and it is the first MODEL kind: no |
| + // recogniser produces one, because an address has no checksum, no |
| + // grammar and no length to validate against. §8.9 sends it to a |
| + // model; this package only lets a query ask for it. |
| + Address Kind = "address" |
| ) |
| |
| +// modelKinds is §8.9's allowlist: the kinds that may be sent to a model. |
| +// |
| +// **An allowlist, and never "whatever has no recogniser".** Draft 1 of |
| +// §8.9 phrased eligibility as "where the deterministic layer produced |
| +// nothing", which invites asking a model for a phone number whenever the |
| +// box happens to be empty — and §8.8's box is empty for five different |
| +// reasons, from zero candidates to a collision. Eligibility is a |
| +// property of the kind. TestNoModelKindHasARecogniser holds the two sets |
| +// apart, so adding a recogniser for Address later forces a decision |
| +// rather than quietly sending a validated kind to a model. |
| +var modelKinds = map[Kind]bool{Address: true} |
| + |
| +// ModelKind reports whether k may be sent to a model under §8.9. |
| +func ModelKind(k Kind) bool { return modelKinds[k] } |
| + |
| // 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} |
| +var order = []Kind{Eircode, Phone, IBAN, VAT, Email, Address} |
| |
| // Found is one validated fact, located in a passage. |
| type Found struct { |
| @@ -98,6 +118,7 @@ type phrase struct { |
| // generous lexicon costs a box that does not appear. |
| var lexicon = []phrase{ |
| {[]string{"email", "address"}, Email}, |
| + {[]string{"postal", "address"}, Address}, |
| {[]string{"phone", "number"}, Phone}, |
| {[]string{"bank", "details"}, IBAN}, |
| {[]string{"post", "code"}, Eircode}, |
| @@ -112,14 +133,19 @@ var lexicon = []phrase{ |
| {[]string{"iban"}, IBAN}, |
| {[]string{"vat"}, VAT}, |
| {[]string{"email"}, Email}, |
| + // Bare "address" is POSTAL. It used to prime nothing, because it is |
| + // ambiguous with email and only email had a validator; decision 15's |
| + // amendment makes postal a model kind, and "email address" is matched |
| + // as a phrase before this single word is ever tried. |
| + {[]string{"address"}, Address}, |
| } |
| |
| // 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. |
| +// Deliberately absent: "number", which is generic and counts only in |
| +// combination, and would prime on ordinary queries that are not asking |
| +// for a fact. ("address" used to be absent too; it is now the postal |
| +// model kind — see the lexicon.) |
| func Wanted(q string) []Kind { |
| toks := tokens(q) |
| want := map[Kind]bool{} |
| @@ -833,3 +859,65 @@ func Scan(text, heading, title string, want []Kind) []Found { |
| // 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...) } |
| + |
| +// --------------------------------------------------------------------- |
| +// What §8.9's model half borrows from this layer. |
| +// |
| +// Exported so the model half uses THIS code rather than a copy of it: a |
| +// second implementation of the label rules is how a model row would come |
| +// to be labelled differently from a validated row sitting beside it, |
| +// with nothing to say which rule was which. |
| +// --------------------------------------------------------------------- |
| + |
| +// RecognisedSpans returns the byte range of every validated fact of every |
| +// kind in text, sorted by end. |
| +// |
| +// §8.9 refuses a model's span that lies inside one of these — a street |
| +// name inside an email address is not an address — and needs the ranges |
| +// of ALL kinds for the same reason Scan does: the answer must not depend |
| +// on which kinds the query happened to name. |
| +func RecognisedSpans(text string) [][2]int { |
| + var spans [][2]int |
| + for _, r := range recognisers { |
| + for _, m := range r.re.FindAllStringIndex(text, -1) { |
| + span := text[m[0]:m[1]] |
| + if r.valid(span, r.canon(span)) { |
| + spans = append(spans, [2]int{m[0], m[1]}) |
| + } |
| + } |
| + } |
| + sort.Slice(spans, func(i, j int) bool { return spans[i][1] < spans[j][1] }) |
| + return spans |
| +} |
| + |
| +// LabelAt applies §8.8's five label rules to a value at byte offset start. |
| +// It returns the label, and whether the kind must render no box because |
| +// the document labelled the value too long to show. |
| +func LabelAt(text string, start int, heading, title string) (string, bool) { |
| + return labelFor(text, start, RecognisedSpans(text), heading, title) |
| +} |
| + |
| +// WordBounded reports whether text[start:end] begins and ends on a word |
| +// boundary — neither neighbour is a letter or a digit. |
| +// |
| +// §8.9 requires it of a model's span, because an exact byte match inside |
| +// a longer word ("Street" found in "Streetside") reproduces source bytes |
| +// and names nothing. |
| +func WordBounded(text string, start, end int) bool { |
| + if start < 0 || end > len(text) || start >= end { |
| + return false |
| + } |
| + if start > 0 { |
| + if r, _ := utf8.DecodeLastRuneInString(text[:start]); isWordRune(r) { |
| + return false |
| + } |
| + } |
| + if end < len(text) { |
| + if r, _ := utf8.DecodeRuneInString(text[end:]); isWordRune(r) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +func isWordRune(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) } |