/** * 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', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'qualitative-known-scales', effort: 500 , artifacts: { dir: '', persist: 'never' }}); 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', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'qualitative-fallback', effort: 800 , artifacts: { dir: '', persist: 'never' }}); 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', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'qualitative-stable-identity', effort: 1500 , artifacts: { dir: '', persist: 'never' }}); 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', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'qualitative-zero-periods', effort: 1500 , artifacts: { dir: '', persist: 'never' }}); 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', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'qualitative-down-monotone', effort: 1500 , artifacts: { dir: '', persist: 'never' }}); 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', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'qualitative-up-monotone', effort: 1500 , artifacts: { dir: '', persist: 'never' }}); 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', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'qualitative-result-in-scale', effort: 1500 , artifacts: { dir: '', persist: 'never' }}); 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', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'qualitative-lower-le-upper', effort: 1500 , artifacts: { dir: '', persist: 'never' }}); 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', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'qualitative-point-contained', effort: 1500 , artifacts: { dir: '', persist: 'never' }}); 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', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'qualitative-bounds-in-scale', effort: 1500 , artifacts: { dir: '', persist: 'never' }}); 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', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'qualitative-zero-blur', effort: 1500 , artifacts: { dir: '', persist: 'never' }}); 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', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'qualitative-loss-is-zero', effort: 800 , artifacts: { dir: '', persist: 'never' }}); 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', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'qualitative-loss-symmetric', effort: 800 , artifacts: { dir: '', persist: 'never' }}); 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`); }); });