koala73/worldmonitor · error · Error

Invalid disruption override

Error message

Invalid disruption override

What it means

computeScenario() rejects a disruptionPct override that cannot legally apply to the chosen scenario. An override must be an integer in [0,100], and for 'tariff shock' scenarios (those with no affected chokepoints) overrides are forbidden entirely because severity is driven by tariff logic, not a chokepoint disruption percentage.

Solutions

  1. For tariff-shock scenarios, do not pass disruptionPct at all — leave it undefined to use the template default.
  2. Clamp and round overrides before the call: Math.min(100, Math.max(0, Math.round(pct))).
  3. Coerce form/query input to a number first (Number(input)) and check Number.isInteger.
  4. If a percentage override is genuinely needed for tariff scenarios, extend the template/validation logic rather than bypassing the check.

Example fix

// before
await computeScenario('tariff-shock-2025', null, 40); // tariff scenario rejects overrides
await computeScenario('chokepoint-closure', null, 12.5);
// after
const pct = scenarioId.startsWith('tariff') ? undefined : Math.min(100, Math.max(0, Math.round(Number(rawPct))));
await computeScenario(scenarioId, null, pct);
Defensive patterns

Strategy: validation

Validate before calling

const isTariffShock = template.affectedChokepointIds.length === 0;
function sanitizeOverride(rawPct, isTariff) {
  if (rawPct === undefined || rawPct === null || isTariff) return undefined;
  const n = Number(rawPct);
  if (!Number.isInteger(n) || n < 0 || n > 100) throw new Error(`Override must be an integer 0-100, got ${rawPct}`);
  return n;
}

Type guard

const isValidOverride = (v) => v === undefined || (typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 100);

Try / catch

try {
  await computeScenario(id, iso2, pct);
} catch (e) {
  if (e.message === 'Invalid disruption override') {
    log.warn(`Override ${pct} rejected for ${id}; falling back to template default`);
    return computeScenario(id, iso2, undefined);
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling computeScenario for a tariff-shock template (affectedChokepointIds empty) with any disruptionPct other than undefined; or for any scenario with a non-integer (e.g. 12.5), out-of-range (<0 or >100), or non-number (string '50') override.

Common situations: A slider UI producing fractional values; passing a form string instead of a number; assuming overrides apply to all scenario types; sending 0 or 150 from unbounded input; API callers replaying overrides against a tariff scenario id.

Understand the failure class

Background: "value must be between 0 and 1" / "out of range" / "must not be negative" errors: fixing range-validation failures across open-source libraries — this error's family across 42 libraries.

Related errors


AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-09-15). Data as JSON: /api/errors/b52b886229eb702b. Report an issue: GitHub.

Appendix: source

Thrown at scripts/scenario-worker.mjs:236

/** @param {number} score @param {number} severity @param {number} multiplier */
export function physicalImpact(score, severity, multiplier) {
  return score * (severity / 100) * multiplier;
}

/**
 * @param {string} scenarioId
 * @param {string | null} iso2
 * @param {number | undefined} [disruptionPct]
 */
export async function computeScenario(scenarioId, iso2, disruptionPct) {
  const template = SCENARIO_TEMPLATES.find(t => t.id === scenarioId);
  if (!template) throw new Error(`Unknown scenario: ${scenarioId}`);
  const isTariffShock = template.affectedChokepointIds.length === 0;
  if (iso2 !== null && (typeof iso2 !== 'string' || !/^[A-Z]{2}$/.test(iso2))) {
    throw new Error('Invalid country');
  }
  if (disruptionPct !== undefined && (isTariffShock || !Number.isInteger(disruptionPct) || disruptionPct < 0 || disruptionPct > 100)) {
    throw new Error('Invalid disruption override');
  }
  const severity = disruptionPct ?? template.disruptionPct;
  const manifest = await redisGet('seed-meta:supply_chain:chokepoint-exposure').catch(() => null);
  const validIds = (values, pattern, limit) => Array.isArray(values) && values.length > 0
    && values.length <= limit && values.every(v => typeof v === 'string' && pattern.test(v))
    && new Set(values).size === values.length;
  // Deliberately NOT gated on `manifest.status === 'ok'`. The manifest's country/sector
  // arrays describe the seeder's static universe, not the outcome of its last run — a
  // failed run leaves them true while invalidating per-key freshness, which the per-record
  // `missing` state already reports. Requiring 'ok' here turned any single seeder failure
  // into a total feature blackout even though the exposure keys stay TTL-extended.
  const manifestKnown = manifest?.manifestVersion === 1
    && validIds(manifest.countryIds, /^[A-Z]{2}$/, 250)
    && validIds(manifest.hs2Codes, /^(0[1-9]|[1-9][0-9])$/, 99);
  const countryIds = iso2 ? [iso2] : manifestKnown ? manifest.countryIds : [];
  const hs2Codes = template.affectedHs2 ?? (manifestKnown ? manifest.hs2Codes : []);
  const records = [];
  const pending = [];

View on GitHub (pinned to 7d06c8633d)