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;
  }
}