Why per-day SHAP, and why now

The Voidly Atlas 7-day shutdown forecast at /v1/forecast/{cc}/7day has shipped probabilities since February 2026, and a single aggregate top_features field of three SHAP contributions since the sentinel trust layer landed in April. That aggregate is honest about what is driving today's risk estimate. It is not honest about why day 5 is higher than day 0.

Journalists hit that wall every time. "Iran's election is in 12 days, so the forecast trends up — show me the day that flipped." Until this finding shipped, the only answer was a hand-wave: "there's an event boost the model doesn't see, applied in the loop after the prediction." Correct, but useless for citation.

What the endpoint returns

Two things, both honest:

  1. A new top_features_per_day array on the existing /v1/forecast/{cc}/7day response, aligned 1:1 with the 8-day forecast list. Each entry exposes the top-3 signed contributions for that day, ranked across two pools:
  2. A new lean endpoint /v1/forecast/{cc}/7day-shap that returns ONLY the per-day attribution for callers that do not need the full forecast envelope.

Sample: Iran, 2026-05-21

DayDateRiskTop-3 contributions
02026-05-210.030Iranian Election in 11d (+0.035)
12026-05-220.060Iranian Election in 10d (+0.038), day_decay_t+1 (+0.005)
32026-05-240.049Iranian Election in 8d (+0.044), day_decay_t+3 (+0.015)
52026-05-260.090Iranian Election in 6d (+0.054), day_decay_t+5 (+0.025)
72026-05-280.121Iranian Election in 4d (+0.068), day_decay_t+7 (+0.035)

Notice that the model-side contributions (ooni_anomaly_7d, block_rate_roll30_mean, block_rate_lag1) do not appear in any day's top-3. That is not a bug — for Iran on this date, the deterministic event boost dominates the model contributions in absolute magnitude. Those model contributions are still returned separately as the aggregate top_features field (and as top_features_aggregate on the lean 7day-shap endpoint) so the caller can see both layers.

Sample: China, 2026-05-21 (Tiananmen anniversary)

Here the model and overlay are both visible in the per-day output, because the overlay event is further out and grows day-over-day:

DayRiskTop-3 contributions
00.031Tiananmen Anniversary in 14d (+0.019, overlay), block_rate_roll30_mean (-0.019, model), gdelt_unrest_30d (+0.016, model)
40.055Tiananmen in 10d (+0.025, overlay), day_decay_t+4 (+0.020, overlay), block_rate_roll30_mean (-0.019, model)
70.066day_decay_t+7 (+0.035, overlay), Tiananmen in 7d (+0.032, overlay), block_rate_roll30_mean (-0.019, model)

Caching and latency

The model SHAP is cached in the sentinel_trust module per (model_version, feature_vector_hash) — same key strategy as the existing aggregate top_features. The per-day attribution is cached per (country, hour) on the forecast service, capped at 256 entries with LRU eviction. The forecast itself re-runs hourly, so 6h cache TTL gives ~6 cache hits per cycle.

Latency overhead added to the upstream service: under 1ms p95 (within noise on a localhost benchmark). The permutation explainer costs ~3ms on its first call after a process restart, then caches. Per-day pool construction is pure Python list-sort with 5-10 candidates per day — negligible.

Honest caveats

Inline in every /7day-shap response, in the honest_caveats field:

Implementation

scripts/patch-forecast-per-day-shap.py — idempotent patcher that splices into forecast_api.py: captures per-day boost/decay/noise inside the existing forecast loop, computes the per-day SHAP pool after the loop using the already- computed model SHAP, and registers the new /v1/forecast/{cc}/7day-shap Flask route.

API Worker handleForecast7daySHAP() — thin proxy through the API gateway with 1h Cache-Control + 502 fallback. Registered in the Worker’s router ahead of the existing /7day$ regex with the same per-resource rate limit and the same 402-on- rate-limit conversion as the rest of the forecast family.

OpenAPI updated at /openapi.json with a new summary for /v1/forecast/{country}/7day-shap.