From faa6485e266260d171151e20cb9449842c594d51 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Sun, 2 Aug 2026 09:51:40 -0700 Subject: [PATCH] =?UTF-8?q?js-rigor:=20lossless=20persistence=20=E2=80=94?= =?UTF-8?q?=20validity,=20decay=20config,=20and=20TTLs=20survive=20snapsho?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/SnapshotBinary.js | 50 +++++++++++++++++++-- src/core/arbiter/ArbiterSnapshot.js | 4 +- src/core/graph/CondensedGraphIndices.js | 11 ++++- src/core/relation/RelationSnapshotAccess.js | 13 +++++- tests/rigor/validity-parity.test.js | 30 +++++++++++++ 5 files changed, 102 insertions(+), 6 deletions(-) diff --git a/src/core/SnapshotBinary.js b/src/core/SnapshotBinary.js index 6d6a795..8cd383c 100644 --- a/src/core/SnapshotBinary.js +++ b/src/core/SnapshotBinary.js @@ -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; } diff --git a/src/core/arbiter/ArbiterSnapshot.js b/src/core/arbiter/ArbiterSnapshot.js index 4934653..f716c58 100644 --- a/src/core/arbiter/ArbiterSnapshot.js +++ b/src/core/arbiter/ArbiterSnapshot.js @@ -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; diff --git a/src/core/graph/CondensedGraphIndices.js b/src/core/graph/CondensedGraphIndices.js index a074d4e..32ac583 100644 --- a/src/core/graph/CondensedGraphIndices.js +++ b/src/core/graph/CondensedGraphIndices.js @@ -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); diff --git a/src/core/relation/RelationSnapshotAccess.js b/src/core/relation/RelationSnapshotAccess.js index 4f4fe65..07543b5 100644 --- a/src/core/relation/RelationSnapshotAccess.js +++ b/src/core/relation/RelationSnapshotAccess.js @@ -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; } } diff --git a/tests/rigor/validity-parity.test.js b/tests/rigor/validity-parity.test.js index d7abc1f..99d9b8e 100644 --- a/tests/rigor/validity-parity.test.js +++ b/tests/rigor/validity-parity.test.js @@ -213,3 +213,33 @@ describe('Security affordances (rigor)', () => { ); }); }); + +describe('Persistence losslessness (rigor)', () => { + it('FIXED: validity, decay config, and TTLs survive the snapshot round trip', async () => { + const a = new Arbiter(); + a.addNode('u:0', 'user'); + a.addNode('d:0', 'doc'); + a.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); + a.addRelation('u:0', 'owner', 'd:0', { + possibility: 0.8, + reliability: 0.42, + value: 7, + validity: 'finite_sample', + decayConfig: { halfLifeMs: 60000 } + }); + a.valueManager.setTTL('owner', 30000); + a.enableCondensedSnapshot(); + const { serializeArbiterSnapshot } = await import('../../src/core/SnapshotBinary.js'); + const { ArbiterSnapshot } = await import('../../src/core/arbiter/ArbiterSnapshot.js'); + const restored = ArbiterSnapshot.fromSnapshotBinary(serializeArbiterSnapshot(a), {}, () => new Arbiter()); + const rel = restored.relationManager.getDirectRelation(restored.resolveNodeId('u:0'), 'owner', restored.resolveNodeId('d:0')); + assert.equal(rel.validity, 'finite_sample', 'validity label survives persistence'); + assert.deepEqual(rel.decayConfig, { halfLifeMs: 60000 }, 'decay config survives persistence'); + assert.equal(restored.valueManager.getTTL('owner'), 30000, 'per-relation TTL survives persistence'); + const r = restored.check('u:0', 'can_read', 'd:0', { includeMeta: true }); + assert.equal(r.validity.label, 'finite_sample', 'restored check carries the persisted label'); + // and a second restore of the same buffer is byte-stable + const restored2 = ArbiterSnapshot.fromSnapshotBinary(serializeArbiterSnapshot(a), {}, () => new Arbiter()); + assert.equal(restored2.relationManager.getDirectRelation(restored2.resolveNodeId('u:0'), 'owner', restored2.resolveNodeId('d:0')).validity, 'finite_sample'); + }); +});