|
| 1 | +export const RESILIENCE_INTERVAL_KEY_PREFIX = 'resilience:intervals:v3:'; |
| 2 | +export const RESILIENCE_INTERVAL_METHODOLOGY = 'weight-perturbation-sensitivity-v1'; |
| 3 | +export const DRAWS = 100; |
| 4 | + |
| 5 | +export const DOMAIN_WEIGHTS = { |
| 6 | + economic: 0.17, |
| 7 | + infrastructure: 0.15, |
| 8 | + energy: 0.11, |
| 9 | + 'social-governance': 0.19, |
| 10 | + 'health-food': 0.13, |
| 11 | + recovery: 0.25, |
| 12 | +}; |
| 13 | + |
| 14 | +export const DOMAIN_ORDER = [ |
| 15 | + 'economic', |
| 16 | + 'infrastructure', |
| 17 | + 'energy', |
| 18 | + 'social-governance', |
| 19 | + 'health-food', |
| 20 | + 'recovery', |
| 21 | +]; |
| 22 | + |
| 23 | +export const PILLAR_WEIGHTS = { |
| 24 | + 'structural-readiness': 0.40, |
| 25 | + 'live-shock-exposure': 0.35, |
| 26 | + 'recovery-capacity': 0.25, |
| 27 | +}; |
| 28 | + |
| 29 | +export const PILLAR_ORDER = [ |
| 30 | + 'structural-readiness', |
| 31 | + 'live-shock-exposure', |
| 32 | + 'recovery-capacity', |
| 33 | +]; |
| 34 | + |
| 35 | +export const PENALTY_ALPHA = 0.50; |
| 36 | + |
| 37 | +function round(value, places = 2) { |
| 38 | + const factor = 10 ** places; |
| 39 | + return Math.round(value * factor) / factor; |
| 40 | +} |
| 41 | + |
| 42 | +function roundInterval(value) { |
| 43 | + return Math.round(value * 10) / 10; |
| 44 | +} |
| 45 | + |
| 46 | +function floorInterval(value) { |
| 47 | + return Math.floor(value * 10) / 10; |
| 48 | +} |
| 49 | + |
| 50 | +function ceilInterval(value) { |
| 51 | + return Math.ceil(value * 10) / 10; |
| 52 | +} |
| 53 | + |
| 54 | +export function createIntervalDiagnostics() { |
| 55 | + return { |
| 56 | + activeScoreClampCount: 0, |
| 57 | + activeScoreClampMaxDelta: 0, |
| 58 | + activeScoreClampSamples: [], |
| 59 | + }; |
| 60 | +} |
| 61 | + |
| 62 | +function normalizeFormula(value) { |
| 63 | + return value === 'pc' || value === 'd6' ? value : null; |
| 64 | +} |
| 65 | + |
| 66 | +export function currentEnvFormula() { |
| 67 | + const pillarCombine = (process.env.RESILIENCE_PILLAR_COMBINE_ENABLED ?? 'false').toLowerCase() === 'true'; |
| 68 | + const schemaV2 = (process.env.RESILIENCE_SCHEMA_V2_ENABLED ?? 'true').toLowerCase() === 'true'; |
| 69 | + return pillarCombine && schemaV2 ? 'pc' : 'd6'; |
| 70 | +} |
| 71 | + |
| 72 | +function recordActiveScoreClamp(options, before, after, activeScore) { |
| 73 | + if (before.p05 === after.p05 && before.p95 === after.p95) return; |
| 74 | + const diagnostics = options?.diagnostics; |
| 75 | + if (!diagnostics || typeof diagnostics !== 'object') return; |
| 76 | + |
| 77 | + const delta = activeScore < before.p05 |
| 78 | + ? before.p05 - activeScore |
| 79 | + : activeScore > before.p95 |
| 80 | + ? activeScore - before.p95 |
| 81 | + : 0; |
| 82 | + diagnostics.activeScoreClampCount = (Number(diagnostics.activeScoreClampCount) || 0) + 1; |
| 83 | + diagnostics.activeScoreClampMaxDelta = Math.max(Number(diagnostics.activeScoreClampMaxDelta) || 0, round(delta, 4)); |
| 84 | + if (Array.isArray(diagnostics.activeScoreClampSamples) && diagnostics.activeScoreClampSamples.length < 5) { |
| 85 | + diagnostics.activeScoreClampSamples.push({ |
| 86 | + countryCode: typeof options.countryCode === 'string' ? options.countryCode : undefined, |
| 87 | + formula: normalizeFormula(options.formula) ?? undefined, |
| 88 | + activeScore: round(activeScore, 4), |
| 89 | + before, |
| 90 | + after, |
| 91 | + delta: round(delta, 4), |
| 92 | + }); |
| 93 | + } |
| 94 | +} |
| 95 | + |
| 96 | +function clampToActiveScore(interval, activeScore, options = {}) { |
| 97 | + if (!Number.isFinite(activeScore)) return interval; |
| 98 | + const before = { ...interval }; |
| 99 | + let { p05, p95 } = before; |
| 100 | + if (activeScore < p05) p05 = floorInterval(activeScore); |
| 101 | + if (activeScore > p95) p95 = ceilInterval(activeScore); |
| 102 | + const after = { p05, p95 }; |
| 103 | + recordActiveScoreClamp(options, before, after, activeScore); |
| 104 | + return after; |
| 105 | +} |
| 106 | + |
| 107 | +function percentile(samples, quantile) { |
| 108 | + if (samples.length === 0) return 0; |
| 109 | + const index = Math.min(samples.length - 1, Math.max(0, Math.ceil(samples.length * quantile) - 1)); |
| 110 | + return samples[index]; |
| 111 | +} |
| 112 | + |
| 113 | +function jitterWeights(weights, rng) { |
| 114 | + const jittered = weights.map((w) => w * (0.9 + rng() * 0.2)); |
| 115 | + const sum = jittered.reduce((total, value) => total + value, 0); |
| 116 | + if (!Number.isFinite(sum) || sum <= 0) return weights; |
| 117 | + return jittered.map((w) => w / sum); |
| 118 | +} |
| 119 | + |
| 120 | +export function computeIntervals(domainScores, domainWeights, draws = DRAWS, options = {}) { |
| 121 | + const rng = options.rng ?? Math.random; |
| 122 | + const activeScore = Number(options.activeScore); |
| 123 | + const samples = []; |
| 124 | + const count = Math.max(1, Math.floor(Number(draws) || DRAWS)); |
| 125 | + for (let i = 0; i < count; i++) { |
| 126 | + const normalized = jitterWeights(domainWeights, rng); |
| 127 | + const score = domainScores.reduce((sum, value, index) => sum + value * normalized[index], 0); |
| 128 | + samples.push(score); |
| 129 | + } |
| 130 | + samples.sort((a, b) => a - b); |
| 131 | + return clampToActiveScore({ |
| 132 | + p05: roundInterval(percentile(samples, 0.05)), |
| 133 | + p95: roundInterval(percentile(samples, 0.95)), |
| 134 | + }, activeScore, options); |
| 135 | +} |
| 136 | + |
| 137 | +export function penalizedPillarScore(pillars) { |
| 138 | + if (!pillars.length) return 0; |
| 139 | + const weighted = pillars.reduce((sum, p) => sum + p.score * p.weight, 0); |
| 140 | + const minScore = Math.min(...pillars.map((p) => p.score)); |
| 141 | + const penalty = 1 - PENALTY_ALPHA * (1 - minScore / 100); |
| 142 | + return round(weighted * penalty); |
| 143 | +} |
| 144 | + |
| 145 | +export function computePillarIntervals(pillars, draws = DRAWS, options = {}) { |
| 146 | + const rng = options.rng ?? Math.random; |
| 147 | + const activeScore = Number(options.activeScore); |
| 148 | + const scores = pillars.map((pillar) => Number(pillar.score)); |
| 149 | + const weights = pillars.map((pillar) => Number(pillar.weight)); |
| 150 | + const samples = []; |
| 151 | + const count = Math.max(1, Math.floor(Number(draws) || DRAWS)); |
| 152 | + for (let i = 0; i < count; i++) { |
| 153 | + const normalized = jitterWeights(weights, rng); |
| 154 | + samples.push(penalizedPillarScore(scores.map((score, index) => ({ score, weight: normalized[index] })))); |
| 155 | + } |
| 156 | + samples.sort((a, b) => a - b); |
| 157 | + return clampToActiveScore({ |
| 158 | + p05: roundInterval(percentile(samples, 0.05)), |
| 159 | + p95: roundInterval(percentile(samples, 0.95)), |
| 160 | + }, activeScore, options); |
| 161 | +} |
| 162 | + |
| 163 | +function extractDomains(scoreData) { |
| 164 | + const domains = Array.isArray(scoreData?.domains) ? scoreData.domains : []; |
| 165 | + return DOMAIN_ORDER.map((id) => { |
| 166 | + const domain = domains.find((entry) => entry?.id === id); |
| 167 | + const score = Number(domain?.score); |
| 168 | + const weight = Number(domain?.weight ?? DOMAIN_WEIGHTS[id]); |
| 169 | + if (!Number.isFinite(score) || !Number.isFinite(weight)) return null; |
| 170 | + return { id, score, weight }; |
| 171 | + }).filter(Boolean); |
| 172 | +} |
| 173 | + |
| 174 | +function extractPillars(scoreData) { |
| 175 | + const pillars = Array.isArray(scoreData?.pillars) ? scoreData.pillars : []; |
| 176 | + return PILLAR_ORDER.map((id) => { |
| 177 | + const pillar = pillars.find((entry) => entry?.id === id); |
| 178 | + const score = Number(pillar?.score); |
| 179 | + const weight = Number(pillar?.weight ?? PILLAR_WEIGHTS[id]); |
| 180 | + if (!Number.isFinite(score) || !Number.isFinite(weight)) return null; |
| 181 | + return { id, score, weight }; |
| 182 | + }).filter(Boolean); |
| 183 | +} |
| 184 | + |
| 185 | +export function domainAggregate(domains) { |
| 186 | + if (!domains.length) return null; |
| 187 | + return round(domains.reduce((sum, domain) => sum + domain.score * domain.weight, 0)); |
| 188 | +} |
| 189 | + |
| 190 | +export function inferScoreFormula(scoreData, options = {}) { |
| 191 | + const cached = normalizeFormula(scoreData?._formula); |
| 192 | + if (cached) return cached; |
| 193 | + |
| 194 | + const fallback = normalizeFormula(options.fallbackFormula) ?? currentEnvFormula(); |
| 195 | + const overallScore = Number(scoreData?.overallScore); |
| 196 | + if (!Number.isFinite(overallScore)) return fallback; |
| 197 | + |
| 198 | + const domains = options.domains ?? extractDomains(scoreData); |
| 199 | + const pillars = options.pillars ?? extractPillars(scoreData); |
| 200 | + const d6Score = domainAggregate(domains); |
| 201 | + const pcScore = pillars.length > 0 ? penalizedPillarScore(pillars) : null; |
| 202 | + const d6Diff = d6Score == null ? Number.POSITIVE_INFINITY : Math.abs(overallScore - d6Score); |
| 203 | + const pcDiff = pcScore == null ? Number.POSITIVE_INFINITY : Math.abs(overallScore - pcScore); |
| 204 | + const tolerance = Number(options.tolerance ?? 0.2); |
| 205 | + |
| 206 | + if (pcDiff <= tolerance && d6Diff > tolerance) return 'pc'; |
| 207 | + if (d6Diff <= tolerance && pcDiff > tolerance) return 'd6'; |
| 208 | + if (Number.isFinite(pcDiff) || Number.isFinite(d6Diff)) { |
| 209 | + if (pcDiff + 0.05 < d6Diff) return 'pc'; |
| 210 | + if (d6Diff + 0.05 < pcDiff) return 'd6'; |
| 211 | + } |
| 212 | + // Ambiguous d6 ~= pc ties intentionally fall back to the active seed env. |
| 213 | + // Railway seeders must keep RESILIENCE_PILLAR_COMBINE_ENABLED and |
| 214 | + // RESILIENCE_SCHEMA_V2_ENABLED aligned with the server rollout flags; |
| 215 | + // otherwise an untagged score payload that fits both formulas closely |
| 216 | + // can only be resolved by those env flags. |
| 217 | + return fallback; |
| 218 | +} |
| 219 | + |
| 220 | +export function buildScoreIntervalPayload(scoreData, options = {}) { |
| 221 | + const draws = Math.max(1, Math.floor(Number(options.draws) || DRAWS)); |
| 222 | + const overallScore = Number(scoreData?.overallScore); |
| 223 | + if (!Number.isFinite(overallScore)) return null; |
| 224 | + |
| 225 | + const domains = extractDomains(scoreData); |
| 226 | + const pillars = extractPillars(scoreData); |
| 227 | + const formula = inferScoreFormula(scoreData, { ...options, domains, pillars }); |
| 228 | + |
| 229 | + const interval = formula === 'pc' |
| 230 | + ? (pillars.length > 0 |
| 231 | + ? computePillarIntervals(pillars, draws, { |
| 232 | + rng: options.rng, |
| 233 | + activeScore: overallScore, |
| 234 | + diagnostics: options.diagnostics, |
| 235 | + countryCode: scoreData?.countryCode, |
| 236 | + formula, |
| 237 | + }) |
| 238 | + : null) |
| 239 | + : (domains.length > 0 |
| 240 | + ? computeIntervals( |
| 241 | + domains.map((domain) => domain.score), |
| 242 | + domains.map((domain) => domain.weight), |
| 243 | + draws, |
| 244 | + { |
| 245 | + rng: options.rng, |
| 246 | + activeScore: overallScore, |
| 247 | + diagnostics: options.diagnostics, |
| 248 | + countryCode: scoreData?.countryCode, |
| 249 | + formula, |
| 250 | + }, |
| 251 | + ) |
| 252 | + : null); |
| 253 | + if (!interval) return null; |
| 254 | + |
| 255 | + return { |
| 256 | + p05: interval.p05, |
| 257 | + p95: interval.p95, |
| 258 | + _formula: formula, |
| 259 | + draws, |
| 260 | + computedAt: options.computedAt ?? new Date().toISOString(), |
| 261 | + methodology: RESILIENCE_INTERVAL_METHODOLOGY, |
| 262 | + }; |
| 263 | +} |
0 commit comments