fix: caller clock everywhere — value TTL, decay, qualitative decay, cache TTL
CI / test (push) Successful in 5m52s
CI / benchmark (push) Successful in 22s
CI / publish (push) Has been skipped

The principle: time is caller-provided (options.now / partialGraph.now);
the wall clock is only the fallback for unpinned callers, never a hidden
decision input. Remaining clock leaks:

- getBlurredValue gained an optional now param threaded to _isValueExpired;
  ChainRule (2 sites), MultiHopRule (2 sites), RelationManager (3 sites)
  now pass the caller clock. Previously a pinned-clock caller's chain/
  multi-hop value TTL used the WALL clock (wall in 2026, pinned T0 in
  2001 -> values wrongly expired).
- MultiHopRule's TTL gate used valueFilters.ttl || 24h instead of the
  valueManager's per-relation TTL (inconsistent with chain/comparator);
  now valueManager.getTTL is the authority, valueFilters.ttl the override.
- QualitativeRelationalComparatorRule decay (_calculatePeriodsElapsed)
  and value timestamps used the wall clock, so qualitative possibility
  decay ignored the pinned clock; now threaded through _evaluateOperand.
- ValueManager decay internals (getDecayedRelation, _calculateSeparated
  Decay, _calculateBlurredValue) accept a now param (background worker
  still passes none -> wall clock is correct there).
- PartialGraphContext._addChallengeProof/_addRelation used Date.now()
  instead of the context's own this.now (the partial graph's time).
- Arbiter gained an injectable clock (options.clock) driving unpinned
  cache-entry freshness in DecisionCache, RuleEvaluator, ChainRule, and
  RelationalComparatorRule; DecisionCache explicit clock still wins.
- Collected-value timestamps in DirectRule and RelationalComparatorRule
  honor the caller clock.

