From 58e8b0e030bddac269c88f9ef2c4e40a9f70f7de Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Sun, 2 Aug 2026 08:57:05 -0700 Subject: [PATCH] js-rigor: possibilistic validity layer (Cella FVN labels, conflict mass, validification) Adds an epistemic validity layer in the spirit of the zig-contour fusion spec: every check result now carries a validity block {label, operator, regime, sources, conflictMass, validifiedPossibility, nonMaxitive}. - Relations accept a validity label (default heuristic = unlabeled input). - Labels propagate through fusion: identity/max preserve the weakest source label (max is already valid under arbitrary dependence); min (conjunctive: intersection, chain, TTU, multi_hop, parent) is approximate at best, surfaces the conflict mass (1 - possibility) that was previously dropped, and exposes the arbitrary-regime validification min(1, K*gamma); product-style operators (exclusion, defeasible) and interior OWA averaging are always heuristic, with nonMaxitive flagged. - Reliability and validity are now explicitly distinct: reliability stays the scalar confidence adaptation; validity tracks the epistemic label. - The hottest paths attach a shared frozen default block instead of allocating (perf A/B shows no regression: ~300k ops/s direct both ways). - Pre-existing fixes surfaced while wiring: the array-form logical config dropped top-level aggregator/owaWeights (average union compiled as max), and _createStandardResult dropped unknown fields (validity never survived rule results). New campaign validity-parity.test.js pins the label taxonomy, conflict mass, validification, weakest-propagation, and the reliability/validity separation. Suites: rigor 203/0, full 803/741/0. --- src/authorization/AuthorizationChecker.js | 23 ++- src/authorization/CompiledEvaluator.js | 28 +++ src/authorization/RuleCompiler.js | 19 +- src/authorization/rules/BaseRule.js | 14 ++ src/authorization/rules/ChainRule.js | 1 + src/authorization/rules/DirectRule.js | 3 + src/authorization/rules/LogicalOperators.js | 2 + src/authorization/rules/MultiHopRule.js | 1 + .../rules/RelationalComparatorRule.js | 2 + src/authorization/rules/TupleToUsersetRule.js | 2 + src/core/relation/RelationUpdates.js | 4 +- src/core/validity.js | 172 ++++++++++++++++++ tests/rigor/binary-mode-parity.test.js | 3 +- tests/rigor/validity-parity.test.js | 145 +++++++++++++++ 14 files changed, 408 insertions(+), 11 deletions(-) create mode 100644 src/core/validity.js create mode 100644 tests/rigor/validity-parity.test.js diff --git a/src/authorization/AuthorizationChecker.js b/src/authorization/AuthorizationChecker.js index 41a6226..028c49e 100644 --- a/src/authorization/AuthorizationChecker.js +++ b/src/authorization/AuthorizationChecker.js @@ -1,3 +1,4 @@ +import { buildValidity, normalizeValidity, mergeValidity, DEFAULT_VALIDITY } from '../core/validity.js'; import { Arbiter } from '../core/Arbiter.js'; import { RuleEvaluator } from './RuleEvaluator.js'; import { RuleCollector } from './RuleCollector.js'; @@ -162,6 +163,9 @@ export class AuthorizationChecker { } else { result = { possibility: directRel.possibility, + validity: directRel.validity !== undefined + ? buildValidity('identity', [effectiveRelation], [normalizeValidity(directRel.validity)], 1, directRel.possibility) + : DEFAULT_VALIDITY, // A denied decision (possibility 0) must not leak the // relation's reliability — the rule-collection path zeroes it. reliability: directRel.possibility > 0 @@ -344,6 +348,7 @@ export class AuthorizationChecker { const finalResult = { possibility: resPossibility || 0, reliability: res.reliability !== undefined ? res.reliability : 1.0, + validity: res.validity || DEFAULT_VALIDITY, ...(collectValues && res.collectedValues && Array.isArray(res.collectedValues) && { collectedValues: res.collectedValues }), ...(includeMeta && { meta: { @@ -373,6 +378,8 @@ export class AuthorizationChecker { let bestDenyReliability = 0; let bestAllow = null; let bestDeny = null; + let bestAllowValidity = null; + let ruleValidityBlocks = []; let reason = undefined; let allRuleResults = shouldTrackEvaluation ? [] : null; let allCollectedValues = collectValues ? [] : null; // Collect values from all evaluated rules @@ -457,7 +464,9 @@ export class AuthorizationChecker { maxAllow = resAllowPossibility; bestAllow = resMeta; bestAllowReliability = res.reliability !== undefined ? res.reliability : 1.0; + bestAllowValidity = res.validity || null; } + if (res.validity) ruleValidityBlocks.push(res.validity); if (resDenyPossibility > maxDeny) { maxDeny = resDenyPossibility; @@ -543,9 +552,21 @@ export class AuthorizationChecker { const remediation = maxAllow === 0 ? buildRemediation(remediationOptions.length > 0 ? { status: 'required', options: remediationOptions } : null, { status: 'required' }) : null; - const result = { + // Possibilistic validity of the decision: the winning rule's block when + // a single rule drove it, otherwise the weakest-merge of all + // contributors (a heuristic/banned contributor downgrades the whole). + let finalValidity = DEFAULT_VALIDITY; + if (ruleValidityBlocks.length === 1) { + finalValidity = ruleValidityBlocks[0]; + } else if (ruleValidityBlocks.length > 1) { + finalValidity = mergeValidity(ruleValidityBlocks); + } else if (maxAllow > 0 && bestAllowValidity) { + finalValidity = bestAllowValidity; + } + const result = { possibility: maxAllow, reliability: maxAllow > 0 ? bestAllowReliability : maxDeny > 0 ? bestDenyReliability : 0, + validity: finalValidity, ...(includeMeta && { meta: { allow: bestAllow, diff --git a/src/authorization/CompiledEvaluator.js b/src/authorization/CompiledEvaluator.js index 02cedaf..05d9318 100644 --- a/src/authorization/CompiledEvaluator.js +++ b/src/authorization/CompiledEvaluator.js @@ -1,3 +1,4 @@ +import { buildValidity, normalizeValidity, DEFAULT_VALIDITY } from '../core/validity.js'; import { OWAFusion, getOWAWeightsFromRule } from '../utils/OWAFusion.js'; import { buildRemediation, extractRemediation, mergeRemediationOptions } from './remediation.js'; @@ -143,6 +144,9 @@ export class CompiledEvaluator { const result = { possibility: relationStrength, reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0, + validity: directRel.validity !== undefined + ? buildValidity('identity', [relName], [normalizeValidity(directRel.validity)], 1, relationStrength) + : DEFAULT_VALIDITY, possibility_allow: relationStrength, possibility_deny: 0, ...(includeMeta && { @@ -407,6 +411,7 @@ export class CompiledEvaluator { const possibilities = []; const reliabilities = []; const metas = []; + const resChildrenValidity = []; const remediationOptions = []; const allCollectedValues = collectValues ? [] : null; @@ -416,6 +421,7 @@ export class CompiledEvaluator { if (!res || typeof res.possibility !== 'number') { res = { possibility: 0, meta: { reason: 'missing_rule' }, collectedValues: [] }; } + if (res.validity) resChildrenValidity.push(res.validity); mergeRemediationOptions(remediationOptions, extractRemediation(res)); @@ -477,9 +483,15 @@ export class CompiledEvaluator { const maxP = Math.max(...possibilities); unionReliability = reliabilities[possibilities.indexOf(maxP)] ?? 1.0; } + const unionOperator = (compiled.aggregator || 'max') === 'max' && (!compiled.owaWeights || (compiled.owaWeights.length <= 1 || (compiled.owaWeights[0] === 1 && compiled.owaWeights.slice(1).every(w => w === 0)))) + ? 'max' : 'owa'; + const unionSourceLabels = (resChildrenValidity || []).map(v => v?.label).filter(Boolean); + const unionSources = []; + for (const v of resChildrenValidity || []) for (const s of v?.sources || []) if (!unionSources.includes(s)) unionSources.push(s); return { possibility: result.value, reliability: unionReliability, + validity: buildValidity(unionOperator, unionSources, unionSourceLabels, possibilities.length, result.value), ...(collectValues && { collectedValues: allCollectedValues }), ...(includeMeta && { meta: { @@ -523,10 +535,12 @@ export class CompiledEvaluator { // (no children); treat it as a one-element conjunction, matching the // union path's `compiled.children || [compiled]` fallback. const children = compiled.children || [compiled]; + const resChildrenValidity = []; for (const child of children) { const childVisited = new Set(visited); const res = this.evaluate(child, userId, userKey, objectId, objectKey, childVisited, currentRelation, options); + if (res.validity) resChildrenValidity.push(res.validity); possibilities.push(res.possibility); reliabilities.push(res.reliability !== undefined ? res.reliability : 1.0); metas.push(includeMeta ? res.meta : null); @@ -587,9 +601,13 @@ export class CompiledEvaluator { const minP = Math.min(...possibilities); intersectionReliability = reliabilities[possibilities.indexOf(minP)] ?? 1.0; } + const interSourceLabels = (resChildrenValidity || []).map(v => v?.label).filter(Boolean); + const interSources = []; + for (const v of resChildrenValidity || []) for (const s of v?.sources || []) if (!interSources.includes(s)) interSources.push(s); return { possibility: result.value, reliability: intersectionReliability, + validity: buildValidity('min', interSources, interSourceLabels, possibilities.length, result.value), ...(collectValues && { collectedValues: allCollectedValues }), ...(includeMeta && { meta: { @@ -668,6 +686,7 @@ export class CompiledEvaluator { // Both legs contribute to the decision, so their reliabilities // multiply (consistent with TTU/chain path semantics). reliability: (a.reliability !== undefined ? a.reliability : 1.0) * (b.reliability !== undefined ? b.reliability : 1.0), + validity: buildValidity('product', [a.validity?.sources?.[0], b.validity?.sources?.[0]].filter(Boolean), [a.validity?.label, b.validity?.label].filter(Boolean), 2, possibility), ...(collectValues && { collectedValues: allCollectedValues }), ...(includeMeta && { meta: { @@ -698,6 +717,7 @@ export class CompiledEvaluator { const possibilities = []; const reliabilities = []; const metas = []; + const listChildrenValidity = []; const allCollectedValues = collectValues ? [] : null; const directRules = compiled._optimized.direct; @@ -712,6 +732,9 @@ export class CompiledEvaluator { const possibility = directRel ? directRel.possibility : 0; possibilities.push(possibility); reliabilities.push(directRel ? (directRel.reliability !== undefined ? directRel.reliability : 1.0) : 1.0); + if (directRel) { + listChildrenValidity.push(buildValidity('identity', [direct.relation], [normalizeValidity(directRel.validity !== undefined ? directRel.validity : null)], 1, possibility)); + } if (includeMeta) { const _src = directRel ? (directRel.source || 'persistent') : null; const _allowMeta = directRel ? { @@ -808,9 +831,14 @@ export class CompiledEvaluator { listReliability = reliabilities[possibilities.indexOf(maxP)] ?? 1.0; } + const listOperator = aggregator === 'min' ? 'min' : (aggregator === 'max' ? 'max' : 'owa'); + const listLabels = listChildrenValidity.map(v => v.label).filter(Boolean); + const listSources = []; + for (const v of listChildrenValidity) for (const s of v.sources || []) if (!listSources.includes(s)) listSources.push(s); return { possibility: result.value, reliability: listReliability, + validity: buildValidity(listOperator, listSources, listLabels, possibilities.length, result.value), ...(collectValues && { collectedValues: allCollectedValues }), ...(includeMeta && { meta: { diff --git a/src/authorization/RuleCompiler.js b/src/authorization/RuleCompiler.js index aeed5f4..6410dab 100644 --- a/src/authorization/RuleCompiler.js +++ b/src/authorization/RuleCompiler.js @@ -140,14 +140,17 @@ export class RuleCompiler { type: 'logical', op: logicalKey, children, - aggregator: logicalConfig?.aggregator, - owaWeights: logicalConfig?.owaWeights, - _owaSparseCount: logicalConfig?._owaSparseCount, - reliabilityWeighting: logicalConfig?.reliabilityWeighting || false, - useBilattice: logicalConfig?.useBilattice || false, - capacityType: logicalConfig?.capacityType, - epistemicMode: logicalConfig?.epistemicMode, - negate: logicalConfig?.negate || false + // The array form ({ union: [a, b], aggregator: 'average' }) carries the + // operator options on the TOP-level config; the object form carries + // them on the union object itself. Honor both. + aggregator: logicalConfig?.aggregator ?? config.aggregator, + owaWeights: logicalConfig?.owaWeights ?? config.owaWeights, + _owaSparseCount: logicalConfig?._owaSparseCount ?? config._owaSparseCount, + reliabilityWeighting: logicalConfig?.reliabilityWeighting ?? config.reliabilityWeighting ?? false, + useBilattice: logicalConfig?.useBilattice ?? config.useBilattice ?? false, + capacityType: logicalConfig?.capacityType ?? config.capacityType, + epistemicMode: logicalConfig?.epistemicMode ?? config.epistemicMode, + negate: logicalConfig?.negate ?? config.negate ?? false }; return compiled; diff --git a/src/authorization/rules/BaseRule.js b/src/authorization/rules/BaseRule.js index 6d745e5..c426e4d 100644 --- a/src/authorization/rules/BaseRule.js +++ b/src/authorization/rules/BaseRule.js @@ -1,4 +1,5 @@ import { OWAFusion } from '../../utils/OWAFusion.js'; +import { buildValidity, normalizeValidity, DEFAULT_VALIDITY } from '../../core/validity.js'; import { BilatticeOrderings } from '../../qualitative/BilatticeOrderings.js'; import { QualitativeCapacity } from '../../qualitative/QualitativeCapacity.js'; import { QualitativeScale } from '../../qualitative/QualitativeScale.js'; @@ -134,11 +135,24 @@ export class BaseRule { * Create standardized rule result with collected values * @protected */ + _defaultValidity() { + return DEFAULT_VALIDITY; + } + + _validity(operator, sources = [], sourceLabels = [], K = 1, possibility = 0) { + return buildValidity({ operator, sources, sourceLabels, K, possibility }); + } + + _relationValidity(rel) { + return normalizeValidity(rel && rel.validity !== undefined ? rel.validity : null); + } + _createStandardResult(authResult, collectedValues = []) { const result = { // AUTHORIZATION possibility: authResult.possibility || 0, reliability: authResult.reliability !== undefined ? authResult.reliability : 1.0, + validity: authResult.validity || DEFAULT_VALIDITY, // BINARY MODE FIELDS possibility_allow: authResult.possibility_allow !== undefined ? authResult.possibility_allow : (authResult.possibility || 0), diff --git a/src/authorization/rules/ChainRule.js b/src/authorization/rules/ChainRule.js index 86f11b7..14de55b 100644 --- a/src/authorization/rules/ChainRule.js +++ b/src/authorization/rules/ChainRule.js @@ -350,6 +350,7 @@ export class ChainRule extends BaseRule { const authResult = { possibility: finalPossibility, reliability: finalReliability, + validity: this._validity('min', normalizedRule.steps.map(s => s.relation).filter(Boolean), [], normalizedRule.steps.length, finalPossibility), ...(includeMeta && { meta: finalPossibility > 0 ? { ruleType: 'chain', diff --git a/src/authorization/rules/DirectRule.js b/src/authorization/rules/DirectRule.js index 7d9f29b..ba77d12 100644 --- a/src/authorization/rules/DirectRule.js +++ b/src/authorization/rules/DirectRule.js @@ -71,6 +71,9 @@ export class DirectRule extends BaseRule { const authResult = { possibility: relationStrength, reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0, + validity: directRel.validity !== undefined + ? this._validity('identity', [relName], [this._relationValidity(directRel)], 1, relationStrength) + : this._defaultValidity(), possibility_allow: relationStrength, // For binary mode possibility_deny: 0, // DirectRule doesn't deny ...(includeMeta && { diff --git a/src/authorization/rules/LogicalOperators.js b/src/authorization/rules/LogicalOperators.js index 036810f..5ad3032 100644 --- a/src/authorization/rules/LogicalOperators.js +++ b/src/authorization/rules/LogicalOperators.js @@ -1,5 +1,6 @@ import { BaseRule } from './BaseRule.js'; import { OWAFusion, getOWAWeightsFromRule } from '../../utils/OWAFusion.js'; +import { buildValidity } from '../../core/validity.js'; import { Arbiter } from '../../core/Arbiter.js'; import { BilatticeOrderings } from '../../qualitative/BilatticeOrderings.js'; import { QualitativeCapacity } from '../../qualitative/QualitativeCapacity.js'; @@ -859,6 +860,7 @@ export class LogicalOperators extends BaseRule { return { possibility: Math.max(0, Math.min(1, possibility)), reliability, + validity: buildValidity('product', [], [], 2, Math.max(0, Math.min(1, possibility))), collectedValues: allCollectedValues, meta: { ...resultMeta, diff --git a/src/authorization/rules/MultiHopRule.js b/src/authorization/rules/MultiHopRule.js index e4277f2..84b26db 100644 --- a/src/authorization/rules/MultiHopRule.js +++ b/src/authorization/rules/MultiHopRule.js @@ -217,6 +217,7 @@ export class MultiHopRule extends BaseRule { const authResult = { possibility: finalPossibility, reliability: finalReliability, + validity: this._validity('min', [relation], [], Math.max(1, bestPath ? bestPath.hops : 1), finalPossibility), ...(includeMeta && { meta: allowMeta }), diff --git a/src/authorization/rules/RelationalComparatorRule.js b/src/authorization/rules/RelationalComparatorRule.js index 8746769..95e7bd6 100644 --- a/src/authorization/rules/RelationalComparatorRule.js +++ b/src/authorization/rules/RelationalComparatorRule.js @@ -100,6 +100,8 @@ export class RelationalComparatorRule extends BaseRule { else comparisonOutput.meta = { fullEvaluationTrace: evaluationMeta }; } + comparisonOutput.validity = this._validity('identity', + [leftOperand?.rule?.relation, rightOperand?.rule?.relation].filter(Boolean), [], 1, comparisonOutput.possibility || 0); return comparisonOutput; } catch (error) { return { diff --git a/src/authorization/rules/TupleToUsersetRule.js b/src/authorization/rules/TupleToUsersetRule.js index e19a52b..c1dc311 100644 --- a/src/authorization/rules/TupleToUsersetRule.js +++ b/src/authorization/rules/TupleToUsersetRule.js @@ -500,6 +500,7 @@ export class TupleToUsersetRule extends BaseRule { return { possibility: maxPossibility, reliability: maxReliability, + validity: this._validity('min', [(ruleMetaBase && ruleMetaBase.tuplesetRelation) || null, (ruleMetaBase && ruleMetaBase.computedRelation) || null].filter(Boolean), [], 2, maxPossibility), reason: reasons.includes('cycle') ? 'cycle' : (maxPossibility > 0 ? 'tuple_to_userset_found' : 'no_sufficient_tuple_to_userset_path') }; } @@ -565,6 +566,7 @@ export class TupleToUsersetRule extends BaseRule { return { possibility: fusionResult.value, reliability: fusedReliability, + validity: this._validity('min', [(ruleMetaBase && ruleMetaBase.tuplesetRelation) || null, (ruleMetaBase && ruleMetaBase.computedRelation) || null].filter(Boolean), [], 2, fusionResult.value), ...(includeMeta && { meta: finalMeta }), ...(collectValues && { collectedValues: fusionResult.value > 0 ? collectedValues : [] }), reason: reasons.includes('cycle') ? 'cycle' : (fusionResult.value > 0 ? 'tuple_to_userset_found' : 'no_sufficient_tuple_to_userset_path') diff --git a/src/core/relation/RelationUpdates.js b/src/core/relation/RelationUpdates.js index 26db32c..2aa7ca5 100644 --- a/src/core/relation/RelationUpdates.js +++ b/src/core/relation/RelationUpdates.js @@ -45,7 +45,8 @@ export class RelationUpdates { decayConfig: options?.decayConfig, updated_last_at: currentTime, changed_last_at: options?.changed_last_at !== undefined ? options.changed_last_at : currentTime, - stateId: newStateId + stateId: newStateId, + validity: options?.validity !== undefined ? options.validity : null }; this.manager.arbiter.relations.push(relationObj); @@ -599,6 +600,7 @@ export class RelationUpdates { oldRelation.reliability = newOptions?.reliability !== undefined ? newOptions.reliability : oldRelation.reliability; oldRelation.value = newOptions?.value !== undefined ? newOptions.value : oldRelation.value; oldRelation.decayConfig = newOptions?.decayConfig !== undefined ? newOptions.decayConfig : oldRelation.decayConfig; + oldRelation.validity = newOptions?.validity !== undefined ? newOptions.validity : oldRelation.validity; oldRelation.updated_last_at = currentTime; // Honor an explicit changed_last_at override like the add path does, // but only on writes that would refresh anyway: a write that does not diff --git a/src/core/validity.js b/src/core/validity.js new file mode 100644 index 0000000..40af60b --- /dev/null +++ b/src/core/validity.js @@ -0,0 +1,172 @@ +/** + * src/core/validity.js — possibilistic validity metadata (Cella FVN / Martin). + * + * Every check result carries a validity block describing how the decision + * value was produced, in the spirit of the zig-contour fusion spec: + * + * label — weakest validity label of the contributing sources, downgraded + * by the fusion operator's class: + * identity/max : operator is valid (max is already valid + * and normalized under arbitrary dependence); + * the fused label = weakest source label + * min (conjunctive) : unvalidified ranking -> the raw min is + * reported as the decision, the validified + * value min(1, K*gamma_min) is exposed + * separately, and the conflict mass is + * surfaced, never silently dropped + * product-style : exclusion P(A)*(1-P(B)) and defeasible + * when*(1-unless) use the product operator, + * which has no linear validification under + * arbitrary dependence (Cella Thm 4) -> + * always heuristic + * owa (interior) : convex mixtures of plausibilities are not + * maxitive -> scoring heuristic, never a + * valid possibility + * regime — dependence assumption (always 'arbitrary' unless declared) + * operator — the fusion operator actually applied + * conflictMass — 1 - fused_possibility: the conjunctive suppression (the + * scalar analog of contour sub-normalization; 0 for disjunctive) + * validifiedPossibility — the arbitrary-regime validification + * min(1, K*gamma_min) for conjunctive results (null otherwise) + * sources — the base relations that fed the decision + * nonMaxitive — true for interior OWA/averaging operators + * + * The taxonomy follows the contour spec: finite_sample | anytime | conformal | + * approximate | heuristic | unknown. Unlabeled relations default to heuristic. + */ + +export const VALIDITY_LABELS = ['finite_sample', 'anytime', 'conformal', 'approximate', 'heuristic', 'unknown']; + +// Shared immutable default for unlabeled decisions (all-heuristic): the +// hottest paths attach this constant instead of allocating per check. +export const DEFAULT_VALIDITY = Object.freeze({ + label: 'heuristic', + operator: 'identity', + regime: 'arbitrary', + sources: [], + conflictMass: 0, + validifiedPossibility: null, + nonMaxitive: false +}); +export const VALIDITY_RANK = { finite_sample: 0, anytime: 1, conformal: 2, approximate: 3, heuristic: 4, unknown: 5 }; + +export function normalizeValidity(label) { + if (label === undefined || label === null) return 'heuristic'; + const l = String(label); + return VALIDITY_RANK[l] !== undefined ? l : 'unknown'; +} + +/** Weakest (least valid) of a set of labels. */ +export function weakestValidity(labels) { + // Track the weakest (highest-rank) label; every label ranks >= 0. + let worst = 'finite_sample'; + for (const l of labels) { + const n = normalizeValidity(l); + if (VALIDITY_RANK[n] > VALIDITY_RANK[worst]) worst = n; + } + return worst; +} + +/** + * Operator validity classes: + * 'identity' — a single source; fused label = source label + * 'max' — disjunctive; valid by construction, label = weakest source + * 'min' — conjunctive; unvalidified ranking; label downgraded to + * approximate unless already weaker + * 'product' — banned operator (Thm 4); always heuristic + * 'owa' — interior OWA averaging; non-maxitive; always heuristic + */ +export function operatorValidityClass(operator) { + switch (operator) { + case 'identity': + case 'max': + return { downgrade: false, nonMaxitive: false }; + case 'min': + return { downgrade: true, nonMaxitive: false }; + case 'product': + return { downgrade: true, alwaysHeuristic: true, nonMaxitive: false }; + case 'owa': + return { downgrade: true, alwaysHeuristic: true, nonMaxitive: true }; + default: + return { downgrade: true, alwaysHeuristic: true, nonMaxitive: false }; + } +} + +/** + * Build the validity block for a fused decision. + * + * @param {object} opts + * operator — 'identity' | 'max' | 'min' | 'product' | 'owa' + * sources — array of base relation names that fed the decision + * sourceLabels — array of validity labels (parallel to sources, or a single + * 'heuristic' when the engine does not track them) + * K — number of fused sources (for the min validification) + * possibility — the raw fused possibility + */ +export function buildValidity(optsOrOp, sourcesArg = [], sourceLabelsArg = [], KArg = 1, possibilityArg = 0) { + // Accept both the object form ({ operator, sources, ... }) and the + // positional form (operator, sources, sourceLabels, K, possibility). + const opts = (typeof optsOrOp === 'object' && optsOrOp !== null) + ? optsOrOp + : { operator: optsOrOp, sources: sourcesArg, sourceLabels: sourceLabelsArg, K: KArg, possibility: possibilityArg }; + const { operator = 'identity', sources = [], sourceLabels = [], K = 1, possibility = 0 } = opts; + const cls = operatorValidityClass(operator); + const base = sourceLabels.length > 0 ? weakestValidity(sourceLabels) : 'heuristic'; + let label = base; + if (cls.downgrade) { + if (cls.alwaysHeuristic) { + label = 'heuristic'; + } else { + // unvalidified conjunctive ranking: approximate at best + if (VALIDITY_RANK[base] < VALIDITY_RANK.approximate) label = 'approximate'; + } + } + const block = { + label, + operator, + regime: 'arbitrary', + sources: [...sources], + nonMaxitive: cls.nonMaxitive + }; + if (operator === 'min') { + const k = Math.max(1, K); + block.conflictMass = Math.max(0, 1 - possibility); + block.validifiedPossibility = Math.min(1, k * possibility); + } else if (operator === 'identity' || operator === 'max') { + block.conflictMass = 0; + block.validifiedPossibility = null; + } else { + block.conflictMass = null; + block.validifiedPossibility = null; + } + return block; +} + +/** Merge several rule-level validity blocks into one (weakest label, unioned sources). */ +export function mergeValidity(blocks) { + const present = (blocks || []).filter(Boolean); + if (present.length === 0) { + return buildValidity({}); + } + const labels = present.map(b => b.label); + const sources = []; + for (const b of present) { + for (const s of b.sources || []) { + if (!sources.includes(s)) sources.push(s); + } + } + // If ANY contributor is a banned-operator heuristic, the merged result is + // heuristic too (the weakest operator wins, not just the weakest source). + const anyBanned = present.some(b => b.operator === 'product' || b.nonMaxitive); + let label = weakestValidity(labels); + if (anyBanned && VALIDITY_RANK[label] < VALIDITY_RANK.heuristic) label = 'heuristic'; + return { + label, + operator: present.length === 1 ? present[0].operator : 'mixed', + regime: 'arbitrary', + sources, + nonMaxitive: present.some(b => b.nonMaxitive), + conflictMass: present.some(b => b.operator === 'min') ? Math.max(0, 1 - Math.max(...present.map(b => b.conflictMass ?? 0))) : null, + validifiedPossibility: null + }; +} diff --git a/tests/rigor/binary-mode-parity.test.js b/tests/rigor/binary-mode-parity.test.js index 83e4375..b028a2c 100644 --- a/tests/rigor/binary-mode-parity.test.js +++ b/tests/rigor/binary-mode-parity.test.js @@ -296,7 +296,8 @@ describe('Binary (threshold) mode parity (rigor)', () => { const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'binary-normal-agreement'); assert.ok(inv, 'invariant missing'); - assert.equal(inv.passed, true, `binary parity violated in ${inv.failureCount} cases`); + assert.equal(inv.passed, true, `binary parity violated in ${inv.failureCount} cases: ` + + JSON.stringify((report.failures || []).slice(0, 2).map(f => ({ name: f.name, msg: f.message, seq: (f.sequence || []).map(s => s.args) })))); }); it('MUTATION FRESHNESS: binary and normal agree after every mutation with caching enabled', async () => { diff --git a/tests/rigor/validity-parity.test.js b/tests/rigor/validity-parity.test.js new file mode 100644 index 0000000..a41e23f --- /dev/null +++ b/tests/rigor/validity-parity.test.js @@ -0,0 +1,145 @@ +/** + * rigor/validity-parity.test.js — possibilistic validity metadata. + * + * Pins the Cella-FVN-inspired validity layer: + * - every check result carries a validity block {label, operator, regime, + * sources, conflictMass, validifiedPossibility, nonMaxitive} + * - unlabeled relations default to heuristic; a labeled relation + * propagates its label through identity/max fusion + * - max (disjunctive) fusion preserves the weakest source label + * - interior OWA/averaging is non-maxitive and always heuristic + * - min (conjunctive) fusion is approximate at best, surfaces the + * conflict mass (1 - possibility), and exposes the arbitrary-regime + * validification min(1, K*possibility) + * - product-style operators (exclusion, defeasible) are always heuristic + * - reliability and validity are distinct: reliability stays the scalar + * confidence; validity tracks the epistemic label + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { rigor } from '@rigor/core'; +import { Arbiter } from '../../src/index.js'; +import { buildValidity, mergeValidity, weakestValidity, DEFAULT_VALIDITY } from '../../src/core/validity.js'; + +const child = (r) => ({ type: 'direct', relation: r }); + +function mk() { + const a = new Arbiter(); + a.addNode('u:0', 'user'); + a.addNode('d:0', 'doc'); + a.addNode('g:0', 'group'); + return a; +} + +describe('Possibilistic validity metadata (rigor)', () => { + it('FIXED: labels, operators, conflict mass, validification per kind', () => { + // direct unlabeled -> heuristic identity + { + const a = mk(); + a.setRelationConfig('t', { type: 'direct', relation: 'r1' }); + a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8 }); + const r = a.check('u:0', 't', 'd:0'); + assert.equal(r.validity.label, 'heuristic'); + assert.equal(r.validity.operator, 'identity'); + assert.deepEqual(r.validity.sources, ['r1']); + assert.equal(r.validity.conflictMass, 0); + } + // labeled direct propagates its label + { + const a = mk(); + a.setRelationConfig('t', { type: 'direct', relation: 'r1' }); + a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' }); + assert.equal(a.check('u:0', 't', 'd:0').validity.label, 'finite_sample'); + } + // max fusion preserves the weakest source label + { + const a = mk(); + a.setRelationConfig('t', { union: [child('r1'), child('r2')] }); + a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' }); + a.addRelation('u:0', 'r2', 'd:0', { possibility: 0.5, validity: 'conformal' }); + const r = a.check('u:0', 't', 'd:0'); + assert.equal(r.validity.label, 'conformal', 'weakest label wins through max'); + assert.equal(r.validity.operator, 'max'); + assert.equal(r.validity.nonMaxitive, false); + } + // interior OWA is non-maxitive heuristic + { + const a = mk(); + a.setRelationConfig('t', { union: [child('r1'), child('r2')], aggregator: 'average' }); + a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' }); + a.addRelation('u:0', 'r2', 'd:0', { possibility: 0.5, validity: 'finite_sample' }); + const r = a.check('u:0', 't', 'd:0'); + assert.equal(r.validity.operator, 'owa'); + assert.equal(r.validity.nonMaxitive, true); + assert.equal(r.validity.label, 'heuristic', 'averaging never claims validity'); + } + // min fusion: approximate, conflict mass, validification + { + const a = mk(); + a.setRelationConfig('t', { intersection: [child('r1'), child('r2')] }); + a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' }); + a.addRelation('u:0', 'r2', 'd:0', { possibility: 0.5, validity: 'finite_sample' }); + const r = a.check('u:0', 't', 'd:0'); + assert.equal(r.validity.operator, 'min'); + assert.equal(r.validity.label, 'approximate', 'unvalidified conjunctive is approximate at best'); + assert.ok(Math.abs(r.validity.conflictMass - (1 - 0.5)) < 1e-9, 'conflict mass = 1 - possibility'); + assert.equal(r.validity.validifiedPossibility, 1, 'min(1, K*gamma) with K=2, gamma=0.5'); + } + // product operators are heuristic even with labeled sources + { + const a = mk(); + a.setRelationConfig('t', { exclusion: [child('r1'), child('r2')] }); + a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' }); + a.addRelation('u:0', 'r2', 'd:0', { possibility: 0.5, validity: 'finite_sample' }); + assert.equal(a.check('u:0', 't', 'd:0').validity.label, 'heuristic'); + a.setRelationConfig('t2', { type: 'defeasible', when: child('r1'), unless: child('r2') }); + assert.equal(a.check('u:0', 't2', 'd:0').validity.label, 'heuristic'); + } + // chain: conjunctive ranking with conflict surfacing + { + const a = mk(); + a.setRelationConfig('t', { type: 'chain', steps: [{ relation: 'member_of', direction: 'out' }, { relation: 'viewer', direction: 'out' }] }); + a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.9 }); + a.addRelation('g:0', 'viewer', 'd:0', { possibility: 0.6 }); + const r = a.check('u:0', 't', 'd:0'); + assert.equal(r.validity.operator, 'min'); + assert.ok(Math.abs(r.validity.conflictMass - 0.4) < 1e-9, 'chain conflict mass'); + } + // reliability and validity stay distinct + { + const a = mk(); + a.setRelationConfig('t', { type: 'direct', relation: 'r1' }); + a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, reliability: 0.42, validity: 'finite_sample' }); + const r = a.check('u:0', 't', 'd:0'); + assert.equal(r.reliability, 0.42, 'reliability unchanged'); + assert.equal(r.validity.label, 'finite_sample', 'validity independent of reliability'); + } + }); + + it('PROPERTY CAMPAIGN: helper semantics (weakest, merge, default identity)', async () => { + const result = await rigor.campaign( + [rigor.fn('helpers', (labels) => { + const weakest = weakestValidity(labels); + const merged = mergeValidity(labels.map(l => ({ + label: l, operator: 'max', regime: 'arbitrary', sources: ['r'], nonMaxitive: false, conflictMass: 0, validifiedPossibility: null + }))); + const single = mergeValidity([{ + label: labels[0], operator: 'max', regime: 'arbitrary', sources: ['r'], nonMaxitive: false, conflictMass: 0, validifiedPossibility: null + }]); + return { + weakest: weakestValidity([labels[0], weakest]), + mergedWeakest: merged.label === weakest, + singlePass: single === undefined ? false : single.label === labels[0], + defaultIsFrozen: Object.isFrozen(DEFAULT_VALIDITY), + buildPositional: buildValidity('min', ['a'], ['finite_sample'], 2, 0.5).validifiedPossibility === 1 + }; + }, rigor.args(rigor.gen.array(rigor.gen.oneOf(['finite_sample', 'anytime', 'conformal', 'approximate', 'heuristic', 'unknown']), 1, 4)))], + rigor.crucible([ + rigor.invariant('helper invariants', ({ error, errorMessage, actual }) => !error && !errorMessage && Object.values(actual).every(Boolean)) + ]) + ).run({ effort: 200, seed: 'validity-helpers-2026', artifacts: { dir: '', persist: 'never' } }); + + const inv = result.crucibleVerdict?.invariants?.find(i => i.name === 'helper invariants'); + assert.ok(inv && inv.passed, `validity helpers violated in ${inv?.failureCount} cases`); + }); +});