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