diff --git a/index.js b/index.js index d56cc61..809c394 100644 --- a/index.js +++ b/index.js @@ -390,6 +390,7 @@ import { buildAuthorityDiagnosticsBundlePath, buildAuthorityDiagnosticsManifestPath, buildAuthorityPerformanceBaseline, + buildAuthorityPerformanceBaselineComparison, readAuthorityDiagnosticsManifest, removeAuthorityDiagnosticsManifestEntry, upsertAuthorityDiagnosticsManifestEntry, @@ -1894,6 +1895,10 @@ function getGraphPersistenceLiveState() { graphPersistenceState.authorityPerformanceBaseline, null, ), + authorityPerformanceBaselineComparison: cloneRuntimeDebugValue( + graphPersistenceState.authorityPerformanceBaselineComparison, + null, + ), authorityPerformanceBaselineUpdatedAt: String( graphPersistenceState.authorityPerformanceBaselineUpdatedAt || "", ), @@ -2326,11 +2331,19 @@ function buildAuthorityPerformanceBaselineSnapshot(options = {}) { } function captureAuthorityPerformanceBaseline(options = {}) { + const previousBaseline = + graphPersistenceState.authorityPerformanceBaseline && + typeof graphPersistenceState.authorityPerformanceBaseline === "object" && + !Array.isArray(graphPersistenceState.authorityPerformanceBaseline) + ? graphPersistenceState.authorityPerformanceBaseline + : null; const baseline = buildAuthorityPerformanceBaselineSnapshot(options); + const comparison = buildAuthorityPerformanceBaselineComparison(previousBaseline, baseline); const capturedAt = String(baseline?.capturedAt || new Date().toISOString()); const reason = String(options.reason || "manual-authority-performance-baseline"); updateGraphPersistenceState({ authorityPerformanceBaseline: cloneRuntimeDebugValue(baseline, null), + authorityPerformanceBaselineComparison: cloneRuntimeDebugValue(comparison, null), authorityPerformanceBaselineUpdatedAt: capturedAt, authorityPerformanceBaselineReason: reason, }); @@ -2360,12 +2373,19 @@ async function exportAuthorityDiagnosticsBundle(options = {}) { } const reason = String(options.reason || "diagnostics-bundle").trim() || "diagnostics-bundle"; const liveGraphPersistence = getGraphPersistenceLiveState(); + const previousBaseline = + liveGraphPersistence.authorityPerformanceBaseline && + typeof liveGraphPersistence.authorityPerformanceBaseline === "object" && + !Array.isArray(liveGraphPersistence.authorityPerformanceBaseline) + ? liveGraphPersistence.authorityPerformanceBaseline + : null; const baseline = buildAuthorityPerformanceBaseline({ chatId, graphPersistence: liveGraphPersistence, graph: currentGraph, consistencyAudit: liveGraphPersistence.authorityConsistencyAudit, }); + const baselineComparison = buildAuthorityPerformanceBaselineComparison(previousBaseline, baseline); const bundle = buildAuthorityDiagnosticsBundle({ chatId, reason, @@ -2387,6 +2407,7 @@ async function exportAuthorityDiagnosticsBundle(options = {}) { lastExtract: lastExtractedItems, lastRecall: lastRecalledItems, performanceBaseline: baseline, + performanceBaselineComparison: baselineComparison, }); const path = buildAuthorityDiagnosticsBundlePath(chatId, reason); const manifestPath = buildAuthorityDiagnosticsManifestPath(chatId); @@ -2439,6 +2460,7 @@ async function exportAuthorityDiagnosticsBundle(options = {}) { }); updateGraphPersistenceState({ authorityPerformanceBaseline: cloneRuntimeDebugValue(baseline, null), + authorityPerformanceBaselineComparison: cloneRuntimeDebugValue(baselineComparison, null), authorityPerformanceBaselineUpdatedAt: String(baseline?.capturedAt || updatedAt), authorityPerformanceBaselineReason: reason, authorityDiagnosticsBundlePath: String(result?.path || path), diff --git a/maintenance/authority-diagnostics-bundle.js b/maintenance/authority-diagnostics-bundle.js index ecdf0ac..7fd6264 100644 --- a/maintenance/authority-diagnostics-bundle.js +++ b/maintenance/authority-diagnostics-bundle.js @@ -87,8 +87,9 @@ function sanitizeValue(value, key = "", depth = 0) { } function buildGraphSummary(graph = null) { - const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []; - const edges = Array.isArray(graph?.edges) ? graph.edges : []; + const runtimeGraph = graph && typeof graph === "object" && !Array.isArray(graph) ? graph : {}; + const nodes = Array.isArray(runtimeGraph.nodes) ? runtimeGraph.nodes : []; + const edges = Array.isArray(runtimeGraph.edges) ? runtimeGraph.edges : []; const activeNodes = nodes.filter((node) => !node?.archived); const archivedNodes = nodes.filter((node) => node?.archived); const typeCounts = {}; @@ -157,6 +158,15 @@ function buildGraphSummary(graph = null) { }; } +function readFiniteNumber(value, fallback = 0) { + const normalized = Number(value); + return Number.isFinite(normalized) ? normalized : fallback; +} + +function buildBaselineDelta(currentValue, previousValue) { + return readFiniteNumber(currentValue) - readFiniteNumber(previousValue); +} + function buildStatusSnapshot(status = null) { if (!status || typeof status !== "object" || Array.isArray(status)) { return null; @@ -292,6 +302,89 @@ export function buildAuthorityPerformanceBaseline({ }; } +export function buildAuthorityPerformanceBaselineComparison(previousBaseline = null, currentBaseline = null) { + const previous = + previousBaseline && typeof previousBaseline === "object" && !Array.isArray(previousBaseline) + ? previousBaseline + : null; + const current = + currentBaseline && typeof currentBaseline === "object" && !Array.isArray(currentBaseline) + ? currentBaseline + : null; + if (!previous || !current) { + return null; + } + return { + kind: "authority-performance-baseline-comparison", + chatId: normalizeRecordId(current.chatId || previous.chatId), + previousCapturedAt: String(previous.capturedAt || ""), + currentCapturedAt: String(current.capturedAt || ""), + previousGraphRevision: readFiniteNumber(previous.graphRevision), + currentGraphRevision: readFiniteNumber(current.graphRevision), + deltaGraphRevision: buildBaselineDelta(current.graphRevision, previous.graphRevision), + deltaNodeCount: buildBaselineDelta(current.graphNodeCount, previous.graphNodeCount), + deltaEdgeCount: buildBaselineDelta(current.graphEdgeCount, previous.graphEdgeCount), + deltaExtractionCount: buildBaselineDelta(current.extractionCount, previous.extractionCount), + load: safeClone( + { + totalMs: buildBaselineDelta(current.load?.totalMs, previous.load?.totalMs), + preApplyMs: buildBaselineDelta(current.load?.preApplyMs, previous.load?.preApplyMs), + hydrateMs: buildBaselineDelta(current.load?.hydrateMs, previous.load?.hydrateMs), + hydrateNativeRecordsMs: buildBaselineDelta( + current.load?.hydrateNativeRecordsMs, + previous.load?.hydrateNativeRecordsMs, + ), + applyRuntimeMs: buildBaselineDelta( + current.load?.applyRuntimeMs, + previous.load?.applyRuntimeMs, + ), + }, + null, + ), + persist: safeClone( + { + totalMs: buildBaselineDelta(current.persist?.totalMs, previous.persist?.totalMs), + buildMs: buildBaselineDelta(current.persist?.buildMs, previous.persist?.buildMs), + snapshotBuildMs: buildBaselineDelta( + current.persist?.snapshotBuildMs, + previous.persist?.snapshotBuildMs, + ), + commitQueueWaitMs: buildBaselineDelta( + current.persist?.commitQueueWaitMs, + previous.persist?.commitQueueWaitMs, + ), + commitMs: buildBaselineDelta(current.persist?.commitMs, previous.persist?.commitMs), + commitPayloadBytes: buildBaselineDelta( + current.persist?.commitPayloadBytes, + previous.persist?.commitPayloadBytes, + ), + commitWalBytes: buildBaselineDelta( + current.persist?.commitWalBytes, + previous.persist?.commitWalBytes, + ), + }, + null, + ), + soak: safeClone( + { + recentJobCount: buildBaselineDelta( + current.soak?.recentJobCount, + previous.soak?.recentJobCount, + ), + runningJobCount: buildBaselineDelta( + current.soak?.runningJobCount, + previous.soak?.runningJobCount, + ), + failedJobCount: buildBaselineDelta( + current.soak?.failedJobCount, + previous.soak?.failedJobCount, + ), + }, + null, + ), + }; +} + export function sanitizeDiagnosticsSettings(settings = {}) { return sanitizeValue(settings, "settings", 0); } @@ -542,6 +635,7 @@ export function buildAuthorityDiagnosticsBundle({ lastExtract = [], lastRecall = [], performanceBaseline = null, + performanceBaselineComparison = null, } = {}) { const createdAt = new Date().toISOString(); return { @@ -556,6 +650,7 @@ export function buildAuthorityDiagnosticsBundle({ graphPersistence: safeClone(graphPersistence, null), graphSummary: buildGraphSummary(graph), performanceBaseline: safeClone(performanceBaseline, null), + performanceBaselineComparison: safeClone(performanceBaselineComparison, null), lastStatuses: { extraction: buildStatusSnapshot(lastExtractionStatus), vector: buildStatusSnapshot(lastVectorStatus), diff --git a/tests/authority-diagnostics-bundle.mjs b/tests/authority-diagnostics-bundle.mjs index 874819c..36abc88 100644 --- a/tests/authority-diagnostics-bundle.mjs +++ b/tests/authority-diagnostics-bundle.mjs @@ -5,6 +5,7 @@ import { buildAuthorityDiagnosticsBundlePath, buildAuthorityDiagnosticsManifestPath, buildAuthorityPerformanceBaseline, + buildAuthorityPerformanceBaselineComparison, readAuthorityDiagnosticsManifest, removeAuthorityDiagnosticsManifestEntry, sanitizeDiagnosticsSettings, @@ -123,6 +124,70 @@ function createMockAdapter() { assert.equal(baseline.artifacts.diagnosticsBundlePath, "user/files/diag.json"); } +{ + const previousBaseline = { + kind: "authority-performance-baseline", + chatId: "chat/main", + capturedAt: "2026-01-01T00:00:00.000Z", + graphRevision: 12, + graphNodeCount: 2, + graphEdgeCount: 1, + extractionCount: 5, + load: { + totalMs: 45, + hydrateMs: 18, + }, + persist: { + totalMs: 22, + commitMs: 8, + commitPayloadBytes: 2048, + commitWalBytes: 128, + }, + soak: { + recentJobCount: 2, + runningJobCount: 0, + failedJobCount: 1, + }, + }; + const currentBaseline = { + kind: "authority-performance-baseline", + chatId: "chat/main", + capturedAt: "2026-01-02T00:00:00.000Z", + graphRevision: 15, + graphNodeCount: 4, + graphEdgeCount: 3, + extractionCount: 8, + load: { + totalMs: 60, + hydrateMs: 24, + }, + persist: { + totalMs: 30, + commitMs: 11, + commitPayloadBytes: 3072, + commitWalBytes: 256, + }, + soak: { + recentJobCount: 4, + runningJobCount: 1, + failedJobCount: 0, + }, + }; + const comparison = buildAuthorityPerformanceBaselineComparison( + previousBaseline, + currentBaseline, + ); + assert.equal(comparison.kind, "authority-performance-baseline-comparison"); + assert.equal(comparison.previousCapturedAt, "2026-01-01T00:00:00.000Z"); + assert.equal(comparison.currentCapturedAt, "2026-01-02T00:00:00.000Z"); + assert.equal(comparison.deltaGraphRevision, 3); + assert.equal(comparison.deltaNodeCount, 2); + assert.equal(comparison.deltaEdgeCount, 2); + assert.equal(comparison.load.totalMs, 15); + assert.equal(comparison.persist.commitMs, 3); + assert.equal(comparison.soak.failedJobCount, -1); +} + { const bundle = buildAuthorityDiagnosticsBundle({ chatId: "chat/main", @@ -209,6 +274,13 @@ function createMockAdapter() { graphRevision: 9, load: { totalMs: 12 }, }, + performanceBaselineComparison: { + kind: "authority-performance-baseline-comparison", + deltaGraphRevision: 2, + load: { totalMs: 4 }, + persist: { totalMs: 3 }, + soak: { failedJobCount: -1 }, + }, }); assert.equal(bundle.kind, "st-bme-authority-diagnostics"); @@ -223,6 +295,7 @@ function createMockAdapter() { assert.equal(bundle.recentExtractedItems.length, 1); assert.equal(bundle.recentRecalledItems.length, 1); assert.equal(bundle.performanceBaseline?.graphRevision, 9); + assert.equal(bundle.performanceBaselineComparison?.deltaGraphRevision, 2); } { diff --git a/ui/panel.js b/ui/panel.js index c12df2f..33a30d1 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -3110,6 +3110,12 @@ function _refreshTaskPersistence() { !Array.isArray(ps.authorityPerformanceBaseline) ? ps.authorityPerformanceBaseline : null; + const authorityBaselineComparison = + ps.authorityPerformanceBaselineComparison && + typeof ps.authorityPerformanceBaselineComparison === "object" && + !Array.isArray(ps.authorityPerformanceBaselineComparison) + ? ps.authorityPerformanceBaselineComparison + : null; const authorityBaselineUpdatedLabel = ps.authorityPerformanceBaselineUpdatedAt ? _formatTaskProfileTime(ps.authorityPerformanceBaselineUpdatedAt) : authorityBaseline?.capturedAt @@ -3127,6 +3133,21 @@ function _refreshTaskPersistence() { const authorityBaselineGraphLabel = authorityBaseline ? `rev ${Number(authorityBaseline.graphRevision || 0)} · ${Number(authorityBaseline.graphNodeCount || 0)} 节点 / ${Number(authorityBaseline.graphEdgeCount || 0)} 边` : "—"; + const authorityBaselinePreviousLabel = authorityBaselineComparison?.previousCapturedAt + ? _formatTaskProfileTime(authorityBaselineComparison.previousCapturedAt) + : "—"; + const authorityBaselineDeltaLoadLabel = authorityBaselineComparison?.load + ? `${_formatSignedMetricDelta(authorityBaselineComparison.load.totalMs, _formatDurationMs)} / hydrate ${_formatSignedMetricDelta(authorityBaselineComparison.load.hydrateMs, _formatDurationMs)}` + : "—"; + const authorityBaselineDeltaPersistLabel = authorityBaselineComparison?.persist + ? `${_formatSignedMetricDelta(authorityBaselineComparison.persist.totalMs, _formatDurationMs)} / commit ${_formatSignedMetricDelta(authorityBaselineComparison.persist.commitMs, _formatDurationMs)}` + : "—"; + const authorityBaselineDeltaSoakLabel = authorityBaselineComparison?.soak + ? `${_formatSignedMetricDelta(authorityBaselineComparison.soak.recentJobCount, null, "0")} recent / ${_formatSignedMetricDelta(authorityBaselineComparison.soak.failedJobCount, null, "0")} failed / ${_formatSignedMetricDelta(authorityBaselineComparison.soak.runningJobCount, null, "0")} running` + : "—"; + const authorityBaselineDeltaGraphLabel = authorityBaselineComparison + ? `${_formatSignedMetricDelta(authorityBaselineComparison.deltaGraphRevision, null, "0")} rev · ${_formatSignedMetricDelta(authorityBaselineComparison.deltaNodeCount, null, "0")} 节点 / ${_formatSignedMetricDelta(authorityBaselineComparison.deltaEdgeCount, null, "0")} 边` + : "—"; const authorityBundlePathLabel = String(ps.authorityDiagnosticsBundlePath || "").trim() || "—"; const authorityBundleUpdatedLabel = ps.authorityDiagnosticsBundleUpdatedAt ? _formatTaskProfileTime(ps.authorityDiagnosticsBundleUpdatedAt) @@ -3249,6 +3270,11 @@ function _refreshTaskPersistence() { ["Baseline Load", authorityBaselineLoadLabel], ["Baseline Persist", authorityBaselinePersistLabel], ["Baseline Soak", authorityBaselineSoakLabel], + ["对比基线", authorityBaselinePreviousLabel], + ["Delta 图谱", authorityBaselineDeltaGraphLabel], + ["Delta Load", authorityBaselineDeltaLoadLabel], + ["Delta Persist", authorityBaselineDeltaPersistLabel], + ["Delta Soak", authorityBaselineDeltaSoakLabel], ["最近 Baseline", authorityBaselineUpdatedLabel], ["诊断包路径", authorityBundlePathLabel], ["诊断包大小", authorityBundleSizeLabel], @@ -9288,6 +9314,18 @@ function _formatDurationMs(durationMs) { return `${(normalized / 1000).toFixed(normalized >= 10000 ? 0 : 1)}s`; } +function _formatSignedMetricDelta(value, formatter = null, zeroLabel = "0") { + const normalized = Number(value); + if (!Number.isFinite(normalized)) return "—"; + if (normalized === 0) return zeroLabel; + const sign = normalized > 0 ? "+" : "-"; + const absValue = Math.abs(normalized); + const formatted = typeof formatter === "function" + ? formatter(absValue) + : String(Math.round(absValue)); + return `${sign}${formatted === "—" ? String(Math.round(absValue)) : formatted}`; +} + function _formatDataSizeBytes(byteCount) { const normalized = Number(byteCount); if (!Number.isFinite(normalized) || normalized <= 0) return "—"; diff --git a/ui/ui-status.js b/ui/ui-status.js index b301982..dc3548f 100644 --- a/ui/ui-status.js +++ b/ui/ui-status.js @@ -181,6 +181,7 @@ export function createGraphPersistenceState() { authorityCheckpointRestoreUpdatedAt: "", authorityCheckpointRestoreError: "", authorityPerformanceBaseline: null, + authorityPerformanceBaselineComparison: null, authorityPerformanceBaselineUpdatedAt: "", authorityPerformanceBaselineReason: "", authorityDiagnosticsBundlePath: "",