Pinned-clock chain probe: values fresh at T0, expired at T0+61s, with the
wall clock in 2026. Rigor 251/251, full suite 853/791/0.
This commit is contained in:
John Dvorak
2026-08-02 17:50:24 -07:00
parent f530532e48
commit 440230b2c5
11 changed files with 84 additions and 46 deletions
+3 -1
View File
@@ -36,7 +36,9 @@ export class DecisionCache {
*/
constructor(arbiter, options = {}) {
this.arbiter = arbiter;
this.clock = options.clock || (() => Date.now());
// Precedence: an explicit cache-level clock wins; else the arbiter's
// injected clock (options.clock on the Arbiter); else the wall clock.
this.clock = options.clock || ((arbiter && typeof arbiter.clock === 'function') ? arbiter.clock.bind(arbiter) : (() => Date.now()));
this._enabled = !!arbiter;
}
+4 -2
View File
@@ -55,7 +55,8 @@ export class RuleEvaluator {
if (canCacheRuleResult) {
const cached = this.arbiter.ruleResultCache.get(ruleCacheKey);
if (cached && Date.now() - cached.timestamp < this.arbiter.ruleResultCacheTTL) {
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
if (cached && cacheNow - cached.timestamp < this.arbiter.ruleResultCacheTTL) {
this.arbiter.ruleResultCacheStats.hits++;
if (collectValues && finalValueContext && cached.result?.collectedValues?.length) {
const ruleType = rule?.type || (rule?.union ? 'union' : rule?.intersection ? 'intersection' : rule?.exclusion ? 'exclusion' : 'rule');
@@ -159,9 +160,10 @@ export class RuleEvaluator {
_maybeCacheRuleResult(result, relation, cacheKey, enabled) {
if (!enabled || !cacheKey || !relation) return result;
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
this.arbiter.ruleResultCache.set(cacheKey, {
result,
timestamp: Date.now()
timestamp: cacheNow
});
this.arbiter._cacheRuleResult(relation, cacheKey);
return result;
+8 -4
View File
@@ -433,7 +433,8 @@ export class ChainRule extends BaseRule {
const key = this._getChainResultCacheKey(userId, objectId, steps);
const entry = this.chainResultCache.get(key);
if (entry && Date.now() - entry.timestamp < this.cacheTTL) {
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
if (entry && cacheNow - entry.timestamp < this.cacheTTL) {
return entry.result;
}
@@ -451,9 +452,10 @@ export class ChainRule extends BaseRule {
const key = this._getChainResultCacheKey(userId, objectId, steps);
// HyperbolicLRUCache handles eviction automatically based on frequency and recency
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
this.chainResultCache.set(key, {
result,
timestamp: Date.now()
timestamp: cacheNow
});
}
@@ -514,7 +516,8 @@ export class ChainRule extends BaseRule {
return collectedValues;
}
const blurred = this.arbiter.valueManager.getBlurredValue(relation);
const callerNow = options && options.now !== undefined && options.now !== null ? options.now : null;
const blurred = this.arbiter.valueManager.getBlurredValue(relation, callerNow);
if (blurred.interval) {
const sourceEntity = direction === 'in' ?
@@ -590,7 +593,8 @@ export class ChainRule extends BaseRule {
Arbiter.DEBUG && Arbiter.log('ChainRule: relationManager or valueManager is undefined');
return collectedValues;
}
const blurred = this.arbiter.relationManager.valueManager.getBlurredValue(tempRelation);
const callerNow = options && options.now !== undefined && options.now !== null ? options.now : null;
const blurred = this.arbiter.relationManager.valueManager.getBlurredValue(tempRelation, callerNow);
if (blurred.interval) {
const collectedValue = this._createCollectedValue(
+2 -1
View File
@@ -108,7 +108,8 @@ export class DirectRule extends BaseRule {
step: 0
},
{
timestamp: directRel.changed_last_at || directRel.updated_last_at || Date.now(),
timestamp: directRel.changed_last_at || directRel.updated_last_at ||
(options && options.now !== undefined && options.now !== null ? options.now : Date.now()),
reliability: 1.0,
source: directRel.source || 'persistent'
}
+12 -6
View File
@@ -370,21 +370,27 @@ export class MultiHopRule extends BaseRule {
// Collect from direct edge if available
if (step.edge && step.edge.value !== undefined && step.edge.value !== null) {
if (this._passesValueFilters(step.edge.value, valueFilters)) {
// Check TTL
const ttl = valueFilters.ttl || 24 * 60 * 60 * 1000;
const timestamp = step.edge.changed_last_at || step.edge.updated_last_at || Date.now();
// Check TTL. The valueManager's per-relation TTL is the
// authority (matching chain/comparator paths); valueFilters.ttl
// is an explicit override. Timestamps and the age computation
// use the caller-pinned clock, never the wall clock.
const ttl = valueFilters.ttl || (valueManager && valueManager.getTTL
? valueManager.getTTL(step.edge.rel)
: 24 * 60 * 60 * 1000);
const timestamp = step.edge.changed_last_at || step.edge.updated_last_at ||
(evaluationNow !== undefined && evaluationNow !== null ? evaluationNow : Date.now());
if (!OWAFusion.isWithinTTL(timestamp, ttl, evaluationNow)) {
Arbiter.DEBUG && Arbiter.log('MultiHop skipping value due to TTL:', {
value: step.edge.value,
timestamp,
ttl,
age: Date.now() - timestamp
age: evaluationNow - timestamp
});
continue;
}
const blurred = valueManager.getBlurredValue(step.edge);
const blurred = valueManager.getBlurredValue(step.edge, evaluationNow);
if (blurred.interval) {
const collectedValue = this._createCollectedValue(
@@ -447,7 +453,7 @@ export class MultiHopRule extends BaseRule {
changed_last_at: contextValue.timestamp
};
const blurred = valueManager.getBlurredValue(tempRelation);
const blurred = valueManager.getBlurredValue(tempRelation, evaluationNow);
if (blurred.interval) {
const collectedValue = this._createCollectedValue(
@@ -244,7 +244,7 @@ export class QualitativeRelationalComparatorRule extends BaseRule {
}
// Extract blurred values with qualitative decay and blur
const blurredValues = this._extractBlurredValues(adjustedValues, operandConfig, scale);
const blurredValues = this._extractBlurredValues(adjustedValues, operandConfig, scale, options);
if (operandMeta) {
operandMeta.blurredValues = blurredValues;
@@ -299,7 +299,7 @@ export class QualitativeRelationalComparatorRule extends BaseRule {
values.push({
value: qualitativeValue,
possibility: qualitativeValue,
timestamp: Date.now(),
timestamp: options && options.now !== undefined && options.now !== null ? options.now : Date.now(),
relation: 'rule_result',
meta: { source: 'rule_possibility' }
});
@@ -351,7 +351,7 @@ export class QualitativeRelationalComparatorRule extends BaseRule {
* Extract blurred values with qualitative decay and blur
* @private
*/
_extractBlurredValues(values, operandConfig, scale) {
_extractBlurredValues(values, operandConfig, scale, options = null) {
const {
decaySteps = 1,
decayPeriod = 'HOUR',
@@ -366,10 +366,11 @@ export class QualitativeRelationalComparatorRule extends BaseRule {
for (const valueObj of values) {
const pointValue = valueObj.value;
const initialPossibility = valueObj.possibility;
const timestamp = valueObj.timestamp || Date.now();
const timestamp = valueObj.timestamp ||
(options && options.now !== undefined && options.now !== null ? options.now : Date.now());
// Calculate periods elapsed
const periodsElapsed = this._calculatePeriodsElapsed(timestamp, decayPeriod);
const periodsElapsed = this._calculatePeriodsElapsed(timestamp, decayPeriod, options);
// Calculate decayed possibility
const decayedPossibility = this._calculateDecayedPossibility(
@@ -415,8 +416,8 @@ export class QualitativeRelationalComparatorRule extends BaseRule {
* Calculate periods elapsed since timestamp
* @private
*/
_calculatePeriodsElapsed(timestamp, decayPeriod) {
const now = Date.now();
_calculatePeriodsElapsed(timestamp, decayPeriod, options = null) {
const now = options && options.now !== undefined && options.now !== null ? options.now : Date.now();
const elapsed = now - timestamp;
const periodMs = {
@@ -533,7 +534,8 @@ export class QualitativeRelationalComparatorRule extends BaseRule {
step: index
},
metadata: {
timestamp: bv.timestamp || Date.now(),
timestamp: bv.timestamp ||
(options && options.now !== undefined && options.now !== null ? options.now : Date.now()),
reliability: 1.0,
originalValue: bv.originalValue,
originalPossibility: bv.originalPossibility,
@@ -228,7 +228,8 @@ export class RelationalComparatorRule extends BaseRule {
: null;
if (canCacheDerived) {
const cached = this.arbiter.ruleResultCache.get(derivedCacheKey);
if (cached && Date.now() - cached.timestamp < this.arbiter.ruleResultCacheTTL) {
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
if (cached && cacheNow - cached.timestamp < this.arbiter.ruleResultCacheTTL) {
const cachedResult = cached.result;
let valueInterval = cachedResult.valueInterval;
let operandPossibility = cachedResult.operandPossibility || 0;
@@ -316,7 +317,8 @@ export class RelationalComparatorRule extends BaseRule {
step: 0
},
{
timestamp: directRel.changed_last_at || directRel.updated_last_at || Date.now(),
timestamp: directRel.changed_last_at || directRel.updated_last_at ||
(evaluationNow !== undefined && evaluationNow !== null ? evaluationNow : Date.now()),
reliability: directRel.reliability || 1.0,
source: directRel.source || 'persistent'
}
@@ -324,6 +326,7 @@ export class RelationalComparatorRule extends BaseRule {
}
if (canCacheDerived && derivedCacheKey) {
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
this.arbiter.ruleResultCache.set(derivedCacheKey, {
result: {
valueInterval,
@@ -333,7 +336,7 @@ export class RelationalComparatorRule extends BaseRule {
source: operandSource,
collectedValues: operandCollectedValues
},
timestamp: Date.now()
timestamp: cacheNow
});
this.arbiter._cacheRuleResult(currentRelation, derivedCacheKey);
if (operandEvalMeta) operandEvalMeta.steps.push({ step: 'DerivedValueCached', derivedCacheKey });
@@ -397,7 +400,8 @@ export class RelationalComparatorRule extends BaseRule {
value: rel.value,
possibility: rel.possibility !== undefined ? rel.possibility : 1.0,
reliability: rel.reliability || 1.0,
timestamp: rel.changed_last_at || rel.updated_last_at || Date.now(),
timestamp: rel.changed_last_at || rel.updated_last_at ||
(evaluationNow !== undefined && evaluationNow !== null ? evaluationNow : Date.now()),
source: 'direct_relation',
originalValue: rel.value
});
@@ -414,7 +418,8 @@ export class RelationalComparatorRule extends BaseRule {
step: 0
},
{
timestamp: rel.changed_last_at || rel.updated_last_at || Date.now(),
timestamp: rel.changed_last_at || rel.updated_last_at ||
(evaluationNow !== undefined && evaluationNow !== null ? evaluationNow : Date.now()),
reliability: rel.reliability || 1.0,
source: rel.source || 'persistent'
}
@@ -450,6 +455,7 @@ export class RelationalComparatorRule extends BaseRule {
}
if (canCacheDerived && derivedCacheKey) {
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
this.arbiter.ruleResultCache.set(derivedCacheKey, {
result: {
valueInterval,
@@ -459,7 +465,7 @@ export class RelationalComparatorRule extends BaseRule {
source: operandSource,
collectedValues: operandCollectedValues
},
timestamp: Date.now()
timestamp: cacheNow
});
this.arbiter._cacheRuleResult(currentRelation, derivedCacheKey);
if (operandEvalMeta) operandEvalMeta.steps.push({ step: 'DerivedValueCached', derivedCacheKey });
@@ -566,6 +572,7 @@ export class RelationalComparatorRule extends BaseRule {
}
if (canCacheDerived && derivedCacheKey) {
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
this.arbiter.ruleResultCache.set(derivedCacheKey, {
result: {
valueInterval,
@@ -575,7 +582,7 @@ export class RelationalComparatorRule extends BaseRule {
source: operandSource,
collectedValues: operandCollectedValues
},
timestamp: Date.now()
timestamp: cacheNow
});
this.arbiter._cacheRuleResult(currentRelation, derivedCacheKey);
if (operandEvalMeta) operandEvalMeta.steps.push({ step: 'DerivedValueCached', derivedCacheKey });
+6
View File
@@ -31,6 +31,12 @@ export class Arbiter {
// is built only when the hook exists, so the default path is untouched.
this._auditHook = typeof options.audit === 'function' ? options.audit : null;
// The engine's clock. The caller may inject a clock (options.clock);
// the wall clock is the fallback for unpinned callers only. Per-check
// time is always the caller's `{ now }` / partialGraph.now — this
// clock only drives UNPINNED cache-entry freshness.
this.clock = typeof options.clock === 'function' ? options.clock : (() => Date.now());
// Injectable cache factory (DI): defaults to the built-in SimpleLRUCache.
// Pass options.cacheFactory to substitute another cache implementation.
this.cacheFactory = options.cacheFactory || defaultCacheFactory;
+4 -2
View File
@@ -126,7 +126,9 @@ export class PartialGraphContext {
_addChallengeProof(proof) {
const subjectId = this._resolveNodeId(proof.subject);
const now = Date.now();
// The partial graph carries the caller's clock; fall back to the wall
// clock only when the caller provided no time at all.
const now = this.now !== null && this.now !== undefined ? this.now : Date.now();
// 0 is a valid timestamp (epoch-issued / already-expired); `||` would
// replace it with the wall clock or null, making an epoch-issued proof
// the most recent one and an epoch-expired proof never expire.
@@ -150,7 +152,7 @@ export class PartialGraphContext {
const srcId = this._resolveNodeId(rel.src);
const dstId = this._resolveNodeId(rel.dst);
const relation = rel.relation;
const now = Date.now();
const now = this.now !== null && this.now !== undefined ? this.now : Date.now();
const relationObj = {
src: srcId,
rel: relation,
+6 -4
View File
@@ -327,7 +327,8 @@ export class RelationManager {
this._ensureIndicesBuilt();
const rel = this.arbiter.indices.getDirectRelation(srcId, relation, dstId);
if (rel && rel.value !== undefined) {
const blurred = this.arbiter.valueManager.getBlurredValue(rel);
const callerNow = options && options.now !== undefined && options.now !== null ? options.now : null;
const blurred = this.arbiter.valueManager.getBlurredValue(rel, callerNow);
const result = {
pointValue: rel.value, // Original crisp value
value: rel.value, // Backward compatibility
@@ -543,7 +544,7 @@ export class RelationManager {
* @param {number} epsilon - Optional epsilon for equality
* @returns {number} Possibility (0-1) that comparison holds
*/
compareRelationValues(leftRelation, rightRelation, comparator, epsilon = null) {
compareRelationValues(leftRelation, rightRelation, comparator, epsilon = null, options = null) {
// Get actual relation objects if needed
const leftRel = leftRelation.value !== undefined ? leftRelation :
this.getDirectRelation(leftRelation.srcId, leftRelation.relation, leftRelation.dstId);
@@ -554,8 +555,9 @@ export class RelationManager {
return 0; // Cannot compare if either has no value
}
const leftBlurred = this.arbiter.valueManager.getBlurredValue(leftRel);
const rightBlurred = this.arbiter.valueManager.getBlurredValue(rightRel);
const callerNow = options && options.now !== undefined && options.now !== null ? options.now : null;
const leftBlurred = this.arbiter.valueManager.getBlurredValue(leftRel, callerNow);
const rightBlurred = this.arbiter.valueManager.getBlurredValue(rightRel, callerNow);
if (!leftBlurred.interval || !rightBlurred.interval) {
return 0; // Cannot compare null intervals
+15 -11
View File
@@ -170,9 +170,11 @@ export class ValueManager {
/**
* Get or calculate the blurred value interval for a relation
* @param {Object} relation - The relation object
* @param {number|null} [now] - Caller-pinned clock; the wall clock is
* only the fallback for unpinned callers.
* @returns {Object} { interval: {min, max}, possibility: number, reliability: number }
*/
getBlurredValue(relation) {
getBlurredValue(relation, now = null) {
// If relation has no value, return null interval
if (relation.value === undefined || relation.value === null) {
return {
@@ -183,7 +185,7 @@ export class ValueManager {
}
// Check TTL first - if expired, return null interval
if (this._isValueExpired(relation)) {
if (this._isValueExpired(relation, now)) {
return {
interval: null,
possibility: 0,
@@ -204,7 +206,7 @@ export class ValueManager {
* @param {Object} relation - The relation object
* @returns {Object} { pointValue, blurredInterval, currentPossibility, originalPossibility, reliability }
*/
getDecayedRelation(relation) {
getDecayedRelation(relation, now = null) {
if (relation.value === undefined || relation.value === null) {
return {
pointValue: null,
@@ -232,18 +234,19 @@ export class ValueManager {
* Calculate separated decay for value blurring and possibility
* @private
*/
_calculateSeparatedDecay(relation) {
_calculateSeparatedDecay(relation, now = null) {
const pointValue = relation.value;
const initialPossibility = relation.possibility !== undefined ? relation.possibility : 1.0;
const reliability = relation.reliability !== undefined ? relation.reliability : 1.0;
const timestamp = relation.changed_last_at || relation.updated_last_at || Date.now();
const timestamp = relation.changed_last_at || relation.updated_last_at ||
(now !== null && now !== undefined ? now : Date.now());
// Get decay configuration
const config = this._getRelationDecayConfig(relation);
// Calculate age
const now = Date.now();
const ageMs = Math.max(0, now - timestamp);
const evalNow = now !== null && now !== undefined ? now : Date.now();
const ageMs = Math.max(0, evalNow - timestamp);
const periodMs = PERIOD_TO_MS[config.decayPeriod.toUpperCase()] || PERIOD_TO_MS.HOUR;
const ageInPeriod = ageMs / periodMs;
@@ -636,18 +639,19 @@ export class ValueManager {
* Calculate the blurred value interval for a relation
* @private
*/
_calculateBlurredValue(relation) {
_calculateBlurredValue(relation, now = null) {
const pointValue = relation.value;
const initialPossibility = relation.possibility !== undefined ? relation.possibility : 1.0;
const reliability = relation.reliability !== undefined ? relation.reliability : 1.0;
const timestamp = relation.changed_last_at || relation.updated_last_at || Date.now();
const timestamp = relation.changed_last_at || relation.updated_last_at ||
(now !== null && now !== undefined ? now : Date.now());
// Get decay configuration
const config = this._getRelationDecayConfig(relation);
// Calculate age
const now = Date.now();
const ageMs = Math.max(0, now - timestamp); // Ensure non-negative
const evalNow = now !== null && now !== undefined ? now : Date.now();
const ageMs = Math.max(0, evalNow - timestamp); // Ensure non-negative
const periodMs = PERIOD_TO_MS[config.decayPeriod.toUpperCase()] || PERIOD_TO_MS.HOUR;
const ageInPeriod = ageMs / periodMs;