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.
@@ -0,0 +1,195 @@
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 { EntityType } from "../types.js";
21
+ // ── Derived weights ─────────────────────────────────────────────────────
22
+ //
23
+ // Measured on 152,300 documents:
24
+ //
25
+ // signal agree total P(doc=X|signal) raw log-odds
26
+ // e164Prefix 41,402 41,402 0.9999 9.21
27
+ // bicCountry 2,588 2,588 0.9999 9.21
28
+ // emailTld 97,865 98,949 0.9890 4.50
29
+ // vatPrefix 19,022 20,136 0.9447 2.84
30
+ // ibanPrefix 110,572 126,428 0.8746 1.94
31
+ //
32
+ // The IBAN prefix being the *weakest* signal is real and worth knowing: a
33
+ // Belgian IBAN in a Dutch invoice is ordinary, so the account's country is only
34
+ // weak evidence about the document's.
35
+ //
36
+ // The perfect scores are capped. They are perfect because the generator emits
37
+ // country-consistent values, not because a foreign mobile number never appears
38
+ // in a real document. Treating a synthetic 1.0 as certainty would make a single
39
+ // phone number unfalsifiable by any amount of contrary evidence.
40
+ const MAX_LOG_ODDS = 4.0;
41
+ export const WEIGHTS = {
42
+ e164Prefix: Math.min(9.21, MAX_LOG_ODDS),
43
+ bicCountry: Math.min(9.21, MAX_LOG_ODDS),
44
+ emailTld: Math.min(4.5, MAX_LOG_ODDS),
45
+ vatPrefix: 2.84,
46
+ ibanPrefix: 1.94,
47
+ declared: 2.0, // the caller's `countries`, a prior rather than a measurement
48
+ };
49
+ // ── Signal tables ───────────────────────────────────────────────────────
50
+ /**
51
+ * E.164 calling code -> country, for the countries this engine supports. Codes
52
+ * are unique within this set; a shared code (+1, +7) would have to emit
53
+ * evidence for every candidate country rather than one.
54
+ */
55
+ export const DIALLING_CODES = {
56
+ "43": "AT", "32": "BE", "359": "BG", "41": "CH", "357": "CY", "420": "CZ",
57
+ "49": "DE", "45": "DK", "372": "EE", "30": "EL", "34": "ES", "358": "FI",
58
+ "33": "FR", "385": "HR", "36": "HU", "353": "IE", "354": "IS", "39": "IT",
59
+ "370": "LT", "352": "LU", "371": "LV", "356": "MT", "31": "NL", "47": "NO",
60
+ "48": "PL", "351": "PT", "40": "RO", "46": "SE", "386": "SI", "421": "SK",
61
+ "44": "UK",
62
+ };
63
+ /**
64
+ * ccTLD -> country. Deliberately excludes .eu/.com/.org/.net: those are not
65
+ * weak evidence, they are no evidence, and treating them as weak would let a
66
+ * .com address outvote a genuine national signal.
67
+ */
68
+ export const CC_TLDS = {
69
+ at: "AT", be: "BE", bg: "BG", ch: "CH", cy: "CY", cz: "CZ",
70
+ de: "DE", dk: "DK", ee: "EE", gr: "EL", es: "ES", fi: "FI",
71
+ fr: "FR", hr: "HR", hu: "HU", ie: "IE", is: "IS", it: "IT",
72
+ lt: "LT", lu: "LU", lv: "LV", mt: "MT", nl: "NL", no: "NO",
73
+ pl: "PL", pt: "PT", ro: "RO", se: "SE", si: "SI", sk: "SK",
74
+ uk: "UK",
75
+ };
76
+ /** VAT prefixes that are not the ISO code: Greece trades as EL, Northern
77
+ * Ireland as XI. Naive alpha-2 handling gets both wrong. */
78
+ const VAT_PREFIX_OVERRIDES = { EL: "EL", XI: "UK" };
79
+ const emitIban = (det) => {
80
+ const code = det.text.slice(0, 2).toUpperCase();
81
+ if (!/^[A-Z]{2}$/.test(code))
82
+ return [];
83
+ return [{ country: code, source: "ibanPrefix", logOdds: WEIGHTS.ibanPrefix,
84
+ span: [det.start, det.end] }];
85
+ };
86
+ const emitE164 = (det) => {
87
+ if (!det.text.trimStart().startsWith("+"))
88
+ return [];
89
+ const digits = det.text.replace(/\D/g, "");
90
+ for (const len of [3, 2]) {
91
+ const country = DIALLING_CODES[digits.slice(0, len)];
92
+ if (country) {
93
+ return [{ country, source: "e164Prefix", logOdds: WEIGHTS.e164Prefix,
94
+ span: [det.start, det.end] }];
95
+ }
96
+ }
97
+ return [];
98
+ };
99
+ const emitVat = (det) => {
100
+ const prefix = det.text.slice(0, 2).toUpperCase();
101
+ if (!/^[A-Z]{2}$/.test(prefix))
102
+ return [];
103
+ const country = VAT_PREFIX_OVERRIDES[prefix] ?? prefix;
104
+ return [{ country, source: "vatPrefix", logOdds: WEIGHTS.vatPrefix,
105
+ span: [det.start, det.end] }];
106
+ };
107
+ const emitBic = (det) => {
108
+ const clean = det.text.replace(/ /g, "").toUpperCase();
109
+ if (clean.length < 6)
110
+ return [];
111
+ return [{ country: clean.slice(4, 6), source: "bicCountry",
112
+ logOdds: WEIGHTS.bicCountry, span: [det.start, det.end] }];
113
+ };
114
+ const emitTld = (det) => {
115
+ const parts = det.text.split(".");
116
+ const country = CC_TLDS[parts[parts.length - 1].toLowerCase()];
117
+ if (!country)
118
+ return [];
119
+ return [{ country, source: "emailTld", logOdds: WEIGHTS.emailTld,
120
+ span: [det.start, det.end] }];
121
+ };
122
+ /** Dispatch by entity type, mirroring the suppressor tables. */
123
+ const TYPE_EMITTERS = {
124
+ [EntityType.BANK_ACCOUNT]: [emitIban],
125
+ [EntityType.PHONE]: [emitE164],
126
+ [EntityType.VAT]: [emitVat],
127
+ [EntityType.BIC]: [emitBic],
128
+ [EntityType.EMAIL]: [emitTld],
129
+ };
130
+ /** Entity types that can carry country evidence. */
131
+ export const EVIDENCE_TYPES = new Set(Object.keys(TYPE_EMITTERS));
132
+ /**
133
+ * Country evidence carried by *detections*, in document order.
134
+ *
135
+ * Deduplicated by (span, source): several countries' patterns can match the
136
+ * same IBAN, and counting that span once per pattern would let a single entity
137
+ * outvote the rest of the document.
138
+ */
139
+ export function collect(detections) {
140
+ const evidence = [];
141
+ const seen = new Set();
142
+ for (const det of detections) {
143
+ for (const emit of TYPE_EMITTERS[det.entityType] ?? []) {
144
+ for (const ev of emit(det)) {
145
+ const key = `${ev.span[0]}:${ev.span[1]}:${ev.source}`;
146
+ if (!seen.has(key)) {
147
+ seen.add(key);
148
+ evidence.push(ev);
149
+ }
150
+ }
151
+ }
152
+ }
153
+ // Document order, not pattern-registration order: this list is an audit
154
+ // trail, and a reader checking it against the text should not have to jump
155
+ // around. Also keeps the two SDKs byte-comparable.
156
+ evidence.sort((a, b) => a.span[0] - b.span[0] || a.span[1] - b.span[1]);
157
+ return evidence;
158
+ }
159
+ /**
160
+ * Sum evidence into a per-country log-odds score.
161
+ *
162
+ * Returns a weighted set, never a single winner: a Belgian supplier invoicing a
163
+ * German customer is not "70% Belgian", both countries are genuinely present,
164
+ * and an ambiguous value must be resolvable against either.
165
+ */
166
+ export function accumulate(evidence, declared) {
167
+ const scores = new Map();
168
+ for (const ev of evidence) {
169
+ scores.set(ev.country, (scores.get(ev.country) ?? 0) + ev.logOdds);
170
+ }
171
+ for (const country of declared ?? []) {
172
+ if (country !== "SHARED" && country !== "CUSTOM") {
173
+ scores.set(country, (scores.get(country) ?? 0) + WEIGHTS.declared);
174
+ }
175
+ }
176
+ return scores;
177
+ }
178
+ /**
179
+ * Convert per-country log-odds into confidences in [0, 1].
180
+ *
181
+ * Each country is scored independently — a logistic on its own log-odds —
182
+ * because document countries are not mutually exclusive. A Belgian supplier
183
+ * invoicing a German customer is genuinely both; softmaxing across countries
184
+ * would report the German side of that invoice at 0.00 and make cross-border
185
+ * documents indistinguishable from misattributions.
186
+ *
187
+ * Confidences therefore do not sum to 1, by design.
188
+ */
189
+ export function weightsToRanking(scores) {
190
+ const out = new Map();
191
+ for (const [country, score] of scores) {
192
+ out.set(country, 1 / (1 + Math.exp(-score)));
193
+ }
194
+ return out;
195
+ }
@@ -7,6 +7,16 @@ export declare class MultiPatternMatcher {
7
7
  addPattern(patternDef: PatternDef, countryCode: string): void;
8
8
  addCountry(config: CountryConfig): void;
9
9
  compile(): void;
10
+ /**
11
+ * Scan text against patterns.
12
+ *
13
+ * `countryCodes` restricts which patterns run. **The engine never passes
14
+ * it.** Generation is country-blind by contract — see
15
+ * `RuleEngine.generateCandidates` and `src/__tests__/inference.ts` — because
16
+ * gating generation on the caller's declared country caused silent misses.
17
+ * The parameter is kept for a future opt-in fast mode, which would be the
18
+ * only caller permitted to trade recall for speed.
19
+ */
10
20
  scan(text: string, countryCodes?: Set<string> | null): RawMatch[];
11
21
  validate(match: RawMatch): boolean;
12
22
  }
