Vigil
← All news

v1.17.1 — Auto-refresh fight_model on report ingest

v1.17.0 unified cloned encounter IDs but the fight_model only rebuilt when an operator explicitly POSTed `/api/encounters/{id}/fight-model/persist` (then `classify` + `annotate-ca…

Why

v1.17.0 unified cloned encounter IDs but the fight_model only rebuilt when an operator explicitly POSTed /api/encounters/{id}/fight-model/persist (then classify + annotate-cactbot). The user wants this auto-triggered on each new report ingest so every fresh pull contributes to the canonical model immediately — particularly important during new-ultimate prog where every kill is rare and load-bearing.

Added — analysis/fight_model_refresh.py

  • refresh_fight_model_for_encounter(session, encounter_id, *, throttle_seconds=60, force=False) — runs the 3-step pipeline write_consensus_to_fight_modelclassify_canonical_abilitiesannotate_fight_model_for_encounter for one canonical encounter (v1.17.0 helpers canonicalize input). Returns a structured summary {encounter_id, skipped, persist?, classify?, annotate?, error?}.
  • Throttle: queries MAX(FightModel.updated_at) for the canonical encounter; skips if last refresh < throttle_seconds ago. State lives in the table itself, so the throttle is durable across server restarts.
  • skipped outcomes: "throttle" (recent refresh), "no_data" (consensus produced 0 rows — need ≥3 kills with events), "error_persist" / "error_classify" / "error_annotate" (each step's failure is captured + rolled back without crashing the caller), or None on full success.
  • refresh_for_report(session, code, ...) — convenience wrapper that finds every canonical encounter the report has fights for and refreshes each. Used by the poll path; returns one entry per encounter touched.
  • encounter_ids_for_report — exported as a helper so callers can pre-filter.

Changed — jobs/poll_watched._poll_one_row auto-refreshes on success

After ingest_events_for_report commits, the poll path now calls refresh_for_report for any encounter this report contributes to. Only fires when meta.new_fights > 0 OR ev.events_inserted > 0 (no-op polls skip the refresh entirely). The new optional kwarg auto_refresh_fight_model=True lets tests / ad-hoc scripts bypass.

Failure semantics: refresh errors land in the response as fight_model_refresh_error but don't flip the poll's status away from "ok" — the ingest already committed. This applies to both the live poller (poll_once) and the Poll-now endpoint (POST /api/watched-reports/{code}/poll), since both go through _poll_one_row.

Changed — jobs/backfill_field.backfill_once refreshes per-encounter

After each encounter's reports + events are pulled, the canonical fight_model refreshes with force=True (nightly cadence already debounces; we want every backfill pass to land in the model). Failures land in per_enc["errors"] without aborting the encounter loop. Opt-out via the new auto_refresh_fight_model=False kwarg.

Cost note

classify_canonical_abilities re-scans every kill's damage events on each refresh. On an Ultimate with hundreds of kills this is multi-second. Acceptable at the 60s debounce default (≤1× per encounter per polling cycle); if a future deployment polls every ~30s during prog sessions, incremental classification could be added later. Not in scope for v1.17.1.

Tests

  • 8 new in tests/test_fight_model_refresh_v1_17_1.py: end-to-end (persist + classify + annotate), throttle skips recent runs, force=True bypasses throttle, no_data outcome on encounters with too few kills, refresh_for_report returns one per canonical encounter, canonicalizes legacy aliases, empty result for unknown report, poll path skips refresh when no new data was ingested, auto_refresh_fight_model=False disables the wire-in.
  • 536 tests passing (528 → 536, +8). No regressions.

Operator notes

  • No schema migration, no new env vars. Existing operators get auto-refresh for free on next Python restart.
  • The 60s throttle is per-canonical-encounter; a multi-encounter prog day (running both M9S + DSR) refreshes both independently.
  • Field backfill bypasses the throttle on purpose — the nightly cadence makes the debounce moot, and we want maximum model freshness from each pass.