/** * LC-P4: Data Checkup Engine * * Health checks for the memory database, inspired by lossless-claw's * IntegrityChecker. Pure read-only diagnostics — never modifies data. * * Checks: * 1. Vector dimension consistency * 2. Orphan memories (scope missing or empty) * 3. Tier distribution health * 4. Conflict backlog * 5. Version group integrity */ import type { MemoryEntry, MemoryStore } from "./store.js"; import { resolveTier, type MemoryTier } from "./decay-engine.js"; import { measureInterferenceDensity } from "./interference-detector.js"; import { readHeartbeats, checkSourceStaleness, formatAge } from "./source-heartbeat.js"; import { deriveUsageStatus, isUsageSignalActive, type UsageStatus } from "./usage-tracker.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface CheckResult { name: string; status: "ok" | "warning" | "error"; detail: string; } export interface CheckupReport { checks: CheckResult[]; totalEntries: number; /** Total entries in store; when > totalEntries, only the most recent were scanned. */ totalAvailable: number; timestamp: string; } // --------------------------------------------------------------------------- // Individual checks // --------------------------------------------------------------------------- /** Check 1: All vectors should have the same dimension. */ function checkVectorDimensions(entries: MemoryEntry[]): CheckResult { // 只对真正取到向量的条目判维度一致性;取不到向量的(dim=0)单独计数后跳过—— // 否则会把"未补到向量"误报成"维度不一致",或把"全 0 维空向量"假报成健康。 const dims = new Map(); // dim → count let missingVectors = 0; for (const e of entries) { const d = e.vector?.length ?? 0; if (d === 0) { missingVectors++; continue; } dims.set(d, (dims.get(d) ?? 0) + 1); } const withVectors = entries.length - missingVectors; if (entries.length === 0) { return { name: "vector_dimensions", status: "ok", detail: "No entries to check" }; } if (withVectors === 0) { return { name: "vector_dimensions", status: "warning", detail: `No retrievable vectors among ${entries.length} entries — dimension consistency not verified`, }; } const suffix = missingVectors > 0 ? ` (${missingVectors} without retrievable vector, skipped)` : ""; if (dims.size === 1) { const dim = dims.keys().next().value ?? 0; return { name: "vector_dimensions", status: "ok", detail: `All ${withVectors} vectors have dimension ${dim}${suffix}` }; } const sorted = [...dims.entries()].sort((a, b) => b[1] - a[1]); const expected = sorted[0][0]; const mismatched = sorted.slice(1).reduce((sum, [, c]) => sum + c, 0); return { name: "vector_dimensions", status: "error", detail: `${mismatched} entries have wrong dimension (expected ${expected}): ${sorted.map(([d, c]) => `dim=${d}: ${c}`).join(", ")}${suffix}`, }; } /** Check 2: Memories with missing/empty scope. */ function checkOrphanMemories(entries: MemoryEntry[]): CheckResult { const orphans = entries.filter(e => !e.scope || e.scope.trim() === "" || e.scope === "__schema__"); if (orphans.length === 0) { return { name: "orphan_memories", status: "ok", detail: "No orphan memories found" }; } return { name: "orphan_memories", status: orphans.length > 10 ? "error" : "warning", detail: `${orphans.length} memories with missing/empty/schema scope (IDs: ${orphans.slice(0, 5).map(e => e.id.slice(0, 8)).join(", ")}${orphans.length > 5 ? "..." : ""})`, }; } /** Check 3: Tier distribution — core should not exceed 500, peripheral should not exceed 70%. */ function checkTierDistribution(entries: MemoryEntry[]): CheckResult { const tierCounts: Record = { core: 0, working: 0, peripheral: 0 }; for (const e of entries) { const tier = resolveTier(e.metadata, e.importance); tierCounts[tier]++; } const total = entries.length; const issues: string[] = []; if (tierCounts.core > 500) { issues.push(`core tier has ${tierCounts.core} entries (max recommended: 500)`); } if (total >= 20 && tierCounts.peripheral / total > 0.7) { const pct = Math.round(tierCounts.peripheral / total * 100); issues.push(`peripheral tier is ${pct}% of total (recommended: ≤70%)`); } const dist = `core=${tierCounts.core}, working=${tierCounts.working}, peripheral=${tierCounts.peripheral}`; if (issues.length === 0) { return { name: "tier_distribution", status: "ok", detail: `Healthy distribution: ${dist}` }; } return { name: "tier_distribution", status: "warning", detail: `${dist} — ${issues.join("; ")}` }; } /** Check 4: Open conflict backlog. */ function checkConflictBacklog(openConflictCount: number): CheckResult { if (openConflictCount === 0) { return { name: "conflict_backlog", status: "ok", detail: "No open conflicts" }; } const status = openConflictCount > 20 ? "error" : openConflictCount > 5 ? "warning" : "ok"; return { name: "conflict_backlog", status, detail: `${openConflictCount} open conflicts` }; } /** Check 5: Version group integrity — members should reference the same group ID. */ function checkVersionGroups(entries: Array>): CheckResult { const groups = new Map(); for (const e of entries) { if (!e.metadata) continue; try { const meta = JSON.parse(e.metadata); const group = meta.version_group; if (typeof group !== "string") continue; if (!groups.has(group)) groups.set(group, { ids: [], ranks: [] }); const g = groups.get(group)!; g.ids.push(e.id); g.ranks.push(typeof meta.version_rank === "number" ? meta.version_rank : -1); } catch { /* skip */ } } if (groups.size === 0) { return { name: "version_groups", status: "ok", detail: "No version groups found" }; } const issues: string[] = []; for (const [groupId, { ids, ranks }] of groups) { // Single-member group is suspicious if (ids.length < 2) { issues.push(`group ${groupId.slice(0, 8)} has only ${ids.length} member`); } // Check for missing ranks const missingRanks = ranks.filter(r => r < 0).length; if (missingRanks > 0) { issues.push(`group ${groupId.slice(0, 8)} has ${missingRanks} members with missing rank`); } } if (issues.length === 0) { return { name: "version_groups", status: "ok", detail: `${groups.size} version groups, all healthy` }; } return { name: "version_groups", status: issues.length > 3 ? "error" : "warning", detail: `${groups.size} groups, ${issues.length} issue(s): ${issues.slice(0, 3).join("; ")}${issues.length > 3 ? "..." : ""}`, }; } /** Check 6 (GB-3): Source health — detect stale connector data sources. */ function checkSourceHealth(heartbeatPath?: string): CheckResult { const heartbeats = readHeartbeats(heartbeatPath); const sources = Object.values(heartbeats); if (sources.length === 0) { return { name: "source_health", status: "ok", detail: "No connector sources tracked yet" }; } const stale30 = checkSourceStaleness(30, heartbeatPath); const stale7 = checkSourceStaleness(7, heartbeatPath); const summary = sources .map((s) => `${s.source}: ${formatAge(s.lastIngest)}`) .join(", "); if (stale30.length > 0) { const names = stale30.map((s) => `${s.source} (${s.daysSince}d)`).join(", "); return { name: "source_health", status: "error", detail: `${summary} — critically stale: ${names}` }; } if (stale7.length > 0) { const names = stale7.map((s) => `${s.source} (${s.daysSince}d)`).join(", "); return { name: "source_health", status: "warning", detail: `${summary} — stale: ${names}` }; } return { name: "source_health", status: "ok", detail: `All sources fresh: ${summary}` }; } /** Check 7 (F2): Interference density — detect semantic clustering pressure. */ function checkInterferenceDensity(entries: MemoryEntry[]): CheckResult { // Only check entries with vectors (skip schema/empty entries) const withVectors = entries.filter(e => e.vector && e.vector.length > 0); if (withVectors.length < 10) { return { name: "interference_density", status: "ok", detail: "Too few entries for interference analysis" }; } // Sample up to 200 entries for performance (interference detection is O(n²)) const sample = withVectors.length > 200 ? withVectors.sort(() => Math.random() - 0.5).slice(0, 200) : withVectors; const density = measureInterferenceDensity(sample); const issues: string[] = []; if (density.highRiskCount > sample.length * 0.2) { issues.push(`${density.highRiskCount} high-risk entries (>${Math.round(sample.length * 0.2)} threshold)`); } if (density.avgClusterSize > 5) { issues.push(`avg cluster size ${density.avgClusterSize.toFixed(1)} (recommended: ≤5)`); } const detail = `${density.clusterCount} clusters, avg size ${density.avgClusterSize.toFixed(1)}, ${density.highRiskCount} high-risk`; if (issues.length === 0) { return { name: "interference_density", status: "ok", detail: `Healthy: ${detail}` }; } return { name: "interference_density", status: issues.length > 1 ? "error" : "warning", detail: `${detail} — ${issues.join("; ")}`, }; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- /** Check 8 (P0 B-1 观察): usage 四档分布 — injection-vs-use 影子统计的离线观察口。 * 纯报告,不接任何决策;cold = 被反复 surface(accessCount≥6)却从未被 * reconstruction 引用,是第二阶段 decay 联动前要先观察校准的核心对象。 */ function checkUsageDistribution(entries: MemoryEntry[]): CheckResult { // use 信号未采集时(reconstruction flag 关),useCount 恒零 → "cold" 全是假阳性。 // 如实报告信号缺失,不输出不可解释的判定。 if (!isUsageSignalActive()) { return { name: "usage_distribution", status: "ok", detail: "use signal inactive (RECALLNEST_CONSTRUCTIVE_RETRIEVAL off) — useCount 无写入路径,cold/warm/hot 不可判,统计跳过。该 flag 因合成召回缺失被 Alice 主动关闭(2026-06-11),usage 线搁置中。", }; } const counts: Record = { unused: 0, cold: 0, warm: 0, hot: 0 }; const coldSamples: Array<{ id: string; injection: number; ageDays: number; scope: string }> = []; const now = Date.now(); for (const e of entries) { const status = deriveUsageStatus(e); counts[status]++; if (status === "cold") { let injection = 0; try { const meta = JSON.parse(e.metadata || "{}") as Record; injection = typeof meta.accessCount === "number" ? meta.accessCount : 0; } catch { /* keep 0 */ } coldSamples.push({ id: e.id.slice(0, 8), injection, ageDays: Math.floor((now - e.timestamp) / 86_400_000), scope: e.scope || "(none)", }); } } const total = entries.length; const coldPct = total > 0 ? (counts.cold / total) * 100 : 0; const top = coldSamples .sort((a, b) => b.injection - a.injection) .slice(0, 20) .map(s => `${s.id}(inj=${s.injection}, ${s.ageDays}d, ${s.scope})`) .join(", "); return { name: "usage_distribution", status: coldPct > 10 ? "warning" : "ok", detail: `unused=${counts.unused} cold=${counts.cold} (${coldPct.toFixed(1)}%) ` + `warm=${counts.warm} hot=${counts.hot}` + (coldSamples.length > 0 ? `; cold top-${Math.min(20, coldSamples.length)}: ${top}` : ""), }; } export interface CheckupDeps { store: Pick; openConflictCount: number; /** Optional override path for source-heartbeat.json (used in tests). */ heartbeatPath?: string; } export async function runDataCheckup(deps: CheckupDeps): Promise { const SCAN_LIMIT = 10000; const listed = await deps.store.list(undefined, undefined, SCAN_LIMIT, 0); // list() 为性能不返回向量(vector: []);维度/干扰检查靠向量,否则把"全 0 维" // 误判成健康、干扰分析永远"样本不足"。补回真实向量再检查。 const vectorMap = await deps.store.getVectors(listed.map(e => e.id)); const entries = listed.map(e => ({ ...e, vector: vectorMap.get(e.id) ?? [] })); // 截断披露:库总量 > 已扫,说明只体检了最近 SCAN_LIMIT 条。 const totalAvailable = (await deps.store.stats()).totalCount; // Version-group membership must be checked against the whole corpus. Looking only // at the newest SCAN_LIMIT rows turns a healthy pair into a fake singleton whenever // its other member falls outside the sample. Keep this second pass lightweight by // retaining only id + metadata and never loading vectors. const VERSION_GROUP_PAGE_SIZE = 5000; const versionEntries: Array> = []; for (let offset = 0; ; offset += VERSION_GROUP_PAGE_SIZE) { const page = await deps.store.listPage({ limit: VERSION_GROUP_PAGE_SIZE, offset }); for (const entry of page) { versionEntries.push({ id: entry.id, metadata: entry.metadata }); } if (page.length < VERSION_GROUP_PAGE_SIZE) break; } return { checks: [ checkVectorDimensions(entries), checkOrphanMemories(entries), checkTierDistribution(entries), checkConflictBacklog(deps.openConflictCount), checkVersionGroups(versionEntries), checkSourceHealth(deps.heartbeatPath), checkInterferenceDensity(entries), checkUsageDistribution(entries), ], totalEntries: entries.length, totalAvailable, timestamp: new Date().toISOString(), }; } export function formatCheckupReport(report: CheckupReport): string { const truncationNote = report.totalAvailable > report.totalEntries ? ` (of ${report.totalAvailable} total — only the most recent ${report.totalEntries} analyzed)` : ""; const lines = [ `Data Checkup Report (${report.timestamp})`, `Total entries scanned: ${report.totalEntries}${truncationNote}`, "", ]; for (const check of report.checks) { const icon = check.status === "ok" ? "[OK]" : check.status === "warning" ? "[WARN]" : "[ERR]"; lines.push(`${icon} ${check.name}: ${check.detail}`); } const errorCount = report.checks.filter(c => c.status === "error").length; const warnCount = report.checks.filter(c => c.status === "warning").length; lines.push(""); lines.push(`Summary: ${errorCount} error(s), ${warnCount} warning(s), ${report.checks.length - errorCount - warnCount} ok`); return lines.join("\n"); }