File size: 5,034 Bytes
ee888e1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | // @ts-check
// Evaluates structured trigger thresholds against current snapshot inputs.
// Each trigger maps to one of three states: active, watching, or dormant.
import { num } from './_helpers.mjs';
import { TRIGGER_DEFS } from './triggers.config.mjs';
import { CII_RISK_SCORE_CACHE_KEYS } from '../_cii-risk-cache-keys.mjs';
/**
* @param {string} regionId
* @param {Record<string, any>} sources
* @param {import('../../shared/regions.types.js').BalanceVector} balance
* @returns {import('../../shared/regions.types.js').TriggerLadder}
*/
export function evaluateTriggers(regionId, sources, balance) {
const active = [];
const watching = [];
const dormant = [];
for (const def of TRIGGER_DEFS) {
if (def.regionId !== regionId) continue;
const metricValue = resolveMetric(def.threshold.metric, sources, balance, regionId);
if (metricValue === null) {
dormant.push(buildTrigger(def, false));
continue;
}
const passes = evaluateThreshold(metricValue, def.threshold);
if (passes === true) {
active.push(buildTrigger(def, true));
} else if (isCloseToThreshold(metricValue, def.threshold)) {
watching.push(buildTrigger(def, false));
} else {
dormant.push(buildTrigger(def, false));
}
}
return { active, watching, dormant };
}
function buildTrigger(def, activated) {
return {
id: def.id,
description: def.description,
threshold: def.threshold,
activated,
activated_at: activated ? Date.now() : 0,
scenario_lane: def.scenario_lane,
evidence_ids: [],
};
}
/**
* Resolve a metric reference like `chokepoint:hormuz:threat_level` against
* the current snapshot inputs. Returns null if the metric is unavailable.
*/
function resolveMetric(metric, sources, balance, regionId) {
// balance:{region}:{axis}
if (metric.startsWith('balance:')) {
const parts = metric.split(':');
if (parts.length !== 3) return null;
const [, mRegion, axis] = parts;
if (mRegion !== regionId) return null;
const v = balance[axis];
return typeof v === 'number' ? v : null;
}
// chokepoint:{id}:{field}
if (metric.startsWith('chokepoint:')) {
const parts = metric.split(':');
const [, cpId, field] = parts;
const cps = sources['supply_chain:chokepoints:v4']?.chokepoints;
const cp = Array.isArray(cps) ? cps.find((c) => c?.id === cpId) : null;
if (!cp) return null;
if (field === 'threat_level') {
const map = { war_zone: 1.0, critical: 0.8, high: 0.6, elevated: 0.4, normal: 0.0 };
return map[String(cp.threatLevel ?? 'normal').toLowerCase()] ?? 0;
}
if (field === 'transit_count') {
const summaries = sources['supply_chain:transit-summaries:v1']?.summaries ?? {};
return num(summaries[cpId]?.todayTotal, 0);
}
return null;
}
// cii:{iso2}:{field}
if (metric.startsWith('cii:')) {
const parts = metric.split(':');
const [, iso] = parts;
const cii = sources[CII_RISK_SCORE_CACHE_KEYS.stale]?.ciiScores;
if (!Array.isArray(cii)) return null;
const entry = cii.find((s) => s?.region === iso);
return entry ? num(entry.combinedScore) : null;
}
// oref:active_alerts_count
// Reads the canonical relay:oref:history:v1 key shape:
// { history, historyCount24h, totalHistoryCount, activeAlertCount, persistedAt }
// Prefer activeAlertCount when present (live count), fall back to historyCount24h
// (rolling 24h window) so the trigger still fires after the relay restarts.
if (metric === 'oref:active_alerts_count') {
const oref = sources['relay:oref:history:v1'];
if (!oref || typeof oref !== 'object') return 0;
if (typeof oref.activeAlertCount === 'number') return oref.activeAlertCount;
if (typeof oref.historyCount24h === 'number') return oref.historyCount24h;
return 0;
}
// theater:* metrics not yet implemented in Phase 0
return null;
}
function evaluateThreshold(value, threshold) {
switch (threshold.operator) {
case 'gt': return value > threshold.value;
case 'gte': return value >= threshold.value;
case 'lt': return value < threshold.value;
case 'lte': return value <= threshold.value;
// delta_gt and delta_lt require historical snapshots. Phase 0 has no
// history yet, so these operators are dormant by design. Phase 1
// populates a baseline reader and re-enables them.
case 'delta_gt': return false;
case 'delta_lt': return false;
default: return false;
}
}
export function isCloseToThreshold(value, threshold) {
const target = threshold.value;
if (target === 0) return false;
const band = Math.abs(target) * 0.2;
switch (threshold.operator) {
case 'gt':
case 'gte':
return value < target && value >= target - band;
case 'lt':
case 'lte':
return value > target && value <= target + band;
// delta_* operators need historical baselines and remain dormant in Phase 0.
case 'delta_gt':
case 'delta_lt':
return false;
default:
return false;
}
}
|