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.
Two things, both honest:
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:
shap.Explainer
permutation algorithm on the calibrated XGBoost +
isotonic predict_proba. Constant across days
because the model takes exactly one country feature vector
per call.
/v1/forecast/{cc}/7day-shap that
returns ONLY the per-day attribution for callers that do not need
the full forecast envelope.
| Day | Date | Risk | Top-3 contributions |
|---|---|---|---|
| 0 | 2026-05-21 | 0.030 | Iranian Election in 11d (+0.035) |
| 1 | 2026-05-22 | 0.060 | Iranian Election in 10d (+0.038), day_decay_t+1 (+0.005) |
| 3 | 2026-05-24 | 0.049 | Iranian Election in 8d (+0.044), day_decay_t+3 (+0.015) |
| 5 | 2026-05-26 | 0.090 | Iranian Election in 6d (+0.054), day_decay_t+5 (+0.025) |
| 7 | 2026-05-28 | 0.121 | Iranian 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.
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:
| Day | Risk | Top-3 contributions |
|---|---|---|
| 0 | 0.031 | Tiananmen Anniversary in 14d (+0.019, overlay), block_rate_roll30_mean (-0.019, model), gdelt_unrest_30d (+0.016, model) |
| 4 | 0.055 | Tiananmen in 10d (+0.025, overlay), day_decay_t+4 (+0.020, overlay), block_rate_roll30_mean (-0.019, model) |
| 7 | 0.066 | day_decay_t+7 (+0.035, overlay), Tiananmen in 7d (+0.032, overlay), block_rate_roll30_mean (-0.019, model) |
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.
Inline in every /7day-shap response, in the
honest_caveats field:
predict_proba
(post-isotonic). For watched-set countries the per-country
piecewise calibrator runs first; SHAP still maps onto the
post-calibration probability, not the raw XGBoost output.
/v1/forecast/{cc}/multi-horizon. That is a
DIFFERENT codepath (three XGBoost models, one per horizon) and
its attribution carries different semantics.
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.