mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-14 02:40:45 +08:00
refactor(rebirth): add graph head model
This commit is contained in:
261
graph/graph-head.js
Normal file
261
graph/graph-head.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
180
tests/graph-head.mjs
Normal 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");
|
||||
Reference in New Issue
Block a user