perf: complete persist-load P2 hydration pass

This commit is contained in:
Youzini-afk
2026-04-22 19:31:44 +08:00
parent 37c6266a81
commit e880fe0b39
10 changed files with 1037 additions and 23 deletions

View File

@@ -58,6 +58,48 @@ function normalizeStringArray(values = []) {
return result; 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 = []) { function normalizeOwnerValueSet(values = []) {
return new Set( return new Set(
normalizeStringArray(values).map((value) => normalizeKey(value)), normalizeStringArray(values).map((value) => normalizeKey(value)),
@@ -88,6 +130,9 @@ export function createDefaultMemoryScope(overrides = {}) {
} }
export function normalizeMemoryScope(scope = {}, defaults = {}) { export function normalizeMemoryScope(scope = {}, defaults = {}) {
if (canReuseNormalizedMemoryScope(scope, defaults)) {
return scope;
}
const merged = { const merged = {
...DEFAULT_MEMORY_SCOPE, ...DEFAULT_MEMORY_SCOPE,
...(defaults || {}), ...(defaults || {}),

View File

@@ -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 = {}) { export function normalizeStoryTime(value = {}, defaults = {}) {
if (canReuseNormalizedStoryTime(value, defaults)) {
return value;
}
return createDefaultStoryTime({ return createDefaultStoryTime({
...defaults, ...defaults,
...(value && typeof value === "object" && !Array.isArray(value) ? value : {}), ...(value && typeof value === "object" && !Array.isArray(value) ? value : {}),
@@ -155,6 +198,9 @@ export function normalizeStoryTime(value = {}, defaults = {}) {
} }
export function normalizeStoryTimeSpan(value = {}, defaults = {}) { export function normalizeStoryTimeSpan(value = {}, defaults = {}) {
if (canReuseNormalizedStoryTimeSpan(value, defaults)) {
return value;
}
return createDefaultStoryTimeSpan({ return createDefaultStoryTimeSpan({
...defaults, ...defaults,
...(value && typeof value === "object" && !Array.isArray(value) ? value : {}), ...(value && typeof value === "object" && !Array.isArray(value) ? value : {}),

View File

@@ -14,6 +14,9 @@
"test:trivial-input": "node tests/trivial-user-input.mjs", "test:trivial-input": "node tests/trivial-user-input.mjs",
"bench:graph-layout": "node tests/perf/graph-layout-bench.mjs", "bench:graph-layout": "node tests/perf/graph-layout-bench.mjs",
"bench:persist-delta": "node tests/perf/persist-delta-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", "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: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", "test:persistence-matrix": "npm run test:p0 && npm run test:runtime-history && npm run test:graph-persistence && npm run test:indexeddb",

View File

@@ -10,6 +10,7 @@ import {
} from "../graph/knowledge-state.js"; } from "../graph/knowledge-state.js";
import { import {
createDefaultTimelineState, createDefaultTimelineState,
normalizeTimelineState,
normalizeGraphStoryTimeline, normalizeGraphStoryTimeline,
} from "../graph/story-timeline.js"; } from "../graph/story-timeline.js";
import { import {
@@ -224,10 +225,12 @@ function getRequiredJournalCoverageStartFloor(graph, journals = []) {
return null; return null;
} }
export function normalizeGraphRuntimeState(graph, chatId = "") { export function normalizeGraphRuntimeState(graph, chatId = "", options = {}) {
if (!graph || typeof graph !== "object") { if (!graph || typeof graph !== "object") {
return graph; return graph;
} }
const skipRecordFieldNormalization =
options?.skipRecordFieldNormalization === true;
const hadSummaryState = const hadSummaryState =
graph.summaryState && graph.summaryState &&
typeof graph.summaryState === "object" && typeof graph.summaryState === "object" &&
@@ -475,10 +478,10 @@ export function normalizeGraphRuntimeState(graph, chatId = "") {
graph.historyState = historyState; graph.historyState = historyState;
graph.vectorIndexState = vectorIndexState; graph.vectorIndexState = vectorIndexState;
if (Array.isArray(graph.nodes)) { if (!skipRecordFieldNormalization && Array.isArray(graph.nodes)) {
graph.nodes.forEach((node) => normalizeNodeMemoryScope(node)); graph.nodes.forEach((node) => normalizeNodeMemoryScope(node));
} }
if (Array.isArray(graph.edges)) { if (!skipRecordFieldNormalization && Array.isArray(graph.edges)) {
graph.edges.forEach((edge) => normalizeEdgeMemoryScope(edge)); graph.edges.forEach((edge) => normalizeEdgeMemoryScope(edge));
} }
graph.batchJournal = Array.isArray(graph.batchJournal) graph.batchJournal = Array.isArray(graph.batchJournal)
@@ -496,10 +499,16 @@ export function normalizeGraphRuntimeState(graph, chatId = "") {
: createDefaultMaintenanceJournal(); : createDefaultMaintenanceJournal();
graph.knowledgeState = createDefaultKnowledgeState(graph.knowledgeState); graph.knowledgeState = createDefaultKnowledgeState(graph.knowledgeState);
graph.regionState = createDefaultRegionState(graph.regionState); graph.regionState = createDefaultRegionState(graph.regionState);
graph.timelineState = createDefaultTimelineState(graph.timelineState); graph.timelineState = skipRecordFieldNormalization
? normalizeTimelineState(graph.timelineState)
: createDefaultTimelineState(graph.timelineState);
graph.summaryState = createDefaultSummaryState(graph.summaryState); graph.summaryState = createDefaultSummaryState(graph.summaryState);
normalizeGraphCognitiveState(graph); normalizeGraphCognitiveState(graph);
normalizeGraphStoryTimeline(graph); if (skipRecordFieldNormalization) {
graph.timelineState = normalizeTimelineState(graph.timelineState);
} else {
normalizeGraphStoryTimeline(graph);
}
normalizeGraphSummaryState(graph); normalizeGraphSummaryState(graph);
if (!hadSummaryState) { if (!hadSummaryState) {
importLegacySynopsisToSummaryState(graph); importLegacySynopsisToSummaryState(graph);

View File

@@ -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();

View File

@@ -40,6 +40,8 @@ export const BME_RUNTIME_TIMELINE_STATE_META_KEY = "timelineState";
export const BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY = export const BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY =
"runtimeLastProcessedSeq"; "runtimeLastProcessedSeq";
export const BME_RUNTIME_GRAPH_VERSION_META_KEY = "runtimeGraphVersion"; 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({ export const BME_DB_TABLE_SCHEMAS = Object.freeze({
nodes: nodes:
@@ -153,6 +155,169 @@ function toArray(value) {
return Array.isArray(value) ? 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 = []) { function toMetaMap(rows = []) {
const output = {}; const output = {};
for (const row of rows) { for (const row of rows) {
@@ -927,6 +1092,7 @@ export function buildSnapshotFromGraph(graph, options = {}) {
) )
? Number(runtimeGraph.version) ? Number(runtimeGraph.version)
: Number(baseSnapshot.meta?.[BME_RUNTIME_GRAPH_VERSION_META_KEY] || 0), : Number(baseSnapshot.meta?.[BME_RUNTIME_GRAPH_VERSION_META_KEY] || 0),
[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY]: true,
}; };
if (snapshotDiagnostics) { if (snapshotDiagnostics) {
snapshotDiagnostics.metaMs = readPersistDeltaNow() - metaStartedAt; snapshotDiagnostics.metaMs = readPersistDeltaNow() - metaStartedAt;
@@ -2133,6 +2299,8 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
snapshotMeta?.[BME_RUNTIME_VECTOR_META_KEY], snapshotMeta?.[BME_RUNTIME_VECTOR_META_KEY],
{}, {},
); );
const snapshotRecordsNormalized =
snapshotMeta?.[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY] === true;
const runtimeGraph = createEmptyGraph(); const runtimeGraph = createEmptyGraph();
runtimeGraph.version = Number.isFinite( runtimeGraph.version = Number.isFinite(
@@ -2142,14 +2310,14 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
: runtimeGraph.version; : runtimeGraph.version;
const hydrateNodesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; const hydrateNodesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
runtimeGraph.nodes = toArray(toPlainData(snapshotView.nodes, [])); runtimeGraph.nodes = cloneHydrateSnapshotNodeRecords(snapshotView.nodes);
if (hydrateDiagnostics) { if (hydrateDiagnostics) {
hydrateDiagnostics.nodeCount = runtimeGraph.nodes.length; hydrateDiagnostics.nodeCount = runtimeGraph.nodes.length;
hydrateDiagnostics.nodesMs = readPersistDeltaNow() - hydrateNodesStartedAt; hydrateDiagnostics.nodesMs = readPersistDeltaNow() - hydrateNodesStartedAt;
} }
const hydrateEdgesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; const hydrateEdgesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
runtimeGraph.edges = toArray(toPlainData(snapshotView.edges, [])); runtimeGraph.edges = cloneHydrateSnapshotEdgeRecords(snapshotView.edges);
if (hydrateDiagnostics) { if (hydrateDiagnostics) {
hydrateDiagnostics.edgeCount = runtimeGraph.edges.length; hydrateDiagnostics.edgeCount = runtimeGraph.edges.length;
hydrateDiagnostics.edgesMs = readPersistDeltaNow() - hydrateEdgesStartedAt; hydrateDiagnostics.edgesMs = readPersistDeltaNow() - hydrateEdgesStartedAt;
@@ -2302,7 +2470,9 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
} }
const normalizeStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; const normalizeStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
const normalizedGraph = normalizeGraphRuntimeState(runtimeGraph, chatId); const normalizedGraph = normalizeGraphRuntimeState(runtimeGraph, chatId, {
skipRecordFieldNormalization: snapshotRecordsNormalized,
});
if (hydrateDiagnostics) { if (hydrateDiagnostics) {
hydrateDiagnostics.normalizeMs = readPersistDeltaNow() - normalizeStartedAt; hydrateDiagnostics.normalizeMs = readPersistDeltaNow() - normalizeStartedAt;
} }

View File

@@ -4,6 +4,7 @@ import {
BME_DB_SCHEMA_VERSION, BME_DB_SCHEMA_VERSION,
BME_RUNTIME_BATCH_JOURNAL_META_KEY, BME_RUNTIME_BATCH_JOURNAL_META_KEY,
BME_RUNTIME_HISTORY_META_KEY, BME_RUNTIME_HISTORY_META_KEY,
BME_RUNTIME_RECORDS_NORMALIZED_META_KEY,
BME_RUNTIME_VECTOR_META_KEY, BME_RUNTIME_VECTOR_META_KEY,
BME_TOMBSTONE_RETENTION_MS, BME_TOMBSTONE_RETENTION_MS,
BmeDatabase, BmeDatabase,
@@ -618,6 +619,33 @@ async function testGraphSnapshotConverters() {
title: "Converter Node", title: "Converter Node",
}, },
updatedAt: Date.now(), 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; let snapshotDiagnostics = null;
@@ -630,6 +658,7 @@ async function testGraphSnapshotConverters() {
}); });
assert.equal(snapshot.meta.chatId, "chat-a"); assert.equal(snapshot.meta.chatId, "chat-a");
assert.equal(snapshot.meta.revision, 17); 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.lastProcessedFloor, 9);
assert.equal(snapshot.state.extractionCount, 4); assert.equal(snapshot.state.extractionCount, 4);
assert.equal(snapshot.nodes.length, 1); assert.equal(snapshot.nodes.length, 1);
@@ -687,18 +716,44 @@ async function testGraphSnapshotConverters() {
const rebuilt = buildGraphFromSnapshot(snapshot, { const rebuilt = buildGraphFromSnapshot(snapshot, {
chatId: "chat-a", 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.lastProcessedAssistantFloor, 9);
assert.equal(rebuilt.historyState.extractionCount, 4); assert.equal(rebuilt.historyState.extractionCount, 4);
assert.equal(rebuilt.nodes.length, 1); assert.equal(rebuilt.nodes.length, 1);
assert.equal(rebuilt.nodes[0].id, "node-converter"); 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.vectorIndexState.hashToNodeId["vec-hash"], "node-converter");
assert.equal(rebuilt.maintenanceJournal[0].id, "maintenance-1"); assert.equal(rebuilt.maintenanceJournal[0].id, "maintenance-1");
assert.equal(rebuilt.knowledgeState.activeOwnerKey, "owner:hero"); assert.equal(rebuilt.knowledgeState.activeOwnerKey, "owner:hero");
assert.equal(rebuilt.regionState.activeRegion, "camp"); assert.equal(rebuilt.regionState.activeRegion, "camp");
assert.equal(rebuilt.timelineState.activeSegmentId, "segment-1"); assert.equal(rebuilt.timelineState.activeSegmentId, "segment-1");
assert.equal(rebuilt.summaryState.entries[0].id, "summary-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].fields.title = "Mutated Converter Node";
rebuilt.nodes[0].embedding[0] = 99;
rebuilt.historyState.processedMessageHashes[1] = "mutated-hash"; rebuilt.historyState.processedMessageHashes[1] = "mutated-hash";
rebuilt.vectorIndexState.hashToNodeId["vec-hash"] = "node-mutated"; rebuilt.vectorIndexState.hashToNodeId["vec-hash"] = "node-mutated";
rebuilt.batchJournal[0].processedRange[0] = 99; rebuilt.batchJournal[0].processedRange[0] = 99;
@@ -713,6 +768,11 @@ async function testGraphSnapshotConverters() {
"hash-1", "hash-1",
"buildGraphFromSnapshot 不应复用 snapshot historyState 的嵌套对象引用", "buildGraphFromSnapshot 不应复用 snapshot historyState 的嵌套对象引用",
); );
assert.equal(
snapshot.nodes[0].embedding[0],
0.25,
"buildGraphFromSnapshot 不应复用 snapshot 节点的数组字段引用",
);
assert.equal( assert.equal(
snapshot.meta[BME_RUNTIME_VECTOR_META_KEY].hashToNodeId["vec-hash"], snapshot.meta[BME_RUNTIME_VECTOR_META_KEY].hashToNodeId["vec-hash"],
"node-converter", "node-converter",

View File

@@ -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();

View File

@@ -12,6 +12,7 @@ import {
import { createMemoryOpfsRoot } from "../helpers/memory-opfs.mjs"; import { createMemoryOpfsRoot } from "../helpers/memory-opfs.mjs";
const RUNS = 4; const RUNS = 4;
const outputJson = process.argv.includes("--json");
const SIZE_PRESETS = [ const SIZE_PRESETS = [
{ label: "M", seed: 17, nodeCount: 1200, edgeCount: 3600, churn: 0.08 }, { label: "M", seed: 17, nodeCount: 1200, edgeCount: 3600, churn: 0.08 },
{ label: "L", seed: 29, nodeCount: 3600, edgeCount: 10800, churn: 0.1 }, { label: "L", seed: 29, nodeCount: 3600, edgeCount: 10800, churn: 0.1 },
@@ -260,6 +261,11 @@ async function runPreset(preset) {
const opfsCommitSamples = []; const opfsCommitSamples = [];
const snapshotNodesSamples = []; const snapshotNodesSamples = [];
const hydrateRuntimeMetaSamples = []; const hydrateRuntimeMetaSamples = [];
const hydrateNodesSamples = [];
const hydrateEdgesSamples = [];
const hydrateStateSamples = [];
const hydrateNormalizeSamples = [];
const hydrateIntegritySamples = [];
const walFileWriteSamples = []; const walFileWriteSamples = [];
const manifestFileWriteSamples = []; const manifestFileWriteSamples = [];
@@ -295,31 +301,64 @@ async function runPreset(preset) {
opfsCommitSamples.push(opfsCommitResult.elapsedMs); opfsCommitSamples.push(opfsCommitResult.elapsedMs);
snapshotNodesSamples.push(Number(afterSnapshotResult.diagnostics?.nodesMs || 0)); snapshotNodesSamples.push(Number(afterSnapshotResult.diagnostics?.nodesMs || 0));
hydrateRuntimeMetaSamples.push(Number(hydrateResult.diagnostics?.runtimeMetaMs || 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)); walFileWriteSamples.push(Number(opfsCommitResult.diagnostics?.walFileWriteMs || 0));
manifestFileWriteSamples.push( manifestFileWriteSamples.push(
Number(opfsCommitResult.diagnostics?.manifestFileWriteMs || 0), Number(opfsCommitResult.diagnostics?.manifestFileWriteMs || 0),
); );
} }
console.log(`\n[ST-BME][persist-load-bench] ${preset.label}`); const result = {
console.log( snapshotBuildMs: summarize(snapshotBuildSamples),
formatSummary("snapshot-build", snapshotBuildSamples), snapshotNodesMs: summarize(snapshotNodesSamples),
`nodesPhaseP95=${summarize(snapshotNodesSamples).p95.toFixed(2)}ms`, hydrateMs: summarize(hydrateSamples),
); hydrateNodesMs: summarize(hydrateNodesSamples),
console.log( hydrateEdgesMs: summarize(hydrateEdgesSamples),
formatSummary("hydrate", hydrateSamples), hydrateStateMs: summarize(hydrateStateSamples),
`runtimeMetaP95=${summarize(hydrateRuntimeMetaSamples).p95.toFixed(2)}ms`, hydrateNormalizeMs: summarize(hydrateNormalizeSamples),
); hydrateIntegrityMs: summarize(hydrateIntegritySamples),
console.log( hydrateRuntimeMetaMs: summarize(hydrateRuntimeMetaSamples),
formatSummary("opfs-commit", opfsCommitSamples), opfsCommitMs: summarize(opfsCommitSamples),
`walFileP95=${summarize(walFileWriteSamples).p95.toFixed(2)}ms`, opfsWalFileWriteMs: summarize(walFileWriteSamples),
`manifestFileP95=${summarize(manifestFileWriteSamples).p95.toFixed(2)}ms`, 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() { async function main() {
const results = {};
for (const preset of SIZE_PRESETS) { 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,
}));
} }
} }

View File

@@ -8,6 +8,11 @@ import {
findLatestNode, findLatestNode,
serializeGraph, serializeGraph,
} from "../graph/graph.js"; } from "../graph/graph.js";
import { normalizeMemoryScope } from "../graph/memory-scope.js";
import {
normalizeStoryTime,
normalizeStoryTimeSpan,
} from "../graph/story-timeline.js";
const graph = createEmptyGraph(); const graph = createEmptyGraph();
const objectiveNode = createNode({ const objectiveNode = createNode({
@@ -53,6 +58,50 @@ const latestPov = findLatestNode(
assert.equal(latestObjective?.id, objectiveNode.id); assert.equal(latestObjective?.id, objectiveNode.id);
assert.equal(latestPov?.id, povNode.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({ const legacyGraph = deserializeGraph({
version: 6, version: 6,
lastProcessedSeq: 0, lastProcessedSeq: 0,