@mailwoman/phrase-grouper 7.2.0 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/group.ts +114 -0
  2. package/index.ts +43 -0
  3. package/package.json +21 -7
  4. package/rules.ts +815 -0
  5. package/types.ts +44 -0
package/group.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `groupPhrases` — Stage 2.7 entry point.
7
+ *
8
+ * Composes per-kind rules over the normalized input + QueryShape and emits one `PhraseProposal` per
9
+ * fired rule. Overlapping proposals are expected — the reconciler (Stage 5) picks the best
10
+ * non-overlapping subset.
11
+ *
12
+ * See `docs/articles/concepts/the-knowledge-ladder.md` § Phrase grouper for the design rationale,
13
+ * and `phrase-grouper/rules.ts` for per-rule documentation.
14
+ */
15
+
16
+ import {
17
+ scoreHyphenatedCompound,
18
+ scoreLocalityPhrase,
19
+ scoreNumeric,
20
+ scorePostcode,
21
+ scoreRegionAbbreviation,
22
+ scoreStreetPhrase,
23
+ scoreVenuePhrase,
24
+ tokenizeSegment,
25
+ type SegmentToken,
26
+ } from "./rules.ts"
27
+ import type { GroupPhrasesOpts, LocaleHint, NormalizedInputLite, PhraseProposal, QueryShapeLike } from "./types.ts"
28
+
29
+ /**
30
+ * Walk every QueryShape segment and emit one `tokens-by-segment` list. Falls back to treating the whole input as a
31
+ * single segment when QueryShape didn't supply segmentation (e.g. callers wiring the grouper into a path that bypasses
32
+ * QueryShape).
33
+ */
34
+ function tokensPerSegment(
35
+ text: string,
36
+ shape: QueryShapeLike
37
+ ): Array<{ tokens: SegmentToken[]; isFirst: boolean; isLast: boolean }> {
38
+ const segs = shape.segments
39
+
40
+ if (segs && segs.length > 0) {
41
+ return segs.map((s, idx) => {
42
+ const start = s.span?.start ?? 0
43
+ const end = s.span?.end ?? text.length
44
+
45
+ return {
46
+ tokens: tokenizeSegment(text.slice(start, end), start),
47
+ isFirst: idx === 0,
48
+ isLast: idx === segs.length - 1,
49
+ }
50
+ })
51
+ }
52
+
53
+ return [{ tokens: tokenizeSegment(text, 0), isFirst: true, isLast: true }]
54
+ }
55
+
56
+ /**
57
+ * Synchronous, pure rule-based implementation. The async wrapper matches the pipeline contract.
58
+ *
59
+ * Emits overlapping proposals freely — the consumer (Stage 5 reconcile) picks the best non-overlapping subset under
60
+ * semantic+hierarchical constraints. Confidence is a [0,1] score per proposal; relative ordering is what matters more
61
+ * than absolute calibration at v0.5.0.
62
+ *
63
+ * The `_locale` parameter is reserved for future locale-aware rule packs (Japanese postcode/honorific patterns, French
64
+ * preposition-bound localities) — currently unused.
65
+ */
66
+ export function groupPhrasesSync(
67
+ input: NormalizedInputLite,
68
+ shape: QueryShapeLike,
69
+ _locale?: LocaleHint,
70
+ _opts: GroupPhrasesOpts = {}
71
+ ): PhraseProposal[] {
72
+ const text = input.normalized
73
+
74
+ if (text.length === 0) return []
75
+
76
+ const proposals: PhraseProposal[] = []
77
+
78
+ // Postcode rule consumes QueryShape directly (segment-agnostic).
79
+ proposals.push(...scorePostcode(shape, text))
80
+
81
+ // Per-segment rules.
82
+ for (const { tokens, isFirst, isLast } of tokensPerSegment(text, shape)) {
83
+ if (tokens.length === 0) continue
84
+ proposals.push(...scoreNumeric(tokens, text))
85
+ proposals.push(...scoreRegionAbbreviation(tokens, text, isLast))
86
+ proposals.push(...scoreHyphenatedCompound(tokens, text))
87
+ proposals.push(...scoreStreetPhrase(tokens, text))
88
+ proposals.push(...scoreLocalityPhrase(tokens, text, isLast))
89
+ proposals.push(...scoreVenuePhrase(tokens, text, isFirst))
90
+ }
91
+
92
+ // Sort: descending confidence, ties broken by span start (left-to-right). Downstream Stage 5
93
+ // can rely on this ordering for top-k selection without re-sorting.
94
+ proposals.sort((a, b) => {
95
+ if (a.confidence !== b.confidence) return b.confidence - a.confidence
96
+
97
+ return a.span.start - b.span.start
98
+ })
99
+
100
+ return proposals
101
+ }
102
+
103
+ /**
104
+ * Async variant matching `RuntimePipelineStages.groupPhrases`. Wraps the sync impl so the pipeline coordinator can use
105
+ * it as-is.
106
+ */
107
+ export async function groupPhrases(
108
+ input: NormalizedInputLite,
109
+ shape: QueryShapeLike,
110
+ locale?: LocaleHint,
111
+ opts?: GroupPhrasesOpts
112
+ ): Promise<PhraseProposal[]> {
113
+ return groupPhrasesSync(input, shape, locale, opts)
114
+ }
package/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `@mailwoman/phrase-grouper` — Stage 2.7 of the runtime pipeline.
7
+ *
8
+ * Proposes coherent input units (boundary discovery) with a structural kind hypothesis +
9
+ * confidence. Decouples boundary discovery from type classification: Stage 3 conditions on these
10
+ * proposals so it answers the simpler "what type is this proposed span?" rather than jointly
11
+ * discovering boundaries and types. Stage 5 consumes the proposals as boundary candidates for
12
+ * joint decoding.
13
+ *
14
+ * Bitter-lesson-safe: only universal structural cues (proximity, punctuation, capitalization,
15
+ * hyphenation, format-shape repetition) — never place-name dictionaries. v0.5.0 ships the
16
+ * rule-based v1; learned 1-2M-param span proposer reserved for v0.5.1.
17
+ *
18
+ * See `docs/articles/concepts/the-knowledge-ladder.md` § Phrase grouper for the design rationale
19
+ * and `docs/articles/plan/phases/PHASE_8_v0_5_0_fresh_slate.md` § E for the v0.5.0 thread.
20
+ */
21
+
22
+ export { groupPhrases, groupPhrasesSync } from "./group.ts"
23
+ export {
24
+ scoreHyphenatedCompound,
25
+ scoreLocalityPhrase,
26
+ scoreNumeric,
27
+ scorePostcode,
28
+ scoreRegionAbbreviation,
29
+ scoreStreetPhrase,
30
+ scoreVenuePhrase,
31
+ tokenizeSegment,
32
+ } from "./rules.ts"
33
+ export type { SegmentToken } from "./rules.ts"
34
+ export type {
35
+ GroupPhrasesOpts,
36
+ LocaleHint,
37
+ NormalizedInputLite,
38
+ PhraseGrouper,
39
+ PhraseKind,
40
+ PhraseProposal,
41
+ QueryShapeLike,
42
+ Section,
43
+ } from "./types.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mailwoman/phrase-grouper",
3
- "version": "7.2.0",
3
+ "version": "7.3.0",
4
4
  "description": "Stage 2.7 of the runtime pipeline — propose coherent input units (boundary discovery) with a structural kind hypothesis + confidence. Rule-based v1 (port of v1 section/sub-section logic); learned 1-2M-param span proposer reserved for v0.5.1.",
