717ae1031e
Zanzibar-style authorization graph engine (direct/chain/TTU/defeasible/ binary modes, condensed snapshots, value relations) with 39 rigor test campaigns. Includes fixes for snapshot binary writer/reader format mismatch (snapshot-of-snapshot corruption), possibility write-boundary validation, empty-graph snapshot serialization, relation lookup cache direction collision, config-redefinition cache invalidation, binary threshold semantics, defeasible compiled routing, and comparator reason whitelisting.
541 lines
19 KiB
JavaScript
541 lines
19 KiB
JavaScript
/**
|
|
* rigor/qualitative-rule-helpers.test.js — js-rigor property tests for
|
|
* QualitativeRelationalComparatorRule's pure-function helpers.
|
|
*
|
|
* These helpers are pure math on qualitative scales. Properties:
|
|
* - _getQualitativeScale: known names → specific scales; unknown → DEFAULT
|
|
* - _calculatePeriodsElapsed: future timestamp → negative or 0; known
|
|
* period → elapsed/periodMs; unknown period → falls back to HOUR
|
|
* - _calculateDecayedPossibility: direction='stable' → identity;
|
|
* direction='down' → ≤ initial; direction='up' → ≥ initial;
|
|
* periodsElapsed=0 → identity; result ∈ scale.values
|
|
* - _calculatePossibilityLossSteps: ≥ 0; symmetric; 0 when initial===decayed
|
|
* - _createQualitativeInterval: lower ≤ upper; lower ≤ pointValue ≤ upper;
|
|
* bounds clamped to scale
|
|
*/
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { rigor } from '@rigor/core';
|
|
import { QualitativeRelationalComparatorRule } from '../../src/authorization/rules/QualitativeRelationalComparatorRule.js';
|
|
import { QualitativeScale, DEFAULT_QUALITATIVE_SCALE } from '../../src/qualitative/QualitativeScale.js';
|
|
|
|
const KNOWN_SCALE_NAMES = ['binary', 'ternary', 'five-point', 'ten-point'];
|
|
const PERIODS = ['MINUTE', 'HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'];
|
|
const DIRECTIONS = ['down', 'up', 'neutral', 'stable'];
|
|
|
|
/**
|
|
* Stub arbiter that satisfies BaseRule's constructor. The helpers
|
|
* tested here don't actually call arbiter methods, but the constructor
|
|
* stores a reference.
|
|
*/
|
|
function makeStubArbiter() {
|
|
return {
|
|
nodeIdByKey: new Map(),
|
|
keyByNodeId: new Map(),
|
|
relations: [],
|
|
nodes: new Map()
|
|
};
|
|
}
|
|
|
|
function makeRule() {
|
|
return new QualitativeRelationalComparatorRule(makeStubArbiter(), {});
|
|
}
|
|
|
|
/**
|
|
* Pick a scale and a value that's IN that scale.
|
|
*/
|
|
function scaleValuePairGen() {
|
|
const scaleArb = rigor.gen.oneOf([
|
|
rigor.gen.constant(QualitativeScale.binary()),
|
|
rigor.gen.constant(QualitativeScale.ternary()),
|
|
rigor.gen.constant(QualitativeScale.fivePoint()),
|
|
rigor.gen.constant(QualitativeScale.tenPoint())
|
|
]);
|
|
// Pick a value from the chosen scale. Use oneOf-constant for the
|
|
// index, then map to the value.
|
|
const valueArb = rigor.gen.int(0, 9).map(i => Math.min(i, 9));
|
|
return rigor.gen.tuple(scaleArb, valueArb).map(([scale, idx]) =>
|
|
[scale, scale.at(Math.min(idx, scale.size - 1))]
|
|
);
|
|
}
|
|
|
|
describe('QualitativeRelationalComparatorRule._getQualitativeScale (rigor)', () => {
|
|
it('known scale names return their corresponding scale', async () => {
|
|
async function check(scaleName) {
|
|
const rule = makeRule();
|
|
const result = rule._getQualitativeScale(scaleName);
|
|
const expectedName = scaleName;
|
|
if (result.name !== expectedName) {
|
|
throw new Error(
|
|
`_getQualitativeScale(${scaleName}) returned scale named ${result.name}`
|
|
);
|
|
}
|
|
// Confirm values match
|
|
const expectedScale = {
|
|
'binary': QualitativeScale.binary(),
|
|
'ternary': QualitativeScale.ternary(),
|
|
'five-point': QualitativeScale.fivePoint(),
|
|
'ten-point': QualitativeScale.tenPoint()
|
|
}[scaleName];
|
|
if (result.size !== expectedScale.size) {
|
|
throw new Error(
|
|
`size mismatch: ${result.size} vs ${expectedScale.size}`
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(rigor.gen.enum(KNOWN_SCALE_NAMES))
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('known-scales', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 500 });
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'known-scales');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`_getQualitativeScale returned wrong scale in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('unknown scale name falls back to DEFAULT_QUALITATIVE_SCALE', async () => {
|
|
async function check(scaleName) {
|
|
const rule = makeRule();
|
|
const result = rule._getQualitativeScale(scaleName);
|
|
// The fallback uses DEFAULT_QUALITATIVE_SCALE which is fivePoint.
|
|
if (result.name !== DEFAULT_QUALITATIVE_SCALE.name) {
|
|
throw new Error(
|
|
`_getQualitativeScale(${scaleName}) did not fall back. got ${result.name}`
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(rigor.gen.string(1, 30))
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('fallback', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 800 });
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'fallback');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`_getQualitativeScale fallback violated in ${inv.failureCount} cases`);
|
|
});
|
|
});
|
|
|
|
describe('QualitativeRelationalComparatorRule._calculateDecayedPossibility (rigor)', () => {
|
|
it('direction=stable is the identity function', async () => {
|
|
async function check(scaleAndValue, periodsElapsed, decaySteps) {
|
|
const [scale, value] = scaleAndValue;
|
|
const rule = makeRule();
|
|
const result = rule._calculateDecayedPossibility(value, periodsElapsed, decaySteps, 'stable', scale);
|
|
if (result !== value) {
|
|
throw new Error(
|
|
`stable changed value: ${value} → ${result} (periods=${periodsElapsed}, steps=${decaySteps})`
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
scaleValuePairGen(),
|
|
rigor.gen.int(0, 100),
|
|
rigor.gen.int(0, 10)
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('stable-identity', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 1500 });
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'stable-identity');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`stable was not identity in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('periodsElapsed=0 yields the original value', async () => {
|
|
async function check(scaleAndValue, direction, decaySteps) {
|
|
const [scale, value] = scaleAndValue;
|
|
const rule = makeRule();
|
|
const result = rule._calculateDecayedPossibility(value, 0, decaySteps, direction, scale);
|
|
if (result !== value) {
|
|
throw new Error(
|
|
`periodsElapsed=0 changed value: ${value} → ${result}`
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
scaleValuePairGen(),
|
|
rigor.gen.enum(DIRECTIONS),
|
|
rigor.gen.int(0, 10)
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('zero-periods', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 1500 });
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'zero-periods');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`periodsElapsed=0 was not identity in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('direction=down never increases the scale index', async () => {
|
|
async function check(scaleAndValue, periodsElapsed, decaySteps) {
|
|
const [scale, value] = scaleAndValue;
|
|
const initialIndex = scale.indexOf(value);
|
|
const rule = makeRule();
|
|
const result = rule._calculateDecayedPossibility(value, periodsElapsed, decaySteps, 'down', scale);
|
|
const resultIndex = scale.indexOf(result);
|
|
if (resultIndex > initialIndex) {
|
|
throw new Error(
|
|
`down went UP: ${value} (${initialIndex}) → ${result} (${resultIndex})`
|
|
);
|
|
}
|
|
// Should be clamped to >= 0
|
|
if (resultIndex < 0) {
|
|
throw new Error(`down produced out-of-range index ${resultIndex}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
scaleValuePairGen(),
|
|
rigor.gen.int(0, 100),
|
|
rigor.gen.int(0, 10)
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('down-monotone', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 1500 });
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'down-monotone');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`direction=down violated monotonicity in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('direction=up never decreases the scale index', async () => {
|
|
async function check(scaleAndValue, periodsElapsed, decaySteps) {
|
|
const [scale, value] = scaleAndValue;
|
|
const initialIndex = scale.indexOf(value);
|
|
const rule = makeRule();
|
|
const result = rule._calculateDecayedPossibility(value, periodsElapsed, decaySteps, 'up', scale);
|
|
const resultIndex = scale.indexOf(result);
|
|
if (resultIndex < initialIndex) {
|
|
throw new Error(
|
|
`up went DOWN: ${value} (${initialIndex}) → ${result} (${resultIndex})`
|
|
);
|
|
}
|
|
if (resultIndex >= scale.size) {
|
|
throw new Error(`up produced out-of-range index ${resultIndex}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
scaleValuePairGen(),
|
|
rigor.gen.int(0, 100),
|
|
rigor.gen.int(0, 10)
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('up-monotone', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 1500 });
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'up-monotone');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`direction=up violated monotonicity in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('result is always a member of the scale', async () => {
|
|
async function check(scaleAndValue, periodsElapsed, decaySteps, direction) {
|
|
const [scale, value] = scaleAndValue;
|
|
const rule = makeRule();
|
|
const result = rule._calculateDecayedPossibility(value, periodsElapsed, decaySteps, direction, scale);
|
|
if (!scale.contains(result)) {
|
|
throw new Error(
|
|
`result ${result} is not in scale ${scale.name}: ${scale.values}`
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
scaleValuePairGen(),
|
|
rigor.gen.int(0, 100),
|
|
rigor.gen.int(0, 10),
|
|
rigor.gen.enum(DIRECTIONS)
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('result-in-scale', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 1500 });
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'result-in-scale');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`decayed value was not in scale in ${inv.failureCount} cases`);
|
|
});
|
|
});
|
|
|
|
describe('QualitativeRelationalComparatorRule._createQualitativeInterval (rigor)', () => {
|
|
it('lower ≤ upper always', async () => {
|
|
async function check(scaleAndValue, blurSteps, direction) {
|
|
const [scale, value] = scaleAndValue;
|
|
const rule = makeRule();
|
|
const result = rule._createQualitativeInterval(value, blurSteps, direction, scale);
|
|
if (scale.indexOf(result.lower) > scale.indexOf(result.upper)) {
|
|
throw new Error(
|
|
`lower (${result.lower}) > upper (${result.upper})`
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
scaleValuePairGen(),
|
|
rigor.gen.int(0, 20),
|
|
rigor.gen.enum(DIRECTIONS)
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('lower-le-upper', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 1500 });
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'lower-le-upper');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`lower > upper in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('pointValue is contained in [lower, upper]', async () => {
|
|
async function check(scaleAndValue, blurSteps, direction) {
|
|
const [scale, value] = scaleAndValue;
|
|
const rule = makeRule();
|
|
const result = rule._createQualitativeInterval(value, blurSteps, direction, scale);
|
|
const lowerIdx = scale.indexOf(result.lower);
|
|
const upperIdx = scale.indexOf(result.upper);
|
|
const valueIdx = scale.indexOf(value);
|
|
if (valueIdx < lowerIdx || valueIdx > upperIdx) {
|
|
throw new Error(
|
|
`point ${value} (${valueIdx}) not in [${result.lower} (${lowerIdx}), ${result.upper} (${upperIdx})]`
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
scaleValuePairGen(),
|
|
rigor.gen.int(0, 20),
|
|
rigor.gen.enum(DIRECTIONS)
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('point-contained', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 1500 });
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'point-contained');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`point not in [lower, upper] in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('bounds stay within scale', async () => {
|
|
async function check(scaleAndValue, blurSteps, direction) {
|
|
const [scale, value] = scaleAndValue;
|
|
const rule = makeRule();
|
|
const result = rule._createQualitativeInterval(value, blurSteps, direction, scale);
|
|
if (!scale.contains(result.lower)) {
|
|
throw new Error(`lower ${result.lower} not in scale`);
|
|
}
|
|
if (!scale.contains(result.upper)) {
|
|
throw new Error(`upper ${result.upper} not in scale`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
scaleValuePairGen(),
|
|
rigor.gen.int(0, 50), // large blurSteps to exercise clamping
|
|
rigor.gen.enum(DIRECTIONS)
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('bounds-in-scale', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 1500 });
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'bounds-in-scale');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`bounds exceeded scale in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('blurSteps=0 yields degenerate interval (lower=upper=pointValue)', async () => {
|
|
async function check(scaleAndValue, direction) {
|
|
const [scale, value] = scaleAndValue;
|
|
const rule = makeRule();
|
|
const result = rule._createQualitativeInterval(value, 0, direction, scale);
|
|
if (result.lower !== value || result.upper !== value) {
|
|
throw new Error(
|
|
`blurSteps=0 did not collapse to point: ${JSON.stringify(result)} for ${value}`
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
scaleValuePairGen(),
|
|
rigor.gen.enum(DIRECTIONS)
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('zero-blur', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 1500 });
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'zero-blur');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`blurSteps=0 was not degenerate in ${inv.failureCount} cases`);
|
|
});
|
|
});
|
|
|
|
describe('QualitativeRelationalComparatorRule._calculatePossibilityLossSteps (rigor)', () => {
|
|
it('returns 0 when initial === decayed', async () => {
|
|
async function check(scaleAndValue) {
|
|
const [scale, value] = scaleAndValue;
|
|
const rule = makeRule();
|
|
const result = rule._calculatePossibilityLossSteps(value, value, scale);
|
|
if (result !== 0) {
|
|
throw new Error(`expected 0, got ${result} for value=${value}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(scaleValuePairGen())
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('loss-is-zero', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 800 });
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'loss-is-zero');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`loss was non-zero in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('is symmetric: loss(a, b) === loss(b, a)', async () => {
|
|
async function check(scale, valueA, valueB) {
|
|
// Pick two valid scale values
|
|
const a = scale.at(Math.min(valueA, scale.size - 1));
|
|
const b = scale.at(Math.min(valueB, scale.size - 1));
|
|
const rule = makeRule();
|
|
const ab = rule._calculatePossibilityLossSteps(a, b, scale);
|
|
const ba = rule._calculatePossibilityLossSteps(b, a, scale);
|
|
if (ab !== ba) {
|
|
throw new Error(`loss not symmetric: ${ab} vs ${ba}`);
|
|
}
|
|
if (ab < 0) {
|
|
throw new Error(`loss was negative: ${ab}`);
|
|
}
|
|
return ab;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.constant(QualitativeScale.fivePoint()),
|
|
rigor.gen.int(0, 4),
|
|
rigor.gen.int(0, 4)
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('loss-symmetric', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 800 });
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'loss-symmetric');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`loss not symmetric in ${inv.failureCount} cases`);
|
|
});
|
|
});
|