refactor(rebirth): add graph head model

This commit is contained in:
youzini
2026-05-30 13:55:08 +00:00
parent a5cdc0310c
commit 70c0639c94
3 changed files with 442 additions and 0 deletions

261
graph/graph-head.js Normal file
View File

@@ -0,0 +1,261 @@
// ST-BME v3 GraphHead model.
//
// Pure helpers only. Phase 3 introduces the v3 data shape without switching
// storage routes. A GraphHead owns graph identity/revision/counts; replicas and
// commit markers are pointers to that head instead of competing authorities.
import { isAcceptedLegacyPersistenceTier } from "../sync/legacy-persistence-repair.js";
import { normalizeIdentityValue } from "../runtime/identity-resolver.js";
import { getGraphStats } from "./graph.js";
export const GRAPH_HEAD_FORMAT_VERSION = 3;
export const GRAPH_REPLICA_POINTER_FORMAT_VERSION = 3;
export const GRAPH_COMMIT_MARKER_V3_FORMAT_VERSION = 3;
function normalizeNonNegativeInteger(value = 0) {
const numeric = Number(value || 0);
if (!Number.isFinite(numeric) || numeric <= 0) return 0;
return Math.floor(numeric);
}
function normalizeFloor(value = -1) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return -1;
return Math.floor(numeric);
}
function normalizeUpdatedAt(value = "") {
return String(value || new Date().toISOString());
}
function normalizeCounts(value = {}) {
return {
nodeCount: normalizeNonNegativeInteger(value.nodeCount ?? value.nodes),
edgeCount: normalizeNonNegativeInteger(value.edgeCount ?? value.edges),
archivedCount: normalizeNonNegativeInteger(value.archivedCount ?? value.archivedNodes),
tombstoneCount: normalizeNonNegativeInteger(value.tombstoneCount ?? value.tombstones),
};
}
function firstIdentity(...values) {
for (const value of values) {
const normalized = normalizeIdentityValue(value);
if (normalized) return normalized;
}
return "";
}
export function normalizeGraphHead(input = null, fallback = {}) {
const source = input && typeof input === "object" && !Array.isArray(input) ? input : {};
const fallbackSource =
fallback && typeof fallback === "object" && !Array.isArray(fallback) ? fallback : {};
const counts = normalizeCounts({
...(fallbackSource.counts || fallbackSource),
...(source.counts || source),
});
const integrity = firstIdentity(source.integrity, fallbackSource.integrity);
const chatId = firstIdentity(source.chatId, fallbackSource.chatId);
const graphId = firstIdentity(source.graphId, fallbackSource.graphId, integrity, chatId);
return {
formatVersion: GRAPH_HEAD_FORMAT_VERSION,
graphId,
chatId,
hostChatId: firstIdentity(source.hostChatId, fallbackSource.hostChatId),
integrity,
revision: normalizeNonNegativeInteger(source.revision ?? fallbackSource.revision),
schemaVersion: normalizeNonNegativeInteger(
source.schemaVersion ?? fallbackSource.schemaVersion,
),
lastProcessedAssistantFloor: normalizeFloor(
source.lastProcessedAssistantFloor ?? fallbackSource.lastProcessedAssistantFloor,
),
extractionCount: normalizeNonNegativeInteger(
source.extractionCount ?? fallbackSource.extractionCount,
),
counts,
updatedAt: normalizeUpdatedAt(source.updatedAt || fallbackSource.updatedAt),
reason: String(source.reason || fallbackSource.reason || ""),
};
}
export function buildGraphHeadFromGraph(
graph = null,
{
graphId = "",
chatId = "",
hostChatId = "",
integrity = "",
revision = 0,
reason = "",
updatedAt = "",
} = {},
) {
const stats = graph ? getGraphStats(graph) : null;
const historyState = graph?.historyState || {};
return normalizeGraphHead({
graphId,
chatId: firstIdentity(chatId, historyState.chatId),
hostChatId,
integrity,
revision,
schemaVersion: graph?.version,
lastProcessedAssistantFloor: Number.isFinite(Number(historyState.lastProcessedAssistantFloor))
? Number(historyState.lastProcessedAssistantFloor)
: Number.isFinite(Number(stats?.lastProcessedSeq))
? Number(stats.lastProcessedSeq)
: -1,
extractionCount: historyState.extractionCount,
counts: {
nodeCount: stats?.activeNodes,
edgeCount: stats?.totalEdges,
archivedCount: stats?.archivedNodes,
tombstoneCount: stats?.tombstones,
},
updatedAt,
reason,
});
}
export function normalizeReplicaPointer(input = null, fallback = {}) {
const source = input && typeof input === "object" && !Array.isArray(input) ? input : {};
const fallbackSource =
fallback && typeof fallback === "object" && !Array.isArray(fallback) ? fallback : {};
const storageTier = String(source.storageTier || fallbackSource.storageTier || "none")
.trim()
.toLowerCase() || "none";
const revision = normalizeNonNegativeInteger(source.revision ?? fallbackSource.revision);
const graphId = firstIdentity(source.graphId, fallbackSource.graphId);
const chatId = firstIdentity(source.chatId, fallbackSource.chatId);
const integrity = firstIdentity(source.integrity, fallbackSource.integrity);
const accepted =
source.accepted === true &&
revision > 0 &&
Boolean(graphId) &&
isAcceptedLegacyPersistenceTier(storageTier);
return {
formatVersion: GRAPH_REPLICA_POINTER_FORMAT_VERSION,
graphId,
revision,
storageTier,
accepted,
chatId,
integrity,
persistedAt: String(source.persistedAt || source.updatedAt || fallbackSource.persistedAt || ""),
source: String(source.source || fallbackSource.source || ""),
reason: String(source.reason || fallbackSource.reason || ""),
};
}
export function isReplicaAccepted(pointer = null) {
return normalizeReplicaPointer(pointer).accepted === true;
}
export function buildCommitMarkerV3({ head = null, replica = null, reason = "", persistedAt = "" } = {}) {
const normalizedHead = normalizeGraphHead(head);
const normalizedReplica = normalizeReplicaPointer(replica, {
graphId: normalizedHead.graphId,
revision: normalizedHead.revision,
chatId: normalizedHead.chatId,
integrity: normalizedHead.integrity,
reason,
persistedAt,
});
const replicaMatchesHead =
normalizedReplica.accepted === true &&
normalizedReplica.graphId === normalizedHead.graphId &&
normalizedReplica.revision === normalizedHead.revision;
return {
formatVersion: GRAPH_COMMIT_MARKER_V3_FORMAT_VERSION,
graphId: normalizedHead.graphId,
revision: normalizedHead.revision,
accepted: replicaMatchesHead,
storageTier: normalizedReplica.storageTier,
chatId: normalizedHead.chatId || normalizedReplica.chatId,
hostChatId: normalizedHead.hostChatId,
integrity: normalizedHead.integrity || normalizedReplica.integrity,
nodeCount: normalizedHead.counts.nodeCount,
edgeCount: normalizedHead.counts.edgeCount,
archivedCount: normalizedHead.counts.archivedCount,
tombstoneCount: normalizedHead.counts.tombstoneCount,
lastProcessedAssistantFloor: normalizedHead.lastProcessedAssistantFloor,
extractionCount: normalizedHead.extractionCount,
persistedAt: normalizedReplica.persistedAt || persistedAt || normalizedHead.updatedAt,
reason: String(reason || normalizedReplica.reason || normalizedHead.reason || ""),
};
}
export function normalizeCommitMarkerV3(marker = null) {
if (!marker || typeof marker !== "object" || Array.isArray(marker)) return null;
const head = normalizeGraphHead({
graphId: marker.graphId,
chatId: marker.chatId,
hostChatId: marker.hostChatId,
integrity: marker.integrity,
revision: marker.revision,
lastProcessedAssistantFloor: marker.lastProcessedAssistantFloor,
extractionCount: marker.extractionCount,
counts: marker,
updatedAt: marker.persistedAt,
reason: marker.reason,
});
const replica = normalizeReplicaPointer({
graphId: head.graphId,
revision: head.revision,
storageTier: marker.storageTier,
accepted: marker.accepted,
chatId: head.chatId,
integrity: head.integrity,
persistedAt: marker.persistedAt,
reason: marker.reason,
});
return buildCommitMarkerV3({ head, replica, reason: marker.reason, persistedAt: marker.persistedAt });
}
export function graphHeadFromLegacyPersistenceMeta({ meta = null, graph = null } = {}) {
const legacyMeta = meta && typeof meta === "object" && !Array.isArray(meta) ? meta : {};
return buildGraphHeadFromGraph(graph, {
graphId: legacyMeta.graphId,
chatId: legacyMeta.chatId,
integrity: legacyMeta.integrity,
revision: legacyMeta.revision,
reason: legacyMeta.reason,
updatedAt: legacyMeta.updatedAt,
});
}
export function graphHeadFromLegacyCommitMarker(marker = null) {
return normalizeGraphHead({
graphId: marker?.graphId,
chatId: marker?.chatId,
integrity: marker?.integrity,
revision: marker?.revision,
lastProcessedAssistantFloor: marker?.lastProcessedAssistantFloor,
extractionCount: marker?.extractionCount,
counts: marker,
updatedAt: marker?.persistedAt,
reason: marker?.reason,
});
}
// Test/importer/diagnostic bridge only. Do not use this in v3 runtime hot paths;
// v3 storage routes should write v3 GraphHead/ReplicaPointer directly.
export function commitMarkerV3ToLegacyMarker(marker = null) {
const normalized = normalizeCommitMarkerV3(marker);
if (!normalized) return null;
return {
revision: normalized.revision,
lastProcessedAssistantFloor: normalized.lastProcessedAssistantFloor,
extractionCount: normalized.extractionCount,
nodeCount: normalized.nodeCount,
edgeCount: normalized.edgeCount,
archivedCount: normalized.archivedCount,
persistedAt: normalized.persistedAt,
storageTier: normalized.storageTier,
accepted: normalized.accepted,
reason: normalized.reason,
chatId: normalized.chatId,
integrity: normalized.integrity,
};
}

