euredact 0.3.1 → 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.
@@ -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
+ }
@@ -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>;
@@ -3,6 +3,7 @@ import { MultiPatternMatcher } from "./matchers.js";
3
3
  import { shouldSuppress } from "./suppressors.js";
4
4
  import { detectStructuralDob } from "./structural.js";
5
5
  import { COUNTRY_CONFIGS } from "./countries/index.js";
6
+ import * as evidenceMod from "./evidence.js";
6
7
  /**
7
8
  * Heuristic to detect nested quantifiers that cause catastrophic backtracking.
8
9
  * After stripping escaped chars and character classes, look for an unbounded
@@ -100,118 +101,248 @@ export class RuleEngine {
100
101
  if (newCountries)
101
102
  this.matcher.compile();
102
103
  }
103
- detect(text, countryCodes) {
104
- this.loadCountries(countryCodes);
105
- // Scope the scan to requested countries for efficiency
106
- // Resolve through the alias table so `countries: ["GB"]` actually scans
107
- // the patterns registered under "UK".
108
- let scanCodes = null;
104
+ /**
105
+ * Generate candidate matches. Country-blind, by contract.
106
+ *
107
+ * This is the only caller of {@link MultiPatternMatcher.scan}, and it takes
108
+ * no country argument. Which country the caller declared may influence how a
109
+ * match is *scored*; it may never influence whether the match is *found*.
110
+ * Before this held, `countries: ["BE"]` made a valid Dutch BSN vanish
111
+ * entirely because the Dutch patterns were never run.
112
+ */
113
+ generateCandidates(text) {
114
+ this.loadCountries(null);
115
+ return this.matcher.scan(text, null);
116
+ }
117
+ detect(text, countryCodes, countryHint) {
118
+ return this.detectWithEvidence(text, countryCodes, countryHint).detections;
119
+ }
120
+ /**
121
+ * {@link RuleEngine.detect}, plus the country evidence it reasoned from.
122
+ *
123
+ * `countryCodes` is **scope**: it flags detections from elsewhere as
124
+ * `outOfScope` and, like `countryHint`, contributes a prior. `countryHint` is
125
+ * **only** a prior — it helps resolve ambiguity without marking anything out
126
+ * of scope. Neither gates what is looked for.
127
+ */
128
+ detectWithEvidence(text, countryCodes, countryHint, priorEvidence) {
129
+ const rawMatches = this.generateCandidates(text);
130
+ // The declared countries are a scoring signal from here on: they rank
131
+ // candidates and flag out-of-scope detections. They do not gate.
132
+ let declared = null;
109
133
  if (countryCodes !== null) {
110
- scanCodes = new Set();
134
+ declared = new Set();
111
135
  for (const c of countryCodes) {
112
136
  const code = resolveCountryCode(c);
113
- if (code !== null)
114
- scanCodes.add(code);
137
+ if (code === null)
138
+ warnUnknownCountry(c);
139
+ else
140
+ declared.add(code);
115
141
  }
116
- scanCodes.add("SHARED");
117
- scanCodes.add("CUSTOM");
142
+ declared.add("SHARED");
143
+ declared.add("CUSTOM");
144
+ }
145
+ // The prior fed to the evidence bus. Declared countries count too — a
146
+ // caller naming a country is asserting context, which is exactly what a
147
+ // prior is — but a hint never narrows scope, so it is kept out of
148
+ // `declared` and cannot flag anything out of scope.
149
+ const prior = new Set(declared ?? []);
150
+ for (const c of countryHint ?? []) {
151
+ const code = resolveCountryCode(c);
152
+ if (code === null)
153
+ warnUnknownCountry(c);
154
+ else
155
+ prior.add(code);
118
156
  }
119
- let rawMatches = this.matcher.scan(text, scanCodes);
120
- // Matches that have a validator but fail it create suppression zones:
121
- // the span is recognisably a specific entity (e.g. IBAN-shaped) so
122
- // overlapping regex-only matches (license plate, phone) are false
123
- // positives and must be suppressed.
157
+ // Pass 1: checksum validation. A span that matched a checksummed pattern
158
+ // but failed the checksum is recorded with its country and entity type;
159
+ // what it is allowed to demote is decided below, once the document's
160
+ // countries are known.
124
161
  const validated = [];
125
- const rawZones = [];
162
+ const failedSpans = [];
126
163
  for (const m of rawMatches) {
127
- const isValid = this.matcher.validate(m);
128
- if (isValid) {
164
+ if (this.matcher.validate(m)) {
129
165
  validated.push({ match: m, hasValidValidator: m.patternDef.validator !== null });
130
166
  }
131
167
  else if (m.patternDef.validator !== null && !m.patternDef.requiresContext) {
132
- rawZones.push([m.start, m.end]);
168
+ failedSpans.push({ start: m.start, end: m.end, code: m.countryCode,
169
+ etype: String(m.patternDef.entityType) });
133
170
  }
134
171
  }
135
- // Merge overlapping suppression zones for O(log n) containment checks
136
- let zoneStarts = [];
137
- let zoneEnds = [];
138
- if (rawZones.length > 0) {
139
- rawZones.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
140
- let [ms, me] = rawZones[0];
141
- for (let i = 1; i < rawZones.length; i++) {
142
- const [zs, ze] = rawZones[i];
143
- if (zs <= me) {
172
+ // Evidence pass: work out which countries this document belongs to, from
173
+ // the entities that carry their country in the string.
174
+ //
175
+ // Every surviving candidate is eligible, not only checksum-backed ones. An
176
+ // earlier version required a validator here, which silently dropped the
177
+ // email ccTLD — the second-strongest signal at 4.50 log-odds and present in
178
+ // 98,949 of 152,300 corpus documents — because EMAIL has nothing to
179
+ // checksum. Candidates that failed a validator never reach `validated`, so
180
+ // this admits the unvalidatable, not the invalid.
181
+ const evidenceDets = [];
182
+ for (const { match } of validated) {
183
+ if (!evidenceMod.EVIDENCE_TYPES.has(String(match.patternDef.entityType)))
184
+ continue;
185
+ evidenceDets.push({
186
+ entityType: match.patternDef.entityType,
187
+ start: match.start, end: match.end, text: match.text,
188
+ source: DetectionSource.RULES,
189
+ country: (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
190
+ ? match.countryCode : null,
191
+ confidence: "high",
192
+ });
193
+ }
194
+ const countryEvidence = evidenceMod.collect(evidenceDets);
195
+ const countryScores = evidenceMod.accumulate(priorEvidence && priorEvidence.length ? [...countryEvidence, ...priorEvidence] : countryEvidence, prior);
196
+ const hasScores = countryScores.size > 0;
197
+ const corroborated = (code) => !hasScores || code === "SHARED" || code === "CUSTOM" || countryScores.has(code);
198
+ // Now that the document's countries are known, decide which failed
199
+ // validators are entitled to demote anything. A checksum failure only means
200
+ // "not a valid X"; it says nothing about the span unless X was plausible
201
+ // here in the first place. A Belgian national-ID pattern failing on
202
+ // 0612345678 is not evidence against that span being a Dutch phone number.
203
+ //
204
+ // Zones are also per entity type. A failed checksum is evidence against
205
+ // *that type*, not against the span: Sweden's own personnummer checksum
206
+ // failing on 0708787668 used to demote the Swedish *phone* candidate for
207
+ // the same digits, handing the span to a Danish CPR that happened to
208
+ // validate. Worth 878 mistyped phone numbers per 30,000 documents.
209
+ const zonesByType = new Map();
210
+ for (const f of failedSpans) {
211
+ if (!corroborated(f.code))
212
+ continue;
213
+ const list = zonesByType.get(f.etype);
214
+ if (list)
215
+ list.push([f.start, f.end]);
216
+ else
217
+ zonesByType.set(f.etype, [[f.start, f.end]]);
218
+ }
219
+ const mergedZones = new Map();
220
+ for (const [etype, spans] of zonesByType) {
221
+ spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
222
+ const starts = [];
223
+ const ends = [];
224
+ let [ms, me] = spans[0];
225
+ for (let i = 1; i < spans.length; i++) {
226
+ const [zs, ze] = spans[i];
227
+ if (zs <= me)
144
228
  me = Math.max(me, ze);
145
- }
146
229
  else {
147
- zoneStarts.push(ms);
148
- zoneEnds.push(me);
230
+ starts.push(ms);
231
+ ends.push(me);
149
232
  ms = zs;
150
233
  me = ze;
151
234
  }
152
235
  }
153
- zoneStarts.push(ms);
154
- zoneEnds.push(me);
236
+ starts.push(ms);
237
+ ends.push(me);
238
+ mergedZones.set(etype, { starts, ends });
155
239
  }
156
- // Build candidates with priority: validated (3) > custom (2) > regex-only (1)
157
240
  const candidates = [];
158
241
  for (const { match, hasValidValidator } of validated) {
159
242
  if (shouldSuppress(text, match))
160
243
  continue;
161
- // O(log n) binary-search containment check on merged zones
162
- if (match.patternDef.validator === null && zoneStarts.length > 0) {
163
- let lo = 0, hi = zoneStarts.length;
164
- while (lo < hi) {
165
- const mid = (lo + hi) >>> 1;
166
- if (zoneStarts[mid] <= match.start)
167
- lo = mid + 1;
168
- else
169
- hi = mid;
244
+ // A validator-less match inside a failed-validation span of its own type
245
+ // is demoted below every other candidate rather than deleted. Demotion
246
+ // cannot silence a detection, which is the property deletion lacked:
247
+ // measured across the corpus, deletion removed 454 detections of which
248
+ // 454 overlapped real labelled PII.
249
+ let demoted = false;
250
+ if (match.patternDef.validator === null) {
251
+ const zone = mergedZones.get(String(match.patternDef.entityType));
252
+ if (zone) {
253
+ let lo = 0, hi = zone.starts.length;
254
+ while (lo < hi) {
255
+ const mid = (lo + hi) >>> 1;
256
+ if (zone.starts[mid] <= match.start)
257
+ lo = mid + 1;
258
+ else
259
+ hi = mid;
260
+ }
261
+ const idx = lo - 1;
262
+ demoted = idx >= 0 && zone.ends[idx] >= match.end;
170
263
  }
171
- const idx = lo - 1;
172
- if (idx >= 0 && zoneEnds[idx] >= match.end)
173
- continue;
174
264
  }
175
- // A bare digit run is the weakest evidence in the engine and must never
176
- // re-cut a span a structured detector claims (PHONE, SSN, NATIONAL_ID,
177
- // BANK_ACCOUNT, VAT...). Postal codes take unclaimed spans only,
178
- // whatever the span lengths.
179
- const priority = hasValidValidator
180
- ? 3
181
- : match.countryCode === "CUSTOM"
182
- ? 2
183
- : match.patternDef.entityType === EntityType.POSTAL_CODE
184
- ? 0
185
- : 1;
186
- const spanLength = match.end - match.start;
187
- const score = priority * 1_000_000 + spanLength;
265
+ let priority;
266
+ if (demoted) {
267
+ priority = -1;
268
+ }
269
+ else if (hasValidValidator) {
270
+ // A passing checksum normally outranks everything. But a checksum only
271
+ // says the digits fit *some* national scheme, and weak ones fit by luck
272
+ // — a mod-11 scheme accepts a random number about one time in eleven.
273
+ // So a validated candidate from a country the document shows no trace
274
+ // of is treated as coincidence, not evidence.
275
+ //
276
+ // Concretely: the Dutch phone number 0612345678 also passes the Danish
277
+ // CPR checksum. Entities that carry their own country vouch for
278
+ // themselves — an IBAN emits evidence for its own country — so a
279
+ // foreign IBAN in a domestic invoice keeps its rank.
280
+ priority = corroborated(match.countryCode) ? 3 : 1;
281
+ }
282
+ else if (match.countryCode === "CUSTOM") {
283
+ priority = 2;
284
+ }
285
+ else if (match.patternDef.entityType === EntityType.POSTAL_CODE) {
286
+ // A bare digit run is the weakest evidence in the engine and must never
287
+ // re-cut a span a structured detector claims (PHONE, SSN, NATIONAL_ID,
288
+ // BANK_ACCOUNT, VAT...). Postal codes take unclaimed spans only,
289
+ // whatever the span lengths.
290
+ priority = 0;
291
+ }
292
+ else {
293
+ priority = 1;
294
+ }
295
+ const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
296
+ ? match.countryCode : null;
188
297
  candidates.push({
298
+ priority,
299
+ length: match.end - match.start,
300
+ countryScore: countryScores.get(match.countryCode) ?? 0,
301
+ inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
189
302
  det: {
190
303
  entityType: match.patternDef.entityType,
191
304
  start: match.start,
192
305
  end: match.end,
193
306
  text: match.text,
194
307
  source: DetectionSource.RULES,
195
- country: (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM") ? match.countryCode : null,
308
+ country,
196
309
  confidence: "high",
310
+ outOfScope: declared !== null && country !== null && !declared.has(match.countryCode),
197
311
  },
198
- score,
199
312
  });
200
313
  }
201
314
  for (const d of detectStructuralDob(text)) {
202
- candidates.push({ det: d, score: 1_000_000 + (d.end - d.start) });
315
+ candidates.push({ priority: 1, length: d.end - d.start, countryScore: 0, inScope: 1, det: d });
316
+ }
317
+ const detections = this.deduplicate(candidates);
318
+ detections.sort((a, b) => a.start - b.start || b.end - a.end);
319
+ // Attach the confidence behind each country attribution, so a caller can
320
+ // tell "Danish, and the document is plainly Danish" from "Danish, on a
321
+ // checksum alone".
322
+ const ranking = evidenceMod.weightsToRanking(countryScores);
323
+ for (const d of detections) {
324
+ d.countryConfidence = (d.country !== null ? ranking.get(d.country) : undefined) ?? 0;
203
325
  }
204
- return this.deduplicate(candidates);
326
+ return { detections, evidence: countryEvidence, scores: countryScores };
205
327
  }
206
328
  /**
207
329
  * Remove overlapping detections with priority-aware resolution.
208
- * Priority: validated (3) > custom (2) > regex-only (1).
209
- * Within the same tier, longer span wins.
330
+ * Priority: validated (3) > custom (2) > regex-only (1) > postal (0) >
331
+ * demoted (-1). Within a tier, longer span wins, then country evidence.
210
332
  */
211
333
  deduplicate(candidates) {
212
334
  if (candidates.length === 0)
213
335
  return [];
214
- const sorted = [...candidates].sort((a, b) => b.score - a.score);
336
+ // Span length outranks scope: preferring the declared country over the
337
+ // longest match can truncate an entity. Measured: with countries ["BE"],
338
+ // the Belgian phone pattern claims 11 of the 14 characters of
339
+ // "06 12 34 56 78" and leaves three digits exposed. Scope only breaks ties
340
+ // between candidates of equal length, so it can change WHICH country is
341
+ // attributed, never WHAT is masked.
342
+ const sorted = [...candidates].sort((a, b) => b.priority - a.priority ||
343
+ b.length - a.length ||
344
+ b.countryScore - a.countryScore ||
345
+ b.inScope - a.inScope);
215
346
  const result = [];
216
347
  const occupied = new Set();
217
348
  for (const { det } of sorted) {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Country evidence: what a document says about which country it belongs to.
3
+ *
4
+ * Some entities carry their country in the string — an IBAN's country code, a
5
+ * `+CC` dialling prefix, a VAT prefix, an email ccTLD. Those are used to work
6
+ * out which country a document belongs to, which in turn decides which
7
+ * national scheme owns an ambiguous value elsewhere in the same document.
8
+ * 36.6% of national-ID values in the corpus validate under more than one
9
+ * country's checksum, so without this the engine cannot tell a Dutch BSN from
10
+ * a Polish PESEL.
11
+ *
12
+ * Evidence influences **scoring only**. It ranks candidates that already exist
13
+ * and annotates the result; it never decides what is looked for. A wrong
14
+ * inference must not be able to cause a miss.
15
+ *
16
+ * Weights are derived, not chosen — measured as P(document country = X |
17
+ * signal claims X) across the corpus. Kept identical to the Python SDK's
18
+ * `rules/evidence.py`; re-derive there and port, rather than tuning by feel.
19
+ */
20
+ import { type CountryEvidence, type Detection } from "../types.js";
21
+ export declare const WEIGHTS: Record<string, number>;
22
+ /**
23
+ * E.164 calling code -> country, for the countries this engine supports. Codes
24
+ * are unique within this set; a shared code (+1, +7) would have to emit
25
+ * evidence for every candidate country rather than one.
26
+ */
27
+ export declare const DIALLING_CODES: Record<string, string>;
28
+ /**
29
+ * ccTLD -> country. Deliberately excludes .eu/.com/.org/.net: those are not
30
+ * weak evidence, they are no evidence, and treating them as weak would let a
31
+ * .com address outvote a genuine national signal.
32
+ */
33
+ export declare const CC_TLDS: Record<string, string>;
34
+ /** Entity types that can carry country evidence. */
35
+ export declare const EVIDENCE_TYPES: ReadonlySet<string>;
36
+ /**
37
+ * Country evidence carried by *detections*, in document order.
38
+ *
39
+ * Deduplicated by (span, source): several countries' patterns can match the
40
+ * same IBAN, and counting that span once per pattern would let a single entity
41
+ * outvote the rest of the document.
42
+ */
43
+ export declare function collect(detections: Detection[]): CountryEvidence[];
44
+ /**
45
+ * Sum evidence into a per-country log-odds score.
46
+ *
47
+ * Returns a weighted set, never a single winner: a Belgian supplier invoicing a
48
+ * German customer is not "70% Belgian", both countries are genuinely present,
49
+ * and an ambiguous value must be resolvable against either.
50
+ */
51
+ export declare function accumulate(evidence: CountryEvidence[], declared?: Iterable<string> | null): Map<string, number>;
52
+ /**
53
+ * Convert per-country log-odds into confidences in [0, 1].
54
+ *
55
+ * Each country is scored independently — a logistic on its own log-odds —
56
+ * because document countries are not mutually exclusive. A Belgian supplier
57
+ * invoicing a German customer is genuinely both; softmaxing across countries
58
+ * would report the German side of that invoice at 0.00 and make cross-border
59
+ * documents indistinguishable from misattributions.
60
+ *
61
+ * Confidences therefore do not sum to 1, by design.
62
+ */
63
+ export declare function weightsToRanking(scores: Map<string, number>): Map<string, number>;