@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.
- package/README.md +6 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +40 -0
- package/dist/cluster.d.ts +51 -0
- package/dist/cluster.js +118 -0
- package/dist/consolidate.d.ts +63 -1
- package/dist/consolidate.js +228 -2
- package/dist/db.js +26 -0
- package/dist/dedup.d.ts +157 -0
- package/dist/dedup.js +445 -0
- package/dist/eval/decay-eval.d.ts +110 -0
- package/dist/eval/decay-eval.js +252 -0
- package/dist/eval/dups.d.ts +100 -0
- package/dist/eval/dups.js +174 -0
- package/dist/eval/eval-db.d.ts +25 -0
- package/dist/eval/eval-db.js +67 -0
- package/dist/eval/graph-eval.d.ts +76 -0
- package/dist/eval/graph-eval.js +200 -0
- package/dist/eval/reflection-census.d.ts +19 -0
- package/dist/eval/reflection-census.js +25 -0
- package/dist/eval/run-eval.d.ts +17 -0
- package/dist/eval/run-eval.js +275 -0
- package/dist/mcp-server.js +30 -11
- package/dist/memory-instructions.d.ts +38 -0
- package/dist/memory-instructions.js +63 -0
- package/dist/nightly.js +4 -0
- package/dist/relink.d.ts +3 -2
- package/dist/relink.js +6 -11
- package/dist/seed-lesson.d.ts +1 -1
- package/dist/seed-lesson.js +1 -1
- package/dist/state.d.ts +10 -0
- package/dist/storage.d.ts +10 -0
- package/dist/storage.js +23 -0
- package/dist/types.d.ts +15 -0
- package/package.json +2 -1
|
@@ -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;
|