/** * @copyright Sister Software * @license AGPL-3.0 * @author Teffen Ellis, et al. * * Postcode anchor — the first member of the "anchor-based parsing" family (Direction D, #240). See * `docs/articles/plan/2026-06-03-anchor-based-parsing.md`. * * A postcode is the most information-dense token in an address: a hierarchical geo-encoding that * places a query on Earth far more cheaply than the rest of the parse. This module lifts the * postcode out of the BIO sequence-labelling problem and treats it as a structured anchor. It * runs the same per-country shape regexes the decoder repair pass uses ({@link collectMatches}), * resolves each shaped span against a postcode gazetteer, and returns a SOFT signal: a country * posterior plus a calibrated confidence. It never decides a postcode's identity on its own — it * reports "this string is (or is not) a real postcode, in these countries, near here", and leaves * the parser to weigh that against the surrounding tokens. * * Two design rules carried from the DeepSeek consult * (`.agents/skills/deepseek-consult/ds-pc-turn{1,2}-postcode-anchor.txt`): * * - The country posterior is UNIFORM over the countries a string actually exists in. We never weight * by per-country postcode volume, because that skews "75001" toward whichever country owns * more 5-digit codes — the exact bias the anchor exists to avoid. Disambiguation is the * parser's job, using script, city tokens, and user locale. * - Confidence combines gazetteer MEMBERSHIP with country AMBIGUITY. A string that matches a postcode * regex but exists in no gazetteer (a bare `27`, or a 5-digit house number that is not a real * code) gets confidence 0, so the parser treats it as a house number. A real-but-ambiguous * code (`75001` in FR and US) gets moderate confidence. A real, single-country code gets * 1.0. */ /** * A gazetteer hit for a postcode string. `lat`/`lon` of 0 means "known postcode, no centroid yet". */ export interface PostcodePlace { country: string; lat: number; lon: number; } /** * The minimal surface the anchor needs from a gazetteer. Implementations: an in-memory fake (tests) or a SQLite-backed * lookup over the `postalcode-*.db` shards (`@mailwoman/resolver-wof-sqlite`). Keeping the seam this narrow lets a * future FST/WASM resolver drop in without touching the anchor logic. */ export interface PostcodeResolver { /** * Exact-match lookup of a normalized postcode string across every country shard. */ lookup(postcode: string): PostcodePlace[]; } export interface PostcodeAnchor { /** * The shaped substring as it appeared in the raw text, with char offsets. */ span: { text: string; start: number; end: number; }; /** * The normalized form actually queried (uppercased, `D-` prefix stripped, whitespace collapsed). */ normalized: string; /** * Coordinate-bearing gazetteer hits — best-effort centroid(s), one representative per country. */ candidates: PostcodePlace[]; /** * Uniform distribution over the countries the postcode exists in (membership, coordinate-independent). */ posterior: Record; /** * `1 - normalizedEntropy(posterior)` when the postcode exists; `0` when it is in no gazetteer. */ confidence: number; /** * `exact` — the string is a real postcode; `outward` — a GB unit (`SO4 3RX`) resolved to its outward district * (`SO4`), the granularity the GB gazetteer is aggregated at (no penalty — it is a real, confident GB match); `fuzzy` * — only an edit-distance-1 variant exists (a likely typo / OCR slip), so the confidence carries a penalty; `none` — * in no gazetteer. */ matchType: "exact" | "outward" | "fuzzy" | "none"; /** * Structural house-number prior in [0, 1]: `1` for a code that cannot be a house number, and below `1` for a * digit-only code sharing its comma-delimited segment with a street word (so it reads as a house number rather than a * postcode). Already folded into {@link confidence}; exposed so a consumer can rank competing spans, or see why one * was down-weighted, without re-deriving it. */ positionFactor: number; } export interface ExtractPostcodeAnchorsOpts { /** * When an exact lookup finds nothing, retry Damerau–Levenshtein ≤1 variants to absorb typos and OCR slips (`75OO8` → * `75008`). Off by default so existing callers keep exact-match behaviour. */ fuzzy?: boolean; } /** * Class-aware edit-distance-1 variants of a postcode string: deletions, same-class substitutions (digit↔digit, * letter↔letter), same-class insertions, and adjacent transpositions. Restricting substitutions/insertions to the * character's class mirrors how humans mistype or OCR a postcode (a digit becomes another digit, not a letter) and * keeps the candidate set small. */ export declare function editDistance1Variants(s: string): string[]; /** * Normalize a shaped span to the canonical gazetteer key: uppercase, collapse internal whitespace to a single space, * and strip the German `D-` courtesy prefix (the shards store `68161`, not `D-68161`). */ export declare function normalizePostcode(raw: string): string; /** * The GB outward code of a normalized unit postcode — the part before the space when the inward half is `\d[A-Z]{2}` * (`SO4 3RX` → `SO4`). The GB gazetteer is aggregated to outward codes (2.7M units is too large + too fine for an * anchor), so the extractor retries the outward code when a full GB unit misses. Returns `null` for any string that * isn't a GB unit postcode (so it never fires elsewhere). */ export declare function gbOutwardCode(normalized: string): string | null; /** * Extract postcode anchors from raw text. For each postcode-shaped span, resolve it against the gazetteer and emit a * soft anchor (country posterior + confidence). Spans that match a shape but exist in no gazetteer are still returned, * with an empty posterior and confidence 0 — an explicit "looks like a postcode, but isn't one" so the caller can see * the extractor fired and chose not to anchor. */ export declare function extractPostcodeAnchors(text: string, resolver: PostcodeResolver, opts?: ExtractPostcodeAnchorsOpts): PostcodeAnchor[]; //# sourceMappingURL=postcode-anchor.d.ts.map