js-rigor: TTU operand values flow; modify honors changed_last_at override
Two issues found by the extended probe sweep: - A relational_comparator operand backed by a tuple_to_userset rule always denied: the TTU rule collected only the intermediate KEY, which the operand extraction skips as non-numeric, so no value was ever available. The TTU rule now emits a value-carrying collected entry when the tupleset edge carries a numeric value (entityKey = tuple src, relation = tupleset relation), keeping the bare intermediate key when there is no value. Comparator-with-TTU-operand now allows/denies on the tuple value through both persistent and partial contexts. - _modifyRelation ignored the changed_last_at override that the add path honors: value-changing modifies stamped fresh Date.now() regardless of the pin, so replay/restore tools pinning timestamps got different semantics via modify vs add. The override now applies to refresh events (value/reliability/possibility change) and is ignored for value-unchanged writes, preserving the TTL parity contract that identical replays never un-expire old values. Verified clean: intersection through partial, defeasible with logical when, challenge subject object/session with sessionKey, non-binary minAllowPossibility threshold, batch value updates, explain agreement under partial.
This commit is contained in:
@@ -175,6 +175,27 @@ export class TupleToUsersetRule extends BaseRule {
|
|||||||
let reasons = [];
|
let reasons = [];
|
||||||
let intermediateEvaluationDetails = includeMeta ? [] : null;
|
let intermediateEvaluationDetails = includeMeta ? [] : null;
|
||||||
let processedCount = 0;
|
let processedCount = 0;
|
||||||
|
|
||||||
|
// Collected values carry the tupleset edge's value when it has one (the
|
||||||
|
// intermediate key alone is not enough for downstream value consumers
|
||||||
|
// like relational_comparator operands); otherwise the intermediate key
|
||||||
|
// is collected for path-based value lookups.
|
||||||
|
const buildCollectedValue = (tupleEdge, tupleSrcKey, intermediateKey) => {
|
||||||
|
if (tupleEdge && typeof tupleEdge.value === 'number') {
|
||||||
|
return this._createCollectedValue(
|
||||||
|
tupleEdge.value,
|
||||||
|
tupleEdge.possibility,
|
||||||
|
[tupleSrcKey, intermediateKey],
|
||||||
|
{ entityKey: tupleSrcKey, relation: rule.tuplesetRelation, step: 0 },
|
||||||
|
{
|
||||||
|
timestamp: tupleEdge.changed_last_at || tupleEdge.updated_last_at || Date.now(),
|
||||||
|
reliability: tupleEdge.reliability || 1.0,
|
||||||
|
source: tupleEdge.source || 'persistent'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return intermediateKey;
|
||||||
|
};
|
||||||
|
|
||||||
// Process direct tuples with early exit optimization
|
// Process direct tuples with early exit optimization
|
||||||
const computedRelationCache = new Map();
|
const computedRelationCache = new Map();
|
||||||
@@ -258,7 +279,7 @@ export class TupleToUsersetRule extends BaseRule {
|
|||||||
computedRelation: { relation: rule.computedRelation, possibility: res.possibility, reliability: res.reliability, meta: res.meta }
|
computedRelation: { relation: rule.computedRelation, possibility: res.possibility, reliability: res.reliability, meta: res.meta }
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
...(!useLightweightPaths && collectValues && { collectedValue: intermediateKey })
|
...(!useLightweightPaths && collectValues && { collectedValue: buildCollectedValue(tupleEdge, resolveKey(tupleEdge.src, options), intermediateKey) })
|
||||||
};
|
};
|
||||||
allValidPaths.push(path);
|
allValidPaths.push(path);
|
||||||
if (!bestPath || combinedPossibility > bestPath.possibility) {
|
if (!bestPath || combinedPossibility > bestPath.possibility) {
|
||||||
@@ -365,7 +386,7 @@ export class TupleToUsersetRule extends BaseRule {
|
|||||||
computedRelation: { relation: rule.computedRelation, possibility: res.possibility, reliability: res.reliability, meta: res.meta }
|
computedRelation: { relation: rule.computedRelation, possibility: res.possibility, reliability: res.reliability, meta: res.meta }
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
...(!useLightweightPaths && collectValues && { collectedValue: intermediateKey })
|
...(!useLightweightPaths && collectValues && { collectedValue: buildCollectedValue(t, entry.srcKey, intermediateKey) })
|
||||||
};
|
};
|
||||||
|
|
||||||
allValidPaths.push(pathData);
|
allValidPaths.push(pathData);
|
||||||
|
|||||||
@@ -600,11 +600,16 @@ export class RelationUpdates {
|
|||||||
oldRelation.value = newOptions?.value !== undefined ? newOptions.value : oldRelation.value;
|
oldRelation.value = newOptions?.value !== undefined ? newOptions.value : oldRelation.value;
|
||||||
oldRelation.decayConfig = newOptions?.decayConfig !== undefined ? newOptions.decayConfig : oldRelation.decayConfig;
|
oldRelation.decayConfig = newOptions?.decayConfig !== undefined ? newOptions.decayConfig : oldRelation.decayConfig;
|
||||||
oldRelation.updated_last_at = currentTime;
|
oldRelation.updated_last_at = currentTime;
|
||||||
oldRelation.changed_last_at = (newOptions?.value !== oldValue ||
|
// Honor an explicit changed_last_at override like the add path does,
|
||||||
(newOptions?.reliability !== undefined && newOptions.reliability !== oldReliability) ||
|
// but only on writes that would refresh anyway: a write that does not
|
||||||
(newOptions?.possibility !== undefined && newOptions.possibility !== oldPossibility))
|
// change the value (or its interpretation) keeps the old timestamp,
|
||||||
? currentTime
|
// so replaying identical tuples never un-expires old values.
|
||||||
: oldRelation.changed_last_at;
|
const shouldRefresh = newOptions?.value !== oldValue ||
|
||||||
|
(newOptions?.reliability !== undefined && newOptions.reliability !== oldReliability) ||
|
||||||
|
(newOptions?.possibility !== undefined && newOptions.possibility !== oldPossibility);
|
||||||
|
oldRelation.changed_last_at = shouldRefresh
|
||||||
|
? (newOptions?.changed_last_at !== undefined ? newOptions.changed_last_at : currentTime)
|
||||||
|
: oldRelation.changed_last_at;
|
||||||
oldRelation.stateId = newStateId;
|
oldRelation.stateId = newStateId;
|
||||||
this.manager._relationsChanged = true;
|
this.manager._relationsChanged = true;
|
||||||
this.manager._invalidateRelationCaches(srcId, relation, dstId);
|
this.manager._invalidateRelationCaches(srcId, relation, dstId);
|
||||||
|
|||||||
@@ -442,6 +442,53 @@ describe('Rule-kind × partial-graph parity (rigor)', () => {
|
|||||||
const p = restored.check('u:0', 'can_read', 'doc:0');
|
const p = restored.check('u:0', 'can_read', 'doc:0');
|
||||||
assert.equal(round4(p.possibility), 0.7, 'snapshot-restored TTU');
|
assert.equal(round4(p.possibility), 0.7, 'snapshot-restored TTU');
|
||||||
}
|
}
|
||||||
|
// ---- comparator operand as TTU (value flows via the tupleset edge) ----
|
||||||
|
{
|
||||||
|
const a = mkArbiter();
|
||||||
|
a.setRelationConfig('can_access', {
|
||||||
|
type: 'relational_comparator', comparator: '>=',
|
||||||
|
left: { rule: { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' }, extractValue: true },
|
||||||
|
right: { rule: { type: 'direct', relation: 'requested_level' }, extractValue: true, evaluateFrom: 'object' }
|
||||||
|
});
|
||||||
|
a.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' });
|
||||||
|
a.addRelation('doc:0', 'owner', 'g:0', { possibility: 0.9, value: 7 });
|
||||||
|
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 });
|
||||||
|
a.addRelation('doc:0', 'requested_level', 'doc:0', { possibility: 1.0, value: 4 });
|
||||||
|
assert.equal(a.check('u:0', 'can_access', 'doc:0').possibility, 1, 'ttu operand persistent allow');
|
||||||
|
a.removeRelation('doc:0', 'owner', 'g:0');
|
||||||
|
a.removeRelation('u:0', 'member_of', 'g:0');
|
||||||
|
a.removeRelation('doc:0', 'requested_level', 'doc:0');
|
||||||
|
const r = a.check('u:0', 'can_access', 'doc:0', {
|
||||||
|
partialGraph: { relations: [
|
||||||
|
{ src: 'doc:0', relation: 'owner', dst: 'g:0', possibility: 0.9, value: 7 },
|
||||||
|
{ src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.8 },
|
||||||
|
{ src: 'doc:0', relation: 'requested_level', dst: 'doc:0', possibility: 1.0, value: 4 }
|
||||||
|
] }
|
||||||
|
});
|
||||||
|
assert.equal(r.possibility, 1, 'ttu operand partial allow');
|
||||||
|
a.addRelation('doc:0', 'owner', 'g:0', { possibility: 0.9, value: 3 });
|
||||||
|
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 });
|
||||||
|
a.addRelation('doc:0', 'requested_level', 'doc:0', { possibility: 1.0, value: 4 });
|
||||||
|
assert.equal(a.check('u:0', 'can_access', 'doc:0').possibility, 0, 'ttu operand below threshold deny');
|
||||||
|
a.removeRelation('doc:0', 'owner', 'g:0');
|
||||||
|
a.addRelation('doc:0', 'owner', 'g:0', { possibility: 0.9 });
|
||||||
|
assert.equal(a.check('u:0', 'can_access', 'doc:0').possibility, 0, 'ttu operand without value denies');
|
||||||
|
}
|
||||||
|
// ---- modify changed_last_at override (value-changing modify honors the pin) ----
|
||||||
|
{
|
||||||
|
const a = mkArbiter();
|
||||||
|
a.addRelation('u:0', 'balance', 'doc:0', { value: 10, changed_last_at: 1000 });
|
||||||
|
a.relationManager.updateRelationsBatch([
|
||||||
|
{ operation: 'modify', srcKey: 'u:0', relation: 'balance', dstKey: 'doc:0', options: { value: 20, changed_last_at: 5000 } }
|
||||||
|
]);
|
||||||
|
const after = a.relationManager.getDirectRelation(a.resolveNodeId('u:0'), 'balance', a.resolveNodeId('doc:0'));
|
||||||
|
assert.equal(after.changed_last_at, 5000, 'value-changing modify honors changed_last_at override');
|
||||||
|
a.relationManager.updateRelationsBatch([
|
||||||
|
{ operation: 'modify', srcKey: 'u:0', relation: 'balance', dstKey: 'doc:0', options: { value: 20, changed_last_at: 9999 } }
|
||||||
|
]);
|
||||||
|
const unchanged = a.relationManager.getDirectRelation(a.resolveNodeId('u:0'), 'balance', a.resolveNodeId('doc:0'));
|
||||||
|
assert.equal(unchanged.changed_last_at, 5000, 'value-unchanged modify keeps old timestamp (override not a refresh)');
|
||||||
|
}
|
||||||
// ---- binary mode agrees with normal at the same threshold ----
|
// ---- binary mode agrees with normal at the same threshold ----
|
||||||
{
|
{
|
||||||
const a = mkArbiter();
|
const a = mkArbiter();
|
||||||
|
|||||||
Reference in New Issue
Block a user