5
5
  "license": "AGPL-3.0-only OR LicenseRef-Commercial",
6
6
  "repository": {
@@ -12,21 +12,35 @@
12
12
  "out/**/*.js",
13
13
  "out/**/*.js.map",
14
14
  "out/**/*.d.ts",
15
- "out/**/*.d.ts.map"
15
+ "out/**/*.d.ts.map",
16
+ "*.ts",
17
+ "*.tsx",
18
+ "**/*.ts",
19
+ "**/*.tsx",
20
+ "!*.test.ts",
21
+ "!*.test.tsx",
22
+ "!**/*.test.ts",
23
+ "!**/*.test.tsx"
16
24
  ],
17
25
  "type": "module",
18
26
  "exports": {
19
27
  "./package.json": "./package.json",
20
28
  ".": {
21
- "node": "./index.ts",
22
- "default": "./out/index.js",
23
- "types": "./out/index.d.ts"
29
+ "types": "./out/index.d.ts",
30
+ "default": "./out/index.js"
24
31
  }
25
32
  },
26
33
  "publishConfig": {
27
- "access": "public"
34
+ "access": "public",
35
+ "exports": {
36
+ "./package.json": "./package.json",
37
+ ".": {
38
+ "types": "./out/index.d.ts",
39
+ "default": "./out/index.js"
40
+ }
41
+ }
28
42
  },
29
43
  "dependencies": {
30
- "@mailwoman/core": "7.2.0"
44
+ "@mailwoman/core": "7.3.0"
31
45
  }
32
46
  }
