euredact 0.3.0 → 0.3.2

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.
package/dist/cjs/sdk.js CHANGED
@@ -4,6 +4,7 @@ exports.EuRedact = void 0;
4
4
  const cache_js_1 = require("./cache.js");
5
5
  const normalizer_js_1 = require("./normalizer.js");
6
6
  const engine_js_1 = require("./rules/engine.js");
7
+ const evidence_js_1 = require("./rules/evidence.js");
7
8
  const types_js_1 = require("./types.js");
8
9
  const DATE_TYPES = new Set([types_js_1.EntityType.DOB, types_js_1.EntityType.DATE_OF_DEATH]);
9
10
  class ReferentialMapper {
@@ -47,12 +48,18 @@ class EuRedact {
47
48
  `(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
48
49
  `increase maxInputLength when constructing EuRedact.`);
49
50
  }
50
- const { countries = null, mode = "rules", referentialIntegrity = false, detectDates = false, cache = true, } = options;
51
+ const { countries = null, countryHint = null, context = null, chunkOffset = 0, mode = "rules", referentialIntegrity = false, detectDates = false, } = options;
52
+ // A context makes the result depend on evidence from other chunks, so the
53
+ // text no longer identifies the result. Caching is disabled rather than
54
+ // keyed on the context, whose contents change as chunks arrive.
55
+ const cache = context !== null ? false : (options.cache ?? true);
51
56
  const [normalizedText, offsetMapping] = (0, normalizer_js_1.normalize)(text);
52
57
  const countriesTuple = countries
53
58
  ? countries.map(c => c.toUpperCase()).sort()
54
59
  : ["ALL"];
55
- const cacheMode = `${mode}|dates=${detectDates}`;
60
+ // countryHint changes attribution, so it must key the cache too.
61
+ const hintKey = countryHint ? countryHint.map(c => c.toUpperCase()).sort().join(",") : "";
62
+ const cacheMode = `${mode}|dates=${detectDates}|hint=${hintKey}`;
56
63
  let cacheKey;
57
64
  if (cache) {
58
65
  cacheKey = this.cache.key(normalizedText, countriesTuple, cacheMode);
@@ -60,7 +67,10 @@ class EuRedact {
60
67
  if (cached !== null)
61
68
  return cached;
62
69
  }
63
- let detections = this.engine.detect(normalizedText, countries);
70
+ const { detections: rawDetections, evidence, scores, } = this.engine.detectWithEvidence(normalizedText, countries, countryHint, context !== null ? context.evidence() : null);
71
+ if (context !== null)
72
+ context.add(evidence, chunkOffset);
73
+ let detections = rawDetections;
64
74
  if (offsetMapping !== null) {
65
75
  detections = detections.map(d => ({
66
76
  ...d,
@@ -80,11 +90,18 @@ class EuRedact {
80
90
  : `[${det.entityType}]`;
81
91
  redacted = redacted.slice(0, det.start) + replacement + redacted.slice(det.end);
82
92
  }
93
+ // Report the inference so it can be audited. Spans in `evidence` are
94
+ // offsets into the normalised text, matching `detections`.
95
+ const inferredCountries = [...(0, evidence_js_1.weightsToRanking)(scores).entries()]
96
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
83
97
  const result = {
84
98
  redactedText: redacted,
85
99
  detections,
86
100
  source: "rules",
87
101
  degraded: false,
102
+ inferredCountries,
103
+ evidence,
104
+ detectionMode: countries && countries.length ? "declared" : "inferred",
88
105
  };
89
106
  if (cache && cacheKey) {
90
107
  this.cache.put(cacheKey, result);
@@ -92,8 +109,9 @@ class EuRedact {
92
109
  return result;
93
110
  }
94
111
  redactBatch(texts, options = {}) {
95
- const countries = options.countries ?? null;
96
- this.engine.loadCountries(countries ? countries.map(c => c.toUpperCase()) : null);
112
+ // Generation is country-blind, so every country's patterns are loaded
113
+ // regardless of what the caller declared.
114
+ this.engine.loadCountries(null);
97
115
  return texts.map(text => this.redact(text, options));
98
116
  }
99
117
  }
@@ -1,5 +1,6 @@
1
1
  export { EntityType, DetectionSource, LEGACY_TYPE_ALIASES } from "./types.js";
2
- export type { Detection, RedactResult, PatternDef, CountryConfig } from "./types.js";
2
+ export type { Detection, RedactResult, PatternDef, CountryConfig, CountryEvidence } from "./types.js";
3
+ export { DocumentContext } from "./rules/context.js";
3
4
  export { EuRedact, type RedactOptions } from "./sdk.js";
4
5
  export { COUNTRY_CONFIGS } from "./rules/countries/index.js";
5
6
  export { setBicRegistry, getBicRegistry, SEED_BIC6_PREFIXES, type BicRegistryProvider } from "./rules/bicRegistry.js";
package/dist/esm/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { EntityType, DetectionSource, LEGACY_TYPE_ALIASES } from "./types.js";
2
+ export { DocumentContext } from "./rules/context.js";
2
3
  export { EuRedact } from "./sdk.js";
3
4
  export { COUNTRY_CONFIGS } from "./rules/countries/index.js";
4
5
  export { setBicRegistry, getBicRegistry, SEED_BIC6_PREFIXES } from "./rules/bicRegistry.js";
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Country evidence shared across the chunks of one document.
3
+ *
4
+ * A long document is usually redacted in pieces. Each piece is scanned
5
+ * independently, so each infers its own country — and a chunk that happens to
6
+ * contain no IBAN, no `+CC` number and no ccTLD infers nothing at all, even
7
+ * when the first page identified the document beyond doubt:
8
+ *
9
+ * page 1 "Factuur — IBAN NL91ABNA0417164300, info@jansen.nl"
10
+ * page 7 "Telefoon 0612345678" <- Dutch mobile, or Danish CPR?
11
+ *
12
+ * Scanned alone, page 7 has nothing to go on. Sharing a context across the two
13
+ * carries page 1's evidence forward, and the number resolves as a Dutch phone.
14
+ *
15
+ * Spans are rebased by `chunkOffset` on the way in, so evidence collected from
16
+ * a chunk points at offsets in the whole document rather than the chunk.
17
+ *
18
+ * A context influences **scoring only**. It can change which national scheme is
19
+ * attributed to a value. It can never change which spans are found, so a
20
+ * context carried over from the wrong document cannot cause a miss.
21
+ */
22
+ import type { CountryEvidence } from "../types.js";
23
+ export declare class DocumentContext {
24
+ private seen;
25
+ private items;
26
+ /** Record evidence from a chunk starting at `chunkOffset`. */
27
+ add(evidence: CountryEvidence[], chunkOffset?: number): void;
28
+ /** Everything recorded so far, spans rebased to the whole document. */
29
+ evidence(): CountryEvidence[];
30
+ get size(): number;
31
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Country evidence shared across the chunks of one document.
3
+ *
4
+ * A long document is usually redacted in pieces. Each piece is scanned
5
+ * independently, so each infers its own country — and a chunk that happens to
6
+ * contain no IBAN, no `+CC` number and no ccTLD infers nothing at all, even
7
+ * when the first page identified the document beyond doubt:
8
+ *
9
+ * page 1 "Factuur — IBAN NL91ABNA0417164300, info@jansen.nl"
10
+ * page 7 "Telefoon 0612345678" <- Dutch mobile, or Danish CPR?
11
+ *
12
+ * Scanned alone, page 7 has nothing to go on. Sharing a context across the two
13
+ * carries page 1's evidence forward, and the number resolves as a Dutch phone.
14
+ *
15
+ * Spans are rebased by `chunkOffset` on the way in, so evidence collected from
16
+ * a chunk points at offsets in the whole document rather than the chunk.
17
+ *
18
+ * A context influences **scoring only**. It can change which national scheme is
19
+ * attributed to a value. It can never change which spans are found, so a
20
+ * context carried over from the wrong document cannot cause a miss.
21
+ */
22
+ export class DocumentContext {
23
+ // Deduped on the same key evidence.collect() uses, so re-running a chunk — a
24
+ // retry, an overlapping window — cannot let one entity vote twice and
25
+ // outweigh the rest of the document.
26
+ seen = new Set();
27
+ items = [];
28
+ /** Record evidence from a chunk starting at `chunkOffset`. */
29
+ add(evidence, chunkOffset = 0) {
30
+ for (const ev of evidence) {
31
+ const span = [ev.span[0] + chunkOffset, ev.span[1] + chunkOffset];
32
+ const key = `${span[0]}:${span[1]}:${ev.source}`;
33
+ if (this.seen.has(key))
34
+ continue;
35
+ this.seen.add(key);
36
+ this.items.push(chunkOffset === 0 ? ev : { ...ev, span });
37
+ }
38
+ }
39
+ /** Everything recorded so far, spans rebased to the whole document. */
40
+ evidence() {
41
+ return [...this.items];
42
+ }
43
+ get size() {
44
+ return this.items.length;
45
+ }
46
+ }
@@ -214,7 +214,13 @@ const DE = {
214
214
  p(EntityType.PHONE, String.raw `\b0[1-9]\d{1,4}[\s/\-]?\d{3,8}\b`),
215
215
  p(EntityType.PHONE, String.raw `\+49\s?\d{2,5}[\s/\-]?\d{3,8}\b`),
216
216
  p(EntityType.PASSPORT, String.raw `\b[CFGHJK][0-9A-Z]{8,9}\b`, null, "", ["Reisepass", "passport", "Passnummer", "Reisepassnummer", "Reisepass Nummer", "Reisepass-Nr", "Pass Nr"], true),
217
- p(EntityType.LICENSE_PLATE, String.raw `\b[A-ZÄÖÜ]{1,3}[\s\-]?[A-Z]{1,2}[\s\-]?\d{1,4}[EH]?\b`),
217
+ // The separator after the city code is mandatory. When it was optional, a
218
+ // contiguous letter run split across both groups and the hyphen was
219
+ // consumed as the *second* separator, so any "LETTERS-DIGITS" token
220
+ // matched: ICD-10 parsed as city "IC" + letters "D" + "-" + "10", and
221
+ // ISO-9001, COVID-19, RFC-2822, POL-2025 likewise. A real plate always
222
+ // separates the city code: "B-AB 1234", "M-XY 99".
223
+ p(EntityType.LICENSE_PLATE, String.raw `\b[A-ZÄÖÜ]{1,3}[\s\-][A-Z]{1,2}[\s\-]?\d{1,4}[EH]?\b`),
218
224
  p(EntityType.POSTAL_CODE, String.raw `\b\d{5}\b`, null, "", ["PLZ", "Postleitzahl", "postal code", "postcode", "Anschrift", "Adresse", "Wohnort", "Ort", "Stadt", "wohnt", "wohnhaft", "Straße", "Str.", "Weg", "Platz", "Allee", "Ring"], true),
219
225
  p(EntityType.CHAMBER_OF_COMMERCE, String.raw `\bHR[AB]\s?\d{4,6}\b`),
220
226
  p(EntityType.SSN, String.raw `\b\d{2}[\s]?\d{6}[\s]?[A-Z][\s]?\d{3}\b`, null, "", ["Rentenversicherung", "RV-Nummer", "Sozialversicherung", "SV-Nummer", "Versicherungsnummer", "Rentenversicherungsnummer", "Sozialversicherungsnummer"], true),
@@ -0,0 +1,16 @@
1
+ /**
2
+ * German vehicle registration district codes (Unterscheidungszeichen).
3
+ *
4
+ * The set is closed: codes are fixed by the Fahrzeug-Zulassungsverordnung and
5
+ * maintained by the Kraftfahrt-Bundesamt, changing only when a district is
6
+ * created, merged, or a historic code is reinstated. That makes membership a
7
+ * usable precision signal — unlike the open-ended blocklist of standards
8
+ * prefixes it replaces, which only ever catches acronyms someone thought of.
9
+ *
10
+ * Used as a **tier**, not a filter: code on this list -> emit; code absent but
11
+ * a plate cue nearby -> emit; neither -> reject. So a missing code costs
12
+ * recall only where there is also no cue. Some entries are also standards
13
+ * prefixes — DIN is Dinslaken as well as the standards body — which is why the
14
+ * standards guard is kept alongside this one.
15
+ */
16
+ export declare const DE_DISTRICT_CODES: ReadonlySet<string>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * German vehicle registration district codes (Unterscheidungszeichen).
3
+ *
4
+ * The set is closed: codes are fixed by the Fahrzeug-Zulassungsverordnung and
5
+ * maintained by the Kraftfahrt-Bundesamt, changing only when a district is
6
+ * created, merged, or a historic code is reinstated. That makes membership a
7
+ * usable precision signal — unlike the open-ended blocklist of standards
8
+ * prefixes it replaces, which only ever catches acronyms someone thought of.
9
+ *
10
+ * Used as a **tier**, not a filter: code on this list -> emit; code absent but
11
+ * a plate cue nearby -> emit; neither -> reject. So a missing code costs
12
+ * recall only where there is also no cue. Some entries are also standards
13
+ * prefixes — DIN is Dinslaken as well as the standards body — which is why the
14
+ * standards guard is kept alongside this one.
15
+ */
16
+ export const DE_DISTRICT_CODES = new Set([
17
+ "A", "AA", "AB", "ABG", "AC", "AE", "AH", "AIB", "AIC", "AK", "AL", "ALF",
18
+ "ALS", "ALZ", "AM", "AN", "ANA", "ANG", "ANK", "AP", "APD", "AR", "ARN",
19
+ "ART", "AS", "ASD", "ASL", "ASZ", "AT", "AU", "AUR", "AW", "AZ", "AZE",
20
+ "AÖ", "B", "BA", "BAD", "BAR", "BB", "BBG", "BBL", "BC", "BCH", "BD",
21
+ "BE", "BED", "BEI", "BEL", "BER", "BF", "BG", "BGD", "BGL", "BH", "BI",
22
+ "BID", "BIN", "BIR", "BIT", "BIW", "BK", "BKS", "BL", "BLB", "BLK", "BM",
23
+ "BN", "BNA", "BO", "BOG", "BOH", "BOR", "BOT", "BP", "BR", "BRA", "BRB",
24
+ "BRG", "BRI", "BRK", "BRL", "BRV", "BS", "BSB", "BSK", "BT", "BTF", "BU",
25
+ "BUL", "BUR", "BW", "BWL", "BYL", "BZ", "BZA", "BÖ", "BÜD", "BÜS", "BÜZ",
26
+ "C", "CA", "CAS", "CB", "CE", "CHA", "CHL", "CLP", "CLZ", "CO", "COC",
27
+ "COE", "CR", "CT", "CUX", "CW", "D", "DA", "DAH", "DAN", "DAU", "DB",
28
+ "DBR", "DD", "DE", "DEG", "DEL", "DG", "DGF", "DH", "DI", "DIL", "DIN",
29
+ "DIZ", "DKB", "DL", "DLG", "DM", "DN", "DO", "DON", "DS", "DT", "DU",
30
+ "DUD", "DW", "DZ", "DÜW", "E", "EA", "EB", "EBE", "EBN", "EBS", "ECK",
31
+ "ED", "EE", "EF", "EG", "EH", "EHI", "EI", "EIC", "EIH", "EIL", "EIN",
32
+ "EIS", "EL", "EM", "EMD", "EMS", "EN", "ER", "ERB", "ERH", "ERK", "ES",
33
+ "ESA", "ESB", "ESW", "EU", "EUT", "EW", "F", "FAL", "FB", "FD", "FDB",
34
+ "FDS", "FEU", "FF", "FFB", "FG", "FH", "FI", "FKB", "FL", "FLÖ", "FN",
35
+ "FO", "FOR", "FR", "FRG", "FRI", "FRW", "FS", "FT", "FTL", "FW", "FZ",
36
+ "FÜ", "FÜS", "G", "GA", "GAN", "GAP", "GC", "GD", "GDB", "GE", "GEL",
37
+ "GEM", "GEO", "GER", "GF", "GG", "GHA", "GHC", "GI", "GK", "GL", "GLA",
38
+ "GM", "GMN", "GN", "GNT", "GOA", "GOH", "GP", "GR", "GRA", "GRH", "GRI",
39
+ "GRM", "GRS", "GRZ", "GS", "GT", "GTH", "GUB", "GUN", "GV", "GVM", "GW",
40
+ "GZ", "GÖ", "GÜ", "H", "HA", "HAB", "HAL", "HAM", "HAS", "HB", "HBN",
41
+ "HBS", "HC", "HCH", "HD", "HDH", "HDL", "HE", "HEB", "HEF", "HEI", "HEL",
42
+ "HER", "HET", "HF", "HG", "HGN", "HGW", "HH", "HHM", "HI", "HIG", "HIP",
43
+ "HK", "HL", "HM", "HMU", "HN", "HO", "HOG", "HOH", "HOL", "HOM", "HOR",
44
+ "HOT", "HP", "HR", "HRO", "HS", "HSK", "HST", "HU", "HUS", "HV", "HVL",
45
+ "HW", "HWI", "HX", "HY", "HZ", "HÖS", "HÜN", "IGB", "IK", "IL", "ILL",
46
+ "IN", "IS", "IZ", "J", "JB", "JE", "JEV", "JL", "JÜL", "K", "KA", "KAR",
47
+ "KB", "KC", "KE", "KEH", "KEL", "KEM", "KF", "KG", "KH", "KI", "KIB",
48
+ "KK", "KL", "KLE", "KLT", "KLZ", "KM", "KN", "KO", "KOZ", "KR", "KRU",
49
+ "KS", "KT", "KU", "KUS", "KW", "KY", "KYF", "KÖN", "KÖT", "KÜN", "L",
50
+ "LA", "LAN", "LAT", "LAU", "LB", "LBS", "LBZ", "LC", "LD", "LDK", "LDS",
51
+ "LE", "LEO", "LER", "LEV", "LF", "LG", "LH", "LI", "LIB", "LIF", "LIN",
52
+ "LIP", "LK", "LL", "LM", "LN", "LOH", "LOS", "LP", "LR", "LS", "LSA",
53
+ "LSN", "LSZ", "LU", "LUK", "LWL", "LÖ", "LÖB", "LÜD", "LÜN", "M", "MA",
54
+ "MAB", "MAI", "MAK", "MAL", "MAR", "MB", "MC", "MD", "ME", "MED", "MEG",
55
+ "MEI", "MEK", "MEL", "MEP", "MER", "MES", "MET", "MG", "MGH", "MGN", "MH",
56
+ "MHL", "MI", "MIL", "MK", "MKK", "ML", "MM", "MN", "MO", "MOD", "MOL",
57
+ "MON", "MOS", "MQ", "MR", "MS", "MSP", "MST", "MT", "MTK", "MTL", "MVL",
58
+ "MW", "MY", "MYK", "MZ", "MZG", "MÜ", "MÜB", "MÜL", "MÜN", "MÜR", "N",
59
+ "NAB", "NAI", "NAU", "NB", "ND", "NDH", "NE", "NEA", "NEB", "NEC", "NEN",
60
+ "NES", "NEU", "NEW", "NF", "NH", "NI", "NIB", "NK", "NL", "NM", "NMB",
61
+ "NMS", "NOH", "NOL", "NOM", "NOR", "NP", "NR", "NRW", "NRÜ", "NT", "NU",
62
+ "NVP", "NW", "NWM", "NY", "NZ", "NÖ", "OA", "OAL", "OB", "OBB", "OBG",
63
+ "OC", "OCH", "OD", "OE", "OF", "OG", "OH", "OHA", "OHV", "OHZ", "OK",
64
+ "OL", "OLD", "OP", "OPR", "OR", "OS", "OSL", "OTT", "OTW", "OVI", "OVL",
65
+ "OVP", "OZ", "P", "PA", "PAF", "PAN", "PAR", "PB", "PCH", "PE", "PEG",
66
+ "PER", "PF", "PI", "PIR", "PK", "PL", "PLÖ", "PM", "PN", "PR", "PRÜ",
67
+ "PS", "PW", "PZ", "QFT", "QLB", "R", "RA", "RC", "RD", "RDG", "RE", "REG",
68
+ "REH", "REI", "RG", "RH", "RI", "RID", "RIE", "RL", "RM", "RN", "RO",
69
+ "ROD", "ROF", "ROH", "ROK", "ROL", "ROS", "ROT", "ROW", "RPL", "RS",
70
+ "RSL", "RT", "RU", "RV", "RW", "RWL", "RY", "RZ", "RÜD", "RÜG", "S", "SA",
71
+ "SAB", "SAD", "SAL", "SAN", "SAO", "SAW", "SB", "SBG", "SBK", "SC", "SCZ",
72
+ "SDH", "SDL", "SDT", "SE", "SEB", "SEE", "SEF", "SEL", "SF", "SFA", "SFB",
73
+ "SFT", "SG", "SGH", "SH", "SHA", "SHG", "SHK", "SHL", "SHS", "SI", "SIG",
74
+ "SIM", "SK", "SL", "SLE", "SLF", "SLG", "SLK", "SLN", "SLS", "SLZ", "SLÜ",
75
+ "SM", "SMÜ", "SN", "SNH", "SO", "SOB", "SOG", "SOK", "SOL", "SON", "SP",
76
+ "SPB", "SPN", "SPR", "SR", "SRB", "SRO", "ST", "STA", "STB", "STD", "STE",
77
+ "STH", "STL", "STO", "SU", "SUL", "SW", "SWA", "SWZ", "SY", "SZ", "SZB",
78
+ "SÄK", "SÖM", "SÜW", "TBB", "TE", "TF", "TG", "TGO", "THL", "THW", "TIR",
79
+ "TO", "TP", "TR", "TS", "TT", "TUT", "TÖL", "TÖN", "TÜ", "UE", "UEM",
80
+ "UER", "UFF", "UH", "UL", "UM", "UN", "USI", "V", "VAI", "VB", "VEC",
81
+ "VER", "VIB", "VIE", "VIT", "VK", "VL", "VOF", "VOH", "VR", "VS", "W",
82
+ "WA", "WAF", "WAK", "WAM", "WAN", "WAR", "WAT", "WB", "WBS", "WD", "WDA",
83
+ "WE", "WEB", "WEG", "WEI", "WEL", "WEM", "WEN", "WER", "WES", "WF", "WG",
84
+ "WHV", "WI", "WIL", "WIS", "WIT", "WIZ", "WK", "WL", "WLG", "WM", "WMS",
85
+ "WN", "WND", "WO", "WOB", "WOH", "WOL", "WOR", "WOS", "WR", "WRN", "WS",
86
+ "WSF", "WST", "WSW", "WT", "WTL", "WTM", "WUG", "WUM", "WUN", "WUR", "WW",
87
+ "WZ", "WZL", "WÜ", "X", "Y", "Z", "ZE", "ZEL", "ZI", "ZIG", "ZP", "ZR",
88
+ "ZS", "ZW", "ZZ", "ÖHR", "ÜB",
89
+ ]);
@@ -1,4 +1,5 @@
1
- import { type Detection } from "../types.js";
1
+ import { type CountryEvidence, type Detection } from "../types.js";
2
+ import { type RawMatch } from "./matchers.js";
2
3
  /**
3
4
  * The country modules use the EU/VAT convention (UK, EL) while the public
4
5
  * contract is ISO 3166-1 alpha-2 (GB, GR). Accept both spellings so a caller
@@ -12,11 +13,34 @@ export declare class RuleEngine {
12
13
  private loadedCountries;
13
14
  addCustomPattern(name: string, pattern: string): void;
14
15
  loadCountries(countryCodes: string[] | null): void;
15
- detect(text: string, countryCodes: string[] | null): Detection[];
16
+ /**
17
+ * Generate candidate matches. Country-blind, by contract.
18
+ *
19
+ * This is the only caller of {@link MultiPatternMatcher.scan}, and it takes
20
+ * no country argument. Which country the caller declared may influence how a
21
+ * match is *scored*; it may never influence whether the match is *found*.
22
+ * Before this held, `countries: ["BE"]` made a valid Dutch BSN vanish
23
+ * entirely because the Dutch patterns were never run.
24
+ */
25
+ generateCandidates(text: string): RawMatch[];
26
+ detect(text: string, countryCodes: string[] | null, countryHint?: string[] | null): Detection[];
27
+ /**
28
+ * {@link RuleEngine.detect}, plus the country evidence it reasoned from.
29
+ *
30
+ * `countryCodes` is **scope**: it flags detections from elsewhere as
31
+ * `outOfScope` and, like `countryHint`, contributes a prior. `countryHint` is
32
+ * **only** a prior — it helps resolve ambiguity without marking anything out
33
+ * of scope. Neither gates what is looked for.
34
+ */
35
+ detectWithEvidence(text: string, countryCodes: string[] | null, countryHint?: string[] | null, priorEvidence?: CountryEvidence[] | null): {
36
+ detections: Detection[];
37
+ evidence: CountryEvidence[];
38
+ scores: Map<string, number>;
39
+ };
16
40
  /**
17
41
  * Remove overlapping detections with priority-aware resolution.
18
- * Priority: validated (3) > custom (2) > regex-only (1).
19
- * Within the same tier, longer span wins.
42
+ * Priority: validated (3) > custom (2) > regex-only (1) > postal (0) >
43
+ * demoted (-1). Within a tier, longer span wins, then country evidence.
20
44
  */
21
45
  private deduplicate;
22
46
  getLoadedCountries(): Set<string>;