View File

@@ -9,6 +9,7 @@
"test:rebirth-phase0": "node tests/rebirth-phase0.mjs",
"test:identity-resolver": "node tests/identity-resolver.mjs",
"test:persistence-reducer": "node tests/persistence-reducer.mjs",
"test:graph-head": "node tests/graph-head.mjs",
"test:hide-engine": "node tests/hide-engine.mjs",
"test:maintenance-journal": "node tests/maintenance-journal.mjs",
"test:indexeddb-persistence": "node tests/indexeddb-persistence.mjs",

180
tests/graph-head.mjs Normal file
View File

@@ -0,0 +1,180 @@
// ST-BME restrained rebirth — Phase 3 GraphHead model tests.
import assert from "node:assert/strict";
import { createEmptyGraph } from "../graph/graph.js";
import {
GRAPH_COMMIT_MARKER_V3_FORMAT_VERSION,
GRAPH_HEAD_FORMAT_VERSION,
buildCommitMarkerV3,
buildGraphHeadFromGraph,
commitMarkerV3ToLegacyMarker,
graphHeadFromLegacyCommitMarker,
graphHeadFromLegacyPersistenceMeta,
isReplicaAccepted,
normalizeCommitMarkerV3,
normalizeGraphHead,
normalizeReplicaPointer,
} from "../graph/graph-head.js";
const graph = createEmptyGraph();
graph.version = 9;
graph.historyState.chatId = "chat-a";
graph.historyState.lastProcessedAssistantFloor = 8.9;
graph.historyState.extractionCount = 3.2;
graph.lastProcessedSeq = 7;
graph.nodes.push(
{ id: "n1", type: "event", archived: false },
{ id: "n2", type: "event", archived: true },
);
graph.edges.push({ id: "e1", from: "n1", to: "n2" });
const head = buildGraphHeadFromGraph(graph, {
graphId: "graph-a",
chatId: "chat-a",
hostChatId: "host-chat-a",
integrity: "integrity-a",
revision: 12.7,
reason: "unit-test",
updatedAt: "2026-01-01T00:00:00.000Z",
});
assert.equal(head.formatVersion, GRAPH_HEAD_FORMAT_VERSION);
assert.equal(head.graphId, "graph-a");
assert.equal(head.chatId, "chat-a");
assert.equal(head.hostChatId, "host-chat-a");
assert.equal(head.integrity, "integrity-a");
assert.equal(head.revision, 12);
assert.equal(head.schemaVersion, 9);
assert.equal(head.lastProcessedAssistantFloor, 8);
assert.equal(head.extractionCount, 3);
assert.deepEqual(head.counts, {
nodeCount: 1,
edgeCount: 1,
archivedCount: 1,
tombstoneCount: 0,
});
console.log(" ✓ GraphHead owns normalized graph identity, revision, and counts");
const acceptedPointer = normalizeReplicaPointer({
graphId: head.graphId,
revision: head.revision,
storageTier: "authority-sql",
accepted: true,
chatId: head.chatId,
integrity: head.integrity,
persistedAt: "2026-01-01T00:00:01.000Z",
});
assert.equal(isReplicaAccepted(acceptedPointer), true);
const unsafePointer = normalizeReplicaPointer({
graphId: head.graphId,
revision: head.revision,
storageTier: "metadata-full",
accepted: true,
});
assert.equal(unsafePointer.accepted, false);
assert.equal(isReplicaAccepted(unsafePointer), false);
const missingGraphIdPointer = normalizeReplicaPointer({
revision: head.revision,
storageTier: "authority-sql",
accepted: true,
});
assert.equal(
missingGraphIdPointer.accepted,
false,
"accepted replica pointers must carry graphId evidence",
);
console.log(" ✓ ReplicaPointer accepts only canonical storage tiers");
const marker = buildCommitMarkerV3({
head,
replica: acceptedPointer,
reason: "accepted-save",
});
assert.equal(marker.formatVersion, GRAPH_COMMIT_MARKER_V3_FORMAT_VERSION);
assert.equal(marker.graphId, "graph-a");
assert.equal(marker.revision, 12);
assert.equal(marker.accepted, true);
assert.equal(marker.storageTier, "authority-sql");
assert.equal(marker.nodeCount, 1);
assert.equal(marker.edgeCount, 1);
assert.equal(marker.archivedCount, 1);
assert.equal(marker.lastProcessedAssistantFloor, 8);
assert.equal(marker.extractionCount, 3);
assert.deepEqual(normalizeCommitMarkerV3(marker), marker);
const mismatchedReplicaMarker = buildCommitMarkerV3({
head,
replica: {
...acceptedPointer,
revision: head.revision - 1,
},
});
assert.equal(
mismatchedReplicaMarker.accepted,
false,
"v3 marker must not accept head revision from a mismatched replica pointer",
);
console.log(" ✓ v3 commit marker is a small accepted replica pointer plus head diagnostics");
const legacyMarker = commitMarkerV3ToLegacyMarker(marker);
assert.deepEqual(legacyMarker, {
revision: 12,
lastProcessedAssistantFloor: 8,
extractionCount: 3,
nodeCount: 1,
edgeCount: 1,
archivedCount: 1,
persistedAt: acceptedPointer.persistedAt,
storageTier: "authority-sql",
accepted: true,
reason: "accepted-save",
chatId: "chat-a",
integrity: "integrity-a",
});
const headFromLegacyMarker = graphHeadFromLegacyCommitMarker(legacyMarker);
assert.equal(headFromLegacyMarker.revision, 12);
assert.equal(headFromLegacyMarker.counts.nodeCount, 1);
assert.equal(headFromLegacyMarker.counts.edgeCount, 1);
assert.equal(headFromLegacyMarker.graphId, "integrity-a");
const headFromLegacyMeta = graphHeadFromLegacyPersistenceMeta({
graph,
meta: {
revision: 9,
chatId: "meta-chat",
integrity: "meta-integrity",
updatedAt: "2026-01-02T00:00:00.000Z",
reason: "legacy-meta",
},
});
assert.equal(headFromLegacyMeta.revision, 9);
assert.equal(headFromLegacyMeta.chatId, "meta-chat");
assert.equal(headFromLegacyMeta.graphId, "meta-integrity");
assert.equal(headFromLegacyMeta.counts.archivedCount, 1);
console.log(" ✓ legacy marker/meta can be converted without becoming runtime compatibility paths");
const normalizedHead = normalizeGraphHead({
revision: -5,
lastProcessedAssistantFloor: "bad",
counts: { nodeCount: -1, edgeCount: 2.9 },
});
assert.equal(normalizedHead.revision, 0);
assert.equal(normalizedHead.lastProcessedAssistantFloor, -1);
assert.equal(normalizedHead.counts.nodeCount, 0);
assert.equal(normalizedHead.counts.edgeCount, 2);
assert.equal(
head.counts.tombstoneCount,
0,
"tombstoneCount is reserved until a canonical tombstone collection is introduced",
);
console.log(" ✓ GraphHead normalization is safe for malformed inputs");
console.log("graph-head tests passed");