package/rules.ts ADDED
@@ -0,0 +1,815 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Rule-based scorers for Stage 2.7 phrase grouping. Each rule inspects the tokenized segment +
7
+ * QueryShape priors and emits zero or more `PhraseProposal`s with a confidence in [0, 1].
8
+ *
9
+ * Bitter-lesson-safe: only universal structural cues (proximity, punctuation, capitalization,
10
+ * hyphenation, format-shape repetition). No place-name dictionaries — a `LOCALITY_PHRASE`
11
+ * proposal means "this looks shaped like a multi-word capitalized run that COULD be a city name",
12
+ * not "this IS a city name". Typing the span is the classifier's job; this layer only answers "do
13
+ * these tokens belong together?".
14
+ *
15
+ * Per "possibilities not constraints", rules emit overlapping proposals freely. The reconciler
16
+ * (Stage 5) picks the best non-overlapping subset.
17
+ */
18
+
19
+ import { Span } from "@mailwoman/core/tokenization"
20
+
21
+ import type { PhraseProposal, QueryShapeLike } from "./types.ts"
22
+
23
+ /**
24
+ * One token within a segment — absolute offsets into the normalized input. Built by `tokenizeSegment` from a
25
+ * (segment-text, segment-start) pair.
26
+ */
27
+ export interface SegmentToken {
28
+ body: string
29
+ start: number
30
+ end: number
31
+ }
32
+
33
+ const WHITESPACE = /\s+/
34
+
35
+ /**
36
+ * Neutral baseline confidence for phrase proposals when no structural cue (position, length, known
37
+ * suffix/prefix/marker, format-hit) lifts or penalizes the score. Each rule adds bonuses on top of this base (e.g.
38
+ * +0.15 for 2-token locality runs, +0.1 for tail-of-last-segment) and subtracts penalties (e.g. −0.2 for a known US
39
+ * region name that isn't at segment-tail).
40
+ */
41
+ const NEUTRAL_PROPOSAL_CONFIDENCE = 0.55
42
+
43
+ const US_REGION_NAMES: ReadonlySet<string> = new Set([
44
+ "alabama",
45
+ "alaska",
46
+ "arizona",
47
+ "arkansas",
48
+ "california",
49
+ "colorado",
50
+ "connecticut",
51
+ "delaware",
52
+ "florida",
53
+ "georgia",
54
+ "hawaii",
55
+ "idaho",
56
+ "illinois",
57
+ "indiana",
58
+ "iowa",
59
+ "kansas",
60
+ "kentucky",
61
+ "louisiana",
62
+ "maine",
63
+ "maryland",
64
+ "massachusetts",
65
+ "michigan",
66
+ "minnesota",
67
+ "mississippi",
68
+ "missouri",
69
+ "montana",
70
+ "nebraska",
71
+ "nevada",
72
+ "ohio",
73
+ "oklahoma",
74
+ "oregon",
75
+ "pennsylvania",
76
+ "tennessee",
77
+ "texas",
78
+ "utah",
79
+ "vermont",
80
+ "virginia",
81
+ "washington",
82
+ "wisconsin",
83
+ "wyoming",
84
+ ])
85
+
86
+ /**
87
+ * Split a segment body into whitespace-separated tokens. Offsets are absolute into the original input (caller supplies
88
+ * the segment's `start` offset).
89
+ */
90
+ export function tokenizeSegment(segmentBody: string, segmentStart: number): SegmentToken[] {
91
+ const tokens: SegmentToken[] = []
92
+ let i = 0
93
+
94
+ while (i < segmentBody.length) {
95
+ while (i < segmentBody.length && WHITESPACE.test(segmentBody[i]!)) {
96
+ i++
97
+ }
98
+
99
+ if (i >= segmentBody.length) break
100
+ const start = i
101
+
102
+ while (i < segmentBody.length && !WHITESPACE.test(segmentBody[i]!)) {
103
+ i++
104
+ }
105
+ tokens.push({
106
+ body: segmentBody.slice(start, i),
107
+ start: segmentStart + start,
108
+ end: segmentStart + i,
109
+ })
110
+ }
111
+
112
+ return tokens
113
+ }
114
+
115
+ /** Build a `Section` (Span instance) from absolute offsets into the original text. */
116
+ function makeSection(text: string, start: number, end: number): Span {
117
+ return Span.from(text.slice(start, end), { start })
118
+ }
119
+
120
+ /** True when token body is non-empty digits only. */
121
+ function isAllDigit(s: string): boolean {
122
+ return s.length > 0 && /^[0-9]+$/.test(s)
123
+ }
124
+
125
+ /** True when token body is 2-3 uppercase Latin letters (US state, Canadian province abbreviation). */
126
+ function isRegionAbbreviation(s: string): boolean {
127
+ return /^[A-Z]{2,3}$/.test(s)
128
+ }
129
+
130
+ /**
131
+ * True when token starts with an uppercase letter — the common Western proper-noun shape. Unicode-aware (`\p{Lu}`) so
132
+ * accented Latin capitals (`Évellys`, `Étagnac`, `Ñuñoa`, `Ávila`) count as proper nouns too; an ASCII-only `[A-Z]`
133
+ * silently dropped those localities from the grouper (#425 residual).
134
+ */
135
+ function startsCapitalized(s: string): boolean {
136
+ return /^\p{Lu}/u.test(s)
137
+ }
138
+
139
+ /**
140
+ * Common street-type suffixes (en-US + en-GB + abbreviated forms). Match case-insensitively against the raw token body.
141
+ * The set is intentionally short — coverage extension belongs in a future per-locale rule pack, not as a 500-entry
142
+ * dictionary in this rule.
143
+ */
144
+ const STREET_SUFFIXES: ReadonlySet<string> = new Set([
145
+ "st",
146
+ "st.",
147
+ "street",
148
+ "ave",
149
+ "ave.",
150
+ "avenue",
151
+ "blvd",
152
+ "blvd.",
153
+ "boulevard",
154
+ "rd",
155
+ "rd.",
156
+ "road",
157
+ "ln",
158
+ "ln.",
159
+ "lane",
160
+ "dr",
161
+ "dr.",
162
+ "drive",
163
+ "way",
164
+ "pl",
165
+ "pl.",
166
+ "place",
167
+ "ct",
168
+ "ct.",
169
+ "court",
170
+ "pkwy",
171
+ "parkway",
172
+ "hwy",
173
+ "highway",
174
+ "ter",
175
+ "terrace",
176
+ "cir",
177
+ "circle",
178
+ "sq",
179
+ "square",
180
+ "trl",
181
+ "trail",
182
+ ])
183
+
184
+ function isStreetSuffix(token: string): boolean {
185
+ return STREET_SUFFIXES.has(token.toLowerCase())
186
+ }
187
+
188
+ /**
189
+ * Romance/Latin street-TYPE words that LEAD the street ("Via Trento", "Calle Mayor", "Corso Italia"). English puts the
190
+ * type last (a suffix — see STREET_SUFFIXES); Romance languages put it first. Without this, a leading "Via"/"Calle" is
191
+ * capitalized first-segment text the locality rule happily proposes, and on OOD intl input the model can't type it
192
+ * either — so the grouper-audit promotes it to a spurious `locality`, burying the real city (#425 re-gate).
193
+ *
194
+ * Street-TYPES only — deliberately NOT the ambiguous area/development words ("Polígono", "Urbanización", "Lugar",
195
+ * "Partida", "Borgo") that legitimately serve AS localities. This stays a bounded linguistic category; per-locale
196
+ * breadth belongs in a future rule pack, not an exception pile.
197
+ */
198
+ const STREET_PREFIXES: ReadonlySet<string> = new Set([
199
+ // Italian
200
+ "via",
201
+ "viale",
202
+ "corso",
203
+ "largo",
204
+ "vicolo",
205
+ "strada",
206
+ "piazza",
207
+ "piazzale",
208
+ "contrada",
209
+ "traversa",
210
+ "lungomare",
211
+ // Spanish / Catalan
212
+ "calle",
213
+ "avenida",
214
+ "avinguda",
215
+ "carrer",
216
+ "plaza",
217
+ "plaça",
218
+ "paseo",
219
+ "passeig",
220
+ "camino",
221
+ "carretera",
222
+ "ronda",
223
+ "travesía",
224
+ // Portuguese
225
+ "rua",
226
+ "travessa",
227
+ "praça",
228
+ // French
229
+ "rue",
230
+ "avenue",
231
+ "boulevard",
232
+ "chemin",
233
+ "impasse",
234
+ "allée",
235
+ "quai",
236
+ ])
237
+
238
+ function isStreetPrefix(token: string): boolean {
239
+ return STREET_PREFIXES.has(token.toLowerCase())
240
+ }
241
+
242
+ /**
243
+ * Lowercase connective particles that live INSIDE multi-word place names — the Romance/Germanic glue that bridges two
244
+ * capitalized content words: "Las Palmas **de** Gran Canaria", "San Pietro **in** Casale", "Alphen **aan den** Rijn",
245
+ * "Frankfurt **am** Main", "Rothenburg **ob der** Tauber". This is a BOUNDED linguistic category (place-name
246
+ * connectives), not a gazetteer or a stopword dump — and it only ever fires when bracketed by capitalized content on
247
+ * BOTH sides (see `scoreLocalityPhrase`), so a stray "and"/"the" in a street phrase can't smuggle a particle through.
248
+ * Keep coverage to the connectives that actually bridge place-name tokens; growing it into a per-locale stopword list
249
+ * is the wrong move — that pressure belongs on the gazetteer/reconciler, not here.
250
+ */
251
+ const PLACE_NAME_PARTICLES: ReadonlySet<string> = new Set([
252
+ // Spanish / Catalan / Portuguese
253
+ "de",
254
+ "del",
255
+ "la",
256
+ "las",
257
+ "los",
258
+ "el",
259
+ "i",
260
+ // Italian
261
+ "di",
262
+ "della",
263
+ "dei",
264
+ "degli",
265
+ "delle",
266
+ "in",
267
+ "a",
268
+ "sul",
269
+ "sulla",
270
+ // French
271
+ "du",
272
+ "des",
273
+ "le",
274
+ "les",
275
+ "sur",
276
+ "sous",
277
+ "en",
278
+ "lès",
279
+ // Dutch / Flemish
280
+ "aan",
281
+ "op",
282
+ "den",
283
+ "ter",
284
+ "ten",
285
+ // German
286
+ "am",
287
+ "an",
288
+ "auf",
289
+ "ob",
290
+ "im",
291
+ "vor",
292
+ "bei",
293
+ "der",
294
+ ])
295
+
296
+ /**
297
+ * A short lowercase particle fused via apostrophe to a capitalized name — the Italian/French elision that the tokenizer
298
+ * keeps as ONE token: `nell'Emilia`, `dell'Adda`, `l'Aquila`. Treated as place-name CONTENT (it carries the proper
299
+ * noun), so it can both start and continue a locality run.
300
+ */
301
+ function isFusedParticleName(s: string): boolean {
302
+ return /^\p{Ll}{1,6}['’]\p{Lu}/u.test(s)
303
+ }
304
+
305
+ /**
306
+ * Place-name content token: a capitalized word OR an apostrophe-fused particle name (`nell'Emilia`).
307
+ */
308
+ function isPlaceNameContent(s: string): boolean {
309
+ return startsCapitalized(s) || isFusedParticleName(s)
310
+ }
311
+
312
+ /** True when the token is a known lowercase place-name connective (`de`, `in`, `aan`, `am`, …). */
313
+ function isPlaceNameParticle(s: string): boolean {
314
+ return PLACE_NAME_PARTICLES.has(s.toLowerCase())
315
+ }
316
+
317
+ /**
318
+ * Venue-marker nouns with per-term confidence weights. Same caveat as STREET_SUFFIXES — universal structural markers,
319
+ * not a places dictionary. Higher weight = stronger venue signal.
320
+ */
321
+ const VENUE_MARKERS: ReadonlyMap<string, number> = new Map([
322
+ // Dining (0.90 — unambiguous venue markers)
323
+ ["steakhouse", 0.9],
324
+ ["restaurant", 0.9],
325
+ ["bistro", 0.9],
326
+ ["diner", 0.85],
327
+ ["cafe", 0.85],
328
+ ["café", 0.85],
329
+ ["grill", 0.8],
330
+ ["pizzeria", 0.9],
331
+ ["bakery", 0.85],
332
+ ["brewery", 0.85],
333
+ ["winery", 0.85],
334
+ ["tavern", 0.8],
335
+ ["pub", 0.75],
336
+ ["bar", 0.7],
337
+ // Lodging
338
+ ["hotel", 0.9],
339
+ ["motel", 0.9],
340
+ ["inn", 0.75],
341
+ ["resort", 0.85],
342
+ ["lodge", 0.75],
343
+ ["hostel", 0.85],
344
+ // Entertainment / culture
345
+ ["theater", 0.85],
346
+ ["theatre", 0.85],
347
+ ["cinema", 0.85],
348
+ ["stadium", 0.9],
349
+ ["arena", 0.85],
350
+ ["museum", 0.85],
351
+ ["gallery", 0.75],
352
+ ["casino", 0.85],
353
+ ["lounge", 0.7],
354
+ // Retail / commercial
355
+ ["market", 0.7],
356
+ ["mall", 0.8],
357
+ ["plaza", 0.7],
358
+ ["tower", 0.65],
359
+ ["center", 0.6],
360
+ ["centre", 0.6],
361
+ // Medical / institutional
362
+ ["hospital", 0.9],
363
+ ["clinic", 0.85],
364
+ ["pharmacy", 0.85],
365
+ // Education
366
+ ["university", 0.9],
367
+ ["college", 0.85],
368
+ ["school", 0.8],
369
+ ["academy", 0.8],
370
+ // Civic / religious
371
+ ["church", 0.8],
372
+ ["temple", 0.8],
373
+ ["mosque", 0.8],
374
+ ["synagogue", 0.85],
375
+ ["cathedral", 0.85],
376
+ ["chapel", 0.75],
377
+ ["library", 0.85],
378
+ // Outdoor
379
+ ["park", 0.6],
380
+ ["gardens", 0.65],
381
+ ["ranch", 0.7],
382
+ ["farm", 0.65],
383
+ ])
384
+
385
+ /**
386
+ * Unit-designator tokens that gate the venue-by-exclusion heuristic. When any token in a segment matches one of these,
387
+ * the segment is likely a unit/suite line, not a venue name.
388
+ */
389
+ const UNIT_MARKERS: ReadonlySet<string> = new Set([
390
+ "apt",
391
+ "apt.",
392
+ "apartment",
393
+ "unit",
394
+ "ste",
395
+ "ste.",
396
+ "suite",
397
+ "room",
398
+ "rm",
399
+ "rm.",
400
+ "floor",
401
+ "fl",
402
+ "fl.",
403
+ "bldg",
404
+ "bldg.",
405
+ "building",
406
+ "dept",
407
+ "dept.",
408
+ "department",
409
+ "#",
410
+ ])
411
+
412
+ function venueMarkerWeight(tokens: ReadonlyArray<SegmentToken>): number {
413
+ let maxWeight = 0
414
+
415
+ for (const t of tokens) {
416
+ const w = VENUE_MARKERS.get(t.body.toLowerCase())
417
+
418
+ if (w !== undefined && w > maxWeight) {
419
+ maxWeight = w
420
+ }
421
+ }
422
+
423
+ return maxWeight
424
+ }
425
+
426
+ function hasUnitMarker(tokens: ReadonlyArray<SegmentToken>): boolean {
427
+ return tokens.some((t) => UNIT_MARKERS.has(t.body.toLowerCase()))
428
+ }
429
+
430
+ /**
431
+ * `NUMERIC` rule: emit one proposal per all-digit token. House numbers, postcodes (when no format hit), unit numbers
432
+ * all surface here as a base hypothesis.
433
+ *
434
+ * Confidence drops for very long runs (5+ digits) where POSTCODE will typically win; the reconciler does the final
435
+ * pick.
436
+ */
437
+ export function scoreNumeric(tokens: ReadonlyArray<SegmentToken>, text: string): PhraseProposal[] {
438
+ const out: PhraseProposal[] = []
439
+
440
+ for (const t of tokens) {
441
+ if (!isAllDigit(t.body)) continue
442
+ const len = t.body.length
443
+ // 1-4 digit pure-numerics are clearly NUMERIC (house number). 5+ are ambiguous with POSTCODE
444
+ // — emit anyway at lower confidence so the reconciler sees both options.
445
+ const confidence = len <= 4 ? 0.95 : NEUTRAL_PROPOSAL_CONFIDENCE
446
+ out.push({
447
+ span: makeSection(text, t.start, t.end),
448
+ kindHypothesis: "NUMERIC",
449
+ confidence,
450
+ })
451
+ }
452
+
453
+ return out
454
+ }
455
+
456
+ /**
457
+ * `POSTCODE` rule: lift each `QueryShape.knownFormats` postcode hit directly. The QueryShape stage already did the
458
+ * format-shape recognition — Stage 2.7's job is just to publish the spans as phrase proposals so the reconciler can use
459
+ * them.
460
+ */
461
+ export function scorePostcode(shape: QueryShapeLike, text: string): PhraseProposal[] {
462
+ const out: PhraseProposal[] = []
463
+
464
+ for (const hit of shape.knownFormats) {
465
+ // `po_box` is not a postcode; the kind classifier owns that signal. Skip non-postcode
466
+ // formats here so we don't pollute POSTCODE proposals.
467
+ if (hit.format === "po_box") continue
468
+ out.push({
469
+ span: makeSection(text, hit.span.start, hit.span.end),
470
+ kindHypothesis: "POSTCODE",
471
+ // Lift the format-hit confidence directly — Stage 5 can weight it against alternatives.
472
+ confidence: hit.confidence,
473
+ })
474
+ }
475
+
476
+ return out
477
+ }
478
+
479
+ /**
480
+ * `REGION_ABBREVIATION` rule: 2-3 uppercase Latin letters. Tail-of-segment position boosts confidence because that's
481
+ * the canonical "City, ST ZIP" shape.
482
+ */
483
+ export function scoreRegionAbbreviation(
484
+ tokens: ReadonlyArray<SegmentToken>,
485
+ text: string,
486
+ segmentIsLast: boolean
487
+ ): PhraseProposal[] {
488
+ const out: PhraseProposal[] = []
489
+
490
+ for (let i = 0; i < tokens.length; i++) {
491
+ const t = tokens[i]!
492
+
493
+ if (!isRegionAbbreviation(t.body)) continue
494
+ // A region code is canonically standalone — the tail of "City, ST ZIP", never immediately
495
+ // followed by another place-name word. When the next token IS place-name content (and not
496
+ // itself a region abbreviation or a street suffix), this token is the HEAD of a multi-word
497
+ // place name ("SAN" NAZARIO, "DI" CASTELLO — common in all-caps intl data where every short
498
+ // word matches the 2-3-uppercase shape), not a region. Suppressing the region proposal here
499
+ // keeps it from out-deduping the same span's LOCALITY_PHRASE in the reconciler (#425).
500
+ const after = tokens[i + 1]
501
+
502
+ if (after && isPlaceNameContent(after.body) && !isRegionAbbreviation(after.body) && !isStreetSuffix(after.body)) {
503
+ continue
504
+ }
505
+ // Position cue: last token in a segment (canonical region slot) → high confidence. Anywhere
506
+ // else, moderate. Anywhere in the LAST segment → slightly elevated (region is canonically the
507
+ // final non-postcode component).
508
+ const atTail = i === tokens.length - 1
509
+ const confidence = atTail ? 0.85 : segmentIsLast ? 0.7 : NEUTRAL_PROPOSAL_CONFIDENCE
510
+ out.push({
511
+ span: makeSection(text, t.start, t.end),
512
+ kindHypothesis: "REGION_ABBREVIATION",
513
+ confidence,
514
+ })
515
+ }
516
+
517
+ return out
518
+ }
519
+
520
+ /**
521
+ * `HYPHENATED_COMPOUND` rule: tokens containing an internal hyphen. Captures `NY-NY` (venue disambiguation case),
522
+ * `Saint-Denis` (French locality compound), `10118-1234` (ZIP+4 written as a single token).
523
+ *
524
+ * Internal hyphen is the cue; the rule doesn't pre-judge what the compound MEANS — that's typing (classifier) or
525
+ * reconcile work. A high confidence here just says "this is one unit, not two".
526
+ */
527
+ export function scoreHyphenatedCompound(tokens: ReadonlyArray<SegmentToken>, text: string): PhraseProposal[] {
528
+ const out: PhraseProposal[] = []
529
+
530
+ for (const t of tokens) {
531
+ if (!t.body.includes("-")) continue
532
+
533
+ // Skip leading/trailing hyphens (likely punctuation drift) — require an interior hyphen
534
+ // surrounded by non-hyphen characters.
535
+ if (!/[^-]-[^-]/.test(t.body)) continue
536
+ out.push({
537
+ span: makeSection(text, t.start, t.end),
538
+ kindHypothesis: "HYPHENATED_COMPOUND",
539
+ confidence: 0.88,
540
+ })
541
+ }
542
+
543
+ return out
544
+ }
545
+
546
+ /**
547
+ * `STREET_PHRASE` rule: a token run that contains a street-type suffix. The span covers a leading numeric (house
548
+ * number) when present, through the suffix token.
549
+ *
550
+ * Confidence reflects how canonical the run looks: NUMERIC + 1-3 capitalized words + SUFFIX scores highest; suffix-only
551
+ * or non-leading-numeric variants score lower but still emit.
552
+ */
553
+ export function scoreStreetPhrase(tokens: ReadonlyArray<SegmentToken>, text: string): PhraseProposal[] {
554
+ const out: PhraseProposal[] = []
555
+
556
+ for (let suffixIdx = 0; suffixIdx < tokens.length; suffixIdx++) {
557
+ if (!isStreetSuffix(tokens[suffixIdx]!.body)) continue
558
+ // Walk left from the suffix gathering capitalized/numeric/ordinal tokens. Stop when we hit
559
+ // something un-street-y (lowercase non-suffix, another suffix, etc.).
560
+ let start = suffixIdx
561
+
562
+ for (let i = suffixIdx - 1; i >= 0; i--) {
563
+ const body = tokens[i]!.body
564
+
565
+ if (isAllDigit(body) || /^\d+(st|nd|rd|th)$/i.test(body) || startsCapitalized(body)) {
566
+ start = i
567
+ } else {
568
+ break
569
+ }
570
+ }
571
+
572
+ // Need at least one preceding token (or a numeric house number) for STREET_PHRASE — a
573
+ // suffix-only token "Street" alone isn't a street phrase.
574
+ if (start === suffixIdx) continue
575
+ // #565: a leading ALL-DIGIT token is a house NUMBER, not part of the street name. Exclude it from
576
+ // the STREET_PHRASE span — the NUMERIC rule already proposes the house number separately — so the
577
+ // joint reconciler types the house number and the street as DISTINCT nodes instead of fusing the
578
+ // whole run ("3075 Hill Street") into one (the regression behind #566). Ordinals ("5th Ave") are
579
+ // part of the name and stay.
580
+ let hadHouseNumber = false
581
+
582
+ if (isAllDigit(tokens[start]!.body)) {
583
+ start += 1
584
+ hadHouseNumber = true
585
+
586
+ if (start === suffixIdx) continue // only "<number> <suffix>" remained — no street name to phrase
587
+ }
588
+ const startTok = tokens[start]!
589
+ const endTok = tokens[suffixIdx]!
590
+ // A preceding (now-excluded) house number is still strong evidence this run is a street → high
591
+ // confidence. A capitalized-run + suffix with no number scores slightly lower (could be a venue).
592
+ const confidence = hadHouseNumber ? 0.9 : 0.75
593
+ out.push({
594
+ span: makeSection(text, startTok.start, endTok.end),
595
+ kindHypothesis: "STREET_PHRASE",
596
+ confidence,
597
+ })
598
+ }
599
+
600
+ // Romance street pattern: the street TYPE LEADS ("Via Trento", "Calle Mayor", "Largo Millefiori").
601
+ // Walk RIGHT from a street-prefix token gathering capitalized place-name words (bridging particles),
602
+ // stopping at a digit house-number or any non-place token.
603
+ for (let prefixIdx = 0; prefixIdx < tokens.length; prefixIdx++) {
604
+ if (!isStreetPrefix(tokens[prefixIdx]!.body)) continue
605
+ let end = prefixIdx
606
+
607
+ for (let i = prefixIdx + 1; i < tokens.length; i++) {
608
+ const body = tokens[i]!.body
609
+
610
+ if (isStreetPrefix(body)) break
611
+
612
+ if (isPlaceNameContent(body) || isPlaceNameParticle(body)) {
613
+ end = i
614
+ } else {
615
+ break
616
+ }
617
+ }
618
+
619
+ // Don't end on a trailing connective particle ("Calle de" is not a street name).
620
+ while (end > prefixIdx && isPlaceNameParticle(tokens[end]!.body)) {
621
+ end--
622
+ }
623
+ const startTok = tokens[prefixIdx]!
624
+ const endTok = tokens[end]!
625
+ // Prefix + name scores moderately; a bare prefix still emits a low-confidence marker so the
626
+ // audit types the leftover span `street`, never `locality`.
627
+ out.push({
628
+ span: makeSection(text, startTok.start, endTok.end),
629
+ kindHypothesis: "STREET_PHRASE",
630
+ confidence: end > prefixIdx ? 0.72 : 0.5,
631
+ })
632
+ }
633
+
634
+ return out
635
+ }
636
+
637
+ /**
638
+ * `LOCALITY_PHRASE` rule: runs of contiguous place-name tokens (1-6 long). Emits multiple overlapping proposals so the
639
+ * reconciler can choose between e.g. `Saint Petersburg` as one phrase vs `Saint` + `Petersburg` as two.
640
+ *
641
+ * The run bridges lowercase place-name PARTICLES (`de`, `in`, `aan den`, `am`, …) and apostrophe-fused names
642
+ * (`nell'Emilia`) when they sit between capitalized content — without that, the walk used to stop dead at the first
643
+ * lowercase token, so it never proposed "Reggio nell'Emilia", "Las Palmas de Gran Canaria", or "San Pietro in Casale"
644
+ * as single spans. Those gaps are exactly the native-order multi-word localities the joint-decode A/B fragmented (Route
645
+ * A Phase I, #425).
646
+ *
647
+ * Confidence scales with: run length (2-5 are good place-name lengths), tail-of-segment position, and whether the span
648
+ * sits at a segment boundary.
649
+ */
650
+ export function scoreLocalityPhrase(
651
+ tokens: ReadonlyArray<SegmentToken>,
652
+ text: string,
653
+ segmentIsLast: boolean
654
+ ): PhraseProposal[] {
655
+ const out: PhraseProposal[] = []
656
+
657
+ for (let i = 0; i < tokens.length; i++) {
658
+ if (!isPlaceNameContent(tokens[i]!.body)) continue
659
+
660
+ // A leading street-type word ("Via", "Calle", "Corso") heads a STREET, not a locality — let
661
+ // scoreStreetPhrase own it so the audit never promotes it to a spurious locality.
662
+ if (isStreetPrefix(tokens[i]!.body)) continue
663
+
664
+ // A region-abbreviation-SHAPED head (2-3 uppercase letters) starts a LOCALITY_PHRASE only when
665
+ // place-name content follows it. This keeps a standalone trailing "NY"/"TX" owned by
666
+ // REGION_ABBREVIATION, while still forming "SAN NAZARIO" / "CITTÀ DI CASTELLO" — in all-caps
667
+ // intl data (OpenAddresses), the head/connector of a place name ("SAN", "DI", "DEL") matches
668
+ // the abbreviation shape, so a hard skip here dropped the multi-word locality entirely (#425).
669
+ if (isRegionAbbreviation(tokens[i]!.body)) {
670
+ const after = tokens[i + 1]
671
+
672
+ if (!after || !(isPlaceNameContent(after.body) || isPlaceNameParticle(after.body))) continue
673
+ }
674
+ // Walk forward grabbing place-name content. Bridge connective particles (lowercase "de"/"in" or
675
+ // all-caps "DI"/"DEL") ONLY when a content token follows within a short run (≤2 consecutive
676
+ // particles: "aan den Rijn"), so a dangling "Palmas de" at end-of-segment doesn't extend the
677
+ // run. Stop on digits, street suffixes, and NON-particle region abbreviations ("Springfield IL"
678
+ // must not absorb "IL").
679
+ let j = i
680
+
681
+ for (;;) {
682
+ const next = tokens[j + 1]
683
+
684
+ if (!next) break
685
+ const b = next.body
686
+
687
+ if (isAllDigit(b) || isStreetSuffix(b) || isStreetPrefix(b)) break
688
+
689
+ if (isRegionAbbreviation(b) && !isPlaceNameParticle(b)) break
690
+
691
+ if (isPlaceNameContent(b) && !isPlaceNameParticle(b)) {
692
+ j++
693
+ continue
694
+ }
695
+
696
+ if (isPlaceNameParticle(b)) {
697
+ // Look past a short run of consecutive particles for the next content token.
698
+ let k = j + 2
699
+
700
+ while (tokens[k] && isPlaceNameParticle(tokens[k]!.body)) {
701
+ k++
702
+ }
703
+
704
+ if (tokens[k] && k - (j + 1) <= 2 && isPlaceNameContent(tokens[k]!.body)) {
705
+ j = k // jump onto the content token; the bridged particles stay inside the span
706
+ continue
707
+ }
708
+ }
709
+ break
710
+ }
711
+ // Emit proposals for every prefix-length of the run starting at i, capped at 6 tokens (covers
712
+ // "Las Palmas de Gran Canaria" = 5). Each starting i contributes ≤6 proposals → O(n) per segment.
713
+ const maxLen = Math.min(j - i + 1, 6)
714
+
715
+ for (let len = 1; len <= maxLen; len++) {
716
+ const startTok = tokens[i]!
717
+ const endTok = tokens[i + len - 1]!
718
+
719
+ // Never end a proposal ON a connective particle ("Las Palmas de" / "CITTÀ DI" is not a place).
720
+ if (isPlaceNameParticle(endTok.body)) continue
721
+ const spanText = text.slice(startTok.start, endTok.end)
722
+ const isRegionName = len === 1 && US_REGION_NAMES.has(spanText.toLowerCase())
723
+ const atTail = i + len - 1 === tokens.length - 1
724
+ const lenBonus = len === 2 ? 0.15 : len === 3 ? 0.12 : len >= 4 ? 0.08 : 0
725
+ let confidence = NEUTRAL_PROPOSAL_CONFIDENCE + lenBonus
726
+
727
+ if (isRegionName && !atTail) {
728
+ confidence -= 0.2
729
+ }
730
+
731
+ if (atTail && segmentIsLast) {
732
+ confidence += 0.1
733
+ }
734
+
735
+ if (atTail) {
736
+ confidence += 0.05
737
+ }
738
+ out.push({
739
+ span: makeSection(text, startTok.start, endTok.end),
740
+ kindHypothesis: "LOCALITY_PHRASE",
741
+ confidence: Math.min(0.95, confidence),
742
+ })
743
+ }
744
+ // Do NOT skip past the run — let i++ advance normally so every capitalized token gets a
745
+ // chance to emit single-token proposals from its own starting position. (Saint Petersburg
746
+ // needs `Saint`, `Petersburg`, AND `Saint Petersburg`; a run-skip would lose `Petersburg`.)
747
+ }
748
+
749
+ return out
750
+ }
751
+
752
+ /**
753
+ * `VENUE_PHRASE` rule: capitalized run containing a venue-marker noun (Steakhouse, Hotel, etc.) OR containing a
754
+ * hyphenated compound + ≥1 capitalized word.
755
+ *
756
+ * The shape "NY-NY Steakhouse" — the kryptonite case the reconciler eventually needs to lift the NY tokens off REGION —
757
+ * surfaces here as a `VENUE_PHRASE` proposal at moderate-high confidence.
758
+ *
759
+ * Also includes a venue-by-exclusion positional prior: multi-word capitalized run in the first segment with no street
760
+ * suffix, no house number, and no unit marker → weak VENUE_PHRASE at 0.50-0.55. The idea: if we can't identify what
761
+ * something IS, but it's in the venue slot (first segment) and doesn't look like any other component, it might be a
762
+ * venue name.
763
+ */
764
+ export function scoreVenuePhrase(
765
+ tokens: ReadonlyArray<SegmentToken>,
766
+ text: string,
767
+ segmentIsFirst?: boolean
768
+ ): PhraseProposal[] {
769
+ const out: PhraseProposal[] = []
770
+ let i = 0
771
+
772
+ while (i < tokens.length) {
773
+ if (!startsCapitalized(tokens[i]!.body)) {
774
+ i++
775
+ continue
776
+ }
777
+ let j = i
778
+
779
+ while (j + 1 < tokens.length && (startsCapitalized(tokens[j + 1]!.body) || tokens[j + 1]!.body.includes("-"))) {
780
+ j++
781
+ }
782
+ const run = tokens.slice(i, j + 1)
783
+ const markerWeight = venueMarkerWeight(run)
784
+ const hasHyphenCompound = run.some((t) => /[^-]-[^-]/.test(t.body))
785
+
786
+ if (markerWeight > 0 || (hasHyphenCompound && run.length >= 2)) {
787
+ const startTok = run[0]!
788
+ const endTok = run[run.length - 1]!
789
+ const confidence = markerWeight > 0 ? markerWeight : 0.65
790
+ out.push({
791
+ span: makeSection(text, startTok.start, endTok.end),
792
+ kindHypothesis: "VENUE_PHRASE",
793
+ confidence,
794
+ })
795
+ } else if (segmentIsFirst && run.length >= 2) {
796
+ const hasStreet = run.some((t) => isStreetSuffix(t.body))
797
+ const hasLeadingNum = isAllDigit(run[0]!.body)
798
+ const hasUnit = hasUnitMarker(run)
799
+
800
+ if (!hasStreet && !hasLeadingNum && !hasUnit) {
801
+ const startTok = run[0]!
802
+ const endTok = run[run.length - 1]!
803
+ out.push({
804
+ span: makeSection(text, startTok.start, endTok.end),
805
+ kindHypothesis: "VENUE_PHRASE",
806
+ confidence: run.length >= 3 ? NEUTRAL_PROPOSAL_CONFIDENCE : 0.5,
807
+ })
808
+ }
809
+ }
810
+
811
+ i = j + 1
812
+ }
813
+
814
+ return out
815
+ }
package/types.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ */
6
+
7
+ /** Re-exports of the canonical types from `@mailwoman/core/pipeline`. */
8
+ export type { LocaleHint, PhraseGrouper, PhraseKind, PhraseProposal } from "@mailwoman/core/pipeline"
9
+
10
+ /** Re-export of the canonical `Section` type from `@mailwoman/core/types`. `Section = Span`. */
11
+ export type { Section } from "@mailwoman/core/types"
12
+
13
+ /**
14
+ * Minimal `NormalizedInput` shape consumed by `groupPhrases`. Compatible with `@mailwoman/normalize`'s output.
15
+ */
16
+ export interface NormalizedInputLite {
17
+ raw: string
18
+ normalized: string
19
+ appliedLocale?: string
20
+ }
21
+
22
+ /**
23
+ * Minimal `QueryShape` shape consumed by `groupPhrases`. Compatible with `@mailwoman/query-shape`'s output.
24
+ */
25
+ export interface QueryShapeLike {
26
+ knownFormats: ReadonlyArray<{
27
+ format: string
28
+ span: { start: number; end: number }
29
+ confidence: number
30
+ }>
31
+ segments?: ReadonlyArray<{ body: string; index: number; span?: { start: number; end: number } }>
32
+ tokenClasses?: ReadonlyArray<{
33
+ span: { start: number; end: number; body: string }
34
+ class: string
35
+ length: number
36
+ }>
37
+ characterClass?: string
38
+ totalLength?: number
39
+ }
40
+
41
+ export interface GroupPhrasesOpts {
42
+ /** Reserved for future tunables (e.g. confidence floor, per-kind biasing). Currently unused. */
43
+ confidenceFloor?: number
44
+ }