Vigil
← All news

v1.16.1 — Near-wall plateau + non-attributable inference + scoped contributors

[analysis/fault_attribution.py](analysis/fault_attribution.py). New `NEAR_WALL_TOLERANCE = 0.5` constant: when a wipe's prog distance is within half a unit of the running-best wal…

Changed — _prog_relevance gains a near-wall plateau

analysis/fault_attribution.py. New NEAR_WALL_TOLERANCE = 0.5 constant: when a wipe's prog distance is within half a unit of the running-best wall (e.g. a P4 fp=50% wipe vs P5 wall, delta=0.5), prog relevance stays at 1.0. Decay only kicks in BEYOND the plateau. Previously even a tiny backslide got immediate exp decay: P4 fp=50% was 0.78×; now 1.0×. The math beyond the plateau is max(0.3, exp(-PROG_DECAY_K * (delta - NEAR_WALL_TOLERANCE))) with PROG_DECAY_K = 0.3 — same K as v1.16.0, gentler effective curve because the plateau eats the first half-unit. fight_score_multiplier(3, 50, 5) went from 1.48 to 1.72.

Changed — running-best is now continuous, not integer phase

In fault_aggregate_for_encounter, the running-best tracker switched from integer last_phase to continuous prog_distance = phase + (1 - fp/100). A pull that reaches P5 fp=20% (prog 5.8) sets the wall higher than a pull that reached P5 fp=80% (prog 5.2). Subsequent wipes are de-weighted relative to actual furthest progress, not just phase number. is_past_wall similarly uses the prog delta + the NEAR_WALL_TOLERANCE so a wipe within half a unit of the wall isn't treated as past-wall for the repeat-offender amplifier either.

Added — non-attributable death inference

analysis/fault_attribution.py gains two helpers: _infer_killer_from_cast_proximity (most recent enemy cast within INFER_LOOKBACK_MS = 8000 whose type_label is raidwide / aoe_party / tankbuster / enrage) and _infer_killer_from_cactbot_drift (per-phase cactbot expected_t + this pull's median drift, ±INFER_CACTBOT_TOLERANCE_MS = 2500).

compute_fault_scores_for_fight runs the inference for every death with ability_game_id is None and source_id = -1 (FFLogs' "could not attribute" pattern). The inferred (ability_id, label) is fed into _death_kind so the death classifies as root / mit_failure / heal_failure / cascade based on the real mechanic it most likely hit — instead of defaulting to cascade. The original ability_game_id = None is preserved in the death record alongside three new fields:

  • inferred_ability_id — the guess (int or null)
  • inferred_ability_label — the type_label from fight_model
  • inferred_from"cast_proximity" | "cactbot_drift" | null (no match)

Cactbot fallback is lazy-loaded: only fights that actually have non-attributable deaths trigger the cactbot timeline + timeline_diff + phase-boundary queries. Most fights pay zero cost.

Added — scoped top contributors per player

analysis/fault_attribution.py fault_aggregate_for_encounter now emits per-player scoped_top_contributors: list[{name, member_id, score}] and scoped_wipes_count: int. Computed by:

  1. Building per-(player, fight) weighted scores during the existing aggregate loop.
  2. Folding into member-aware identities (player_id alone if no member; member_id otherwise — sub-accounts merge with their main).
  3. For each focal identity, summing OTHER identities' weighted contributions across the focal's attended fight_ids.
  4. Top 5 by total contribution, self excluded.

Home row expansion (web/src/Home.jsx) renders this as "Top contributors across the N wipes {focal} attended" — answers "in Alice's attended wipes, who's actually driving the score?" Useful when someone has uneven attendance: a player who shows clean overall might still be in the room when their static's real problem-spot keeps wiping.

Fixed — worst_wipes.fight_percentage crashed the row expansion

The v1.16.0 worst_wipes payload was serializing fight_percentage as a JSON string (SQLAlchemy Numeric → Python Decimal → FastAPI JSON encoder emits quoted). Frontend's w.fight_percentage.toFixed(1) threw on a string. Cast to float in the backend before stashing in worst_wipes; frontend also wraps every numeric field in Number() as a safety net.

Tests

  • 6 in tests/test_nonattributable_inference_v1_16_1.py — cast-proximity matching (most-recent wins; outside-window skipped; future-casts ignored; no-actionable returns None) + end-to-end reclassification + cascade fallback when no match.
  • 3 in tests/test_scoped_contributors_v1_16_1.py — fields present on every player, full-attendance sees global ranking, partial-attendance filters out unseen wipes.
  • 1 new + several updated in test_fault_phase_weighting_v1_14_5.py for the plateau + softer curve.
  • 509 tests passing (499 → 509, +10).

Note on roster-classification reset

The user reported that v1.15.0 roster classifications (core / sub / substitute attributions on the Roster page) were getting reset on browser refresh. Root cause: the pre-v1.15.1 autouse _clean_roster fixture in tests/test_roster_api.py ran delete(Member) globally, wiping any real user-curated members alongside test fixtures whenever the test suite ran. The fix landed in v1.15.1 (scoped cleanup) but historic data lost during pre-v1.15.1 test runs is unrecoverable. The 6 IgnoredCharacter rows in Default Static survived because they're in a separate table. Re-classification needed.