Files
core/tests/rigor/qualitative-rule-helpers.test.js
T

541 lines
20 KiB
JavaScript
Raw Normal View History

/**
* 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)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
2026-08-02 16:39:36 -07:00
).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`);
});
});