@gamaze/hicortex 0.14.3 → 0.15.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.
@@ -0,0 +1,110 @@
1
+ /**
2
+ * D4 — decay / no-fit lifecycle + #192 adoption audit (#191 mechanical
3
+ * baseline). Four sections, all read-only against a snapshot connection:
4
+ *
5
+ * (a) domain backlog — NULL-domain memories split into "never scanned
6
+ * yet" vs "scanned, no fitting domain"
7
+ * (b) prune dry-run — the REAL production predicate (stageDecayPrune,
8
+ * dryRun=true), not a reimplementation
9
+ * (c) structural strength — how many memories can EVER cross the prune
10
+ * floor, now vs simulated further out
11
+ * (d) adoption — #192 shown_count/access_count signal quality
12
+ */
13
+ import type Database from "better-sqlite3";
14
+ export interface DomainBacklogReport {
15
+ nullDomainTotal: number;
16
+ /** rowid > domainCursor: the classify-domains scan has not reached these rows yet. */
17
+ neverClassifiedBacklog: number;
18
+ /** rowid <= domainCursor: scanned, but the LLM found no fitting domain (no-fit, below weakPrimaryFloor). */
19
+ classifiedButEmpty: number;
20
+ domainCursor: number | null;
21
+ maxRowid: number;
22
+ /** True when domainCursor was mapped 1:1 (classify-domains.ts documents it as "last committed rowid"). */
23
+ cursorMappingConfident: boolean;
24
+ }
25
+ /**
26
+ * Partition NULL-domain memories by the `domainCursor` rowid watermark.
27
+ * `domainCursor` is documented in classify-domains.ts as "last fully
28
+ * committed rowid" — a direct rowid position, not an opaque offset — so the
29
+ * split is a plain rowid comparison. If a future version changes that
30
+ * contract this function still degrades safely: with no cursor, everything
31
+ * NULL is reported as backlog (worst case, never silently wrong).
32
+ */
33
+ export declare function runDomainBacklogAudit(db: Database.Database, stateDir?: string): DomainBacklogReport;
34
+ export interface PruneDryRunReport {
35
+ candidates: number;
36
+ pruned: number;
37
+ failed: number;
38
+ decayHalfLifeDaysUsed: number;
39
+ }
40
+ /**
41
+ * Run the actual `stageDecayPrune` (imported from consolidate.ts, `dryRun:
42
+ * true`) against the snapshot. Configures the decay clock to the given
43
+ * half-life first (bedrock has no `decayHalfLifeDays` override, so the
44
+ * caller should pass the shipped default — see run-eval.ts) so the eval and
45
+ * production score with the same clock.
46
+ */
47
+ export declare function runPruneDryRun(db: Database.Database, decayHalfLifeDays?: number): PruneDryRunReport;
48
+ /**
49
+ * effectiveStrength's asymptotic floor is `base_strength * importance * 0.1`
50
+ * with `importance` defaulting to `base_strength` (retrieval.ts). A memory
51
+ * can EVER cross the prune floor (0.01, stageDecayPrune) only if that
52
+ * asymptote is itself below it: base_strength² * 0.1 < 0.01, i.e.
53
+ * base_strength < sqrt(0.1). Independent of access/link hardening — those
54
+ * only slow the approach, they never raise the floor.
55
+ */
56
+ export declare const EVER_PRUNABLE_BASE_STRENGTH_CEILING: number;
57
+ export declare function histogram(values: number[], edges?: number[]): Record<string, number>;
58
+ export interface StructuralStrengthStats {
59
+ totalMemories: number;
60
+ everPrunableCount: number;
61
+ everPrunableCeiling: number;
62
+ effectiveStrengthNowHistogram: Record<string, number>;
63
+ effectiveStrengthAt180dHistogram: Record<string, number>;
64
+ effectiveStrengthAt365dHistogram: Record<string, number>;
65
+ /** The asymptotic floor per memory (base_strength² × 0.1) — what "at infinity" converges to. */
66
+ effectiveStrengthNeverHistogram: Record<string, number>;
67
+ }
68
+ /**
69
+ * Compute effective-strength distributions now, and simulated further out
70
+ * (same access/link counts, decay continuing with no further access — the
71
+ * "if nothing changes" projection), plus the asymptotic floor per memory.
72
+ * Requires `configureDecay` to already reflect the desired half-life (call
73
+ * `runPruneDryRun` first, or `configureDecay` directly, in the same process).
74
+ */
75
+ export declare function runStructuralStrengthStats(db: Database.Database): StructuralStrengthStats;
76
+ export interface AdoptionStats {
77
+ totalMemories: number;
78
+ shownCountHistogram: Record<string, number>;
79
+ accessCountHistogram: Record<string, number>;
80
+ totalShown: number;
81
+ totalAccess: number;
82
+ /** sum(access_count) / sum(shown_count) across the corpus — null if nothing has ever been shown. */
83
+ usesPerShowingOverall: number | null;
84
+ usesPerShowingBySourceAgent: Record<string, {
85
+ shown: number;
86
+ access: number;
87
+ ratio: number | null;
88
+ }>;
89
+ /** Never shown AND never accessed — completely inert memories. */
90
+ coldShare: {
91
+ coldCount: number;
92
+ total: number;
93
+ share: number;
94
+ };
95
+ /** By memory age bucket: total count, how many have ever been shown, and the share. */
96
+ exposureAgeProfile: Array<{
97
+ bucket: string;
98
+ total: number;
99
+ everShown: number;
100
+ shareShown: number;
101
+ }>;
102
+ }
103
+ /**
104
+ * Adoption snapshot from the #192 exposure/use columns. `shown_count` was
105
+ * added by migration v8 on 27.07.2026 — freshly deployed at snapshot time
106
+ * (~2 days), so near-zero shown counts across the board reflect the
107
+ * feature's youth as much as its effectiveness. Report callers should state
108
+ * that caveat alongside the numbers, not just the numbers.
109
+ */
110
+ export declare function runAdoptionStats(db: Database.Database): AdoptionStats;
@@ -0,0 +1,252 @@
1
+ "use strict";
2
+ /**
3
+ * D4 — decay / no-fit lifecycle + #192 adoption audit (#191 mechanical
4
+ * baseline). Four sections, all read-only against a snapshot connection:
5
+ *
6
+ * (a) domain backlog — NULL-domain memories split into "never scanned
7
+ * yet" vs "scanned, no fitting domain"
8
+ * (b) prune dry-run — the REAL production predicate (stageDecayPrune,
9
+ * dryRun=true), not a reimplementation
10
+ * (c) structural strength — how many memories can EVER cross the prune
11
+ * floor, now vs simulated further out
12
+ * (d) adoption — #192 shown_count/access_count signal quality
13
+ */
14
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ var desc = Object.getOwnPropertyDescriptor(m, k);
17
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
18
+ desc = { enumerable: true, get: function() { return m[k]; } };
19
+ }
20
+ Object.defineProperty(o, k2, desc);
21
+ }) : (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ o[k2] = m[k];
24
+ }));
25
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
26
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
27
+ }) : function(o, v) {
28
+ o["default"] = v;
29
+ });
30
+ var __importStar = (this && this.__importStar) || (function () {
31
+ var ownKeys = function(o) {
32
+ ownKeys = Object.getOwnPropertyNames || function (o) {
33
+ var ar = [];
34
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
35
+ return ar;
36
+ };
37
+ return ownKeys(o);
38
+ };
39
+ return function (mod) {
40
+ if (mod && mod.__esModule) return mod;
41
+ var result = {};
42
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
43
+ __setModuleDefault(result, mod);
44
+ return result;
45
+ };
46
+ })();
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.EVER_PRUNABLE_BASE_STRENGTH_CEILING = void 0;
49
+ exports.runDomainBacklogAudit = runDomainBacklogAudit;
50
+ exports.runPruneDryRun = runPruneDryRun;
51
+ exports.histogram = histogram;
52
+ exports.runStructuralStrengthStats = runStructuralStrengthStats;
53
+ exports.runAdoptionStats = runAdoptionStats;
54
+ const retrieval_js_1 = require("../retrieval.js");
55
+ const consolidate_js_1 = require("../consolidate.js");
56
+ const storage = __importStar(require("../storage.js"));
57
+ const state_js_1 = require("../state.js");
58
+ /**
59
+ * Partition NULL-domain memories by the `domainCursor` rowid watermark.
60
+ * `domainCursor` is documented in classify-domains.ts as "last fully
61
+ * committed rowid" — a direct rowid position, not an opaque offset — so the
62
+ * split is a plain rowid comparison. If a future version changes that
63
+ * contract this function still degrades safely: with no cursor, everything
64
+ * NULL is reported as backlog (worst case, never silently wrong).
65
+ */
66
+ function runDomainBacklogAudit(db, stateDir) {
67
+ const state = (0, state_js_1.loadState)(stateDir);
68
+ const domainCursor = state.domainCursor ?? null;
69
+ const maxRowid = db.prepare("SELECT MAX(rowid) AS r FROM memories").get().r ?? 0;
70
+ const nullDomainTotal = db.prepare("SELECT COUNT(*) AS c FROM memories WHERE domain IS NULL").get().c;
71
+ let neverClassifiedBacklog;
72
+ if (domainCursor !== null) {
73
+ neverClassifiedBacklog = db
74
+ .prepare("SELECT COUNT(*) AS c FROM memories WHERE domain IS NULL AND rowid > ?")
75
+ .get(domainCursor).c;
76
+ }
77
+ else {
78
+ neverClassifiedBacklog = nullDomainTotal;
79
+ }
80
+ return {
81
+ nullDomainTotal,
82
+ neverClassifiedBacklog,
83
+ classifiedButEmpty: nullDomainTotal - neverClassifiedBacklog,
84
+ domainCursor,
85
+ maxRowid,
86
+ cursorMappingConfident: true,
87
+ };
88
+ }
89
+ /**
90
+ * Run the actual `stageDecayPrune` (imported from consolidate.ts, `dryRun:
91
+ * true`) against the snapshot. Configures the decay clock to the given
92
+ * half-life first (bedrock has no `decayHalfLifeDays` override, so the
93
+ * caller should pass the shipped default — see run-eval.ts) so the eval and
94
+ * production score with the same clock.
95
+ */
96
+ function runPruneDryRun(db, decayHalfLifeDays = retrieval_js_1.DEFAULT_DECAY_HALF_LIFE_DAYS) {
97
+ (0, retrieval_js_1.configureDecay)({ halfLifeDays: decayHalfLifeDays });
98
+ const result = (0, consolidate_js_1.stageDecayPrune)(db, true);
99
+ return { ...result, decayHalfLifeDaysUsed: decayHalfLifeDays };
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // (c) Structural strength stats
103
+ // ---------------------------------------------------------------------------
104
+ /**
105
+ * effectiveStrength's asymptotic floor is `base_strength * importance * 0.1`
106
+ * with `importance` defaulting to `base_strength` (retrieval.ts). A memory
107
+ * can EVER cross the prune floor (0.01, stageDecayPrune) only if that
108
+ * asymptote is itself below it: base_strength² * 0.1 < 0.01, i.e.
109
+ * base_strength < sqrt(0.1). Independent of access/link hardening — those
110
+ * only slow the approach, they never raise the floor.
111
+ */
112
+ exports.EVER_PRUNABLE_BASE_STRENGTH_CEILING = Math.sqrt(0.1);
113
+ const STRENGTH_BUCKET_EDGES = [0, 0.01, 0.05, 0.1, 0.2, 0.3, 0.5, 0.7, 1.0];
114
+ function bucketLabel(value, edges) {
115
+ for (let i = 0; i < edges.length - 1; i++) {
116
+ if (value >= edges[i] && value < edges[i + 1])
117
+ return `[${edges[i]}, ${edges[i + 1]})`;
118
+ }
119
+ return `[${edges[edges.length - 1]}, +]`;
120
+ }
121
+ function histogram(values, edges = STRENGTH_BUCKET_EDGES) {
122
+ const buckets = {};
123
+ for (const v of values) {
124
+ const label = bucketLabel(v, edges);
125
+ buckets[label] = (buckets[label] ?? 0) + 1;
126
+ }
127
+ return buckets;
128
+ }
129
+ /**
130
+ * Compute effective-strength distributions now, and simulated further out
131
+ * (same access/link counts, decay continuing with no further access — the
132
+ * "if nothing changes" projection), plus the asymptotic floor per memory.
133
+ * Requires `configureDecay` to already reflect the desired half-life (call
134
+ * `runPruneDryRun` first, or `configureDecay` directly, in the same process).
135
+ */
136
+ function runStructuralStrengthStats(db) {
137
+ const rows = db
138
+ .prepare("SELECT id, base_strength, last_accessed, access_count FROM memories")
139
+ .all();
140
+ const linkCounts = storage.getAllLinkCounts(db);
141
+ const now = new Date();
142
+ const nowVals = [];
143
+ const at180 = [];
144
+ const at365 = [];
145
+ const neverVals = [];
146
+ let everPrunable = 0;
147
+ for (const r of rows) {
148
+ const base = r.base_strength ?? 0.5;
149
+ if (base < exports.EVER_PRUNABLE_BASE_STRENGTH_CEILING)
150
+ everPrunable++;
151
+ const linkCount = linkCounts.get(r.id) ?? 0;
152
+ const opts = { accessCount: r.access_count ?? 0, linkCount };
153
+ nowVals.push((0, retrieval_js_1.effectiveStrength)(base, r.last_accessed, now, opts));
154
+ at180.push((0, retrieval_js_1.effectiveStrength)(base, r.last_accessed, new Date(now.getTime() + 180 * 86_400_000), opts));
155
+ at365.push((0, retrieval_js_1.effectiveStrength)(base, r.last_accessed, new Date(now.getTime() + 365 * 86_400_000), opts));
156
+ neverVals.push(base * base * 0.1);
157
+ }
158
+ return {
159
+ totalMemories: rows.length,
160
+ everPrunableCount: everPrunable,
161
+ everPrunableCeiling: exports.EVER_PRUNABLE_BASE_STRENGTH_CEILING,
162
+ effectiveStrengthNowHistogram: histogram(nowVals),
163
+ effectiveStrengthAt180dHistogram: histogram(at180),
164
+ effectiveStrengthAt365dHistogram: histogram(at365),
165
+ effectiveStrengthNeverHistogram: histogram(neverVals),
166
+ };
167
+ }
168
+ // ---------------------------------------------------------------------------
169
+ // (d) Adoption stats (#192 columns: shown_count, access_count)
170
+ // ---------------------------------------------------------------------------
171
+ const COUNT_BUCKET_EDGES = [0, 1, 2, 5, 10, 25, 50, 100, 1000];
172
+ const AGE_BUCKET_EDGES_DAYS = [0, 7, 30, 90, 180, 365];
173
+ function ageBucketLabel(ageDays) {
174
+ for (let i = 0; i < AGE_BUCKET_EDGES_DAYS.length - 1; i++) {
175
+ if (ageDays >= AGE_BUCKET_EDGES_DAYS[i] && ageDays < AGE_BUCKET_EDGES_DAYS[i + 1]) {
176
+ return `${AGE_BUCKET_EDGES_DAYS[i]}-${AGE_BUCKET_EDGES_DAYS[i + 1]}d`;
177
+ }
178
+ }
179
+ return `${AGE_BUCKET_EDGES_DAYS[AGE_BUCKET_EDGES_DAYS.length - 1]}d+`;
180
+ }
181
+ /**
182
+ * Adoption snapshot from the #192 exposure/use columns. `shown_count` was
183
+ * added by migration v8 on 27.07.2026 — freshly deployed at snapshot time
184
+ * (~2 days), so near-zero shown counts across the board reflect the
185
+ * feature's youth as much as its effectiveness. Report callers should state
186
+ * that caveat alongside the numbers, not just the numbers.
187
+ */
188
+ function runAdoptionStats(db) {
189
+ const rows = db
190
+ .prepare("SELECT id, shown_count, access_count, source_agent, created_at FROM memories")
191
+ .all();
192
+ const now = new Date();
193
+ const shownVals = [];
194
+ const accessVals = [];
195
+ let totalShown = 0;
196
+ let totalAccess = 0;
197
+ let coldCount = 0;
198
+ const byAgent = new Map();
199
+ const ageBuckets = new Map();
200
+ for (const r of rows) {
201
+ const shown = r.shown_count ?? 0;
202
+ const access = r.access_count ?? 0;
203
+ shownVals.push(shown);
204
+ accessVals.push(access);
205
+ totalShown += shown;
206
+ totalAccess += access;
207
+ if (shown === 0 && access === 0)
208
+ coldCount++;
209
+ const agentAgg = byAgent.get(r.source_agent) ?? { shown: 0, access: 0 };
210
+ agentAgg.shown += shown;
211
+ agentAgg.access += access;
212
+ byAgent.set(r.source_agent, agentAgg);
213
+ const ageDays = (now.getTime() - Date.parse(r.created_at)) / 86_400_000;
214
+ const label = ageBucketLabel(Number.isFinite(ageDays) ? Math.max(ageDays, 0) : 0);
215
+ const bucketAgg = ageBuckets.get(label) ?? { total: 0, everShown: 0 };
216
+ bucketAgg.total++;
217
+ if (shown > 0)
218
+ bucketAgg.everShown++;
219
+ ageBuckets.set(label, bucketAgg);
220
+ }
221
+ const usesPerShowingBySourceAgent = {};
222
+ for (const [agent, agg] of byAgent) {
223
+ usesPerShowingBySourceAgent[agent] = {
224
+ shown: agg.shown,
225
+ access: agg.access,
226
+ ratio: agg.shown > 0 ? agg.access / agg.shown : null,
227
+ };
228
+ }
229
+ const exposureAgeProfile = AGE_BUCKET_EDGES_DAYS.map((edge, i) => {
230
+ const label = i < AGE_BUCKET_EDGES_DAYS.length - 1
231
+ ? `${edge}-${AGE_BUCKET_EDGES_DAYS[i + 1]}d`
232
+ : `${edge}d+`;
233
+ const agg = ageBuckets.get(label) ?? { total: 0, everShown: 0 };
234
+ return {
235
+ bucket: label,
236
+ total: agg.total,
237
+ everShown: agg.everShown,
238
+ shareShown: agg.total > 0 ? agg.everShown / agg.total : 0,
239
+ };
240
+ }).filter((b) => b.total > 0);
241
+ return {
242
+ totalMemories: rows.length,
243
+ shownCountHistogram: histogram(shownVals, COUNT_BUCKET_EDGES),
244
+ accessCountHistogram: histogram(accessVals, COUNT_BUCKET_EDGES),
245
+ totalShown,
246
+ totalAccess,
247
+ usesPerShowingOverall: totalShown > 0 ? totalAccess / totalShown : null,
248
+ usesPerShowingBySourceAgent,
249
+ coldShare: { coldCount, total: rows.length, share: rows.length > 0 ? coldCount / rows.length : 0 },
250
+ exposureAgeProfile,
251
+ };
252
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * D1 — duplicate-rate audit (#191 mechanical baseline).
3
+ *
4
+ * For each memory, finds its top-10 nearest neighbors by vector distance and
5
+ * keeps every pair whose cosine similarity clears the lowest of three report
6
+ * thresholds (0.90 / 0.92 / 0.95). Clusters are built per threshold with
7
+ * union-find; the headline number at each threshold is "excess" — how many
8
+ * rows would disappear if every cluster were merged down to one memory.
9
+ *
10
+ * Duplicate pairs are additionally attributed to either the #189 recovery
11
+ * re-ingest (a retried capture segment produced a second row for content
12
+ * already stored) or organic near-duplication (independent sessions that
13
+ * happened to cover the same ground), using two signals: a shared base
14
+ * `source_session` (the `#<segment>` suffix stripped), or ingestion runs that
15
+ * differ while `created_at` is near-identical (a re-ingest preserves the
16
+ * original session date but lands in a later ingestion run).
17
+ *
18
+ * The clustering primitives (union-find, KNN edge building, metadata-mismatch
19
+ * check) live in `../cluster.js` — extracted (#100/#191) so `hicortex dedup`
20
+ * reuses the exact same math instead of re-implementing it. Re-exported below
21
+ * for backward compatibility with existing importers of this module.
22
+ */
23
+ import type Database from "better-sqlite3";
24
+ import { UnionFind, clusterEdges, clusterExcess, clusterMetadataMismatch, type Edge, type ClusterMetadataMismatch } from "../cluster.js";
25
+ export { UnionFind, clusterEdges, clusterExcess, clusterMetadataMismatch, type ClusterMetadataMismatch };
26
+ /** @deprecated import `Edge` from `../cluster.js` instead. */
27
+ export type DupEdge = Edge;
28
+ /** Cosine thresholds the report evaluates, low to high. */
29
+ export declare const DUP_THRESHOLDS: readonly [0.9, 0.92, 0.95];
30
+ export interface DupMemoryRow {
31
+ id: string;
32
+ content: string;
33
+ created_at: string;
34
+ ingested_at: string;
35
+ source_session: string | null;
36
+ project: string | null;
37
+ privacy: string;
38
+ source_agent: string;
39
+ }
40
+ /**
41
+ * Partition memories into ingestion "runs" by `ingested_at`: sorted
42
+ * ascending, a gap greater than `gapHours` starts a new run. Returns a map
43
+ * of memory id -> run id (0-based, monotonically increasing).
44
+ */
45
+ export declare function sessionizeByIngestedAt(rows: Array<{
46
+ id: string;
47
+ ingested_at: string;
48
+ }>, gapHours?: number): Map<string, number>;
49
+ /** Strip the `#<segment>` suffix from a `source_session` value. Null-safe. */
50
+ export declare function baseSessionId(sourceSession: string | null): string | null;
51
+ /** Whether two ISO timestamps fall within `toleranceDays` of each other. */
52
+ export declare function nearIdenticalDate(a: string, b: string, toleranceDays?: number): boolean;
53
+ export type PairAttribution = "recovery_reingest" | "organic";
54
+ /**
55
+ * Classify a duplicate pair as a #189 recovery re-ingest or organic overlap.
56
+ * Recovery re-ingest when either: both sides share the same base
57
+ * `source_session`, or they landed in different ingestion runs but
58
+ * `created_at` is near-identical (the re-ingest preserves the original
59
+ * session date while landing in a later run).
60
+ */
61
+ export declare function attributePair(a: DupMemoryRow, b: DupMemoryRow, runOf: Map<string, number>): PairAttribution;
62
+ export interface DupThresholdResult {
63
+ threshold: number;
64
+ clusterCount: number;
65
+ excess: number;
66
+ /** Cluster sizes, largest first — a quick histogram without dumping content. */
67
+ clusterSizes: number[];
68
+ }
69
+ export interface DupClusterDump {
70
+ size: number;
71
+ members: Array<{
72
+ id: string;
73
+ created_at: string;
74
+ preview: string;
75
+ }>;
76
+ attribution: {
77
+ recoveryReingest: number;
78
+ organic: number;
79
+ };
80
+ metadataMismatch: ClusterMetadataMismatch;
81
+ }
82
+ export interface DupReport {
83
+ totalMemories: number;
84
+ knnK: number;
85
+ thresholds: DupThresholdResult[];
86
+ /** Attribution over every pair at/above the lowest threshold (broadest view). */
87
+ pairAttribution: {
88
+ recoveryReingest: number;
89
+ organic: number;
90
+ totalPairs: number;
91
+ };
92
+ /** Top clusters at the lowest threshold, largest first, for manual review. */
93
+ topClusters: DupClusterDump[];
94
+ }
95
+ /**
96
+ * Run the full D1 duplicate audit against an open (readonly) snapshot
97
+ * connection. Pure aside from the DB reads — safe against a readonly
98
+ * connection, no writes attempted.
99
+ */
100
+ export declare function runDupAudit(db: Database.Database, topN?: number): DupReport;
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ /**
3
+ * D1 — duplicate-rate audit (#191 mechanical baseline).
4
+ *
5
+ * For each memory, finds its top-10 nearest neighbors by vector distance and
6
+ * keeps every pair whose cosine similarity clears the lowest of three report
7
+ * thresholds (0.90 / 0.92 / 0.95). Clusters are built per threshold with
8
+ * union-find; the headline number at each threshold is "excess" — how many
9
+ * rows would disappear if every cluster were merged down to one memory.
10
+ *
11
+ * Duplicate pairs are additionally attributed to either the #189 recovery
12
+ * re-ingest (a retried capture segment produced a second row for content
13
+ * already stored) or organic near-duplication (independent sessions that
14
+ * happened to cover the same ground), using two signals: a shared base
15
+ * `source_session` (the `#<segment>` suffix stripped), or ingestion runs that
16
+ * differ while `created_at` is near-identical (a re-ingest preserves the
17
+ * original session date but lands in a later ingestion run).
18
+ *
19
+ * The clustering primitives (union-find, KNN edge building, metadata-mismatch
20
+ * check) live in `../cluster.js` — extracted (#100/#191) so `hicortex dedup`
21
+ * reuses the exact same math instead of re-implementing it. Re-exported below
22
+ * for backward compatibility with existing importers of this module.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.DUP_THRESHOLDS = exports.clusterMetadataMismatch = exports.clusterExcess = exports.clusterEdges = exports.UnionFind = void 0;
26
+ exports.sessionizeByIngestedAt = sessionizeByIngestedAt;
27
+ exports.baseSessionId = baseSessionId;
28
+ exports.nearIdenticalDate = nearIdenticalDate;
29
+ exports.attributePair = attributePair;
30
+ exports.runDupAudit = runDupAudit;
31
+ const cluster_js_1 = require("../cluster.js");
32
+ Object.defineProperty(exports, "UnionFind", { enumerable: true, get: function () { return cluster_js_1.UnionFind; } });
33
+ Object.defineProperty(exports, "clusterEdges", { enumerable: true, get: function () { return cluster_js_1.clusterEdges; } });
34
+ Object.defineProperty(exports, "clusterExcess", { enumerable: true, get: function () { return cluster_js_1.clusterExcess; } });
35
+ Object.defineProperty(exports, "clusterMetadataMismatch", { enumerable: true, get: function () { return cluster_js_1.clusterMetadataMismatch; } });
36
+ /** Cosine thresholds the report evaluates, low to high. */
37
+ exports.DUP_THRESHOLDS = [0.9, 0.92, 0.95];
38
+ /** Neighbors requested per memory (excluding the memory itself). */
39
+ const KNN_K = 10;
40
+ /** Ingestion-run gap: a pause longer than this starts a new "run". */
41
+ const DEFAULT_RUN_GAP_HOURS = 1;
42
+ /** created_at proximity treated as "near-identical" for cross-run pairs. */
43
+ const DEFAULT_SAME_DATE_TOLERANCE_DAYS = 1;
44
+ // ---------------------------------------------------------------------------
45
+ // Sessionization + attribution (pure — unit tested)
46
+ // ---------------------------------------------------------------------------
47
+ /**
48
+ * Partition memories into ingestion "runs" by `ingested_at`: sorted
49
+ * ascending, a gap greater than `gapHours` starts a new run. Returns a map
50
+ * of memory id -> run id (0-based, monotonically increasing).
51
+ */
52
+ function sessionizeByIngestedAt(rows, gapHours = DEFAULT_RUN_GAP_HOURS) {
53
+ const sorted = [...rows].sort((a, b) => a.ingested_at.localeCompare(b.ingested_at));
54
+ const gapMs = gapHours * 3_600_000;
55
+ const runOf = new Map();
56
+ let runId = -1;
57
+ let prevTime = null;
58
+ for (const r of sorted) {
59
+ const t = Date.parse(r.ingested_at);
60
+ const valid = Number.isFinite(t);
61
+ if (prevTime === null || !valid || t - prevTime > gapMs) {
62
+ runId++;
63
+ }
64
+ runOf.set(r.id, runId);
65
+ if (valid)
66
+ prevTime = t;
67
+ }
68
+ return runOf;
69
+ }
70
+ /** Strip the `#<segment>` suffix from a `source_session` value. Null-safe. */
71
+ function baseSessionId(sourceSession) {
72
+ if (!sourceSession)
73
+ return null;
74
+ const idx = sourceSession.indexOf("#");
75
+ return idx === -1 ? sourceSession : sourceSession.slice(0, idx);
76
+ }
77
+ /** Whether two ISO timestamps fall within `toleranceDays` of each other. */
78
+ function nearIdenticalDate(a, b, toleranceDays = DEFAULT_SAME_DATE_TOLERANCE_DAYS) {
79
+ const ta = Date.parse(a);
80
+ const tb = Date.parse(b);
81
+ if (!Number.isFinite(ta) || !Number.isFinite(tb))
82
+ return false;
83
+ return Math.abs(ta - tb) / 86_400_000 <= toleranceDays;
84
+ }
85
+ /**
86
+ * Classify a duplicate pair as a #189 recovery re-ingest or organic overlap.
87
+ * Recovery re-ingest when either: both sides share the same base
88
+ * `source_session`, or they landed in different ingestion runs but
89
+ * `created_at` is near-identical (the re-ingest preserves the original
90
+ * session date while landing in a later run).
91
+ */
92
+ function attributePair(a, b, runOf) {
93
+ const baseA = baseSessionId(a.source_session);
94
+ const baseB = baseSessionId(b.source_session);
95
+ if (baseA !== null && baseA === baseB)
96
+ return "recovery_reingest";
97
+ const runA = runOf.get(a.id);
98
+ const runB = runOf.get(b.id);
99
+ if (runA !== undefined &&
100
+ runB !== undefined &&
101
+ runA !== runB &&
102
+ nearIdenticalDate(a.created_at, b.created_at)) {
103
+ return "recovery_reingest";
104
+ }
105
+ return "organic";
106
+ }
107
+ /**
108
+ * Run the full D1 duplicate audit against an open (readonly) snapshot
109
+ * connection. Pure aside from the DB reads — safe against a readonly
110
+ * connection, no writes attempted.
111
+ */
112
+ function runDupAudit(db, topN = 15) {
113
+ const rows = db
114
+ .prepare(`SELECT id, content, created_at, ingested_at, source_session, project, privacy, source_agent
115
+ FROM memories`)
116
+ .all();
117
+ const byId = new Map(rows.map((r) => [r.id, r]));
118
+ const runOf = sessionizeByIngestedAt(rows.map((r) => ({ id: r.id, ingested_at: r.ingested_at })));
119
+ const lowestThreshold = Math.min(...exports.DUP_THRESHOLDS);
120
+ const edges = (0, cluster_js_1.buildKnnEdges)(db, { k: KNN_K, minCosine: lowestThreshold });
121
+ const thresholds = exports.DUP_THRESHOLDS.map((threshold) => {
122
+ const clusters = (0, cluster_js_1.clusterEdges)(edges, threshold);
123
+ return {
124
+ threshold,
125
+ clusterCount: clusters.length,
126
+ excess: (0, cluster_js_1.clusterExcess)(clusters),
127
+ clusterSizes: clusters.map((c) => c.length).sort((a, b) => b - a),
128
+ };
129
+ });
130
+ let recoveryReingest = 0;
131
+ let organic = 0;
132
+ for (const e of edges) {
133
+ const a = byId.get(e.a);
134
+ const b = byId.get(e.b);
135
+ if (!a || !b)
136
+ continue;
137
+ if (attributePair(a, b, runOf) === "recovery_reingest")
138
+ recoveryReingest++;
139
+ else
140
+ organic++;
141
+ }
142
+ const broadestClusters = (0, cluster_js_1.clusterEdges)(edges, lowestThreshold).sort((a, b) => b.length - a.length);
143
+ const topClusters = broadestClusters.slice(0, topN).map((memberIds) => {
144
+ const members = memberIds.map((id) => byId.get(id)).filter((m) => !!m);
145
+ let recovery = 0;
146
+ let organicCount = 0;
147
+ for (let i = 0; i < members.length; i++) {
148
+ for (let j = i + 1; j < members.length; j++) {
149
+ if (attributePair(members[i], members[j], runOf) === "recovery_reingest")
150
+ recovery++;
151
+ else
152
+ organicCount++;
153
+ }
154
+ }
155
+ const sortedMembers = [...members].sort((a, b) => a.created_at.localeCompare(b.created_at));
156
+ return {
157
+ size: members.length,
158
+ members: sortedMembers.map((m) => ({
159
+ id: m.id,
160
+ created_at: m.created_at,
161
+ preview: m.content.slice(0, 80),
162
+ })),
163
+ attribution: { recoveryReingest: recovery, organic: organicCount },
164
+ metadataMismatch: (0, cluster_js_1.clusterMetadataMismatch)(members),
165
+ };
166
+ });
167
+ return {
168
+ totalMemories: rows.length,
169
+ knnK: KNN_K,
170
+ thresholds,
171
+ pairAttribution: { recoveryReingest, organic, totalPairs: edges.length },
172
+ topClusters,
173
+ };
174
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Read-only snapshot access for the #191 mechanical audit baseline.
3
+ *
4
+ * The eval NEVER touches a live database — it runs against a checkpointed
5
+ * copy (`data/audit-<date>/snapshot.db`, gitignored). This module opens that
6
+ * copy in better-sqlite3's `readonly` mode and loads the sqlite-vec
7
+ * extension the same way `db.ts#initDb` does, WITHOUT calling `initDb()`
8
+ * itself: `initDb` runs schema migrations, which write to the file. A
9
+ * snapshot is assumed to already be at the current schema version (verified
10
+ * by `assertReadonly`, which also proves no migration silently ran).
11
+ */
12
+ import Database from "better-sqlite3";
13
+ /**
14
+ * Open a DB snapshot for read-only analysis.
15
+ *
16
+ * Throws if the path does not exist, or if a write attempt against the
17
+ * returned connection would (surprisingly) succeed — the second check is a
18
+ * belt-and-suspenders guard against a future better-sqlite3/OS combination
19
+ * where `readonly: true` is silently ignored (e.g. a non-standard
20
+ * filesystem), so a bug can never turn the audit into a mutation of
21
+ * production data.
22
+ */
23
+ export declare function openSnapshot(dbPath: string): Database.Database;
24
+ /** Convert a sqlite-vec embedding BLOB (as read back from `memory_vectors`) to a Float32Array. */
25
+ export declare function blobToEmbedding(blob: Buffer): Float32Array;