js-rigor: TTU reverse+tuplesetDirection-in now honors direction
The reverse branch of TupleToUsersetRule ignored tuplesetDirection: it looked up the tupleset relation as outgoing-from-user even when 'in' was set, while the join honored 'in' by using t.src as the intermediate — the documented shape (intermediates hold the relation TO the user) never matched, and only a degenerate join-on-user shape produced results. The same gap existed in the compiled direct_join optimization. Fixed the fallback tupleset lookup (including the graph-neighbor path and the computed-join tupleEdge direction) and the optimized direct_join to honor tuplesetDirection in reverse mode. Matrix pins reverse-in (both evaluation paths, persistent + partial), multi_hop reverse, chain-in, union-with-chain-child, defeasible split legs, TTU value flow, and challenge-via-binary.
This commit is contained in:
@@ -208,7 +208,9 @@ export class CompiledEvaluator {
|
||||
const tuplesetDirection = optimized.tuplesetDirection || 'out';
|
||||
let tuples;
|
||||
if (optimized.reverse) {
|
||||
tuples = this.arbiter.relationManager.getRelationsFromSrc(userId, optimized.tuplesetRelation, options);
|
||||
tuples = tuplesetDirection === 'in'
|
||||
? this.arbiter.relationManager.getRelationsToDst(userId, optimized.tuplesetRelation, options)
|
||||
: this.arbiter.relationManager.getRelationsFromSrc(userId, optimized.tuplesetRelation, options);
|
||||
} else {
|
||||
tuples = tuplesetDirection === 'in'
|
||||
? this.arbiter.relationManager.getRelationsToDst(objectId, optimized.tuplesetRelation, options)
|
||||
|
||||
@@ -83,19 +83,27 @@ export class TupleToUsersetRule extends BaseRule {
|
||||
let tuples;
|
||||
const useRelationGraph = !options?.partialGraphContext;
|
||||
if (reverse) {
|
||||
// reverse honors tuplesetDirection like the forward path: 'in' means
|
||||
// intermediates hold the tupleset relation TO the user (edge.src is the
|
||||
// intermediate), 'out' means the user holds it to the intermediate.
|
||||
const reverseLookup = tuplesetDirection === 'in';
|
||||
const useGraphNeighbors = useRelationGraph &&
|
||||
this.arbiter.relationManager.shouldUseRelationGraphTraversal(userId, rule.tuplesetRelation, false);
|
||||
this.arbiter.relationManager.shouldUseRelationGraphTraversal(userId, rule.tuplesetRelation, reverseLookup);
|
||||
const neighbors = useGraphNeighbors
|
||||
? this.arbiter.relationManager.getRelationGraphNeighbors(userId, rule.tuplesetRelation, false)
|
||||
? this.arbiter.relationManager.getRelationGraphNeighbors(userId, rule.tuplesetRelation, reverseLookup)
|
||||
: null;
|
||||
if (neighbors) {
|
||||
tuples = [];
|
||||
for (const neighborId of neighbors) {
|
||||
const edge = this.arbiter.relationManager.getDirectRelation(userId, rule.tuplesetRelation, neighborId, options);
|
||||
const edge = reverseLookup
|
||||
? this.arbiter.relationManager.getDirectRelation(neighborId, rule.tuplesetRelation, userId, options)
|
||||
: this.arbiter.relationManager.getDirectRelation(userId, rule.tuplesetRelation, neighborId, options);
|
||||
if (edge) tuples.push(edge);
|
||||
}
|
||||
} else {
|
||||
tuples = this.arbiter.relationManager.getRelationsFromSrc(userId, rule.tuplesetRelation, options);
|
||||
tuples = reverseLookup
|
||||
? this.arbiter.relationManager.getRelationsToDst(userId, rule.tuplesetRelation, options)
|
||||
: this.arbiter.relationManager.getRelationsFromSrc(userId, rule.tuplesetRelation, options);
|
||||
}
|
||||
} else {
|
||||
const reverseLookup = tuplesetDirection === 'in';
|
||||
@@ -189,7 +197,9 @@ export class TupleToUsersetRule extends BaseRule {
|
||||
processedCount++;
|
||||
|
||||
const tupleEdge = reverse
|
||||
? this.arbiter.relationManager.getDirectRelation(userId, rule.tuplesetRelation, intermediateId, options)
|
||||
? (tuplesetDirection === 'in'
|
||||
? this.arbiter.relationManager.getDirectRelation(intermediateId, rule.tuplesetRelation, userId, options)
|
||||
: this.arbiter.relationManager.getDirectRelation(userId, rule.tuplesetRelation, intermediateId, options))
|
||||
: (tuplesetDirection === 'in'
|
||||
? this.arbiter.relationManager.getDirectRelation(intermediateId, rule.tuplesetRelation, objectId, options)
|
||||
: this.arbiter.relationManager.getDirectRelation(objectId, rule.tuplesetRelation, intermediateId, options));
|
||||
|
||||
@@ -257,6 +257,134 @@ describe('Rule-kind × partial-graph parity (rigor)', () => {
|
||||
assert.equal(expired.possibility, 0, 'challenge expired');
|
||||
assert.equal(a.check('u:0', 'can_download', 'doc:0').possibility, 0, 'challenge missing context');
|
||||
}
|
||||
// ---- TTU reverse + tuplesetDirection in (intermediates point AT the user) ----
|
||||
{
|
||||
const a = mkArbiter();
|
||||
a.setRelationConfig('can_view', { type: 'tuple_to_userset', tuplesetRelation: 'member_of', computedRelation: 'viewable', reverse: true, tuplesetDirection: 'in' });
|
||||
a.setRelationConfig('viewable', { type: 'direct', relation: 'viewable' });
|
||||
a.addRelation('g:0', 'member_of', 'u:0', { possibility: 0.8 });
|
||||
a.addRelation('doc:0', 'viewable', 'g:0', { possibility: 0.7 });
|
||||
assert.equal(round4(a.check('u:0', 'can_view', 'doc:0').possibility), 0.7, 'ttu reverse-in persistent optimized');
|
||||
assert.equal(round4(a.check('u:0', 'can_view', 'doc:0', { useCompiled: false }).possibility), 0.7, 'ttu reverse-in persistent fallback');
|
||||
a.removeRelation('g:0', 'member_of', 'u:0');
|
||||
a.removeRelation('doc:0', 'viewable', 'g:0');
|
||||
const r = a.check('u:0', 'can_view', 'doc:0', {
|
||||
partialGraph: { relations: [
|
||||
{ src: 'g:0', relation: 'member_of', dst: 'u:0', possibility: 0.8 },
|
||||
{ src: 'doc:0', relation: 'viewable', dst: 'g:0', possibility: 0.7 }
|
||||
] }
|
||||
});
|
||||
assert.equal(round4(r.possibility), 0.7, 'ttu reverse-in partial');
|
||||
}
|
||||
// ---- multi_hop reverse (backward walk: doc ->member_of-> g:1 ->member_of-> u:0) ----
|
||||
{
|
||||
const a = mkArbiter();
|
||||
a.setRelationConfig('can_access', { type: 'multi_hop', relation: 'member_of', reverse: true, maxDepth: 3 });
|
||||
a.addRelation('g:1', 'member_of', 'u:0', { possibility: 0.8 });
|
||||
a.addRelation('doc:0', 'member_of', 'g:1', { possibility: 0.7 });
|
||||
assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.7, 'multi_hop reverse persistent');
|
||||
a.removeRelation('g:1', 'member_of', 'u:0');
|
||||
a.removeRelation('doc:0', 'member_of', 'g:1');
|
||||
const r = a.check('u:0', 'can_access', 'doc:0', {
|
||||
partialGraph: { relations: [
|
||||
{ src: 'g:1', relation: 'member_of', dst: 'u:0', possibility: 0.8 },
|
||||
{ src: 'doc:0', relation: 'member_of', dst: 'g:1', possibility: 0.7 }
|
||||
] }
|
||||
});
|
||||
assert.equal(round4(r.possibility), 0.7, 'multi_hop reverse partial');
|
||||
}
|
||||
// ---- chain direction in + partial ----
|
||||
{
|
||||
const a = mkArbiter();
|
||||
a.setRelationConfig('can_access', { type: 'chain', steps: [
|
||||
{ relation: 'member_of', direction: 'in' },
|
||||
{ relation: 'reads', direction: 'out' }
|
||||
] });
|
||||
a.addRelation('g:0', 'member_of', 'u:0', { possibility: 0.8 });
|
||||
a.addRelation('g:0', 'reads', 'doc:0', { possibility: 0.7 });
|
||||
assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.7, 'chain-in persistent');
|
||||
a.removeRelation('g:0', 'member_of', 'u:0');
|
||||
a.removeRelation('g:0', 'reads', 'doc:0');
|
||||
const r = a.check('u:0', 'can_access', 'doc:0', {
|
||||
partialGraph: { relations: [
|
||||
{ src: 'g:0', relation: 'member_of', dst: 'u:0', possibility: 0.8 },
|
||||
{ src: 'g:0', relation: 'reads', dst: 'doc:0', possibility: 0.7 }
|
||||
] }
|
||||
});
|
||||
assert.equal(round4(r.possibility), 0.7, 'chain-in partial');
|
||||
}
|
||||
// ---- union with a chain child ----
|
||||
{
|
||||
const a = mkArbiter();
|
||||
a.setRelationConfig('can_access', { union: { rules: [
|
||||
{ type: 'direct', relation: 'owner' },
|
||||
{ type: 'chain', steps: [{ relation: 'member_of', direction: 'out' }, { relation: 'reads', direction: 'out' }] }
|
||||
] } });
|
||||
a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.6 });
|
||||
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 });
|
||||
a.addRelation('g:0', 'reads', 'doc:0', { possibility: 0.7 });
|
||||
assert.equal(round4(a.check('u:0', 'can_access', 'doc:0').possibility), 0.7, 'union chain child persistent (max 0.7)');
|
||||
a.removeRelation('u:0', 'owner', 'doc:0');
|
||||
a.removeRelation('u:0', 'member_of', 'g:0');
|
||||
a.removeRelation('g:0', 'reads', 'doc:0');
|
||||
const r = a.check('u:0', 'can_access', 'doc:0', {
|
||||
partialGraph: { relations: [
|
||||
{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.6 },
|
||||
{ src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.8 },
|
||||
{ src: 'g:0', relation: 'reads', dst: 'doc:0', possibility: 0.7 }
|
||||
] }
|
||||
});
|
||||
assert.equal(round4(r.possibility), 0.7, 'union chain child partial');
|
||||
}
|
||||
// ---- defeasible split legs (when persistent, unless partial) ----
|
||||
{
|
||||
const a = mkArbiter();
|
||||
a.setRelationConfig('can_access', { type: 'defeasible', when: { relation: 'owner' }, unless: { relation: 'banned' } });
|
||||
a.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 });
|
||||
const r = a.check('u:0', 'can_access', 'doc:0', {
|
||||
partialGraph: { relations: [{ src: 'u:0', relation: 'banned', dst: 'doc:0', possibility: 0.5 }] }
|
||||
});
|
||||
assert.equal(round4(r.possibility), 0.4, 'defeasible when persistent, unless partial');
|
||||
a.removeRelation('u:0', 'owner', 'doc:0');
|
||||
const r2 = a.check('u:0', 'can_access', 'doc:0', {
|
||||
partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.8 }] }
|
||||
});
|
||||
assert.equal(round4(r2.possibility), 0.8, 'defeasible when partial only');
|
||||
}
|
||||
// ---- TTU value flow through the tupleset edge (collectValues) ----
|
||||
{
|
||||
const a = mkArbiter();
|
||||
a.setRelationConfig('can_read', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' });
|
||||
a.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' });
|
||||
a.addRelation('doc:0', 'owner', 'g:0', { possibility: 0.8, value: 5 });
|
||||
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.7 });
|
||||
const p = a.check('u:0', 'can_read', 'doc:0', { collectValues: true });
|
||||
assert.equal(p.possibility, 0.7, 'ttu value persistent decision');
|
||||
assert.ok(Array.isArray(p.collectedValues) && p.collectedValues.some(v => v === 'g:0' || v?.entityKey === 'g:0' || v?.value === 5),
|
||||
`ttu value persistent collects intermediate: ${JSON.stringify(p.collectedValues)}`);
|
||||
a.removeRelation('doc:0', 'owner', 'g:0');
|
||||
a.removeRelation('u:0', 'member_of', 'g:0');
|
||||
const r = a.check('u:0', 'can_read', 'doc:0', {
|
||||
collectValues: true,
|
||||
partialGraph: { relations: [
|
||||
{ src: 'doc:0', relation: 'owner', dst: 'g:0', possibility: 0.8, value: 5 },
|
||||
{ src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.7 }
|
||||
] }
|
||||
});
|
||||
assert.equal(r.possibility, 0.7, 'ttu value partial decision');
|
||||
}
|
||||
// ---- challenge via binary mode ----
|
||||
{
|
||||
const a = mkArbiter();
|
||||
a.setRelationConfig('can_download', { type: 'challenge', challenge: 'captcha', subject: 'user', withinMinutes: 5 });
|
||||
const now = Date.now();
|
||||
const r = a.check('u:0', 'can_download', 'doc:0', {
|
||||
binary: true, minAllowPossibility: 0.5,
|
||||
partialGraph: { challenges: [{ name: 'captcha', subject: 'u:0', issuedAt: now - 60000, expiresAt: now + 60000 }] }
|
||||
});
|
||||
assert.equal(r.possibility, 1, 'challenge binary satisfied');
|
||||
assert.equal(r.allow, true, 'challenge binary allow');
|
||||
}
|
||||
// ---- binary mode agrees with normal at the same threshold ----
|
||||
{
|
||||
const a = mkArbiter();
|
||||
|
||||
Reference in New Issue
Block a user