@@ -19,6 +19,16 @@ export class MultiPatternMatcher {
19
19
  compile() {
20
20
  this.compiled = true;
21
21
  }
22
+ /**
23
+ * Scan text against patterns.
24
+ *
25
+ * `countryCodes` restricts which patterns run. **The engine never passes
26
+ * it.** Generation is country-blind by contract — see
27
+ * `RuleEngine.generateCandidates` and `src/__tests__/inference.ts` — because
28
+ * gating generation on the caller's declared country caused silent misses.
29
+ * The parameter is kept for a future opt-in fast mode, which would be the
30
+ * only caller permitted to trade recall for speed.
31
+ */
22
32
  scan(text, countryCodes = null) {
23
33
  if (!this.compiled)
24
34
  this.compile();
package/dist/esm/sdk.d.ts CHANGED
@@ -1,6 +1,27 @@
1
+ import { DocumentContext } from "./rules/context.js";
1
2
  import { type RedactResult } from "./types.js";
2
3
  export interface RedactOptions {
4
+ /**
5
+ * Scope. Detections attributed elsewhere are flagged `outOfScope`, never
6
+ * dropped, and this also acts as a prior when resolving which national
7
+ * scheme owns an ambiguous value. It does **not** gate what is looked for.
8
+ */
3
9
  countries?: string[] | null;
10
+ /**
11
+ * A prior only. Helps resolve ambiguity without narrowing scope or flagging
12
+ * anything out of scope.
13
+ */
14
+ countryHint?: string[] | null;
15
+ /**
16
+ * Shares country evidence across the chunks of one document, so a chunk
17
+ * carrying no country signal of its own is still scored against what the
18
+ * rest of the document showed. Pass the same object for every chunk, with
19
+ * `chunkOffset` set to where the chunk starts in the whole document.
20
+ */
21
+ context?: DocumentContext | null;
22
+ /** Offset of this chunk within the document. Used only to rebase spans
23
+ * recorded in `context`; returned detections are relative to `text`. */
24
+ chunkOffset?: number;
4
25
  mode?: string;
5
26
  referentialIntegrity?: boolean;
6
27
  detectDates?: boolean;
package/dist/esm/sdk.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ResultCache } from "./cache.js";
2
2
  import { normalize, mapOffsetToOriginal } from "./normalizer.js";
3
3
  import { RuleEngine } from "./rules/engine.js";
4
+ import { weightsToRanking } from "./rules/evidence.js";
4
5
  import { EntityType } from "./types.js";
5
6
  const DATE_TYPES = new Set([EntityType.DOB, EntityType.DATE_OF_DEATH]);
6
7
  class ReferentialMapper {
@@ -44,12 +45,18 @@ export class EuRedact {
44
45
  `(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
45
46
  `increase maxInputLength when constructing EuRedact.`);
46
47
  }
47
- const { countries = null, mode = "rules", referentialIntegrity = false, detectDates = false, cache = true, } = options;
48
+ const { countries = null, countryHint = null, context = null, chunkOffset = 0, mode = "rules", referentialIntegrity = false, detectDates = false, } = options;
49
+ // A context makes the result depend on evidence from other chunks, so the
50
+ // text no longer identifies the result. Caching is disabled rather than
51
+ // keyed on the context, whose contents change as chunks arrive.
52
+ const cache = context !== null ? false : (options.cache ?? true);
48
53
  const [normalizedText, offsetMapping] = normalize(text);
49
54
  const countriesTuple = countries
50
55
  ? countries.map(c => c.toUpperCase()).sort()
51
56
  : ["ALL"];
52
- const cacheMode = `${mode}|dates=${detectDates}`;
57
+ // countryHint changes attribution, so it must key the cache too.
58
+ const hintKey = countryHint ? countryHint.map(c => c.toUpperCase()).sort().join(",") : "";
59
+ const cacheMode = `${mode}|dates=${detectDates}|hint=${hintKey}`;
53
60
  let cacheKey;
54
61
  if (cache) {
55
62
  cacheKey = this.cache.key(normalizedText, countriesTuple, cacheMode);
@@ -57,7 +64,10 @@ export class EuRedact {
57
64
  if (cached !== null)
58
65
  return cached;
59
66
  }
60
- let detections = this.engine.detect(normalizedText, countries);
67
+ const { detections: rawDetections, evidence, scores, } = this.engine.detectWithEvidence(normalizedText, countries, countryHint, context !== null ? context.evidence() : null);
68
+ if (context !== null)
69
+ context.add(evidence, chunkOffset);
70
+ let detections = rawDetections;
61
71
  if (offsetMapping !== null) {
62
72
  detections = detections.map(d => ({
63
73
  ...d,
@@ -77,11 +87,18 @@ export class EuRedact {
77
87
  : `[${det.entityType}]`;
78
88
  redacted = redacted.slice(0, det.start) + replacement + redacted.slice(det.end);
79
89
  }
90
+ // Report the inference so it can be audited. Spans in `evidence` are
91
+ // offsets into the normalised text, matching `detections`.
92
+ const inferredCountries = [...weightsToRanking(scores).entries()]
93
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
80
94
  const result = {
81
95
  redactedText: redacted,
82
96
  detections,
83
97
  source: "rules",
84
98
  degraded: false,
99
+ inferredCountries,
100
+ evidence,
101
+ detectionMode: countries && countries.length ? "declared" : "inferred",
85
102
  };
86
103
  if (cache && cacheKey) {
87
104
  this.cache.put(cacheKey, result);
@@ -89,8 +106,9 @@ export class EuRedact {
89
106
  return result;
90
107
  }
91
108
  redactBatch(texts, options = {}) {
92
- const countries = options.countries ?? null;
93
- this.engine.loadCountries(countries ? countries.map(c => c.toUpperCase()) : null);
109
+ // Generation is country-blind, so every country's patterns are loaded
110
+ // regardless of what the caller declared.
111
+ this.engine.loadCountries(null);
94
112
  return texts.map(text => this.redact(text, options));
95
113
  }
96
114
  }
@@ -49,6 +49,24 @@ export declare enum DetectionSource {
49
49
  RULES = "rules",
50
50
  CLOUD = "cloud"
51
51
  }
52
+ /**
53
+ * One reason to believe a document belongs to a country.
54
+ *
55
+ * Emitted by entities that carry their country in the string — an IBAN's
56
+ * country code, a `+CC` dialling prefix, a VAT prefix, an email ccTLD.
57
+ * Auditable by design: every inference traces back to the span and the source
58
+ * that produced it.
59
+ */
60
+ export interface CountryEvidence {
61
+ /** ISO 3166-1 alpha-2. */
62
+ country: string;
63
+ /** Which signal produced this — "ibanPrefix", "e164Prefix", ... */
64
+ source: string;
65
+ /** Natural-log odds. Derived from the corpus, not hand-tuned. */
66
+ logOdds: number;
67
+ /** Offsets into the text that was scanned. */
68
+ span: [number, number];
69
+ }
52
70
  export interface Detection {
53
71
  entityType: EntityType | string;
54
72
  start: number;
@@ -57,12 +75,47 @@ export interface Detection {
57
75
  source: DetectionSource;
58
76
  country: string | null;
59
77
  confidence: string;
78
+ /**
79
+ * How strongly the document supports {@link Detection.country}, in [0, 1].
80
+ *
81
+ * 0 when no country was attributed or nothing corroborated it — which is the
82
+ * signal that an attribution rests on a checksum alone.
83
+ */
84
+ countryConfidence?: number;
85
+ /**
86
+ * True when attributed to a country the caller did not declare.
87
+ *
88
+ * `countries` narrows *scoring*, never detection. An entity from outside the
89
+ * declared set is emitted and flagged, not dropped: callers assert context,
90
+ * they do not suppress evidence.
91
+ */
92
+ outOfScope?: boolean;
60
93
  }
61
94
  export interface RedactResult {
62
95
  redactedText: string;
63
96
  detections: Detection[];
64
97
  source: string;
65
98
  degraded: boolean;
99
+ /**
100
+ * Countries this document appears to belong to, strongest first, as
101
+ * `[country, confidence]` with confidence in [0, 1].
102
+ *
103
+ * Inferred from entities that carry their country in the string. Reported so
104
+ * the inference can be audited; it influences which national scheme is
105
+ * attributed to an ambiguous value, never which spans are found.
106
+ */
107
+ inferredCountries: Array<[string, number]>;
108
+ /** Every signal behind {@link RedactResult.inferredCountries}, with the span
109
+ * that produced it. The audit trail for a country attribution. */
110
+ evidence: CountryEvidence[];
111
+ /**
112
+ * `"declared"` when the caller passed `countries`, `"inferred"` when the
113
+ * country was worked out from the content.
114
+ *
115
+ * Named `detectionMode` rather than `mode` because `redact({ mode })`
116
+ * already means the tier selector, which {@link RedactResult.source} reports.
117
+ */
118
+ detectionMode: string;
66
119
  }
67
120
  export interface PatternDef {
68
121
  entityType: EntityType | string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euredact",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "European PII redaction SDK -- fast, zero-dependency, rule-based detection and redaction of personal data across 31 European countries",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -53,13 +53,14 @@
53
53
  "build:cjs": "tsc -p tsconfig.cjs.json",
54
54
  "postbuild:cjs": "echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json",
55
55
  "build": "rm -rf dist && npm run build:esm && npm run build:cjs",
56
- "test": "npm run test:spec && npm run test:conformance",
56
+ "test": "npm run test:spec && npm run test:inference && npm run test:conformance",
57
57
  "benchmark": "node --import tsx src/__tests__/benchmark.ts",
58
58
  "prepublishOnly": "npm run build",
59
59
  "test:spec": "node --import tsx src/__tests__/spec.ts",
60
60
  "test:eval": "node --import tsx src/__tests__/run.ts",
61
61
  "test:eval:full": "node --max-old-space-size=8192 --import tsx src/__tests__/evalFull.ts",
62
- "test:conformance": "node --import tsx src/__tests__/conformance.ts"
62
+ "test:conformance": "node --import tsx src/__tests__/conformance.ts",
63
+ "test:inference": "node --import tsx src/__tests__/inference.ts"
63
64
  },
64
65
  "devDependencies": {
65
66
  "typescript": "^5.4.0",