js-rigor: lossless persistence — validity, decay config, and TTLs survive snapshots

The persistence probe found three silent-loss defects in the snapshot
round trip: relation validity labels (finite_sample downgraded to
heuristic after save/load!), decay configs, and the value manager's
per-relation TTL settings all vanished. The restored arbiter built its
indices directly from the condensed graph, whose edge channel carries
possibility/reliability/value only.

Format version 2 now carries per-relation metadata (validity, decayConfig)
and the valueTtls table in the snapshot payload; both the snapshot-access
layer and the CondensedGraphIndices build merge the metadata back, so a
restored arbiter is lossless end to end. Version 1 buffers are rejected
with the existing clean version error.

Pinned: a persistence round-trip test asserting validity label, decay
config, TTL, and the restored check's validity label. Suites: rigor
207/0, full 809/747/0.
This commit is contained in:
John Dvorak
2026-08-02 09:51:40 -07:00
parent 86729715f1
commit faa6485e26
5 changed files with 102 additions and 6 deletions
+47 -3
View File
@@ -18,10 +18,15 @@ function toSnapshotSafe(value, stripKeys) {
}));
}
// Format version 2: per-relation metadata (validity, decayConfig) and the
// per-relation TTL settings are carried in the JSON payload. Version 1
// buffers are rejected with a clean error, never misparsed.
const SNAPSHOT_VERSION = 2;
function serializeArbiterSnapshot(arbiter, options = {}) {
const writer = new BinaryWriter();
writer.writeUint32(MAGIC);
writer.writeUint32(1);
writer.writeUint32(SNAPSHOT_VERSION);
const graph = options.graph || arbiter.snapshotGraph;
if (!graph) {
@@ -54,10 +59,37 @@ function serializeArbiterSnapshot(arbiter, options = {}) {
});
}
// Per-relation metadata that the condensed graph edge channel does not
// carry: validity labels and decay configs must survive persistence, or
// a labeled relation silently downgrades to heuristic after a save/load
// cycle. Keyed by the graph's numeric ids (stable within the snapshot).
const relationMetadata = {};
for (const rel of arbiter.relations || []) {
if (!rel || rel.src === undefined || rel.dst === undefined || !rel.rel) continue;
const key = `${rel.src}|${rel.rel}|${rel.dst}`;
if (rel.validity !== undefined || rel.decayConfig !== undefined) {
relationMetadata[key] = {
...(rel.validity !== undefined ? { validity: rel.validity } : {}),
...(rel.decayConfig !== undefined ? { decayConfig: rel.decayConfig } : {})
};
}
}
// Per-relation TTL settings are runtime value-manager state; without them
// a restored arbiter silently applies the default TTL to every relation.
const valueTtls = {};
if (arbiter.valueManager && arbiter.valueManager.ttlConfigs) {
for (const [relation, ttl] of arbiter.valueManager.ttlConfigs.entries()) {
valueTtls[relation] = ttl;
}
}
const snapshot = {
relations: relationEntries,
relationIdSnapshot: arbiter.keyManager.getRelationIdSnapshot(),
nodes: nodeEntries
nodes: nodeEntries,
relationMetadata,
valueTtls
};
const json = JSON.stringify(snapshot);
@@ -72,7 +104,7 @@ function deserializeArbiterSnapshot(buffer, arbiterFactory) {
const magic = reader.readUint32();
if (magic !== MAGIC) throw new Error('Invalid arbiter snapshot');
const version = reader.readUint32();
if (version !== 1) throw new Error(`Unsupported arbiter snapshot version ${version}`);
if (version !== SNAPSHOT_VERSION) throw new Error(`Unsupported arbiter snapshot version ${version}`);
const graphLength = reader.readUint32();
const graphBytes = reader.readBytes(graphLength);
@@ -135,6 +167,18 @@ function deserializeArbiterSnapshot(buffer, arbiterFactory) {
}
}
// Restore the metadata the edge channel cannot carry (consulted by the
// snapshot access layer when it materializes relation objects).
arbiter._snapshotRelationMetadata = (payload.relationMetadata && typeof payload.relationMetadata === 'object')
? payload.relationMetadata
: {};
if (payload.valueTtls && typeof payload.valueTtls === 'object' && arbiter.valueManager) {
for (const [relation, ttl] of Object.entries(payload.valueTtls)) {
arbiter.valueManager.setTTL(relation, ttl);
}
}
return arbiter;
}
+3 -1
View File
@@ -56,7 +56,9 @@ export class ArbiterSnapshot {
const arbiter = deserializeArbiterSnapshot(buffer, () => createArbiter(options));
if (arbiter.snapshotGraph) {
if (options.buildSnapshotIndices !== false) {
arbiter.indices = new CondensedGraphIndices(arbiter.snapshotGraph);
arbiter.indices = new CondensedGraphIndices(arbiter.snapshotGraph, {
relationMetadata: arbiter._snapshotRelationMetadata || null
});
arbiter.indicesBuilt = true;
} else {
arbiter.indicesBuilt = false;
+10 -1
View File
@@ -1,6 +1,7 @@
export class CondensedGraphIndices {
constructor(graph) {
constructor(graph, options = {}) {
this.graph = graph;
this._relationMetadata = options.relationMetadata || null;
this.outgoingEdges = new Map();
this.incomingEdges = new Map();
this._relationsByRelId = new Map();
@@ -77,6 +78,14 @@ export class CondensedGraphIndices {
stateId: `snapshot-${edgeIdx}`
};
// Merge per-relation metadata (validity, decay config) so the
// restored indices are lossless, not just the snapshot access layer.
const meta = this._relationMetadata ? this._relationMetadata[`${srcId}|${relName}|${dstId}`] : null;
if (meta) {
if (meta.validity !== undefined) relation.validity = meta.validity;
if (meta.decayConfig !== undefined) relation.decayConfig = meta.decayConfig;
}
this._relations[edgeIdx] = relation;
const relSet = this._relationsByRelId.get(relId);
+12 -1
View File
@@ -79,7 +79,7 @@ export class RelationSnapshotAccess {
const relBits = graph.adjacency
? graph.adjacency[edgeIdx * 6 + 4]
: graph.edgeReliabilityBits[edgeIdx];
return {
const result = {
src: srcId,
rel: relationName,
relId,
@@ -91,5 +91,16 @@ export class RelationSnapshotAccess {
changed_last_at: graph._builtAt || Date.now(),
source: 'snapshot'
};
// Merge per-relation metadata the edge channel does not carry (validity
// labels, decay configs) so persistence is lossless.
const metaKey = `${srcId}|${relationName}|${dstId}`;
const meta = this.manager.arbiter._snapshotRelationMetadata
? this.manager.arbiter._snapshotRelationMetadata[metaKey]
: null;
if (meta) {
if (meta.validity !== undefined) result.validity = meta.validity;
if (meta.decayConfig !== undefined) result.decayConfig = meta.decayConfig;
}
return result;
}
}