mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(resilience): sensitivity suite v2 + ceiling-effect detection (Phase 2 T2.6/T2.8) Extend weight-perturbation sensitivity analysis for the three-pillar structure: - Pass 1: domain weight perturbation (+-20%, renormalized) - Pass 2: pillar weight perturbation (+-20%, renormalized) - Pass 3: goalpost perturbation (+-10% of indicator goalposts) - Pass 4: alpha-sensitivity curve (sweep 0.0-1.0 in 0.1 steps) Release gate: block if >20% of dimensions have top-10 rank swing >3 positions under perturbation. Ceiling/floor detection: flags any country whose overall score clips to 100 or 0 under standard perturbation passes. 24 unit tests covering perturbWeights, perturbGoalposts, normalizeToGoalposts, computePenalizedPillarScore, spearmanCorrelation, computeReleaseGate, ceiling detection, computePillarScoresFromDomains, and percentile. * fix(resilience): sensitivity exit code + per-dimension goalpost perturbation (#2991 P1) Two P1 findings: 1. The main catch block set process.exitCode = 0, masking fatal errors as success. Changed to exitCode = 1. 2. The goalpost perturbation pass fed the same country-level swings into every dimId, so computeReleaseGate() could only pass or fail ALL dimensions together. Restructured to perturb one dimension's goalposts at a time and record per-dimension rank swings, making the "fail >20% of dimensions" gate actually diagnostic. * fix(resilience): named pillar scores + stable rank sort (#2991 review) Return Array<{id, score}> from computePillarScoresFromDomains and look up weight by pillarWeights[entry.id] instead of positional index. Add localeCompare tiebreaker in rankCountries for deterministic sort.
514 lines
19 KiB
JavaScript
514 lines
19 KiB
JavaScript
#!/usr/bin/env node
|
|
// Sensitivity analysis v2: weight/goalpost/alpha perturbation + ceiling-effect detection.
|
|
// Extends the original coverage-perturbation Monte Carlo with:
|
|
// Pass 1: Domain weight perturbation (±20%)
|
|
// Pass 2: Pillar weight perturbation (±20%, renormalized)
|
|
// Pass 3: Goalpost perturbation (±10%)
|
|
// Pass 4: Alpha-sensitivity curve (0.0-1.0 in 0.1 steps)
|
|
// Usage: node --import tsx/esm scripts/validate-resilience-sensitivity.mjs
|
|
|
|
import { loadEnvFile } from './_seed-utils.mjs';
|
|
|
|
loadEnvFile(import.meta.url);
|
|
|
|
const NUM_DRAWS = 50;
|
|
const DOMAIN_PERTURBATION = 0.2;
|
|
const PILLAR_PERTURBATION = 0.2;
|
|
const GOALPOST_PERTURBATION = 0.1;
|
|
const TOP_N = 50;
|
|
const RANK_SWING_THRESHOLD = 3;
|
|
const DIMENSION_FAIL_THRESHOLD = 0.20;
|
|
const MIN_SAMPLE = 20;
|
|
|
|
const SAMPLE = [
|
|
'NO','IS','NZ','DK','SE','FI','CH','AU','CA',
|
|
'US','DE','GB','FR','JP','KR','IT','ES','PL',
|
|
'BR','MX','TR','TH','MY','CN','IN','ZA','EG',
|
|
'PK','NG','KE','BD','VN','PH','ID','UA','RU',
|
|
'AF','YE','SO','HT','SS','CF','SD','ML','NE','TD','SY','IQ','MM','VE','IR','ET',
|
|
];
|
|
|
|
export function percentile(sortedValues, p) {
|
|
if (sortedValues.length === 0) return 0;
|
|
const index = (p / 100) * (sortedValues.length - 1);
|
|
const lower = Math.floor(index);
|
|
const upper = Math.ceil(index);
|
|
if (lower === upper) return sortedValues[lower];
|
|
return sortedValues[lower] + (sortedValues[upper] - sortedValues[lower]) * (index - lower);
|
|
}
|
|
|
|
function coverageWeightedMean(dims) {
|
|
const totalCoverage = dims.reduce((s, d) => s + d.coverage, 0);
|
|
if (!totalCoverage) return 0;
|
|
return dims.reduce((s, d) => s + d.score * d.coverage, 0) / totalCoverage;
|
|
}
|
|
|
|
export function perturbWeights(weights, range) {
|
|
const perturbed = {};
|
|
let total = 0;
|
|
for (const [k, v] of Object.entries(weights)) {
|
|
const factor = 1 + (Math.random() * 2 - 1) * range;
|
|
perturbed[k] = v * factor;
|
|
total += perturbed[k];
|
|
}
|
|
for (const k of Object.keys(perturbed)) {
|
|
perturbed[k] /= total;
|
|
}
|
|
return perturbed;
|
|
}
|
|
|
|
export function perturbGoalposts(goalposts, range) {
|
|
const span = Math.abs(goalposts.best - goalposts.worst) || 1;
|
|
const worstShift = (Math.random() * 2 - 1) * range * span;
|
|
const bestShift = (Math.random() * 2 - 1) * range * span;
|
|
return {
|
|
worst: goalposts.worst + worstShift,
|
|
best: goalposts.best + bestShift,
|
|
};
|
|
}
|
|
|
|
export function normalizeToGoalposts(value, goalposts, direction) {
|
|
const { worst, best } = goalposts;
|
|
if (best === worst) return 50;
|
|
const raw = direction === 'higherBetter'
|
|
? (value - worst) / (best - worst)
|
|
: (worst - value) / (worst - best);
|
|
return Math.max(0, Math.min(100, raw * 100));
|
|
}
|
|
|
|
function computeOverallFromDomains(dimensions, dimensionDomains, domainWeights) {
|
|
const grouped = new Map();
|
|
for (const domainId of Object.keys(domainWeights)) grouped.set(domainId, []);
|
|
for (const dim of dimensions) {
|
|
const domainId = dimensionDomains[dim.id];
|
|
if (domainId && grouped.has(domainId)) {
|
|
grouped.get(domainId).push({ score: dim.score, coverage: dim.coverage });
|
|
}
|
|
}
|
|
let overall = 0;
|
|
for (const [domainId, dims] of grouped) {
|
|
overall += coverageWeightedMean(dims) * domainWeights[domainId];
|
|
}
|
|
return overall;
|
|
}
|
|
|
|
export function computePenalizedPillarScore(pillarScores, pillarWeights, alpha) {
|
|
if (pillarScores.length === 0) return 0;
|
|
const weighted = pillarScores.reduce((s, entry) => {
|
|
return s + entry.score * (pillarWeights[entry.id] || 0);
|
|
}, 0);
|
|
const minScore = Math.min(...pillarScores.map((e) => e.score));
|
|
const penalty = 1 - alpha * (1 - minScore / 100);
|
|
return weighted * penalty;
|
|
}
|
|
|
|
export function computePillarScoresFromDomains(dimensions, dimensionDomains, pillarDomains, domainWeights) {
|
|
const domainScores = {};
|
|
const grouped = new Map();
|
|
for (const domainId of Object.keys(domainWeights)) grouped.set(domainId, []);
|
|
for (const dim of dimensions) {
|
|
const domainId = dimensionDomains[dim.id];
|
|
if (domainId && grouped.has(domainId)) {
|
|
grouped.get(domainId).push({ score: dim.score, coverage: dim.coverage });
|
|
}
|
|
}
|
|
for (const [domainId, dims] of grouped) {
|
|
domainScores[domainId] = coverageWeightedMean(dims);
|
|
}
|
|
|
|
const pillarScores = [];
|
|
for (const [pillarId, domainIds] of Object.entries(pillarDomains)) {
|
|
const scores = domainIds.map((d) => domainScores[d] || 0);
|
|
const weights = domainIds.map((d) => domainWeights[d] || 0);
|
|
const totalW = weights.reduce((s, w) => s + w, 0);
|
|
const pillarScore = totalW > 0
|
|
? scores.reduce((s, sc, i) => s + sc * weights[i], 0) / totalW
|
|
: 0;
|
|
pillarScores.push({ id: pillarId, score: pillarScore });
|
|
}
|
|
return pillarScores;
|
|
}
|
|
|
|
function rankCountries(scores) {
|
|
const sorted = Object.entries(scores)
|
|
.sort(([a, scoreA], [b, scoreB]) => scoreB - scoreA || a.localeCompare(b));
|
|
const ranks = {};
|
|
for (let i = 0; i < sorted.length; i++) {
|
|
ranks[sorted[i][0]] = i + 1;
|
|
}
|
|
return ranks;
|
|
}
|
|
|
|
export function spearmanCorrelation(ranksA, ranksB) {
|
|
const keys = Object.keys(ranksA).filter((k) => k in ranksB);
|
|
const n = keys.length;
|
|
if (n < 2) return 1;
|
|
const dSqSum = keys.reduce((s, k) => s + (ranksA[k] - ranksB[k]) ** 2, 0);
|
|
return 1 - (6 * dSqSum) / (n * (n * n - 1));
|
|
}
|
|
|
|
export function computeReleaseGate(dimensionResults) {
|
|
const failCount = dimensionResults.filter((d) => !d.pass).length;
|
|
const failPct = dimensionResults.length > 0 ? failCount / dimensionResults.length : 0;
|
|
return {
|
|
pass: failPct <= DIMENSION_FAIL_THRESHOLD,
|
|
failCount,
|
|
failPct: Math.round(failPct * 1000) / 1000,
|
|
threshold: DIMENSION_FAIL_THRESHOLD,
|
|
};
|
|
}
|
|
|
|
async function run() {
|
|
const {
|
|
scoreAllDimensions,
|
|
RESILIENCE_DIMENSION_ORDER,
|
|
RESILIENCE_DIMENSION_DOMAINS,
|
|
getResilienceDomainWeight,
|
|
RESILIENCE_DOMAIN_ORDER,
|
|
createMemoizedSeedReader,
|
|
} = await import('../server/worldmonitor/resilience/v1/_dimension-scorers.ts');
|
|
|
|
const {
|
|
listScorableCountries,
|
|
PENALTY_ALPHA,
|
|
penalizedPillarScore,
|
|
} = await import('../server/worldmonitor/resilience/v1/_shared.ts');
|
|
|
|
const {
|
|
PILLAR_DOMAINS,
|
|
PILLAR_WEIGHTS,
|
|
PILLAR_ORDER,
|
|
} = await import('../server/worldmonitor/resilience/v1/_pillar-membership.ts');
|
|
|
|
const {
|
|
INDICATOR_REGISTRY,
|
|
} = await import('../server/worldmonitor/resilience/v1/_indicator-registry.ts');
|
|
|
|
const domainWeights = {};
|
|
for (const domainId of RESILIENCE_DOMAIN_ORDER) {
|
|
domainWeights[domainId] = getResilienceDomainWeight(domainId);
|
|
}
|
|
|
|
const scorableCountries = await listScorableCountries();
|
|
const validSample = SAMPLE.filter((c) => scorableCountries.includes(c));
|
|
const skipped = SAMPLE.filter((c) => !scorableCountries.includes(c));
|
|
|
|
if (skipped.length > 0) {
|
|
console.log(`Skipping ${skipped.length} countries not in scorable set: ${skipped.join(', ')}`);
|
|
}
|
|
console.log(`Scoring ${validSample.length} countries from live Redis...\n`);
|
|
|
|
const sharedReader = createMemoizedSeedReader();
|
|
const countryData = [];
|
|
|
|
for (const countryCode of validSample) {
|
|
const scoreMap = await scoreAllDimensions(countryCode, sharedReader);
|
|
const dimensions = RESILIENCE_DIMENSION_ORDER.map((dimId) => ({
|
|
id: dimId,
|
|
score: scoreMap[dimId].score,
|
|
coverage: scoreMap[dimId].coverage,
|
|
}));
|
|
countryData.push({ countryCode, dimensions });
|
|
}
|
|
|
|
if (countryData.length < MIN_SAMPLE) {
|
|
console.error(`FATAL: Only ${countryData.length} countries scored (need >= ${MIN_SAMPLE}). Redis may be degraded.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`Scored ${countryData.length} countries. Running sensitivity passes...\n`);
|
|
|
|
const baselineScores = {};
|
|
for (const cd of countryData) {
|
|
const pillarScores = computePillarScoresFromDomains(
|
|
cd.dimensions, RESILIENCE_DIMENSION_DOMAINS, PILLAR_DOMAINS, domainWeights
|
|
);
|
|
baselineScores[cd.countryCode] = computePenalizedPillarScore(
|
|
pillarScores, PILLAR_WEIGHTS, PENALTY_ALPHA
|
|
);
|
|
}
|
|
const baselineRanks = rankCountries(baselineScores);
|
|
const topNCountries = Object.entries(baselineRanks)
|
|
.sort(([, a], [, b]) => a - b)
|
|
.slice(0, TOP_N)
|
|
.map(([cc]) => cc);
|
|
|
|
const ceilingEffects = [];
|
|
|
|
function detectCeiling(scores, passName) {
|
|
for (const [cc, score] of Object.entries(scores)) {
|
|
if (score >= 100) ceilingEffects.push({ countryCode: cc, score, pass: passName, type: 'ceiling' });
|
|
if (score <= 0) ceilingEffects.push({ countryCode: cc, score, pass: passName, type: 'floor' });
|
|
}
|
|
}
|
|
|
|
function computeMaxSwings(perturbedRanks, baseRanks, topCountries) {
|
|
const swings = {};
|
|
for (const cc of topCountries) {
|
|
const base = baseRanks[cc];
|
|
const perturbed = perturbedRanks[cc];
|
|
if (base != null && perturbed != null) {
|
|
swings[cc] = Math.abs(perturbed - base);
|
|
}
|
|
}
|
|
return swings;
|
|
}
|
|
|
|
// Pass 1: Domain weight perturbation
|
|
console.log(`=== PASS 1: Domain weight perturbation (±${DOMAIN_PERTURBATION * 100}%, ${NUM_DRAWS} draws) ===`);
|
|
const domainWeightSwings = {};
|
|
for (const cc of topNCountries) domainWeightSwings[cc] = [];
|
|
|
|
for (let draw = 0; draw < NUM_DRAWS; draw++) {
|
|
const pWeights = perturbWeights(domainWeights, DOMAIN_PERTURBATION);
|
|
const scores = {};
|
|
for (const cd of countryData) {
|
|
const ps = computePillarScoresFromDomains(
|
|
cd.dimensions, RESILIENCE_DIMENSION_DOMAINS, PILLAR_DOMAINS, pWeights
|
|
);
|
|
scores[cd.countryCode] = computePenalizedPillarScore(ps, PILLAR_WEIGHTS, PENALTY_ALPHA);
|
|
}
|
|
detectCeiling(scores, 'domainWeights');
|
|
const ranks = rankCountries(scores);
|
|
const swings = computeMaxSwings(ranks, baselineRanks, topNCountries);
|
|
for (const cc of topNCountries) {
|
|
domainWeightSwings[cc].push(swings[cc] || 0);
|
|
}
|
|
}
|
|
|
|
const domainMaxSwing = Math.max(
|
|
...topNCountries.map((cc) => Math.max(...(domainWeightSwings[cc] || [0])))
|
|
);
|
|
console.log(` Max top-${TOP_N} rank swing: ${domainMaxSwing}`);
|
|
|
|
// Pass 2: Pillar weight perturbation
|
|
console.log(`\n=== PASS 2: Pillar weight perturbation (±${PILLAR_PERTURBATION * 100}%, ${NUM_DRAWS} draws) ===`);
|
|
const pillarWeightSwings = {};
|
|
for (const cc of topNCountries) pillarWeightSwings[cc] = [];
|
|
|
|
for (let draw = 0; draw < NUM_DRAWS; draw++) {
|
|
const pPillarWeights = perturbWeights(PILLAR_WEIGHTS, PILLAR_PERTURBATION);
|
|
const scores = {};
|
|
for (const cd of countryData) {
|
|
const ps = computePillarScoresFromDomains(
|
|
cd.dimensions, RESILIENCE_DIMENSION_DOMAINS, PILLAR_DOMAINS, domainWeights
|
|
);
|
|
scores[cd.countryCode] = computePenalizedPillarScore(ps, pPillarWeights, PENALTY_ALPHA);
|
|
}
|
|
detectCeiling(scores, 'pillarWeights');
|
|
const ranks = rankCountries(scores);
|
|
const swings = computeMaxSwings(ranks, baselineRanks, topNCountries);
|
|
for (const cc of topNCountries) {
|
|
pillarWeightSwings[cc].push(swings[cc] || 0);
|
|
}
|
|
}
|
|
|
|
const pillarMaxSwing = Math.max(
|
|
...topNCountries.map((cc) => Math.max(...(pillarWeightSwings[cc] || [0])))
|
|
);
|
|
console.log(` Max top-${TOP_N} rank swing: ${pillarMaxSwing}`);
|
|
|
|
// Pass 3: Goalpost perturbation
|
|
console.log(`\n=== PASS 3: Goalpost perturbation (±${GOALPOST_PERTURBATION * 100}%, ${NUM_DRAWS} draws) ===`);
|
|
const goalpostSwings = {};
|
|
for (const cc of topNCountries) goalpostSwings[cc] = [];
|
|
const perDimensionSwings = {};
|
|
for (const dimId of RESILIENCE_DIMENSION_ORDER) perDimensionSwings[dimId] = [];
|
|
|
|
for (let draw = 0; draw < NUM_DRAWS; draw++) {
|
|
const perturbedDims = countryData.map((cd) => {
|
|
const newDims = cd.dimensions.map((dim) => {
|
|
const indicators = INDICATOR_REGISTRY.filter((ind) => ind.dimension === dim.id);
|
|
if (indicators.length === 0) return { ...dim };
|
|
let totalWeight = 0;
|
|
let weightedScore = 0;
|
|
for (const ind of indicators) {
|
|
const pg = perturbGoalposts(ind.goalposts, GOALPOST_PERTURBATION);
|
|
const rawScore = normalizeToGoalposts(
|
|
inverseNormalize(dim.score, ind.goalposts, ind.direction),
|
|
pg,
|
|
ind.direction
|
|
);
|
|
weightedScore += rawScore * ind.weight;
|
|
totalWeight += ind.weight;
|
|
}
|
|
const newScore = totalWeight > 0 ? weightedScore / totalWeight : dim.score;
|
|
return { ...dim, score: Math.max(0, Math.min(100, newScore)) };
|
|
});
|
|
return { countryCode: cd.countryCode, dimensions: newDims };
|
|
});
|
|
|
|
const scores = {};
|
|
for (const cd of perturbedDims) {
|
|
const ps = computePillarScoresFromDomains(
|
|
cd.dimensions, RESILIENCE_DIMENSION_DOMAINS, PILLAR_DOMAINS, domainWeights
|
|
);
|
|
scores[cd.countryCode] = computePenalizedPillarScore(ps, PILLAR_WEIGHTS, PENALTY_ALPHA);
|
|
}
|
|
detectCeiling(scores, 'goalposts');
|
|
const ranks = rankCountries(scores);
|
|
const swings = computeMaxSwings(ranks, baselineRanks, topNCountries);
|
|
for (const cc of topNCountries) {
|
|
goalpostSwings[cc].push(swings[cc] || 0);
|
|
}
|
|
}
|
|
|
|
for (const dimId of RESILIENCE_DIMENSION_ORDER) {
|
|
const dimIndicators = INDICATOR_REGISTRY.filter((ind) => ind.dimension === dimId);
|
|
if (dimIndicators.length === 0) continue;
|
|
const perturbedDims = countryData.map((cd) => {
|
|
const newDims = cd.dimensions.map((dim) => {
|
|
if (dim.id !== dimId) return { ...dim };
|
|
let totalWeight = 0;
|
|
let weightedScore = 0;
|
|
for (const ind of dimIndicators) {
|
|
const pg = perturbGoalposts(ind.goalposts, GOALPOST_PERTURBATION);
|
|
const rawScore = normalizeToGoalposts(
|
|
inverseNormalize(dim.score, ind.goalposts, ind.direction),
|
|
pg,
|
|
ind.direction
|
|
);
|
|
weightedScore += rawScore * ind.weight;
|
|
totalWeight += ind.weight;
|
|
}
|
|
const newScore = totalWeight > 0 ? weightedScore / totalWeight : dim.score;
|
|
return { ...dim, score: Math.max(0, Math.min(100, newScore)) };
|
|
});
|
|
return { countryCode: cd.countryCode, dimensions: newDims };
|
|
});
|
|
const dimScores = {};
|
|
for (const cd of perturbedDims) {
|
|
const ps = computePillarScoresFromDomains(
|
|
cd.dimensions, RESILIENCE_DIMENSION_DOMAINS, PILLAR_DOMAINS, domainWeights
|
|
);
|
|
dimScores[cd.countryCode] = computePenalizedPillarScore(ps, PILLAR_WEIGHTS, PENALTY_ALPHA);
|
|
}
|
|
const dimRanks = rankCountries(dimScores);
|
|
const dimSwings = computeMaxSwings(dimRanks, baselineRanks, topNCountries);
|
|
const maxDimSwing = Math.max(...topNCountries.slice(0, 10).map((cc) => dimSwings[cc] || 0), 0);
|
|
perDimensionSwings[dimId].push(maxDimSwing);
|
|
}
|
|
|
|
const goalpostMaxSwing = Math.max(
|
|
...topNCountries.map((cc) => Math.max(...(goalpostSwings[cc] || [0])))
|
|
);
|
|
console.log(` Max top-${TOP_N} rank swing: ${goalpostMaxSwing}`);
|
|
|
|
// Pass 4: Alpha sensitivity curve
|
|
console.log(`\n=== PASS 4: Alpha sensitivity curve (0.0 to 1.0, step 0.1) ===`);
|
|
const baseAlphaRanks = {};
|
|
for (const cd of countryData) {
|
|
const ps = computePillarScoresFromDomains(
|
|
cd.dimensions, RESILIENCE_DIMENSION_DOMAINS, PILLAR_DOMAINS, domainWeights
|
|
);
|
|
baseAlphaRanks[cd.countryCode] = computePenalizedPillarScore(ps, PILLAR_WEIGHTS, 0.5);
|
|
}
|
|
const baseAlphaRanked = rankCountries(baseAlphaRanks);
|
|
|
|
const alphaSensitivity = [];
|
|
for (let alphaStep = 0; alphaStep <= 10; alphaStep++) {
|
|
const alpha = Math.round(alphaStep * 10) / 100;
|
|
const scores = {};
|
|
for (const cd of countryData) {
|
|
const ps = computePillarScoresFromDomains(
|
|
cd.dimensions, RESILIENCE_DIMENSION_DOMAINS, PILLAR_DOMAINS, domainWeights
|
|
);
|
|
scores[cd.countryCode] = computePenalizedPillarScore(ps, PILLAR_WEIGHTS, alpha);
|
|
}
|
|
const ranks = rankCountries(scores);
|
|
const spearman = spearmanCorrelation(baseAlphaRanked, ranks);
|
|
const maxSwing = Math.max(
|
|
...topNCountries.map((cc) => Math.abs((ranks[cc] || 0) - (baseAlphaRanked[cc] || 0)))
|
|
);
|
|
alphaSensitivity.push({
|
|
alpha,
|
|
spearmanVs05: Math.round(spearman * 10000) / 10000,
|
|
maxTop50Swing: maxSwing,
|
|
});
|
|
}
|
|
|
|
console.log(' alpha | spearman_vs_0.5 | max_top50_swing');
|
|
console.log(' ------+-----------------+----------------');
|
|
for (const row of alphaSensitivity) {
|
|
console.log(` ${row.alpha.toFixed(1).padStart(5)} | ${row.spearmanVs05.toFixed(4).padStart(15)} | ${String(row.maxTop50Swing).padStart(14)}`);
|
|
}
|
|
|
|
// Dimension stability
|
|
const dimensionResults = RESILIENCE_DIMENSION_ORDER.map((dimId) => {
|
|
const maxSwing = perDimensionSwings[dimId]?.length > 0
|
|
? Math.max(...perDimensionSwings[dimId])
|
|
: 0;
|
|
return { dimId, maxSwing, pass: maxSwing <= RANK_SWING_THRESHOLD };
|
|
});
|
|
|
|
const releaseGate = computeReleaseGate(dimensionResults);
|
|
|
|
console.log('\n=== DIMENSION STABILITY (goalpost perturbation, top-10 rank swing) ===');
|
|
for (const dr of dimensionResults) {
|
|
console.log(` ${dr.dimId.padEnd(25)} maxSwing=${dr.maxSwing} ${dr.pass ? 'PASS' : 'FAIL'}`);
|
|
}
|
|
|
|
console.log(`\n=== RELEASE GATE ===`);
|
|
console.log(` Threshold: >${releaseGate.threshold * 100}% of dimensions failing (swing > ${RANK_SWING_THRESHOLD} ranks)`);
|
|
console.log(` Failed: ${releaseGate.failCount}/${dimensionResults.length} (${(releaseGate.failPct * 100).toFixed(1)}%)`);
|
|
console.log(` Result: ${releaseGate.pass ? 'PASS' : 'FAIL'}`);
|
|
|
|
// Ceiling effects
|
|
const uniqueCeilings = [];
|
|
const seen = new Set();
|
|
for (const ce of ceilingEffects) {
|
|
const key = `${ce.countryCode}:${ce.type}`;
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
uniqueCeilings.push(ce);
|
|
}
|
|
}
|
|
|
|
if (uniqueCeilings.length > 0) {
|
|
console.log(`\n=== CEILING/FLOOR EFFECTS (${uniqueCeilings.length} unique) ===`);
|
|
for (const ce of uniqueCeilings.slice(0, 20)) {
|
|
console.log(` ${ce.countryCode} ${ce.type} score=${ce.score.toFixed(2)} pass=${ce.pass}`);
|
|
}
|
|
} else {
|
|
console.log('\n=== CEILING/FLOOR EFFECTS: None detected ===');
|
|
}
|
|
|
|
const result = {
|
|
generatedAt: Date.now(),
|
|
passes: {
|
|
domainWeights: { maxSwing: domainMaxSwing, pass: domainMaxSwing <= RANK_SWING_THRESHOLD * 2 },
|
|
pillarWeights: { maxSwing: pillarMaxSwing, pass: pillarMaxSwing <= RANK_SWING_THRESHOLD * 2 },
|
|
goalposts: { maxSwing: goalpostMaxSwing, pass: goalpostMaxSwing <= RANK_SWING_THRESHOLD * 2 },
|
|
},
|
|
alphaSensitivity,
|
|
dimensionStability: dimensionResults,
|
|
releaseGate,
|
|
ceilingEffects: uniqueCeilings,
|
|
};
|
|
|
|
console.log(`\nSensitivity analysis v2 complete.`);
|
|
return result;
|
|
}
|
|
|
|
function inverseNormalize(normalizedScore, goalposts, direction) {
|
|
const { worst, best } = goalposts;
|
|
if (best === worst) return worst;
|
|
if (direction === 'higherBetter') {
|
|
return worst + (normalizedScore / 100) * (best - worst);
|
|
}
|
|
return worst - (normalizedScore / 100) * (worst - best);
|
|
}
|
|
|
|
const isMain = process.argv[1]?.endsWith('validate-resilience-sensitivity.mjs');
|
|
if (isMain) {
|
|
run().then((_result) => {
|
|
console.log('\nJSON output written to stdout (pipe to file if needed).');
|
|
process.exit(0);
|
|
}).catch((err) => {
|
|
console.error('Sensitivity analysis failed:', err);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
export { run };
|