From e880fe0b39743f27a7384c8de0509fe45992df89 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Wed, 22 Apr 2026 19:31:44 +0800 Subject: [PATCH] perf: complete persist-load P2 hydration pass --- graph/memory-scope.js | 45 ++++ graph/story-timeline.js | 46 ++++ package.json | 3 + runtime/runtime-state.js | 19 +- scripts/compare-p1-bench.mjs | 196 ++++++++++++++ sync/bme-db.js | 176 ++++++++++++- tests/indexeddb-persistence.mjs | 60 +++++ tests/perf/load-preapply-bench.mjs | 397 +++++++++++++++++++++++++++++ tests/perf/persist-load-bench.mjs | 69 +++-- tests/scoped-memory.mjs | 49 ++++ 10 files changed, 1037 insertions(+), 23 deletions(-) create mode 100644 scripts/compare-p1-bench.mjs create mode 100644 tests/perf/load-preapply-bench.mjs diff --git a/graph/memory-scope.js b/graph/memory-scope.js index c2a7f94..7731b67 100644 --- a/graph/memory-scope.js +++ b/graph/memory-scope.js @@ -58,6 +58,48 @@ function normalizeStringArray(values = []) { return result; } +function isAlreadyNormalizedStringArray(values = []) { + if (!Array.isArray(values)) return false; + const seen = new Set(); + for (const value of values) { + if (typeof value !== "string") return false; + const normalized = normalizeString(value); + const key = normalizeKey(normalized); + if (!normalized || normalized !== value || seen.has(key)) { + return false; + } + seen.add(key); + } + return true; +} + +function canReuseNormalizedMemoryScope(scope = {}, defaults = {}) { + if ( + !scope || + typeof scope !== "object" || + Array.isArray(scope) || + (defaults && typeof defaults === "object" && Object.keys(defaults).length > 0) + ) { + return false; + } + const layer = normalizeLayer(scope.layer); + const ownerType = normalizeOwnerType(layer, normalizeString(scope.ownerType)); + const ownerId = ownerType + ? normalizeString(scope.ownerId || scope.ownerName) + : ""; + const ownerName = ownerType ? normalizeString(scope.ownerName) : ""; + const regionPrimary = normalizeString(scope.regionPrimary); + return ( + scope.layer === layer && + normalizeString(scope.ownerType) === ownerType && + normalizeString(scope.ownerId || "") === ownerId && + normalizeString(scope.ownerName || "") === ownerName && + normalizeString(scope.regionPrimary || "") === regionPrimary && + isAlreadyNormalizedStringArray(scope.regionPath) && + isAlreadyNormalizedStringArray(scope.regionSecondary) + ); +} + function normalizeOwnerValueSet(values = []) { return new Set( normalizeStringArray(values).map((value) => normalizeKey(value)), @@ -88,6 +130,9 @@ export function createDefaultMemoryScope(overrides = {}) { } export function normalizeMemoryScope(scope = {}, defaults = {}) { + if (canReuseNormalizedMemoryScope(scope, defaults)) { + return scope; + } const merged = { ...DEFAULT_MEMORY_SCOPE, ...(defaults || {}), diff --git a/graph/story-timeline.js b/graph/story-timeline.js index fd187db..cfc0934 100644 --- a/graph/story-timeline.js +++ b/graph/story-timeline.js @@ -147,7 +147,50 @@ export function createDefaultTimelineState(overrides = {}) { }; } +function canReuseNormalizedStoryTime(value = {}, defaults = {}) { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + (defaults && typeof defaults === "object" && Object.keys(defaults).length > 0) + ) { + return false; + } + return ( + normalizeString(value.segmentId || "") === String(value.segmentId || "") && + normalizeString(value.label || "") === String(value.label || "") && + normalizeEnum(value.tense, STORY_TENSE_VALUES, "unknown") === value.tense && + normalizeEnum(value.relation, STORY_RELATION_VALUES, "unknown") === value.relation && + normalizeString(value.anchorLabel || "") === String(value.anchorLabel || "") && + normalizeEnum(value.confidence, STORY_CONFIDENCE_VALUES, "medium") === + value.confidence && + normalizeEnum(value.source, STORY_SOURCE_VALUES, "derived") === value.source + ); +} + +function canReuseNormalizedStoryTimeSpan(value = {}, defaults = {}) { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + (defaults && typeof defaults === "object" && Object.keys(defaults).length > 0) + ) { + return false; + } + return ( + normalizeString(value.startSegmentId || "") === String(value.startSegmentId || "") && + normalizeString(value.endSegmentId || "") === String(value.endSegmentId || "") && + normalizeString(value.startLabel || "") === String(value.startLabel || "") && + normalizeString(value.endLabel || "") === String(value.endLabel || "") && + (value.mixed === true || value.mixed === false) && + normalizeEnum(value.source, STORY_SOURCE_VALUES, "derived") === value.source + ); +} + export function normalizeStoryTime(value = {}, defaults = {}) { + if (canReuseNormalizedStoryTime(value, defaults)) { + return value; + } return createDefaultStoryTime({ ...defaults, ...(value && typeof value === "object" && !Array.isArray(value) ? value : {}), @@ -155,6 +198,9 @@ export function normalizeStoryTime(value = {}, defaults = {}) { } export function normalizeStoryTimeSpan(value = {}, defaults = {}) { + if (canReuseNormalizedStoryTimeSpan(value, defaults)) { + return value; + } return createDefaultStoryTimeSpan({ ...defaults, ...(value && typeof value === "object" && !Array.isArray(value) ? value : {}), diff --git a/package.json b/package.json index ed1ed1c..ff538bb 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "test:trivial-input": "node tests/trivial-user-input.mjs", "bench:graph-layout": "node tests/perf/graph-layout-bench.mjs", "bench:persist-delta": "node tests/perf/persist-delta-bench.mjs", + "bench:persist-load": "node tests/perf/persist-load-bench.mjs", + "bench:load-preapply": "node tests/perf/load-preapply-bench.mjs", + "bench:p1-compare": "node scripts/compare-p1-bench.mjs", "bench:native": "npm run bench:graph-layout && npm run bench:persist-delta", "test:indexeddb": "npm run test:indexeddb-persistence && npm run test:indexeddb-sync && npm run test:indexeddb-migration", "test:persistence-matrix": "npm run test:p0 && npm run test:runtime-history && npm run test:graph-persistence && npm run test:indexeddb", diff --git a/runtime/runtime-state.js b/runtime/runtime-state.js index f6d3096..5ca427a 100644 --- a/runtime/runtime-state.js +++ b/runtime/runtime-state.js @@ -10,6 +10,7 @@ import { } from "../graph/knowledge-state.js"; import { createDefaultTimelineState, + normalizeTimelineState, normalizeGraphStoryTimeline, } from "../graph/story-timeline.js"; import { @@ -224,10 +225,12 @@ function getRequiredJournalCoverageStartFloor(graph, journals = []) { return null; } -export function normalizeGraphRuntimeState(graph, chatId = "") { +export function normalizeGraphRuntimeState(graph, chatId = "", options = {}) { if (!graph || typeof graph !== "object") { return graph; } + const skipRecordFieldNormalization = + options?.skipRecordFieldNormalization === true; const hadSummaryState = graph.summaryState && typeof graph.summaryState === "object" && @@ -475,10 +478,10 @@ export function normalizeGraphRuntimeState(graph, chatId = "") { graph.historyState = historyState; graph.vectorIndexState = vectorIndexState; - if (Array.isArray(graph.nodes)) { + if (!skipRecordFieldNormalization && Array.isArray(graph.nodes)) { graph.nodes.forEach((node) => normalizeNodeMemoryScope(node)); } - if (Array.isArray(graph.edges)) { + if (!skipRecordFieldNormalization && Array.isArray(graph.edges)) { graph.edges.forEach((edge) => normalizeEdgeMemoryScope(edge)); } graph.batchJournal = Array.isArray(graph.batchJournal) @@ -496,10 +499,16 @@ export function normalizeGraphRuntimeState(graph, chatId = "") { : createDefaultMaintenanceJournal(); graph.knowledgeState = createDefaultKnowledgeState(graph.knowledgeState); graph.regionState = createDefaultRegionState(graph.regionState); - graph.timelineState = createDefaultTimelineState(graph.timelineState); + graph.timelineState = skipRecordFieldNormalization + ? normalizeTimelineState(graph.timelineState) + : createDefaultTimelineState(graph.timelineState); graph.summaryState = createDefaultSummaryState(graph.summaryState); normalizeGraphCognitiveState(graph); - normalizeGraphStoryTimeline(graph); + if (skipRecordFieldNormalization) { + graph.timelineState = normalizeTimelineState(graph.timelineState); + } else { + normalizeGraphStoryTimeline(graph); + } normalizeGraphSummaryState(graph); if (!hadSummaryState) { importLegacySynopsisToSummaryState(graph); diff --git a/scripts/compare-p1-bench.mjs b/scripts/compare-p1-bench.mjs new file mode 100644 index 0000000..cd75ba3 --- /dev/null +++ b/scripts/compare-p1-bench.mjs @@ -0,0 +1,196 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(scriptDir, ".."); +const args = new Map( + process.argv.slice(2).map((entry) => { + const [key, ...rest] = String(entry || "").split("="); + return [key, rest.join("=") || true]; + }), +); +const baselineRef = String(args.get("--baseline") || "origin/main"); +const currentRef = String(args.get("--current") || "HEAD"); +const outputJson = args.has("--json"); + +async function runCommand(command, commandArgs, cwd) { + const { stdout, stderr } = await execFileAsync(command, commandArgs, { + cwd, + windowsHide: true, + maxBuffer: 1024 * 1024 * 20, + env: { + ...process.env, + ST_BME_NODE_MODULES_ROOT: projectRoot, + }, + }); + return { + stdout: String(stdout || "").trim(), + stderr: String(stderr || "").trim(), + }; +} + +async function resolveRef(ref) { + const result = await runCommand("git", ["rev-parse", ref], projectRoot); + return result.stdout; +} + +async function ensureFileFromCurrentRepo(relativePath, targetRoot) { + const sourcePath = path.join(projectRoot, relativePath); + const targetPath = path.join(targetRoot, relativePath); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.copyFile(sourcePath, targetPath); +} + +function readJsonLine(stdout = "") { + const trimmed = String(stdout || "").trim(); + const lines = trimmed.split(/\r?\n/).filter(Boolean); + return JSON.parse(lines[lines.length - 1]); +} + +function formatDelta(current = 0, baseline = 0) { + const delta = current - baseline; + const ratio = baseline !== 0 ? (delta / baseline) * 100 : 0; + const sign = delta > 0 ? "+" : ""; + return `${sign}${delta.toFixed(2)}ms (${sign}${ratio.toFixed(1)}%)`; +} + +function collectMetricRows(compare, metricPath, label) { + return Object.entries(compare).map(([preset, metrics]) => ({ + preset, + label, + baseline: Number(metricPath(metrics.baseline) || 0), + current: Number(metricPath(metrics.current) || 0), + })); +} + +function printRows(rows = [], title = "") { + console.log(`\n[ST-BME][P1-compare] ${title}`); + for (const row of rows) { + console.log( + `${row.preset} baseline=${row.baseline.toFixed(2)}ms current=${row.current.toFixed(2)}ms delta=${formatDelta(row.current, row.baseline)}`, + ); + } +} + +async function runBenchSuite(cwd) { + const persistLoad = await runCommand( + process.execPath, + ["tests/perf/persist-load-bench.mjs", "--json"], + cwd, + ); + const loadPreapply = await runCommand( + process.execPath, + ["tests/perf/load-preapply-bench.mjs", "--json"], + cwd, + ); + return { + persistLoad: readJsonLine(persistLoad.stdout), + loadPreapply: readJsonLine(loadPreapply.stdout), + }; +} + +function compareBenchResults(baseline, current) { + const presets = {}; + const presetNames = new Set([ + ...Object.keys(baseline.persistLoad?.presets || {}), + ...Object.keys(current.persistLoad?.presets || {}), + ...Object.keys(baseline.loadPreapply?.presets || {}), + ...Object.keys(current.loadPreapply?.presets || {}), + ]); + for (const preset of presetNames) { + presets[preset] = { + baseline: { + ...(baseline.persistLoad?.presets?.[preset] || {}), + ...(baseline.loadPreapply?.presets?.[preset] || {}), + }, + current: { + ...(current.persistLoad?.presets?.[preset] || {}), + ...(current.loadPreapply?.presets?.[preset] || {}), + }, + }; + } + return presets; +} + +async function createWorktree(ref, tempRoot, name) { + const worktreePath = path.join(tempRoot, name); + await runCommand("git", ["worktree", "add", "--detach", worktreePath, ref], projectRoot); + await ensureFileFromCurrentRepo("tests/perf/persist-load-bench.mjs", worktreePath); + await ensureFileFromCurrentRepo("tests/perf/load-preapply-bench.mjs", worktreePath); + await ensureFileFromCurrentRepo("tests/helpers/memory-opfs.mjs", worktreePath); + return worktreePath; +} + +async function removeWorktree(worktreePath) { + await runCommand("git", ["worktree", "remove", "--force", worktreePath], projectRoot); +} + +async function main() { + const baselineSha = await resolveRef(baselineRef); + const currentSha = await resolveRef(currentRef); + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "st-bme-p1-compare-")); + let baselinePath = ""; + let currentPath = ""; + + try { + baselinePath = await createWorktree(baselineSha, tempRoot, "baseline"); + currentPath = + currentRef === "HEAD" ? projectRoot : await createWorktree(currentSha, tempRoot, "current"); + + const baselineResults = await runBenchSuite(baselinePath); + const currentResults = await runBenchSuite(currentPath); + const compare = compareBenchResults(baselineResults, currentResults); + + if (outputJson) { + console.log( + JSON.stringify({ + baselineRef, + baselineSha, + currentRef, + currentSha, + compare, + }), + ); + return; + } + + console.log(`[ST-BME][P1-compare] baseline=${baselineRef} (${baselineSha.slice(0, 7)})`); + console.log(`[ST-BME][P1-compare] current=${currentRef} (${currentSha.slice(0, 7)})`); + + printRows( + collectMetricRows(compare, (entry) => entry.opfsCommitMs?.p95, "opfsCommitMs.p95"), + "opfs commit p95", + ); + printRows( + collectMetricRows(compare, (entry) => entry.indexedDbProbeRejectMs?.p95, "indexedDbProbeRejectMs.p95"), + "indexeddb probe-reject preApply p95", + ); + printRows( + collectMetricRows(compare, (entry) => entry.opfsProbeRejectMs?.p95, "opfsProbeRejectMs.p95"), + "opfs probe-reject preApply p95", + ); + printRows( + collectMetricRows(compare, (entry) => entry.indexedDbPreApplySuccessMs?.p95, "indexedDbPreApplySuccessMs.p95"), + "indexeddb success preApply p95", + ); + printRows( + collectMetricRows(compare, (entry) => entry.hydrateMs?.p95, "hydrateMs.p95"), + "hydrate p95", + ); + } finally { + if (baselinePath) { + await removeWorktree(baselinePath); + } + if (currentPath && currentPath !== projectRoot) { + await removeWorktree(currentPath); + } + await fs.rm(tempRoot, { recursive: true, force: true }); + } +} + +await main(); diff --git a/sync/bme-db.js b/sync/bme-db.js index 1bf1dc8..74e2a10 100644 --- a/sync/bme-db.js +++ b/sync/bme-db.js @@ -40,6 +40,8 @@ export const BME_RUNTIME_TIMELINE_STATE_META_KEY = "timelineState"; export const BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY = "runtimeLastProcessedSeq"; export const BME_RUNTIME_GRAPH_VERSION_META_KEY = "runtimeGraphVersion"; +export const BME_RUNTIME_RECORDS_NORMALIZED_META_KEY = + "runtimeRecordsNormalized"; export const BME_DB_TABLE_SCHEMAS = Object.freeze({ nodes: @@ -153,6 +155,169 @@ function toArray(value) { return Array.isArray(value) ? value : []; } +function cloneHydrateSnapshotNestedValue(value, fallbackValue = null) { + if (value == null || typeof value !== "object") { + return value == null ? fallbackValue : value; + } + if (Array.isArray(value)) { + const output = new Array(value.length); + for (let index = 0; index < value.length; index += 1) { + const entry = value[index]; + output[index] = + entry != null && typeof entry === "object" + ? cloneHydrateSnapshotNestedValue(entry, entry) + : entry; + } + return output; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return toPlainData(value, fallbackValue ?? value); + } + const output = {}; + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) continue; + const entry = value[key]; + output[key] = + entry != null && typeof entry === "object" + ? cloneHydrateSnapshotNestedValue(entry, entry) + : entry; + } + return output; +} + +function cloneHydrateSnapshotMemoryScope(scope = null) { + if (!scope || typeof scope !== "object" || Array.isArray(scope)) { + return cloneHydrateSnapshotNestedValue(scope, scope); + } + return { + ...scope, + regionPath: Array.isArray(scope.regionPath) ? [...scope.regionPath] : [], + regionSecondary: Array.isArray(scope.regionSecondary) + ? [...scope.regionSecondary] + : [], + }; +} + +function cloneHydrateSnapshotStoryTime(storyTime = null) { + if (!storyTime || typeof storyTime !== "object" || Array.isArray(storyTime)) { + return cloneHydrateSnapshotNestedValue(storyTime, storyTime); + } + return { + ...storyTime, + }; +} + +function cloneHydrateSnapshotStoryTimeSpan(storyTimeSpan = null) { + if ( + !storyTimeSpan || + typeof storyTimeSpan !== "object" || + Array.isArray(storyTimeSpan) + ) { + return cloneHydrateSnapshotNestedValue(storyTimeSpan, storyTimeSpan); + } + return { + ...storyTimeSpan, + }; +} + +function cloneHydrateSnapshotNodeRecord(record = null) { + if (!record || typeof record !== "object" || Array.isArray(record)) { + return null; + } + const cloned = {}; + for (const key in record) { + if (!Object.prototype.hasOwnProperty.call(record, key)) continue; + const value = record[key]; + switch (key) { + case "fields": + cloned.fields = cloneHydrateSnapshotNestedValue(value, {}); + break; + case "seqRange": + cloned.seqRange = Array.isArray(value) + ? value.slice() + : cloneHydrateSnapshotNestedValue(value, value); + break; + case "childIds": + cloned.childIds = Array.isArray(value) + ? value.slice() + : cloneHydrateSnapshotNestedValue(value, value); + break; + case "clusters": + cloned.clusters = Array.isArray(value) + ? value.slice() + : cloneHydrateSnapshotNestedValue(value, value); + break; + case "scope": + cloned.scope = cloneHydrateSnapshotMemoryScope(value); + break; + case "storyTime": + cloned.storyTime = cloneHydrateSnapshotStoryTime(value); + break; + case "storyTimeSpan": + cloned.storyTimeSpan = cloneHydrateSnapshotStoryTimeSpan(value); + break; + default: + cloned[key] = + value != null && typeof value === "object" + ? cloneHydrateSnapshotNestedValue(value, value) + : value; + break; + } + } + return cloned; +} + +function cloneHydrateSnapshotEdgeRecord(record = null) { + if (!record || typeof record !== "object" || Array.isArray(record)) { + return null; + } + const cloned = {}; + for (const key in record) { + if (!Object.prototype.hasOwnProperty.call(record, key)) continue; + const value = record[key]; + if (key === "scope") { + cloned.scope = cloneHydrateSnapshotMemoryScope(value); + continue; + } + cloned[key] = + value != null && typeof value === "object" + ? cloneHydrateSnapshotNestedValue(value, value) + : value; + } + return cloned; +} + +function cloneHydrateSnapshotNodeRecords(records = []) { + const sourceRecords = toArray(records); + if (sourceRecords.length === 0) return []; + const output = new Array(sourceRecords.length); + let writeIndex = 0; + for (let index = 0; index < sourceRecords.length; index += 1) { + const cloned = cloneHydrateSnapshotNodeRecord(sourceRecords[index]); + if (!cloned) continue; + output[writeIndex] = cloned; + writeIndex += 1; + } + output.length = writeIndex; + return output; +} + +function cloneHydrateSnapshotEdgeRecords(records = []) { + const sourceRecords = toArray(records); + if (sourceRecords.length === 0) return []; + const output = new Array(sourceRecords.length); + let writeIndex = 0; + for (let index = 0; index < sourceRecords.length; index += 1) { + const cloned = cloneHydrateSnapshotEdgeRecord(sourceRecords[index]); + if (!cloned) continue; + output[writeIndex] = cloned; + writeIndex += 1; + } + output.length = writeIndex; + return output; +} + function toMetaMap(rows = []) { const output = {}; for (const row of rows) { @@ -927,6 +1092,7 @@ export function buildSnapshotFromGraph(graph, options = {}) { ) ? Number(runtimeGraph.version) : Number(baseSnapshot.meta?.[BME_RUNTIME_GRAPH_VERSION_META_KEY] || 0), + [BME_RUNTIME_RECORDS_NORMALIZED_META_KEY]: true, }; if (snapshotDiagnostics) { snapshotDiagnostics.metaMs = readPersistDeltaNow() - metaStartedAt; @@ -2133,6 +2299,8 @@ export function buildGraphFromSnapshot(snapshot, options = {}) { snapshotMeta?.[BME_RUNTIME_VECTOR_META_KEY], {}, ); + const snapshotRecordsNormalized = + snapshotMeta?.[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY] === true; const runtimeGraph = createEmptyGraph(); runtimeGraph.version = Number.isFinite( @@ -2142,14 +2310,14 @@ export function buildGraphFromSnapshot(snapshot, options = {}) { : runtimeGraph.version; const hydrateNodesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; - runtimeGraph.nodes = toArray(toPlainData(snapshotView.nodes, [])); + runtimeGraph.nodes = cloneHydrateSnapshotNodeRecords(snapshotView.nodes); if (hydrateDiagnostics) { hydrateDiagnostics.nodeCount = runtimeGraph.nodes.length; hydrateDiagnostics.nodesMs = readPersistDeltaNow() - hydrateNodesStartedAt; } const hydrateEdgesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; - runtimeGraph.edges = toArray(toPlainData(snapshotView.edges, [])); + runtimeGraph.edges = cloneHydrateSnapshotEdgeRecords(snapshotView.edges); if (hydrateDiagnostics) { hydrateDiagnostics.edgeCount = runtimeGraph.edges.length; hydrateDiagnostics.edgesMs = readPersistDeltaNow() - hydrateEdgesStartedAt; @@ -2302,7 +2470,9 @@ export function buildGraphFromSnapshot(snapshot, options = {}) { } const normalizeStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; - const normalizedGraph = normalizeGraphRuntimeState(runtimeGraph, chatId); + const normalizedGraph = normalizeGraphRuntimeState(runtimeGraph, chatId, { + skipRecordFieldNormalization: snapshotRecordsNormalized, + }); if (hydrateDiagnostics) { hydrateDiagnostics.normalizeMs = readPersistDeltaNow() - normalizeStartedAt; } diff --git a/tests/indexeddb-persistence.mjs b/tests/indexeddb-persistence.mjs index f9f0855..a5876fd 100644 --- a/tests/indexeddb-persistence.mjs +++ b/tests/indexeddb-persistence.mjs @@ -4,6 +4,7 @@ import { BME_DB_SCHEMA_VERSION, BME_RUNTIME_BATCH_JOURNAL_META_KEY, BME_RUNTIME_HISTORY_META_KEY, + BME_RUNTIME_RECORDS_NORMALIZED_META_KEY, BME_RUNTIME_VECTOR_META_KEY, BME_TOMBSTONE_RETENTION_MS, BmeDatabase, @@ -618,6 +619,33 @@ async function testGraphSnapshotConverters() { title: "Converter Node", }, updatedAt: Date.now(), + embedding: [0.25, 0.5, 0.75], + scope: { + layer: "pov", + ownerType: "character", + ownerId: "hero", + ownerName: "Hero", + regionPrimary: "camp", + regionPath: ["camp", "tent"], + regionSecondary: ["forest"], + }, + storyTime: { + segmentId: "segment-1", + label: "Dawn", + tense: "ongoing", + relation: "same", + anchorLabel: "Night", + confidence: "high", + source: "derived", + }, + storyTimeSpan: { + startSegmentId: "segment-0", + endSegmentId: "segment-1", + startLabel: "Night", + endLabel: "Dawn", + mixed: false, + source: "derived", + }, }); let snapshotDiagnostics = null; @@ -630,6 +658,7 @@ async function testGraphSnapshotConverters() { }); assert.equal(snapshot.meta.chatId, "chat-a"); assert.equal(snapshot.meta.revision, 17); + assert.equal(snapshot.meta[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY], true); assert.equal(snapshot.state.lastProcessedFloor, 9); assert.equal(snapshot.state.extractionCount, 4); assert.equal(snapshot.nodes.length, 1); @@ -687,18 +716,44 @@ async function testGraphSnapshotConverters() { const rebuilt = buildGraphFromSnapshot(snapshot, { chatId: "chat-a", }); + const legacyCompatibleSnapshot = { + ...snapshot, + meta: { + ...snapshot.meta, + }, + }; + delete legacyCompatibleSnapshot.meta[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY]; + legacyCompatibleSnapshot.nodes = [ + { + ...legacyCompatibleSnapshot.nodes[0], + scope: undefined, + storyTime: undefined, + storyTimeSpan: undefined, + }, + ]; + const rebuiltLegacyCompatible = buildGraphFromSnapshot(legacyCompatibleSnapshot, { + chatId: "chat-a", + }); assert.equal(rebuilt.historyState.lastProcessedAssistantFloor, 9); assert.equal(rebuilt.historyState.extractionCount, 4); assert.equal(rebuilt.nodes.length, 1); assert.equal(rebuilt.nodes[0].id, "node-converter"); + assert.equal(rebuilt.nodes[0].scope?.ownerType, "character"); + assert.equal(rebuilt.nodes[0].scope?.regionPrimary, "camp"); + assert.equal(rebuilt.nodes[0].storyTime?.label, "Dawn"); + assert.equal(rebuilt.nodes[0].storyTimeSpan?.endLabel, "Dawn"); assert.equal(rebuilt.vectorIndexState.hashToNodeId["vec-hash"], "node-converter"); assert.equal(rebuilt.maintenanceJournal[0].id, "maintenance-1"); assert.equal(rebuilt.knowledgeState.activeOwnerKey, "owner:hero"); assert.equal(rebuilt.regionState.activeRegion, "camp"); assert.equal(rebuilt.timelineState.activeSegmentId, "segment-1"); assert.equal(rebuilt.summaryState.entries[0].id, "summary-1"); + assert.equal(rebuiltLegacyCompatible.nodes[0].scope?.layer, "objective"); + assert.equal(rebuiltLegacyCompatible.nodes[0].storyTime?.tense, "unknown"); + assert.equal(rebuiltLegacyCompatible.nodes[0].storyTimeSpan?.mixed, false); rebuilt.nodes[0].fields.title = "Mutated Converter Node"; + rebuilt.nodes[0].embedding[0] = 99; rebuilt.historyState.processedMessageHashes[1] = "mutated-hash"; rebuilt.vectorIndexState.hashToNodeId["vec-hash"] = "node-mutated"; rebuilt.batchJournal[0].processedRange[0] = 99; @@ -713,6 +768,11 @@ async function testGraphSnapshotConverters() { "hash-1", "buildGraphFromSnapshot 不应复用 snapshot historyState 的嵌套对象引用", ); + assert.equal( + snapshot.nodes[0].embedding[0], + 0.25, + "buildGraphFromSnapshot 不应复用 snapshot 节点的数组字段引用", + ); assert.equal( snapshot.meta[BME_RUNTIME_VECTOR_META_KEY].hashToNodeId["vec-hash"], "node-converter", diff --git a/tests/perf/load-preapply-bench.mjs b/tests/perf/load-preapply-bench.mjs new file mode 100644 index 0000000..31b275f --- /dev/null +++ b/tests/perf/load-preapply-bench.mjs @@ -0,0 +1,397 @@ +import { performance } from "node:perf_hooks"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; + +import { + BmeDatabase, + buildBmeDbName, + buildGraphFromSnapshot, + buildSnapshotFromGraph, + ensureDexieLoaded, +} from "../../sync/bme-db.js"; +import { + BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY, + OpfsGraphStore, +} from "../../sync/bme-opfs-store.js"; +import { createMemoryOpfsRoot } from "../helpers/memory-opfs.mjs"; + +const RUNS = 4; +const outputJson = process.argv.includes("--json"); +const projectRootHint = String(process.env.ST_BME_NODE_MODULES_ROOT || "").trim(); +const requireFromProjectRoot = projectRootHint + ? createRequire(path.join(projectRootHint, "package.json")) + : null; +const SIZE_PRESETS = [ + { label: "M", seed: 17, nodeCount: 1200, edgeCount: 3600 }, + { label: "L", seed: 29, nodeCount: 3600, edgeCount: 10800 }, + { label: "XL", seed: 43, nodeCount: 7200, edgeCount: 21600 }, +]; + +async function importWithProjectRootFallback(specifier) { + try { + return await import(specifier); + } catch (error) { + if (!requireFromProjectRoot) { + throw error; + } + const resolved = requireFromProjectRoot.resolve(specifier); + return await import(pathToFileURL(resolved).href); + } +} + +function summarize(values = []) { + if (!values.length) { + return { avg: 0, p95: 0, min: 0, max: 0 }; + } + const sorted = [...values].sort((a, b) => a - b); + const sum = sorted.reduce((acc, value) => acc + value, 0); + const p95Index = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95)); + return { + avg: sum / sorted.length, + p95: sorted[p95Index], + min: sorted[0], + max: sorted[sorted.length - 1], + }; +} + +function formatSummary(label, values = []) { + const summary = summarize(values); + return `${label} avg=${summary.avg.toFixed(2)}ms p95=${summary.p95.toFixed(2)}ms min=${summary.min.toFixed(2)}ms max=${summary.max.toFixed(2)}ms`; +} + +function createRandom(seed = 1) { + let state = seed >>> 0; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0xffffffff; + }; +} + +function buildRuntimeGraph(seed = 1, nodeCount = 100, edgeCount = 200, chatId = "bench-chat") { + const rand = createRandom(seed); + const nodes = []; + const edges = []; + for (let index = 0; index < nodeCount; index += 1) { + nodes.push({ + id: `node-${index}`, + type: "event", + updatedAt: 1000 + index, + archived: false, + sourceFloor: index, + fields: { + title: `Node ${index}`, + text: `node-${index}-${Math.floor(rand() * 100000)}`, + }, + }); + } + for (let index = 0; index < edgeCount; index += 1) { + const fromIndex = Math.floor(rand() * nodeCount); + let toIndex = Math.floor(rand() * nodeCount); + if (toIndex === fromIndex) { + toIndex = (toIndex + 1) % nodeCount; + } + edges.push({ + id: `edge-${index}`, + fromId: `node-${fromIndex}`, + toId: `node-${toIndex}`, + relation: "related", + strength: rand(), + updatedAt: 2000 + index, + }); + } + return { + version: 1, + nodes, + edges, + historyState: { + chatId, + lastProcessedAssistantFloor: Math.max(0, Math.floor(nodeCount / 12)), + extractionCount: Math.max(1, Math.floor(nodeCount / 40)), + processedMessageHashes: {}, + processedMessageHashVersion: 1, + processedMessageHashesNeedRefresh: false, + recentRecallOwnerKeys: [], + activeRecallOwnerKey: "", + activeRegion: "", + activeRegionSource: "", + activeStorySegmentId: "", + activeStoryTimeLabel: "", + activeStoryTimeSource: "", + lastBatchStatus: null, + lastMutationSource: "bench", + lastExtractedRegion: "", + lastExtractedStorySegmentId: "", + activeCharacterPovOwner: "", + activeUserPovOwner: "", + }, + vectorIndexState: { + chatId, + collectionId: "", + hashToNodeId: {}, + nodeToHash: {}, + replayRequiredNodeIds: [], + dirty: false, + dirtyReason: "", + pendingRepairFromFloor: null, + lastIntegrityIssue: null, + lastStats: { + nodesIndexed: 0, + updatedAt: 0, + }, + }, + knowledgeState: { + owners: {}, + activeOwnerKey: "", + }, + regionState: { + activeRegion: "", + knownRegions: {}, + manualActiveRegion: "", + }, + timelineState: { + activeSegmentId: "", + manualActiveSegmentId: "", + segments: [], + }, + summaryState: { + updatedAt: 0, + entries: [], + }, + batchJournal: [], + maintenanceJournal: [], + lastRecallResult: null, + lastProcessedSeq: Math.max(0, Math.floor(nodeCount / 12)), + }; +} + +function buildBenchSnapshot({ label, seed, nodeCount, edgeCount }) { + const chatId = `load-bench-${label.toLowerCase()}-${seed}`; + const graph = buildRuntimeGraph(seed, nodeCount, edgeCount, chatId); + return { + chatId, + snapshot: buildSnapshotFromGraph(graph, { + chatId, + revision: 1, + }), + }; +} + +async function setupIndexedDbTestEnv() { + try { + await importWithProjectRootFallback("fake-indexeddb/auto"); + } catch { + // no-op + } + + if (!globalThis.Dexie) { + try { + const imported = await importWithProjectRootFallback("dexie"); + globalThis.Dexie = imported?.default || imported?.Dexie || imported; + } catch { + await import("../../lib/dexie.min.js"); + } + } + + await ensureDexieLoaded(); +} + +async function cleanupDatabase(chatId = "") { + if (!chatId || typeof globalThis.Dexie?.delete !== "function") return; + try { + await globalThis.Dexie.delete(buildBmeDbName(chatId)); + } catch { + // no-op + } +} + +async function prepareIndexedDb(chatId, snapshot) { + await cleanupDatabase(chatId); + const db = new BmeDatabase(chatId, { dexieClass: globalThis.Dexie }); + await db.open(); + await db.importSnapshot(snapshot, { + mode: "replace", + preserveRevision: true, + markSyncDirty: false, + }); + return db; +} + +async function prepareOpfsStore(chatId, snapshot) { + const rootDirectory = createMemoryOpfsRoot(); + const store = new OpfsGraphStore(chatId, { + rootDirectoryFactory: async () => rootDirectory, + storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY, + }); + await store.open(); + await store.importSnapshot(snapshot, { + mode: "replace", + preserveRevision: true, + markSyncDirty: false, + }); + return store; +} + +async function readProbeOrFallback(store) { + let inspectionSnapshot = null; + let exportProbeMs = 0; + let exportSnapshotMs = 0; + let exportSource = ""; + + if (typeof store.exportSnapshotProbe === "function") { + const probeStartedAt = performance.now(); + inspectionSnapshot = await store.exportSnapshotProbe({ includeTombstones: false }); + exportProbeMs = performance.now() - probeStartedAt; + exportSource = "probe"; + } + + if (!inspectionSnapshot) { + const exportStartedAt = performance.now(); + inspectionSnapshot = await store.exportSnapshot({ includeTombstones: false }); + exportSnapshotMs = performance.now() - exportStartedAt; + exportSource = "full-export"; + } + + return { + inspectionSnapshot, + exportProbeMs, + exportSnapshotMs, + exportSource, + }; +} + +async function measureSuccessPreApply(store, chatId) { + const startedAt = performance.now(); + const probeResult = await readProbeOrFallback(store); + let snapshot = probeResult.inspectionSnapshot; + let exportSnapshotMs = probeResult.exportSnapshotMs; + let exportSource = probeResult.exportSource; + + if (snapshot?.__stBmeProbeOnly === true) { + const exportStartedAt = performance.now(); + snapshot = await store.exportSnapshot({ includeTombstones: false }); + exportSnapshotMs += performance.now() - exportStartedAt; + exportSource = + probeResult.exportSource === "probe" ? "probe+full-export" : "full-export"; + } + + const preApplyMs = performance.now() - startedAt; + const hydrateStartedAt = performance.now(); + buildGraphFromSnapshot(snapshot, { chatId }); + const hydrateMs = performance.now() - hydrateStartedAt; + + return { + preApplyMs, + exportProbeMs: probeResult.exportProbeMs, + exportSnapshotMs, + hydrateMs, + exportSource, + }; +} + +async function measureProbeRejectPreApply(store) { + const startedAt = performance.now(); + const probeResult = await readProbeOrFallback(store); + return { + preApplyMs: performance.now() - startedAt, + exportProbeMs: probeResult.exportProbeMs, + exportSnapshotMs: probeResult.exportSnapshotMs, + exportSource: probeResult.exportSource, + }; +} + +async function runPreset(preset) { + const indexedDbSuccessSamples = []; + const indexedDbProbeRejectSamples = []; + const indexedDbProbeSamples = []; + const indexedDbExportSamples = []; + const indexedDbHydrateSamples = []; + const opfsSuccessSamples = []; + const opfsProbeRejectSamples = []; + const opfsProbeSamples = []; + const opfsExportSamples = []; + const opfsHydrateSamples = []; + + for (let run = 0; run < RUNS; run += 1) { + const { chatId, snapshot } = buildBenchSnapshot({ + ...preset, + seed: preset.seed + run * 17, + }); + + const indexedDbChatId = `${chatId}-indexeddb`; + const db = await prepareIndexedDb(indexedDbChatId, snapshot); + const indexedDbSuccess = await measureSuccessPreApply(db, indexedDbChatId); + const indexedDbProbeReject = await measureProbeRejectPreApply(db); + indexedDbSuccessSamples.push(indexedDbSuccess.preApplyMs); + indexedDbProbeRejectSamples.push(indexedDbProbeReject.preApplyMs); + indexedDbProbeSamples.push(indexedDbSuccess.exportProbeMs); + indexedDbExportSamples.push(indexedDbSuccess.exportSnapshotMs); + indexedDbHydrateSamples.push(indexedDbSuccess.hydrateMs); + await db.close(); + await cleanupDatabase(indexedDbChatId); + + const opfsChatId = `${chatId}-opfs`; + const opfsStore = await prepareOpfsStore(opfsChatId, snapshot); + const opfsSuccess = await measureSuccessPreApply(opfsStore, opfsChatId); + const opfsProbeReject = await measureProbeRejectPreApply(opfsStore); + opfsSuccessSamples.push(opfsSuccess.preApplyMs); + opfsProbeRejectSamples.push(opfsProbeReject.preApplyMs); + opfsProbeSamples.push(opfsSuccess.exportProbeMs); + opfsExportSamples.push(opfsSuccess.exportSnapshotMs); + opfsHydrateSamples.push(opfsSuccess.hydrateMs); + await opfsStore.close(); + } + + const result = { + indexedDbPreApplySuccessMs: summarize(indexedDbSuccessSamples), + indexedDbProbeRejectMs: summarize(indexedDbProbeRejectSamples), + indexedDbExportProbeMs: summarize(indexedDbProbeSamples), + indexedDbExportSnapshotMs: summarize(indexedDbExportSamples), + indexedDbHydrateMs: summarize(indexedDbHydrateSamples), + opfsPreApplySuccessMs: summarize(opfsSuccessSamples), + opfsProbeRejectMs: summarize(opfsProbeRejectSamples), + opfsExportProbeMs: summarize(opfsProbeSamples), + opfsExportSnapshotMs: summarize(opfsExportSamples), + opfsHydrateMs: summarize(opfsHydrateSamples), + }; + + if (!outputJson) { + console.log(`\n[ST-BME][load-preapply-bench] ${preset.label}`); + console.log( + formatSummary("indexeddb-preapply-success", indexedDbSuccessSamples), + `probeRejectP95=${result.indexedDbProbeRejectMs.p95.toFixed(2)}ms`, + `probeP95=${result.indexedDbExportProbeMs.p95.toFixed(2)}ms`, + `exportP95=${result.indexedDbExportSnapshotMs.p95.toFixed(2)}ms`, + ); + console.log( + formatSummary("opfs-preapply-success", opfsSuccessSamples), + `probeRejectP95=${result.opfsProbeRejectMs.p95.toFixed(2)}ms`, + `probeP95=${result.opfsExportProbeMs.p95.toFixed(2)}ms`, + `exportP95=${result.opfsExportSnapshotMs.p95.toFixed(2)}ms`, + ); + console.log( + formatSummary("indexeddb-hydrate", indexedDbHydrateSamples), + formatSummary("opfs-hydrate", opfsHydrateSamples), + ); + } + + return result; +} + +async function main() { + await setupIndexedDbTestEnv(); + const results = {}; + for (const preset of SIZE_PRESETS) { + results[preset.label] = await runPreset(preset); + } + if (outputJson) { + console.log( + JSON.stringify({ + runs: RUNS, + presets: results, + }), + ); + } +} + +await main(); diff --git a/tests/perf/persist-load-bench.mjs b/tests/perf/persist-load-bench.mjs index a33f5c0..f075edc 100644 --- a/tests/perf/persist-load-bench.mjs +++ b/tests/perf/persist-load-bench.mjs @@ -12,6 +12,7 @@ import { import { createMemoryOpfsRoot } from "../helpers/memory-opfs.mjs"; const RUNS = 4; +const outputJson = process.argv.includes("--json"); const SIZE_PRESETS = [ { label: "M", seed: 17, nodeCount: 1200, edgeCount: 3600, churn: 0.08 }, { label: "L", seed: 29, nodeCount: 3600, edgeCount: 10800, churn: 0.1 }, @@ -260,6 +261,11 @@ async function runPreset(preset) { const opfsCommitSamples = []; const snapshotNodesSamples = []; const hydrateRuntimeMetaSamples = []; + const hydrateNodesSamples = []; + const hydrateEdgesSamples = []; + const hydrateStateSamples = []; + const hydrateNormalizeSamples = []; + const hydrateIntegritySamples = []; const walFileWriteSamples = []; const manifestFileWriteSamples = []; @@ -295,31 +301,64 @@ async function runPreset(preset) { opfsCommitSamples.push(opfsCommitResult.elapsedMs); snapshotNodesSamples.push(Number(afterSnapshotResult.diagnostics?.nodesMs || 0)); hydrateRuntimeMetaSamples.push(Number(hydrateResult.diagnostics?.runtimeMetaMs || 0)); + hydrateNodesSamples.push(Number(hydrateResult.diagnostics?.nodesMs || 0)); + hydrateEdgesSamples.push(Number(hydrateResult.diagnostics?.edgesMs || 0)); + hydrateStateSamples.push(Number(hydrateResult.diagnostics?.stateMs || 0)); + hydrateNormalizeSamples.push(Number(hydrateResult.diagnostics?.normalizeMs || 0)); + hydrateIntegritySamples.push(Number(hydrateResult.diagnostics?.integrityMs || 0)); walFileWriteSamples.push(Number(opfsCommitResult.diagnostics?.walFileWriteMs || 0)); manifestFileWriteSamples.push( Number(opfsCommitResult.diagnostics?.manifestFileWriteMs || 0), ); } - console.log(`\n[ST-BME][persist-load-bench] ${preset.label}`); - console.log( - formatSummary("snapshot-build", snapshotBuildSamples), - `nodesPhaseP95=${summarize(snapshotNodesSamples).p95.toFixed(2)}ms`, - ); - console.log( - formatSummary("hydrate", hydrateSamples), - `runtimeMetaP95=${summarize(hydrateRuntimeMetaSamples).p95.toFixed(2)}ms`, - ); - console.log( - formatSummary("opfs-commit", opfsCommitSamples), - `walFileP95=${summarize(walFileWriteSamples).p95.toFixed(2)}ms`, - `manifestFileP95=${summarize(manifestFileWriteSamples).p95.toFixed(2)}ms`, - ); + const result = { + snapshotBuildMs: summarize(snapshotBuildSamples), + snapshotNodesMs: summarize(snapshotNodesSamples), + hydrateMs: summarize(hydrateSamples), + hydrateNodesMs: summarize(hydrateNodesSamples), + hydrateEdgesMs: summarize(hydrateEdgesSamples), + hydrateStateMs: summarize(hydrateStateSamples), + hydrateNormalizeMs: summarize(hydrateNormalizeSamples), + hydrateIntegrityMs: summarize(hydrateIntegritySamples), + hydrateRuntimeMetaMs: summarize(hydrateRuntimeMetaSamples), + opfsCommitMs: summarize(opfsCommitSamples), + opfsWalFileWriteMs: summarize(walFileWriteSamples), + opfsManifestFileWriteMs: summarize(manifestFileWriteSamples), + }; + if (!outputJson) { + console.log(`\n[ST-BME][persist-load-bench] ${preset.label}`); + console.log( + formatSummary("snapshot-build", snapshotBuildSamples), + `nodesPhaseP95=${result.snapshotNodesMs.p95.toFixed(2)}ms`, + ); + console.log( + formatSummary("hydrate", hydrateSamples), + `nodesP95=${result.hydrateNodesMs.p95.toFixed(2)}ms`, + `edgesP95=${result.hydrateEdgesMs.p95.toFixed(2)}ms`, + `normalizeP95=${result.hydrateNormalizeMs.p95.toFixed(2)}ms`, + `integrityP95=${result.hydrateIntegrityMs.p95.toFixed(2)}ms`, + `runtimeMetaP95=${result.hydrateRuntimeMetaMs.p95.toFixed(2)}ms`, + ); + console.log( + formatSummary("opfs-commit", opfsCommitSamples), + `walFileP95=${result.opfsWalFileWriteMs.p95.toFixed(2)}ms`, + `manifestFileP95=${result.opfsManifestFileWriteMs.p95.toFixed(2)}ms`, + ); + } + return result; } async function main() { + const results = {}; for (const preset of SIZE_PRESETS) { - await runPreset(preset); + results[preset.label] = await runPreset(preset); + } + if (outputJson) { + console.log(JSON.stringify({ + runs: RUNS, + presets: results, + })); } } diff --git a/tests/scoped-memory.mjs b/tests/scoped-memory.mjs index 519dd50..04fcc0f 100644 --- a/tests/scoped-memory.mjs +++ b/tests/scoped-memory.mjs @@ -8,6 +8,11 @@ import { findLatestNode, serializeGraph, } from "../graph/graph.js"; +import { normalizeMemoryScope } from "../graph/memory-scope.js"; +import { + normalizeStoryTime, + normalizeStoryTimeSpan, +} from "../graph/story-timeline.js"; const graph = createEmptyGraph(); const objectiveNode = createNode({ @@ -53,6 +58,50 @@ const latestPov = findLatestNode( assert.equal(latestObjective?.id, objectiveNode.id); assert.equal(latestPov?.id, povNode.id); +const normalizedScope = { + layer: "pov", + ownerType: "character", + ownerId: "艾琳", + ownerName: "艾琳", + regionPrimary: "钟楼", + regionPath: ["钟楼", "塔顶"], + regionSecondary: ["旧城区"], +}; +assert.equal( + normalizeMemoryScope(normalizedScope), + normalizedScope, + "已规范的 scope 对象应直接复用", +); + +const normalizedStoryTime = { + segmentId: "tl-1", + label: "第二天清晨", + tense: "ongoing", + relation: "same", + anchorLabel: "昨夜", + confidence: "high", + source: "derived", +}; +assert.equal( + normalizeStoryTime(normalizedStoryTime), + normalizedStoryTime, + "已规范的 storyTime 对象应直接复用", +); + +const normalizedStoryTimeSpan = { + startSegmentId: "tl-0", + endSegmentId: "tl-1", + startLabel: "昨夜", + endLabel: "第二天清晨", + mixed: false, + source: "derived", +}; +assert.equal( + normalizeStoryTimeSpan(normalizedStoryTimeSpan), + normalizedStoryTimeSpan, + "已规范的 storyTimeSpan 对象应直接复用", +); + const legacyGraph = deserializeGraph({ version: 6, lastProcessedSeq: 0,