pi-memoria 0.2.0 → 0.3.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/similarity.ts DELETED
@@ -1,374 +0,0 @@
1
- /**
2
- * Similarity heuristics used for housekeeping:
3
- *
4
- * - `suggestMerges`: pairs of notes that look like the same subject filed twice
5
- * ("dietary preferences" vs "food preferences", or a person's note and their
6
- * legacy relation note).
7
- * - `detectContradictions`: fact units that say the same thing with opposite
8
- * polarity ("deploys on Thursday" vs "no longer deploys on Thursday").
9
- *
10
- * Both are deliberately conservative: a false positive wastes a sentence of the
11
- * agent's attention, but a merge that folds two subjects together is worse than
12
- * a missed hint, so the thresholds err on the side of silence.
13
- */
14
-
15
- import type { Contradiction, MergeSuggestion } from "./types.ts";
16
-
17
- /** Enough information to compare two notes. */
18
- export interface SimilarityDoc {
19
- id: string;
20
- title: string;
21
- relPath: string;
22
- category: string;
23
- /** Alternative names the subject goes by (nicknames, relations, former names). */
24
- aliases?: string[];
25
- /** Weighted term frequencies (title + body), as stored in the index. */
26
- tokens: Record<string, number>;
27
- priority?: number;
28
- }
29
-
30
- /** One fact unit extracted from a note body. */
31
- export interface FactUnitLike {
32
- id: string;
33
- relPath: string;
34
- label?: string;
35
- text: string;
36
- }
37
-
38
- /** Words that flip the meaning of a fact. */
39
- const NEGATION_WORDS = new Set([
40
- "not",
41
- "no",
42
- "never",
43
- "none",
44
- "without",
45
- "dont",
46
- "don't",
47
- "doesnt",
48
- "doesn't",
49
- "isnt",
50
- "isn't",
51
- "wasnt",
52
- "wasn't",
53
- "arent",
54
- "aren't",
55
- "wont",
56
- "won't",
57
- "cant",
58
- "can't",
59
- "avoid",
60
- "avoids",
61
- "avoided",
62
- "dislike",
63
- "dislikes",
64
- "disliked",
65
- "stopped",
66
- "stop",
67
- "removed",
68
- "removed",
69
- "deprecated",
70
- "cancelled",
71
- "canceled",
72
- "rejected",
73
- "denied",
74
- "never",
75
- "no-longer",
76
- "longer",
77
- "instead",
78
- "except",
79
- "unlike",
80
- "neither",
81
- "nor",
82
- "rather",
83
- ]);
84
-
85
- /** True when a fact carries negation or a reversal. */
86
- export function hasNegation(text: string): boolean {
87
- for (const token of text.toLowerCase().split(/[^a-z0-9']+/)) {
88
- if (!token) continue;
89
- if (NEGATION_WORDS.has(token)) return true;
90
- }
91
- return false;
92
- }
93
-
94
- /** Tokens that carry meaning for similarity (no negations, no one-letter noise). */
95
- export function contentTokens(tokens: Iterable<string>): string[] {
96
- const out: string[] = [];
97
- for (const token of tokens) {
98
- if (token.length < 3) continue;
99
- if (NEGATION_WORDS.has(token)) continue;
100
- out.push(token);
101
- }
102
- return out;
103
- }
104
-
105
- /** Cosine similarity of two weighted term vectors. Returns 0..1. */
106
- export function cosineSimilarity(a: Record<string, number>, b: Record<string, number>): number {
107
- const [small, large] = Object.keys(a).length <= Object.keys(b).length ? [a, b] : [b, a];
108
- let dot = 0;
109
- for (const [term, weight] of Object.entries(small)) {
110
- const other = large[term];
111
- if (other !== undefined) dot += weight * other;
112
- }
113
- if (dot === 0) return 0;
114
- const norm = (vector: Record<string, number>): number => {
115
- let sum = 0;
116
- for (const weight of Object.values(vector)) sum += weight * weight;
117
- return Math.sqrt(sum);
118
- };
119
- const denominator = norm(a) * norm(b);
120
- return denominator === 0 ? 0 : dot / denominator;
121
- }
122
-
123
- /** Jaccard similarity of two token sets. Returns 0..1. */
124
- export function jaccard(a: Iterable<string>, b: Iterable<string>): number {
125
- const left = new Set(a);
126
- const right = new Set(b);
127
- if (left.size === 0 || right.size === 0) return 0;
128
- let shared = 0;
129
- for (const token of left) if (right.has(token)) shared += 1;
130
- return shared / (left.size + right.size - shared);
131
- }
132
-
133
- export interface MergeOptions {
134
- /** Minimum containment score to report a pair. */
135
- threshold?: number;
136
- /** Minimum number of shared distinctive terms. */
137
- minShared?: number;
138
- /** Maximum number of pairs returned. */
139
- limit?: number;
140
- /** Terms present in more than this fraction of notes are not distinctive. */
141
- maxDocumentRatio?: number;
142
- /** Buckets larger than this are skipped (a term that is not distinctive in practice). */
143
- maxBucketSize?: number;
144
- }
145
-
146
- /** Terms that carry identity for similarity purposes. */
147
- export function isComparableTerm(term: string): boolean {
148
- if (term.length < 4) return false;
149
- // Note ids are indexed so links are searchable; they must not make two
150
- // linked notes look like the same subject.
151
- if (term.startsWith("mem_")) return false;
152
- if (/^[0-9]+$/.test(term)) return false;
153
- return true;
154
- }
155
-
156
- /** IDF weights from a document-frequency table. */
157
- export function makeIdf(df: Map<string, number>, docCount: number): (term: string) => number {
158
- return (term: string) => Math.log(1 + docCount / (1 + (df.get(term) ?? 1))) + 0.5;
159
- }
160
-
161
- /** Slug used for name comparison: apostrophes join, everything else splits. */
162
- function nameSlug(text: string): string {
163
- return text
164
- .toLowerCase()
165
- .normalize("NFKD")
166
- .replace(/[\u0300-\u036f]/g, "")
167
- .replace(/['\u2019\u02bc]+/g, "")
168
- .replace(/[^a-z0-9\u3040-\u30ff\u4e00-\u9fff]+/g, "-")
169
- .replace(/^-+|-+$/g, "");
170
- }
171
-
172
- /** True when `shorter` is a strict leading or trailing run of `longer`. */
173
- function isRunOf(shorter: string[], longer: string[]): boolean {
174
- if (shorter.length === 0 || shorter.length >= longer.length) return false;
175
- const prefix = shorter.every((token, index) => longer[index] === token);
176
- if (prefix) return true;
177
- const offset = longer.length - shorter.length;
178
- return shorter.every((token, index) => longer[offset + index] === token);
179
- }
180
-
181
- /**
182
- * True when two notes are plausibly about the same subject *by their names*:
183
- * equal slugs, or one name being a prefix/suffix of the other
184
- * ("Alice" vs "Alice Smith", "Project Nightingale" vs "Nightingale").
185
- *
186
- * Shared individual words are deliberately not enough: "John Doe" and "John
187
- * Doe's father" share tokens but are two different people, and merging them
188
- * would be worse than missing a duplicate.
189
- */
190
- export function namesLinked(a: SimilarityDoc, b: SimilarityDoc): boolean {
191
- const names = (doc: SimilarityDoc): string[] => [doc.title, ...(doc.aliases ?? [])].map(nameSlug).filter((name) => name.length >= 3);
192
- const left = names(a);
193
- const right = names(b);
194
- for (const x of left) {
195
- const tx = x.split("-").filter(Boolean);
196
- for (const y of right) {
197
- if (x === y) return true;
198
- const ty = y.split("-").filter(Boolean);
199
- if (isRunOf(tx, ty) || isRunOf(ty, tx)) return true;
200
- }
201
- }
202
- return false;
203
- }
204
-
205
- export interface PairSimilarity {
206
- /** IDF-weighted containment of the smaller note's vocabulary in the other. */
207
- score: number;
208
- /** Distinctive terms the two notes share. */
209
- shared: string[];
210
- /** True when the titles/aliases point at the same subject. */
211
- linked: boolean;
212
- /**
213
- * True when the pair is worth reporting as a possible duplicate. A name link
214
- * lowers the bar; without one, the notes must be near-identical.
215
- */
216
- duplicate: boolean;
217
- }
218
-
219
- /**
220
- * Compare two notes.
221
- *
222
- * Score is IDF-weighted *containment*: how much of the smaller note's
223
- * distinctive vocabulary also appears in the other note. Two notes about one
224
- * person (a legacy "John's father" note and a newer "Bob" note) share the entity
225
- * and its facts, so nearly all of the smaller note is contained in the larger
226
- * one. Ordinary notes that merely mention a common word score low.
227
- */
228
- export function compareDocs(a: SimilarityDoc, b: SimilarityDoc, idfOf: (term: string) => number, options: { threshold?: number; minShared?: number } = {}): PairSimilarity {
229
- const threshold = options.threshold ?? 0.62;
230
- // A name link relaxes the shared-term requirement; without one the bar is
231
- // intentionally much higher (see `duplicate` below).
232
- const minShared = options.minShared ?? 1;
233
- const termsOf = (doc: SimilarityDoc): string[] => Object.keys(doc.tokens).filter(isComparableTerm);
234
- const left = termsOf(a);
235
- const right = termsOf(b);
236
- const rightSet = new Set(right);
237
- const shared = left.filter((term) => rightSet.has(term));
238
- const massOf = (terms: string[]): number => terms.reduce((sum, term) => sum + idfOf(term), 0);
239
- const leftMass = massOf(left);
240
- const rightMass = massOf(right);
241
- const sharedMass = massOf(shared);
242
- const smallerMass = Math.min(leftMass, rightMass);
243
- const score = smallerMass === 0 ? 0 : sharedMass / smallerMass;
244
- const linked = namesLinked(a, b);
245
- // Without a name link, a shared paragraph is not enough: a person's note
246
- // routinely mentions their employer, family and projects. Require the notes
247
- // to be near-identical instead.
248
- const duplicate = linked
249
- ? score >= threshold && shared.length >= minShared
250
- : score >= 0.9 && shared.length >= Math.max(5, minShared);
251
- return { score, shared, linked, duplicate };
252
- }
253
-
254
- /**
255
- * Find note pairs that look like the same subject.
256
- *
257
- * Pairs are generated only from shared distinctive terms, so the cost is
258
- * proportional to real overlap instead of the quadratic number of pairs.
259
- */
260
- export function suggestMerges(docs: SimilarityDoc[], options: MergeOptions = {}): MergeSuggestion[] {
261
- const threshold = options.threshold ?? 0.62;
262
- const limit = options.limit ?? 20;
263
- const maxDocumentRatio = options.maxDocumentRatio ?? 0.2;
264
- const maxBucketSize = options.maxBucketSize ?? 40;
265
- if (docs.length < 2) return [];
266
-
267
- const n = docs.length;
268
- const df = new Map<string, number>();
269
- const buckets = new Map<string, number[]>();
270
- for (let i = 0; i < n; i += 1) {
271
- for (const term of Object.keys(docs[i].tokens)) {
272
- if (!isComparableTerm(term)) continue;
273
- df.set(term, (df.get(term) ?? 0) + 1);
274
- const bucket = buckets.get(term);
275
- if (bucket) bucket.push(i);
276
- else buckets.set(term, [i]);
277
- }
278
- }
279
- const idfOf = makeIdf(df, n);
280
- const maxDf = Math.max(2, Math.ceil(n * maxDocumentRatio));
281
-
282
- const pairs = new Map<string, { a: number; b: number }>();
283
- for (const [term, members] of buckets) {
284
- if (members.length < 2 || members.length > maxBucketSize) continue;
285
- if ((df.get(term) ?? 0) > maxDf) continue;
286
- for (let x = 0; x < members.length; x += 1) {
287
- for (let y = x + 1; y < members.length; y += 1) {
288
- const a = members[x];
289
- const b = members[y];
290
- if (docs[a].category !== docs[b].category) continue;
291
- const key = a < b ? `${a}:${b}` : `${b}:${a}`;
292
- if (!pairs.has(key)) pairs.set(key, { a, b });
293
- }
294
- }
295
- }
296
-
297
- const suggestions: MergeSuggestion[] = [];
298
- for (const { a, b } of pairs.values()) {
299
- const similarity = compareDocs(docs[a], docs[b], idfOf, { threshold });
300
- if (!similarity.duplicate) continue;
301
- suggestions.push({
302
- a: { id: docs[a].id, title: docs[a].title, relPath: docs[a].relPath, category: docs[a].category },
303
- b: { id: docs[b].id, title: docs[b].title, relPath: docs[b].relPath, category: docs[b].category },
304
- score: Math.round(similarity.score * 1000) / 1000,
305
- shared: [...new Set(similarity.shared)].sort().slice(0, 8),
306
- category: docs[a].category,
307
- });
308
- }
309
- suggestions.sort((x, y) => y.score - x.score);
310
- return suggestions.slice(0, limit);
311
- }
312
-
313
- /** Suggestions involving one specific note id. */
314
- export function mergesFor(suggestions: MergeSuggestion[], id: string): MergeSuggestion[] {
315
- return suggestions.filter((suggestion) => suggestion.a.id === id || suggestion.b.id === id);
316
- }
317
-
318
- export interface ContradictionOptions {
319
- /** Minimum Jaccard similarity of content tokens. */
320
- threshold?: number;
321
- /** Minimum shared content tokens. */
322
- minShared?: number;
323
- limit?: number;
324
- }
325
-
326
- /**
327
- * Find fact units that share enough wording to be about the same thing but
328
- * disagree on polarity, e.g. "Deploys happen on Thursdays" and "Deploys no
329
- * longer happen on Thursdays".
330
- */
331
- export function detectContradictions(units: FactUnitLike[], options: ContradictionOptions = {}): Contradiction[] {
332
- const threshold = options.threshold ?? 0.5;
333
- const minShared = options.minShared ?? 2;
334
- const limit = options.limit ?? 20;
335
- const parsed = units
336
- .map((unit) => ({ unit, tokens: contentTokens(unit.text.toLowerCase().split(/[^a-z0-9']+/)), negated: hasNegation(unit.text) }))
337
- .filter((entry) => entry.tokens.length >= 3);
338
- // Bucket by content token so only plausible pairs are compared.
339
- const buckets = new Map<string, number[]>();
340
- parsed.forEach((entry, index) => {
341
- for (const token of new Set(entry.tokens)) {
342
- const bucket = buckets.get(token);
343
- if (bucket) bucket.push(index);
344
- else buckets.set(token, [index]);
345
- }
346
- });
347
- const seen = new Set<string>();
348
- const out: Contradiction[] = [];
349
- for (const members of buckets.values()) {
350
- if (members.length < 2 || members.length > 60) continue;
351
- for (let x = 0; x < members.length; x += 1) {
352
- for (let y = x + 1; y < members.length; y += 1) {
353
- const left = parsed[members[x]];
354
- const right = parsed[members[y]];
355
- if (left.negated === right.negated) continue;
356
- if (left.unit.id === right.unit.id && left.unit.text === right.unit.text) continue;
357
- const key = members[x] < members[y] ? `${members[x]}:${members[y]}` : `${members[y]}:${members[x]}`;
358
- if (seen.has(key)) continue;
359
- seen.add(key);
360
- const shared = left.tokens.filter((token) => right.tokens.includes(token));
361
- if (shared.length < minShared) continue;
362
- if (jaccard(left.tokens, right.tokens) < threshold) continue;
363
- out.push({
364
- a: { id: left.unit.id, relPath: left.unit.relPath, text: left.unit.text },
365
- b: { id: right.unit.id, relPath: right.unit.relPath, text: right.unit.text },
366
- shared: [...new Set(shared)].slice(0, 6),
367
- reason: "one side is negated and the wording otherwise matches; confirm which is current",
368
- });
369
- }
370
- }
371
- }
372
- out.sort((a, b) => b.shared.length - a.shared.length);
373
- return out.slice(0, limit);
374
- }