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:
John Dvorak
2026-07-31 18:54:57 -07:00
parent 0149926344
commit 30fc7e5017
3 changed files with 146 additions and 6 deletions
+3 -1
View File
@@ -208,7 +208,9 @@ export class CompiledEvaluator {
const tuplesetDirection = optimized.tuplesetDirection || 'out'; const tuplesetDirection = optimized.tuplesetDirection || 'out';
let tuples; let tuples;
if (optimized.reverse) { 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 { } else {
tuples = tuplesetDirection === 'in' tuples = tuplesetDirection === 'in'
? this.arbiter.relationManager.getRelationsToDst(objectId, optimized.tuplesetRelation, options) ? this.arbiter.relationManager.getRelationsToDst(objectId, optimized.tuplesetRelation, options)
+15 -5
View File
@@ -83,19 +83,27 @@ export class TupleToUsersetRule extends BaseRule {
let tuples; let tuples;
const useRelationGraph = !options?.partialGraphContext; const useRelationGraph = !options?.partialGraphContext;
if (reverse) { 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 && const useGraphNeighbors = useRelationGraph &&
this.arbiter.relationManager.shouldUseRelationGraphTraversal(userId, rule.tuplesetRelation, false); this.arbiter.relationManager.shouldUseRelationGraphTraversal(userId, rule.tuplesetRelation, reverseLookup);
const neighbors = useGraphNeighbors const neighbors = useGraphNeighbors
? this.arbiter.relationManager.getRelationGraphNeighbors(userId, rule.tuplesetRelation, false) ? this.arbiter.relationManager.getRelationGraphNeighbors(userId, rule.tuplesetRelation, reverseLookup)
: null; : null;
if (neighbors) { if (neighbors) {
tuples = []; tuples = [];
for (const neighborId of neighbors) { 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); if (edge) tuples.push(edge);
} }
} else { } 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 { } else {
const reverseLookup = tuplesetDirection === 'in'; const reverseLookup = tuplesetDirection === 'in';
@@ -189,7 +197,9 @@ export class TupleToUsersetRule extends BaseRule {
processedCount++; processedCount++;
const tupleEdge = reverse 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' : (tuplesetDirection === 'in'
? this.arbiter.relationManager.getDirectRelation(intermediateId, rule.tuplesetRelation, objectId, options) ? this.arbiter.relationManager.getDirectRelation(intermediateId, rule.tuplesetRelation, objectId, options)
: this.arbiter.relationManager.getDirectRelation(objectId, rule.tuplesetRelation, intermediateId, 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(expired.possibility, 0, 'challenge expired');
assert.equal(a.check('u:0', 'can_download', 'doc:0').possibility, 0, 'challenge missing context'); 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 ---- // ---- binary mode agrees with normal at the same threshold ----
{ {
const a = mkArbiter(); const a = mkArbiter();