@dszp/csl-screen 0.1.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.
package/src/screen.ts ADDED
@@ -0,0 +1,185 @@
1
+ import { jaroWinkler } from './jaroWinkler.js';
2
+ import { MATCHER_VERSION } from './model.js';
3
+ import type { Match, Party, PartyResult, ScreenOptions, ScreenResult } from './model.js';
4
+ import { DEFAULT_STOP_WORDS, hasUnreadableCharacters, nameTokens } from './normalize.js';
5
+ import type { SlimEntry, Snapshot } from './snapshot.js';
6
+
7
+ interface IndexedName {
8
+ entry: SlimEntry;
9
+ name: string;
10
+ key: string;
11
+ tokens: Set<string>;
12
+ /** Has a token >= minTokenLength. Names without one match only by `exact` (with or without spaces). */
13
+ indexable: boolean;
14
+ }
15
+
16
+ const RULE_RANK = { exact: 3, contains: 2, within: 1, similar: 0 } as const;
17
+ type Rule = keyof typeof RULE_RANK;
18
+
19
+ /** A one-token listed name on these entry types is too generic (a ship, a nickname) to match inside a longer party name. */
20
+ const NON_ENTITY_TYPES: ReadonlySet<string> = new Set(['Individual', 'Vessel', 'Aircraft']);
21
+ /**
22
+ * One-word listed entity names (after stop words are dropped) too generic to flag anywhere but a party
23
+ * name's first token. Each was measured on real customer names: as a later token it flagged only
24
+ * unrelated organisations using it as an ordinary word.
25
+ * A word joins this list only from such a measurement, because each one is a name that can be hidden
26
+ * behind another word.
27
+ */
28
+ export const DEFAULT_GENERIC_TOKENS: readonly string[] = ['center', 'focus', 'technology', 'united'];
29
+
30
+ /** Longest run of adjacent party tokens joined when looking for a listed word split by separators. */
31
+ const MAX_RUN = 3;
32
+ const spaceless = (key: string) => key.replace(/ /g, '');
33
+ const push = <K, V>(m: Map<K, V[]>, k: K, v: V) => {
34
+ const bucket = m.get(k);
35
+ if (bucket) bucket.push(v);
36
+ else m.set(k, [v]);
37
+ };
38
+
39
+ export function screen(parties: Party[], snapshot: Snapshot, options: ScreenOptions = {}): ScreenResult {
40
+ const threshold = options.threshold ?? 0.9;
41
+ const minTokenLength = options.minTokenLength ?? 3;
42
+ if (!(typeof threshold === 'number' && threshold > 0 && threshold <= 1)) {
43
+ throw new TypeError(`threshold must be a number in (0, 1], got ${String(threshold)}`);
44
+ }
45
+ if (!Number.isInteger(minTokenLength) || minTokenLength < 1) {
46
+ throw new TypeError(`minTokenLength must be an integer >= 1, got ${String(minTokenLength)}`);
47
+ }
48
+ const withinMinLength = Math.max(4, minTokenLength);
49
+ // Caller stop words go through the same normalisation as names, so 'Inc' and 'L.L.C.' behave.
50
+ const noStops: ReadonlySet<string> = new Set();
51
+ const stopWords = new Set((options.stopWords ?? DEFAULT_STOP_WORDS).flatMap((w) => nameTokens(w, noStops)));
52
+ const genericTokens = new Set((options.genericTokens ?? DEFAULT_GENERIC_TOKENS).flatMap((w) => nameTokens(w, noStops)));
53
+
54
+ const seenIds = new Set<string>();
55
+ for (const party of parties) {
56
+ if (seenIds.has(party.id)) throw new Error(`Duplicate party id: ${party.id}`);
57
+ seenIds.add(party.id);
58
+ }
59
+
60
+ const index = new Map<string, IndexedName[]>();
61
+ const bySpaceless = new Map<string, IndexedName[]>();
62
+ let longestSpaceless = 0;
63
+
64
+ for (const entry of snapshot.entries) {
65
+ for (const name of [entry.name, ...entry.altNames]) {
66
+ const tokens = nameTokens(name, stopWords);
67
+ if (tokens.length === 0) continue;
68
+ const indexed: IndexedName = { entry, name, key: tokens.join(' '), tokens: new Set(tokens), indexable: tokens.some((t) => t.length >= minTokenLength) };
69
+ push(bySpaceless, spaceless(indexed.key), indexed);
70
+ longestSpaceless = Math.max(longestSpaceless, spaceless(indexed.key).length);
71
+ for (const token of indexed.tokens) if (token.length >= minTokenLength) push(index, token, indexed);
72
+ }
73
+ }
74
+
75
+ const results: PartyResult[] = parties.map((party): PartyResult => {
76
+ const tokens = nameTokens(party.name, stopWords);
77
+ const partyNameKey = tokens.join(' ');
78
+ const ptoks = new Set(tokens);
79
+ // A non-Latin letter or unreadable character would split a word into fragments (or vanish), so a listed name could be missed.
80
+ if (hasUnreadableCharacters(party.name)) return { party, partyNameKey, status: 'unscreenable', reason: 'unreadable-characters' };
81
+ // An empty key cannot be screened; it must not read as clear.
82
+ if (partyNameKey === '') return { party, partyNameKey, status: 'unscreenable', reason: 'empty-name' };
83
+ // `within` needs a party token long enough to be distinctive: at least 4, and never shorter than an indexed token.
84
+ const hasLongToken = tokens.some((t) => t.length >= withinMinLength);
85
+
86
+ // Each party token, and each run of two or three adjacent tokens joined without spaces, with the position
87
+ // it starts at. A run catches a separator inserted inside a listed word ("Ros-neft Trading" holds
88
+ // "rosneft"). At most MAX_RUN units per position keeps the work linear in the party name's length; a run
89
+ // longer than any listed name cannot match and is not looked up.
90
+ const units = new Map<string, number>();
91
+ for (let i = 0; i < tokens.length; i++) {
92
+ let run = '';
93
+ for (let j = i; j < tokens.length && j - i < MAX_RUN; j++) {
94
+ run += tokens[j];
95
+ if (run.length > longestSpaceless && j > i) break;
96
+ if (!units.has(run)) units.set(run, i);
97
+ }
98
+ }
99
+
100
+ const candidates = new Set<IndexedName>();
101
+ for (const u of units.keys()) {
102
+ if (u.length >= minTokenLength) for (const c of index.get(u) ?? []) candidates.add(c);
103
+ for (const c of bySpaceless.get(u) ?? []) candidates.add(c);
104
+ }
105
+ for (const c of bySpaceless.get(spaceless(partyNameKey)) ?? []) candidates.add(c);
106
+
107
+ const best = new Map<string, { rule: Rule; score: number; name: IndexedName; reason: string | null }>();
108
+ for (const cand of candidates) {
109
+ let shared = 0;
110
+ for (const t of cand.tokens) if (ptoks.has(t)) shared++;
111
+ const cover = shared / Math.max(ptoks.size, cand.tokens.size);
112
+ const score = Math.min(jaroWinkler(partyNameKey, cand.key), 0.5 + cover / 2);
113
+ // Contained when every listed token is a party token, or a run of them, or the whole listed name is one run.
114
+ const candSpaceless = spaceless(cand.key);
115
+ const inUnits = [...cand.tokens].every((t) => units.has(t)) || units.has(candSpaceless);
116
+ // A one-token name on an entity counts in any position, except a generic word, which counts only first.
117
+ const at = units.get(candSpaceless);
118
+ const containedAlone =
119
+ cand.tokens.size > 1 ||
120
+ (!NON_ENTITY_TYPES.has(cand.entry.type ?? '') && at !== undefined && (at === 0 || !genericTokens.has(candSpaceless)));
121
+ let rule: Rule | null = null;
122
+ let reason: string | null = null;
123
+ if (partyNameKey === cand.key) rule = 'exact';
124
+ else if (spaceless(partyNameKey) === spaceless(cand.key)) {
125
+ rule = 'exact';
126
+ reason = 'exact ignoring spaces';
127
+ } else if (!cand.indexable) continue;
128
+ else if (shared === cand.tokens.size && containedAlone) rule = 'contains';
129
+ else if (inUnits && containedAlone) {
130
+ rule = 'contains';
131
+ reason = 'contains listed name ignoring spaces';
132
+ }
133
+ else if (shared === ptoks.size && hasLongToken) rule = 'within';
134
+ else if (score >= threshold) rule = 'similar';
135
+ if (rule === null) continue;
136
+ const cur = best.get(cand.entry.key);
137
+ if (!cur || RULE_RANK[rule] > RULE_RANK[cur.rule] || (rule === cur.rule && score > cur.score)) {
138
+ best.set(cand.entry.key, { rule, score, name: cand, reason });
139
+ }
140
+ }
141
+
142
+ if (best.size === 0) return { party, partyNameKey, status: 'clear' };
143
+
144
+ const country = party.country?.toUpperCase();
145
+ const matches: Match[] = [...best.values()].map(({ rule, score, name, reason }) => ({
146
+ entryKey: name.entry.key,
147
+ entryId: name.entry.id,
148
+ entryFingerprint: name.entry.fingerprint,
149
+ matchedName: name.name,
150
+ listSource: name.entry.source,
151
+ programs: name.entry.programs,
152
+ entryType: name.entry.type,
153
+ entryCountries: name.entry.countries,
154
+ countryMismatch: !!country && name.entry.countries.length > 0 && !name.entry.countries.includes(country),
155
+ rule,
156
+ // An exact rule is a full match whatever the token-cover heuristic says (spaceless matches cover fewer tokens).
157
+ score: rule === 'exact' ? 1 : score,
158
+ reason:
159
+ reason ??
160
+ (rule === 'exact' ? 'exact after normalisation'
161
+ : rule === 'contains' ? 'contains listed name'
162
+ : rule === 'within' ? 'listed name contains party name'
163
+ : `similar ${score.toFixed(2)}`),
164
+ state: 'new',
165
+ }));
166
+ matches.sort((a, b) => RULE_RANK[b.rule] - RULE_RANK[a.rule] || b.score - a.score || (a.entryKey < b.entryKey ? -1 : a.entryKey > b.entryKey ? 1 : 0));
167
+ return { party, partyNameKey, status: 'flagged', matches };
168
+ });
169
+
170
+ const count = (s: PartyResult['status']) => results.filter((r) => r.status === s).length;
171
+ const unscreenable = count('unscreenable');
172
+ return {
173
+ check: 'csl',
174
+ matcher: { version: MATCHER_VERSION, threshold, minTokenLength, stopWords: [...stopWords].sort(), genericTokens: [...genericTokens].sort() },
175
+ snapshot: snapshot.identity,
176
+ counts: {
177
+ parties: results.length,
178
+ screened: results.length - unscreenable,
179
+ unscreenable,
180
+ clear: count('clear'),
181
+ flagged: count('flagged'),
182
+ },
183
+ parties: results,
184
+ };
185
+ }
@@ -0,0 +1,186 @@
1
+ import { canonicalJson, sha256Hex } from './canonical.js';
2
+
3
+ export interface SnapshotIdentity {
4
+ searchPerformedAt: string;
5
+ sources: { source: string; sourceLastUpdated: string | null; lastImported: string | null }[];
6
+ entryCount: number;
7
+ /** Entries per CSL `source` (list name), keys sorted. Feeds the next run's `previousEntriesBySource`. */
8
+ entriesBySource: Record<string, number>;
9
+ /** SHA-256 of the downloaded bytes. Changes on every republish, even when no entry changed. */
10
+ sha256: string;
11
+ /** Hash of the entries alone (see `contentHashOf`). Unchanged by `search_performed_at` and per-list `last_imported`. */
12
+ contentSha256: string;
13
+ }
14
+
15
+ export interface SlimEntry {
16
+ /** `${source}|${id}`. The CSL `id` alone is not unique: an entry on two lists shares it. */
17
+ key: string;
18
+ id: string;
19
+ name: string;
20
+ altNames: string[];
21
+ source: string;
22
+ type: string | null;
23
+ countries: string[];
24
+ programs: string[];
25
+ sourceListUrl: string | null;
26
+ /** SHA-256 of the canonical JSON of the whole raw entry; changes when any field changes. */
27
+ fingerprint: string;
28
+ }
29
+
30
+ export interface Snapshot {
31
+ identity: SnapshotIdentity;
32
+ entries: SlimEntry[];
33
+ }
34
+
35
+ export class CslSnapshotError extends Error {
36
+ constructor(
37
+ public kind: 'parse' | 'shape' | 'total-mismatch' | 'sharp-drop' | 'source-drop',
38
+ message: string,
39
+ ) {
40
+ super(message);
41
+ this.name = 'CslSnapshotError';
42
+ }
43
+ }
44
+
45
+ export interface LoadSnapshotOptions {
46
+ /** Entry count of the snapshot being replaced; enables the sharp-drop check. */
47
+ previousEntryCount?: number;
48
+ /** Largest tolerated fractional shrink against previousEntryCount. Default 0.1. */
49
+ maxDropRatio?: number;
50
+ /**
51
+ * `entriesBySource` of the snapshot being replaced; enables the source-drop check. A source in this map that is
52
+ * missing from the new file, or has fewer than half its previous entries, fails the load. Catches the loss of
53
+ * one small list, which the whole-file sharp-drop check cannot see.
54
+ */
55
+ previousEntriesBySource?: Record<string, number>;
56
+ }
57
+
58
+ type Obj = Record<string, unknown>;
59
+ const isObj = (v: unknown): v is Obj => v !== null && typeof v === 'object' && !Array.isArray(v);
60
+ const strOrNull = (v: unknown): string | null => (typeof v === 'string' ? v : null);
61
+ const MIN_SOURCE_RATIO = 0.5;
62
+
63
+ /** Null or absent is an empty list; anything else must be an array of strings, so a format change fails loudly. */
64
+ function strings(v: unknown, where: string): string[] {
65
+ if (v === null || v === undefined) return [];
66
+ if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) {
67
+ throw new CslSnapshotError('shape', `${where} must be an array of strings, null or absent`);
68
+ }
69
+ return v;
70
+ }
71
+
72
+ /**
73
+ * SHA-256 hex over the lines `${key}\t${fingerprint}\n`, sorted by key in byte order (plain `<` on strings, not
74
+ * `localeCompare`, so the result does not depend on the runtime's locale), concatenated. Entry order in the file
75
+ * does not matter; any change to an entry's fingerprint, or to the set of keys, does.
76
+ */
77
+ export async function contentHashOf(entries: readonly { key: string; fingerprint: string }[]): Promise<string> {
78
+ const sorted = [...entries].sort((x, y) => (x.key < y.key ? -1 : x.key > y.key ? 1 : 0));
79
+ return sha256Hex(sorted.map((e) => `${e.key}\t${e.fingerprint}\n`).join(''));
80
+ }
81
+
82
+ export async function loadSnapshot(
83
+ input: Uint8Array | ArrayBuffer | string,
84
+ opts: LoadSnapshotOptions = {},
85
+ ): Promise<Snapshot> {
86
+ const bytes =
87
+ typeof input === 'string' ? new TextEncoder().encode(input)
88
+ : input instanceof Uint8Array ? input
89
+ : new Uint8Array(input);
90
+ const sha256 = await sha256Hex(bytes);
91
+
92
+ let doc: unknown;
93
+ try {
94
+ doc = JSON.parse(new TextDecoder().decode(bytes));
95
+ } catch (e) {
96
+ throw new CslSnapshotError('parse', `CSL file is not valid JSON: ${(e as Error).message}`);
97
+ }
98
+ if (!isObj(doc) || !Array.isArray(doc.results)) {
99
+ throw new CslSnapshotError('shape', 'CSL file has no "results" array');
100
+ }
101
+ const raw = doc.results;
102
+ if (raw.length === 0) {
103
+ throw new CslSnapshotError('shape', 'CSL file has an empty "results" array');
104
+ }
105
+ if (typeof doc.search_performed_at !== 'string') {
106
+ throw new CslSnapshotError('shape', 'CSL file has no "search_performed_at" string');
107
+ }
108
+ const searchPerformedAt = doc.search_performed_at;
109
+ if (typeof doc.total !== 'number' || raw.length !== doc.total) {
110
+ throw new CslSnapshotError('total-mismatch', `results has ${raw.length} entries but total is ${String(doc.total)}`);
111
+ }
112
+ const previous = opts.previousEntryCount;
113
+ const maxDrop = opts.maxDropRatio ?? 0.1;
114
+ // NaN or a ratio of 1 or more would silently disable the check.
115
+ if (!(typeof maxDrop === 'number' && maxDrop >= 0 && maxDrop < 1)) {
116
+ throw new TypeError(`maxDropRatio must be a number in [0, 1), got ${String(maxDrop)}`);
117
+ }
118
+ if (previous !== undefined && previous > 0 && raw.length < previous * (1 - maxDrop)) {
119
+ throw new CslSnapshotError('sharp-drop', `entry count fell from ${previous} to ${raw.length}, beyond the ${maxDrop} allowed drop`);
120
+ }
121
+
122
+ const entries: SlimEntry[] = [];
123
+ const seenKeys = new Set<string>();
124
+ for (const [i, r] of raw.entries()) {
125
+ if (!isObj(r) || typeof r.id !== 'string' || typeof r.name !== 'string' || typeof r.source !== 'string') {
126
+ throw new CslSnapshotError('shape', `results[${i}] lacks a string id, name or source`);
127
+ }
128
+ const countries = new Set<string>();
129
+ const addCountry = (c: unknown) => {
130
+ if (typeof c === 'string' && c) countries.add(c.toUpperCase());
131
+ };
132
+ addCountry(r.country);
133
+ if (Array.isArray(r.addresses)) for (const a of r.addresses) if (isObj(a)) addCountry(a.country);
134
+ const key = `${r.source}|${r.id}`;
135
+ if (seenKeys.has(key)) {
136
+ throw new CslSnapshotError('shape', `duplicate entry key ${key} at results[${i}]`);
137
+ }
138
+ seenKeys.add(key);
139
+ entries.push({
140
+ key,
141
+ id: r.id,
142
+ name: r.name,
143
+ altNames: strings(r.alt_names, `results[${i}].alt_names`),
144
+ source: r.source,
145
+ type: strOrNull(r.type),
146
+ countries: [...countries],
147
+ programs: strings(r.programs, `results[${i}].programs`),
148
+ sourceListUrl: strOrNull(r.source_list_url),
149
+ fingerprint: await sha256Hex(canonicalJson(r)),
150
+ });
151
+ }
152
+
153
+ // Null-prototype maps: a source named "constructor" or "__proto__" is an ordinary key.
154
+ const counted: Record<string, number> = Object.create(null);
155
+ for (const e of entries) counted[e.source] = (counted[e.source] ?? 0) + 1;
156
+ // The published identity is a plain object (callers may use its prototype methods); fromEntries still makes
157
+ // "__proto__" an own data property. Lookups below stay on the null-prototype map.
158
+ const entriesBySource: Record<string, number> = Object.fromEntries(Object.keys(counted).sort().map((k) => [k, counted[k]!]));
159
+ for (const [source, before] of Object.entries(opts.previousEntriesBySource ?? {})) {
160
+ if (typeof before !== 'number' || !Number.isFinite(before) || before < 0) {
161
+ throw new TypeError(`previousEntriesBySource["${source}"] must be a non-negative number, got ${JSON.stringify(before)}`);
162
+ }
163
+ const now = counted[source] ?? 0;
164
+ if (now < before * MIN_SOURCE_RATIO) {
165
+ throw new CslSnapshotError(
166
+ 'source-drop',
167
+ `source "${source}" fell from ${before} to ${now} entries${now === 0 ? ' (missing from the file)' : ''}, below ${MIN_SOURCE_RATIO * 100}% of the previous count`,
168
+ );
169
+ }
170
+ }
171
+
172
+ const sourcesUsed = Array.isArray(doc.sources_used) ? doc.sources_used : [];
173
+ const identity: SnapshotIdentity = {
174
+ searchPerformedAt,
175
+ sources: sourcesUsed.filter(isObj).map((s) => ({
176
+ source: typeof s.source === 'string' ? s.source : '',
177
+ sourceLastUpdated: strOrNull(s.source_last_updated),
178
+ lastImported: strOrNull(s.last_imported),
179
+ })),
180
+ entryCount: entries.length,
181
+ entriesBySource,
182
+ sha256,
183
+ contentSha256: await contentHashOf(entries),
184
+ };
185
+ return { identity, entries };
186
+ }