mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-14 02:40:45 +08:00
262 lines
9.4 KiB
JavaScript
262 lines
9.4 KiB
JavaScript
// 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,
|
|
};
|
|
}
|