@crewhaus/tool-text 0.7.0 → 0.8.1

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,13 @@
1
1
  /** Strip ANSI escapes so a captured terminal log compares as plain text. */
2
2
  export declare function stripAnsi(text: string): string;
3
+ /**
4
+ * `line` without its trailing spaces and tabs: what
5
+ * `line.replace(/[ \t]+$/, "")` returned, in one pass from the end. That
6
+ * pattern tries every start inside a run of blanks that is not at the end of
7
+ * the line and rescans the rest of the run from each, so it was quadratic in
8
+ * the run's length.
9
+ */
10
+ export declare function withoutTrailingBlanks(line: string): string;
3
11
  export type NormalizeOptions = {
4
12
  readonly eol?: "lf" | "crlf";
5
13
  readonly trimTrailingWhitespace?: boolean;
@@ -32,8 +40,19 @@ export declare function sortLines(text: string, opts: SortOptions): string[];
32
40
  * Replace terms using operator-supplied mappings, longest term first so a
33
41
  * multi-word entry wins over a substring of itself. Whole-word by default,
34
42
  * which is what stops "AI" rewriting the middle of "CHAIN".
43
+ *
44
+ * Each term's pass also runs over what earlier terms put in, so a
45
+ * replacement that holds the next term many times multiplied the text once
46
+ * per term: a 382-byte call grew it to 639,999 characters, and a fifth term
47
+ * to 12.8 million. The text may grow to `limit`, and the passes together may
48
+ * read {@link GLOSSARY_PASSES_PER_LIMIT} times it; past either this throws
49
+ * a sentence that says which, never a cut text.
50
+ *
51
+ * Each pass finds its term the way `new RegExp(<escaped term>, "g" | "gi")`
52
+ * did, with `(?<![\w-])` and `(?![\w-])` around it for whole words — but
53
+ * reads each unit of the text once, whatever the term ({@link replaceTerm}).
35
54
  */
36
- export declare function glossaryReplace(text: string, mapping: Readonly<Record<string, string>>, wholeWord: boolean, caseSensitive: boolean): {
55
+ export declare function glossaryReplace(text: string, mapping: Readonly<Record<string, string>>, wholeWord: boolean, caseSensitive: boolean, limit?: number): {
37
56
  text: string;
38
57
  replacements: Record<string, number>;
39
58
  };
@@ -1,4 +1,3 @@
1
- import { escapeFor } from "./format";
2
1
  /** CSI/SGR escapes emitted by terminals into captured output. */
3
2
  const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`, "g");
4
3
  /** Strip ANSI escapes so a captured terminal log compares as plain text. */
@@ -30,6 +29,22 @@ const INVISIBLE_CODE_POINTS = [
30
29
  0x2069,
31
30
  ];
32
31
  const INVISIBLE_PATTERN = new RegExp(INVISIBLE_CODE_POINTS.map((cp) => `\\u${cp.toString(16).padStart(4, "0")}`).join("|"), "g");
32
+ /**
33
+ * `line` without its trailing spaces and tabs: what
34
+ * `line.replace(/[ \t]+$/, "")` returned, in one pass from the end. That
35
+ * pattern tries every start inside a run of blanks that is not at the end of
36
+ * the line and rescans the rest of the run from each, so it was quadratic in
37
+ * the run's length.
38
+ */
39
+ export function withoutTrailingBlanks(line) {
40
+ let end = line.length;
41
+ for (; end > 0; end--) {
42
+ const c = line.charCodeAt(end - 1);
43
+ if (c !== 0x20 && c !== 0x09)
44
+ break;
45
+ }
46
+ return end === line.length ? line : line.slice(0, end);
47
+ }
33
48
  /**
34
49
  * Canonicalize text so two copies differing only in invisible ways compare
35
50
  * equal. Run this before hashing, diffing, or de-duplicating; it is why a
@@ -48,10 +63,7 @@ export function normalizeText(text, opts) {
48
63
  // Fold to LF first so later rules see one shape, then convert at the end.
49
64
  out = out.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
50
65
  if (opts.trimTrailingWhitespace === true) {
51
- out = out
52
- .split("\n")
53
- .map((l) => l.replace(/[ \t]+$/, ""))
54
- .join("\n");
66
+ out = out.split("\n").map(withoutTrailingBlanks).join("\n");
55
67
  }
56
68
  if (opts.collapseBlankLines === true)
57
69
  out = out.replace(/\n{3,}/g, "\n\n");
@@ -98,25 +110,177 @@ export function sortLines(text, opts) {
98
110
  });
99
111
  return opts.order === "desc" ? sorted.reverse() : sorted;
100
112
  }
113
+ /** GlossaryReplace's limit for its text: the default for what the mapping may grow it to. */
114
+ const GLOSSARY_LIMIT = 2_000_000;
115
+ /**
116
+ * Each term is one pass over the whole text, so the passes together may
117
+ * read this many times the limit: thousands of terms over a long document,
118
+ * or a hundred over the longest text.
119
+ */
120
+ const GLOSSARY_PASSES_PER_LIMIT = 100;
101
121
  /**
102
122
  * Replace terms using operator-supplied mappings, longest term first so a
103
123
  * multi-word entry wins over a substring of itself. Whole-word by default,
104
124
  * which is what stops "AI" rewriting the middle of "CHAIN".
125
+ *
126
+ * Each term's pass also runs over what earlier terms put in, so a
127
+ * replacement that holds the next term many times multiplied the text once
128
+ * per term: a 382-byte call grew it to 639,999 characters, and a fifth term
129
+ * to 12.8 million. The text may grow to `limit`, and the passes together may
130
+ * read {@link GLOSSARY_PASSES_PER_LIMIT} times it; past either this throws
131
+ * a sentence that says which, never a cut text.
132
+ *
133
+ * Each pass finds its term the way `new RegExp(<escaped term>, "g" | "gi")`
134
+ * did, with `(?<![\w-])` and `(?![\w-])` around it for whole words — but
135
+ * reads each unit of the text once, whatever the term ({@link replaceTerm}).
105
136
  */
106
- export function glossaryReplace(text, mapping, wholeWord, caseSensitive) {
137
+ export function glossaryReplace(text, mapping, wholeWord, caseSensitive, limit = GLOSSARY_LIMIT) {
107
138
  const replacements = {};
139
+ const terms = Object.keys(mapping).sort((a, b) => b.length - a.length);
140
+ const budget = GLOSSARY_PASSES_PER_LIMIT * limit;
108
141
  let out = text;
109
- for (const term of Object.keys(mapping).sort((a, b) => b.length - a.length)) {
110
- const escaped = escapeFor(term, "regex");
111
- const source = wholeWord ? `(?<![\\w-])${escaped}(?![\\w-])` : escaped;
112
- const re = new RegExp(source, caseSensitive ? "g" : "gi");
113
- let count = 0;
114
- out = out.replace(re, () => {
115
- count++;
116
- return mapping[term];
117
- });
118
- if (count > 0)
119
- replacements[term] = count;
142
+ let read = 0;
143
+ for (const term of terms) {
144
+ if (read + out.length > budget) {
145
+ throw new Error(`${terms.length} glossary terms over ${out.length} characters of text would read more than ${budget} characters, since each term is a pass over the whole text; send fewer terms or a shorter text`);
146
+ }
147
+ read += out.length;
148
+ // What the replacer returned before: a missing value reads "undefined".
149
+ const value = String(mapping[term]);
150
+ const replaced = replaceTerm(out, term, value, wholeWord, caseSensitive, limit);
151
+ if (replaced.count > 0)
152
+ replacements[term] = replaced.count;
153
+ out = replaced.text;
120
154
  }
121
155
  return { text: out, replacements };
122
156
  }
157
+ /** `[\w-]`: what a whole word may not touch. The regex `i` flag (without `u`) folds no other unit into it. */
158
+ function isWordUnit(c) {
159
+ return ((c >= 0x30 && c <= 0x39) ||
160
+ (c >= 0x41 && c <= 0x5a) ||
161
+ (c >= 0x61 && c <= 0x7a) ||
162
+ c === 0x5f ||
163
+ c === 0x2d);
164
+ }
165
+ /** Canonical code unit per code unit, filled on first use; -1 is "not yet". */
166
+ const CANONICAL = new Int32Array(0x10000).fill(-1);
167
+ /**
168
+ * The code unit the regex `i` flag (without `u`) compares `c` as: the
169
+ * ECMAScript Canonicalize — its upper case when that is one code unit, and not
170
+ * ASCII for a non-ASCII `c` — as this engine's own tables have it. The
171
+ * engine's case tables can be older than `toUpperCase`'s, so a pairing the
172
+ * regex does not make is not made here either. The same function is in
173
+ * tool-secure's `lib/literal.ts`; keep the two in step.
174
+ */
175
+ function canonicalUnit(c) {
176
+ const known = CANONICAL[c];
177
+ if (known !== -1)
178
+ return known;
179
+ let out = c;
180
+ if (c < 0x80) {
181
+ if (c >= 0x61 && c <= 0x7a)
182
+ out = c - 0x20;
183
+ }
184
+ else {
185
+ const upper = String.fromCharCode(c).toUpperCase();
186
+ if (upper.length === 1) {
187
+ const u = upper.charCodeAt(0);
188
+ if (u >= 0x80 && u !== c && regexFolds(c, u))
189
+ out = u;
190
+ }
191
+ }
192
+ CANONICAL[c] = out;
193
+ return out;
194
+ }
195
+ function regexFolds(a, b) {
196
+ return new RegExp(`^\\u${a.toString(16).padStart(4, "0")}$`, "i").test(String.fromCharCode(b));
197
+ }
198
+ const sameUnit = (c) => c;
199
+ /**
200
+ * `text` with `term` replaced by `value` wherever `new RegExp(<escaped
201
+ * term>, caseSensitive ? "g" : "gi")` matched it — inside
202
+ * `(?<![\w-])…(?![\w-])` when `wholeWord` — and how many times.
203
+ *
204
+ * That regex tried the whole term from every position of the text, so one
205
+ * pass read the text's length times the term's. Here the term is found with
206
+ * Knuth–Morris–Pratt, comparing code units folded as the `i` flag folds
207
+ * them: each unit of the text is read once, and the comparisons that fail
208
+ * are paid for by ones that matched. Every place the term occurs is found,
209
+ * overlapping ones too, left to right; one is a match when it starts where
210
+ * the last match ended or later and, for whole words, the units on either
211
+ * side of it (in the text as it was, as the lookarounds read it) are not
212
+ * `[\w-]`. That is the leftmost match from each place the regex resumed. The
213
+ * empty term occurs before each unit and at the end, as an empty regex
214
+ * matches.
215
+ *
216
+ * Case folds as the term on its own folded. JavaScriptCore folded some units
217
+ * further when the pattern had the lookbehind, so with whole words the
218
+ * Kelvin sign matched "k" and the long s "S", where the term on its own
219
+ * never did; with whole words on or off, they now fold alike.
220
+ *
221
+ * Every match is as long as the term, so the text's growth only rises or
222
+ * only falls: once past `limit` the whole pass ends past it, and this throws
223
+ * at that match rather than build the text.
224
+ */
225
+ function replaceTerm(text, term, value, wholeWord, caseSensitive, limit) {
226
+ const m = term.length;
227
+ const n = text.length;
228
+ const fold = caseSensitive ? sameUnit : canonicalUnit;
229
+ const parts = [];
230
+ let copied = 0;
231
+ let count = 0;
232
+ let grown = 0;
233
+ const take = (start) => {
234
+ count++;
235
+ grown += value.length - m;
236
+ if (grown > 0 && n + grown > limit) {
237
+ throw new Error(`the glossary would make the text longer than ${limit} characters, the limit for text; terms apply longest first, each to the text the ones before produced, so a replacement that holds a later term is replaced again`);
238
+ }
239
+ parts.push(text.slice(copied, start), value);
240
+ copied = start + m;
241
+ };
242
+ const clearOfWords = (start, end) => !wholeWord ||
243
+ ((start === 0 || !isWordUnit(text.charCodeAt(start - 1))) &&
244
+ (end === n || !isWordUnit(text.charCodeAt(end))));
245
+ if (m === 0) {
246
+ for (let i = 0; i <= n; i++)
247
+ if (clearOfWords(i, i))
248
+ take(i);
249
+ }
250
+ else if (m <= n) {
251
+ // For each prefix of the folded term, its longest proper border.
252
+ const border = new Int32Array(m);
253
+ for (let i = 1, k = 0; i < m; i++) {
254
+ const c = fold(term.charCodeAt(i));
255
+ while (k > 0 && fold(term.charCodeAt(k)) !== c)
256
+ k = border[k - 1];
257
+ if (fold(term.charCodeAt(k)) === c)
258
+ k += 1;
259
+ border[i] = k;
260
+ }
261
+ let k = 0;
262
+ for (let i = 0; i < n; i++) {
263
+ const c = fold(text.charCodeAt(i));
264
+ while (k > 0 && fold(term.charCodeAt(k)) !== c)
265
+ k = border[k - 1];
266
+ if (fold(term.charCodeAt(k)) === c)
267
+ k += 1;
268
+ if (k === m) {
269
+ const start = i + 1 - m;
270
+ if (start >= copied && clearOfWords(start, i + 1)) {
271
+ take(start);
272
+ // The next match starts where this one ends.
273
+ k = 0;
274
+ }
275
+ else {
276
+ // Not a match here; one may still start inside it.
277
+ k = border[m - 1];
278
+ }
279
+ }
280
+ }
281
+ }
282
+ if (count === 0)
283
+ return { text, count };
284
+ parts.push(text.slice(copied));
285
+ return { text: parts.join(""), count };
286
+ }
@@ -7,14 +7,61 @@ export type RegexMatch = {
7
7
  readonly groups: Readonly<Record<string, string>>;
8
8
  readonly captures: ReadonlyArray<string | undefined>;
9
9
  };
10
+ /** Where a tool call's patterns run: its abort signal, and whose runaway workers they count as. */
11
+ export type RegexRunContext = {
12
+ readonly signal?: AbortSignal;
13
+ readonly runawayKey?: string;
14
+ };
15
+ /** The ctx fields a tool's `execute` receives that a regex run uses. */
16
+ export declare function regexRunContext(ctx: unknown): RegexRunContext;
17
+ /** Wall-clock budget for one call's caller patterns. */
18
+ export declare const PATTERN_DEADLINE_MS = 5000;
19
+ /** The screen's limits for a caller pattern. */
20
+ export declare const PATTERN_LIMITS: {
21
+ readonly maxPatternChars: 10000;
22
+ };
23
+ export type RegexExtractResult = {
24
+ readonly ok: true;
25
+ readonly matches: RegexMatch[];
26
+ /** More matches may exist than were returned. */
27
+ readonly truncated: boolean;
28
+ readonly truncatedBy?: "maxMatches" | "maxOutputChars";
29
+ /**
30
+ * Set when the pattern could not be run to the end of the text (the
31
+ * deadline, the engine giving up): `matches` are the ones found before
32
+ * it stopped, and there may be more. Never read as "no more matches".
33
+ */
34
+ readonly undetermined?: string;
35
+ } | {
36
+ readonly ok: false;
37
+ readonly invalid: string;
38
+ };
10
39
  /**
11
- * Run a regex over text and return located matches.
40
+ * Run a caller's regex over text and return located matches.
12
41
  *
13
- * Always compiles with `g` so the scan advances, and a zero-width match bumps
14
- * `lastIndex` by one so a pattern like `a*` terminates instead of spinning
15
- * forever at the same offset.
42
+ * The match runs in `@crewhaus/tool-safety`'s regex worker under a deadline,
43
+ * never on this thread: a synchronous `RegExp` cannot be interrupted, and
44
+ * `a*a*a*a*b` over 400 characters held the whole process for 108 s. The
45
+ * pattern is screened first, so a shape that backtracks exponentially is
46
+ * refused with the reason. `g` is always applied, and a zero-width match
47
+ * advances by one character, so a pattern like `a*` terminates.
16
48
  */
17
- export declare function regexExtractAll(text: string, pattern: string, flags: string, maxMatches: number): {
18
- matches: RegexMatch[];
19
- truncated: boolean;
49
+ export declare function regexExtractAll(text: string, pattern: string, flags: string, maxMatches: number, ctx?: RegexRunContext): Promise<RegexExtractResult>;
50
+ /** One pattern's answer on one text: a verdict, or why there is none. */
51
+ export type PatternAnswer = boolean | {
52
+ readonly undetermined: string;
20
53
  };
54
+ /**
55
+ * Test every pattern against `text` (case-insensitively, as RuleClassify
56
+ * reads its regex rules) in one worker run, so a classifier can score
57
+ * synchronously from the answers. A pattern the screen refuses is reported
58
+ * as `invalid`, with its reason; one the worker could not finish is
59
+ * undetermined, never `false`.
60
+ */
61
+ export declare function answerPatterns(text: string, patterns: ReadonlyArray<string>, flags: string, ctx?: RegexRunContext): Promise<{
62
+ readonly ok: true;
63
+ readonly answers: ReadonlyMap<string, PatternAnswer>;
64
+ } | {
65
+ readonly ok: false;
66
+ readonly invalid: string;
67
+ }>;
package/dist/lib/regex.js CHANGED
@@ -1,36 +1,125 @@
1
+ import { describeRegexOutcome, openRegexSession, runRegex, } from "@crewhaus/tool-safety/regex";
1
2
  import { lineStarts, offsetToLineCol } from "./locate";
3
+ /** The ctx fields a tool's `execute` receives that a regex run uses. */
4
+ export function regexRunContext(ctx) {
5
+ const c = ctx;
6
+ return {
7
+ ...(c?.signal === undefined ? {} : { signal: c.signal }),
8
+ ...(typeof c?.runContext?.sessionId === "string" ? { runawayKey: c.runContext.sessionId } : {}),
9
+ };
10
+ }
11
+ /** Wall-clock budget for one call's caller patterns. */
12
+ export const PATTERN_DEADLINE_MS = 5_000;
13
+ /** The screen's limits for a caller pattern. */
14
+ export const PATTERN_LIMITS = { maxPatternChars: 10_000 };
2
15
  /**
3
- * Run a regex over text and return located matches.
16
+ * Run a caller's regex over text and return located matches.
4
17
  *
5
- * Always compiles with `g` so the scan advances, and a zero-width match bumps
6
- * `lastIndex` by one so a pattern like `a*` terminates instead of spinning
7
- * forever at the same offset.
18
+ * The match runs in `@crewhaus/tool-safety`'s regex worker under a deadline,
19
+ * never on this thread: a synchronous `RegExp` cannot be interrupted, and
20
+ * `a*a*a*a*b` over 400 characters held the whole process for 108 s. The
21
+ * pattern is screened first, so a shape that backtracks exponentially is
22
+ * refused with the reason. `g` is always applied, and a zero-width match
23
+ * advances by one character, so a pattern like `a*` terminates.
8
24
  */
9
- export function regexExtractAll(text, pattern, flags, maxMatches) {
10
- const withGlobal = flags.includes("g") ? flags : `${flags}g`;
11
- const re = new RegExp(pattern, withGlobal);
25
+ export async function regexExtractAll(text, pattern, flags, maxMatches, ctx = {}) {
26
+ const outcome = await runRegex({
27
+ op: "matchAll",
28
+ pattern,
29
+ flags,
30
+ input: text,
31
+ maxMatches,
32
+ deadlineMs: PATTERN_DEADLINE_MS,
33
+ maxInputChars: text.length,
34
+ limits: PATTERN_LIMITS,
35
+ ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
36
+ ...(ctx.runawayKey === undefined ? {} : { runawayKey: ctx.runawayKey }),
37
+ });
38
+ if (outcome.status === "rejected")
39
+ return { ok: false, invalid: outcome.reason };
40
+ const found = outcome.status === "ok"
41
+ ? outcome.result.matches
42
+ : outcome.status === "gave-up"
43
+ ? (outcome.partial?.matches ?? [])
44
+ : [];
12
45
  const starts = lineStarts(text);
13
- const matches = [];
14
- let truncated = false;
15
- for (;;) {
16
- const m = re.exec(text);
17
- if (m === null)
18
- break;
19
- if (matches.length >= maxMatches) {
20
- truncated = true;
21
- break;
22
- }
46
+ const matches = found.map((m) => {
23
47
  const { line, column } = offsetToLineCol(starts, m.index);
24
- matches.push({
25
- match: m[0],
26
- index: m.index,
27
- line,
28
- column,
29
- groups: { ...(m.groups ?? {}) },
30
- captures: m.slice(1),
48
+ const groups = {};
49
+ for (const [name, value] of Object.entries(m.groups ?? {})) {
50
+ if (value !== undefined)
51
+ groups[name] = value;
52
+ }
53
+ return { match: m.match, index: m.index, line, column, groups, captures: m.captures };
54
+ });
55
+ if (outcome.status !== "ok") {
56
+ return {
57
+ ok: true,
58
+ matches,
59
+ truncated: true,
60
+ undetermined: describeRegexOutcome(outcome),
61
+ };
62
+ }
63
+ return {
64
+ ok: true,
65
+ matches,
66
+ truncated: outcome.result.truncated,
67
+ ...(outcome.result.truncatedBy === undefined
68
+ ? {}
69
+ : { truncatedBy: outcome.result.truncatedBy }),
70
+ };
71
+ }
72
+ /**
73
+ * Test every pattern against `text` (case-insensitively, as RuleClassify
74
+ * reads its regex rules) in one worker run, so a classifier can score
75
+ * synchronously from the answers. A pattern the screen refuses is reported
76
+ * as `invalid`, with its reason; one the worker could not finish is
77
+ * undetermined, never `false`.
78
+ */
79
+ export async function answerPatterns(text, patterns, flags, ctx = {}) {
80
+ const distinct = [...new Set(patterns)];
81
+ const answers = new Map();
82
+ if (distinct.length === 0)
83
+ return { ok: true, answers };
84
+ let total = 0;
85
+ for (const p of distinct)
86
+ total += p.length;
87
+ const session = openRegexSession();
88
+ try {
89
+ const outcome = await session.run({
90
+ op: "testMatrix",
91
+ patterns: distinct.map((pattern) => ({ pattern, flags })),
92
+ inputs: [text],
93
+ onGiveUp: "skip",
94
+ deadlineMs: PATTERN_DEADLINE_MS,
95
+ maxInputChars: text.length + 1,
96
+ maxItemChars: text.length,
97
+ maxRules: distinct.length,
98
+ maxTotalPatternChars: total,
99
+ limits: PATTERN_LIMITS,
100
+ ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
101
+ ...(ctx.runawayKey === undefined ? {} : { runawayKey: ctx.runawayKey }),
31
102
  });
32
- if (m[0].length === 0)
33
- re.lastIndex += 1;
103
+ if (outcome.status === "rejected") {
104
+ const which = outcome.ruleIndex === undefined ? "" : `/${distinct[outcome.ruleIndex]}/: `;
105
+ return { ok: false, invalid: `${which}${outcome.reason}` };
106
+ }
107
+ for (const [i, pattern] of distinct.entries()) {
108
+ if (outcome.status !== "ok") {
109
+ answers.set(pattern, { undetermined: describeRegexOutcome(outcome) });
110
+ }
111
+ else if ((outcome.result.undetermined[i] ?? []).length > 0) {
112
+ answers.set(pattern, {
113
+ undetermined: "the regex engine gave up on the text, which it reports as no match",
114
+ });
115
+ }
116
+ else {
117
+ answers.set(pattern, (outcome.result.matched[i] ?? []).length > 0);
118
+ }
119
+ }
120
+ return { ok: true, answers };
121
+ }
122
+ finally {
123
+ session.close();
34
124
  }
35
- return { matches, truncated };
36
125
  }
@@ -1,4 +1,8 @@
1
- /** Classic Levenshtein edit distance, two-row variant. */
1
+ /**
2
+ * Classic Levenshtein edit distance, two-row variant, over UTF-16 code
3
+ * units. The rows are typed arrays reused across the table, not a fresh
4
+ * array per row: the same answers at a fraction of the time per cell.
5
+ */
2
6
  export declare function levenshtein(a: string, b: string): number;
3
7
  /** Edit distance expressed as a 0..1 similarity. */
4
8
  export declare function levenshteinRatio(a: string, b: string): number;
@@ -8,6 +12,21 @@ export declare function jaroWinkler(a: string, b: string): number;
8
12
  export declare function nGrams(text: string, n: number): Set<string>;
9
13
  export declare function jaccard(a: ReadonlySet<string>, b: ReadonlySet<string>): number;
10
14
  export type SimilarityMethod = "levenshtein" | "jaro" | "trigram" | "tokenJaccard";
15
+ /**
16
+ * The most work one Levenshtein or Jaro comparison may do, in Levenshtein
17
+ * cells: about a second of one thread. Both methods are O(n x m) and run
18
+ * synchronously on the harness thread, so two 20,000-character strings
19
+ * stalled every session in the process; this refuses those and still
20
+ * answers the sizes 0.7.0 answered in a fraction of a second.
21
+ */
22
+ export declare const MAX_SIMILARITY_WORK = 200000000;
23
+ /**
24
+ * The work one comparison does, in Levenshtein cells: the DP table for
25
+ * Levenshtein, the matching window for Jaro at its measured discount.
26
+ * Trigram and token overlap are linear, so they cost nothing against the
27
+ * quadratic budget.
28
+ */
29
+ export declare function similarityCost(a: string, b: string, method: SimilarityMethod): number;
11
30
  export declare function similarity(a: string, b: string, method: SimilarityMethod): number;
12
31
  export type FuzzyHit = {
13
32
  readonly candidate: string;
@@ -1,5 +1,9 @@
1
1
  import { tokenize } from "./locate";
2
- /** Classic Levenshtein edit distance, two-row variant. */
2
+ /**
3
+ * Classic Levenshtein edit distance, two-row variant, over UTF-16 code
4
+ * units. The rows are typed arrays reused across the table, not a fresh
5
+ * array per row: the same answers at a fraction of the time per cell.
6
+ */
3
7
  export function levenshtein(a, b) {
4
8
  if (a === b)
5
9
  return 0;
@@ -7,17 +11,31 @@ export function levenshtein(a, b) {
7
11
  return b.length;
8
12
  if (b.length === 0)
9
13
  return a.length;
10
- let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
14
+ const m = b.length;
15
+ // Int32, not Uint32: an unsigned element can exceed 2^31, so the engine
16
+ // reads Uint32 cells as doubles and the table ran four times slower.
17
+ let prev = new Int32Array(m + 1);
18
+ let cur = new Int32Array(m + 1);
19
+ for (let j = 0; j <= m; j++)
20
+ prev[j] = j;
11
21
  for (let i = 1; i <= a.length; i++) {
12
- const cur = new Array(b.length + 1);
13
22
  cur[0] = i;
14
- for (let j = 1; j <= b.length; j++) {
15
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
16
- cur[j] = Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
23
+ const ai = a.charCodeAt(i - 1);
24
+ for (let j = 1; j <= m; j++) {
25
+ let best = prev[j - 1] + (ai === b.charCodeAt(j - 1) ? 0 : 1);
26
+ const insert = cur[j - 1] + 1;
27
+ if (insert < best)
28
+ best = insert;
29
+ const remove = prev[j] + 1;
30
+ if (remove < best)
31
+ best = remove;
32
+ cur[j] = best;
17
33
  }
34
+ const done = prev;
18
35
  prev = cur;
36
+ cur = done;
19
37
  }
20
- return prev[b.length];
38
+ return prev[m];
21
39
  }
22
40
  /** Edit distance expressed as a 0..1 similarity. */
23
41
  export function levenshteinRatio(a, b) {
@@ -33,17 +51,20 @@ export function jaroWinkler(a, b) {
33
51
  if (a.length === 0 || b.length === 0)
34
52
  return 0;
35
53
  const window = Math.max(0, Math.floor(Math.max(a.length, b.length) / 2) - 1);
36
- const aFlags = new Array(a.length).fill(false);
37
- const bFlags = new Array(b.length).fill(false);
54
+ // Typed flags and code units: the same answers, at a fraction of the time
55
+ // per window step.
56
+ const aFlags = new Uint8Array(a.length);
57
+ const bFlags = new Uint8Array(b.length);
38
58
  let matches = 0;
39
59
  for (let i = 0; i < a.length; i++) {
40
60
  const lo = Math.max(0, i - window);
41
61
  const hi = Math.min(b.length - 1, i + window);
62
+ const ai = a.charCodeAt(i);
42
63
  for (let j = lo; j <= hi; j++) {
43
- if (bFlags[j] === true || a[i] !== b[j])
64
+ if (bFlags[j] === 1 || ai !== b.charCodeAt(j))
44
65
  continue;
45
- aFlags[i] = true;
46
- bFlags[j] = true;
66
+ aFlags[i] = 1;
67
+ bFlags[j] = 1;
47
68
  matches++;
48
69
  break;
49
70
  }
@@ -53,11 +74,11 @@ export function jaroWinkler(a, b) {
53
74
  let transpositions = 0;
54
75
  let k = 0;
55
76
  for (let i = 0; i < a.length; i++) {
56
- if (aFlags[i] !== true)
77
+ if (aFlags[i] !== 1)
57
78
  continue;
58
- while (bFlags[k] !== true)
79
+ while (bFlags[k] !== 1)
59
80
  k++;
60
- if (a[i] !== b[k])
81
+ if (a.charCodeAt(i) !== b.charCodeAt(k))
61
82
  transpositions++;
62
83
  k++;
63
84
  }
@@ -86,6 +107,37 @@ export function jaccard(a, b) {
86
107
  const union = a.size + b.size - intersection;
87
108
  return union === 0 ? 0 : intersection / union;
88
109
  }
110
+ /**
111
+ * The most work one Levenshtein or Jaro comparison may do, in Levenshtein
112
+ * cells: about a second of one thread. Both methods are O(n x m) and run
113
+ * synchronously on the harness thread, so two 20,000-character strings
114
+ * stalled every session in the process; this refuses those and still
115
+ * answers the sizes 0.7.0 answered in a fraction of a second.
116
+ */
117
+ export const MAX_SIMILARITY_WORK = 200_000_000;
118
+ /**
119
+ * A Jaro cell (one step of the matching window) costs about a quarter of a
120
+ * Levenshtein cell, measured: 25 M of them take 30-40 ms where 25 M
121
+ * Levenshtein cells take over 100 ms.
122
+ */
123
+ const JARO_CELLS_PER_UNIT = 4;
124
+ /**
125
+ * The work one comparison does, in Levenshtein cells: the DP table for
126
+ * Levenshtein, the matching window for Jaro at its measured discount.
127
+ * Trigram and token overlap are linear, so they cost nothing against the
128
+ * quadratic budget.
129
+ */
130
+ export function similarityCost(a, b, method) {
131
+ if (a === b || a.length === 0 || b.length === 0)
132
+ return 0;
133
+ if (method === "levenshtein")
134
+ return a.length * b.length;
135
+ if (method === "jaro") {
136
+ const window = Math.max(0, Math.floor(Math.max(a.length, b.length) / 2) - 1);
137
+ return Math.ceil((a.length * Math.min(b.length, 2 * window + 1)) / JARO_CELLS_PER_UNIT);
138
+ }
139
+ return 0;
140
+ }
89
141
  export function similarity(a, b, method) {
90
142
  if (method === "levenshtein")
91
143
  return levenshteinRatio(a, b);