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 = {}) {
|
function serializeArbiterSnapshot(arbiter, options = {}) {
|
||||||
const writer = new BinaryWriter();
|
const writer = new BinaryWriter();
|
||||||
writer.writeUint32(MAGIC);
|
writer.writeUint32(MAGIC);
|
||||||
writer.writeUint32(1);
|
writer.writeUint32(SNAPSHOT_VERSION);
|
||||||
|
|
||||||
const graph = options.graph || arbiter.snapshotGraph;
|
const graph = options.graph || arbiter.snapshotGraph;
|
||||||
if (!graph) {
|
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 = {
|
const snapshot = {
|
||||||
relations: relationEntries,
|
relations: relationEntries,
|
||||||
relationIdSnapshot: arbiter.keyManager.getRelationIdSnapshot(),
|
relationIdSnapshot: arbiter.keyManager.getRelationIdSnapshot(),
|
||||||
nodes: nodeEntries
|
nodes: nodeEntries,
|
||||||
|
relationMetadata,
|
||||||
|
valueTtls
|
||||||
};
|
};
|
||||||
|
|
||||||
const json = JSON.stringify(snapshot);
|
const json = JSON.stringify(snapshot);
|
||||||
@@ -72,7 +104,7 @@ function deserializeArbiterSnapshot(buffer, arbiterFactory) {
|
|||||||
const magic = reader.readUint32();
|
const magic = reader.readUint32();
|
||||||
if (magic !== MAGIC) throw new Error('Invalid arbiter snapshot');
|
if (magic !== MAGIC) throw new Error('Invalid arbiter snapshot');
|
||||||
const version = reader.readUint32();
|
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 graphLength = reader.readUint32();
|
||||||
const graphBytes = reader.readBytes(graphLength);
|
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;
|
return arbiter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ export class ArbiterSnapshot {
|
|||||||
const arbiter = deserializeArbiterSnapshot(buffer, () => createArbiter(options));
|
const arbiter = deserializeArbiterSnapshot(buffer, () => createArbiter(options));
|
||||||
if (arbiter.snapshotGraph) {
|
if (arbiter.snapshotGraph) {
|
||||||
if (options.buildSnapshotIndices !== false) {
|
if (options.buildSnapshotIndices !== false) {
|
||||||
arbiter.indices = new CondensedGraphIndices(arbiter.snapshotGraph);
|
arbiter.indices = new CondensedGraphIndices(arbiter.snapshotGraph, {
|
||||||
|
relationMetadata: arbiter._snapshotRelationMetadata || null
|
||||||
|
});
|
||||||
arbiter.indicesBuilt = true;
|
arbiter.indicesBuilt = true;
|
||||||
} else {
|
} else {
|
||||||
arbiter.indicesBuilt = false;
|
arbiter.indicesBuilt = false;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export class CondensedGraphIndices {
|
export class CondensedGraphIndices {
|
||||||
constructor(graph) {
|
constructor(graph, options = {}) {
|
||||||
this.graph = graph;
|
this.graph = graph;
|
||||||
|
this._relationMetadata = options.relationMetadata || null;
|
||||||
this.outgoingEdges = new Map();
|
this.outgoingEdges = new Map();
|
||||||
this.incomingEdges = new Map();
|
this.incomingEdges = new Map();
|
||||||
this._relationsByRelId = new Map();
|
this._relationsByRelId = new Map();
|
||||||
@@ -77,6 +78,14 @@ export class CondensedGraphIndices {
|
|||||||
stateId: `snapshot-${edgeIdx}`
|
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;
|
this._relations[edgeIdx] = relation;
|
||||||
|
|
||||||
const relSet = this._relationsByRelId.get(relId);
|
const relSet = this._relationsByRelId.get(relId);
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export class RelationSnapshotAccess {
|
|||||||
const relBits = graph.adjacency
|
const relBits = graph.adjacency
|
||||||
? graph.adjacency[edgeIdx * 6 + 4]
|
? graph.adjacency[edgeIdx * 6 + 4]
|
||||||
: graph.edgeReliabilityBits[edgeIdx];
|
: graph.edgeReliabilityBits[edgeIdx];
|
||||||
return {
|
const result = {
|
||||||
src: srcId,
|
src: srcId,
|
||||||
rel: relationName,
|
rel: relationName,
|
||||||
relId,
|
relId,
|
||||||
@@ -91,5 +91,16 @@ export class RelationSnapshotAccess {
|
|||||||
changed_last_at: graph._builtAt || Date.now(),
|
changed_last_at: graph._builtAt || Date.now(),
|
||||||
source: 'snapshot'
|
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