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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user