# Historical Match Kills Per Minute Analysis ## Scope This document defines how real historical KPM works for the match detail served to `historico-partida.html`. ## Why KPM Was Not Safe Before Historical match detail already exposed: - per player: - `kills` - `deaths` - `teamkills` - weapon and matchup counters - per match: - `started_at` - `ended_at` - `duration_seconds` What it did not expose was player-level active time. Because of that, dividing by total match duration would have produced a false player KPM. ## Real KPM Rule Real KPM means: ```text kills / (player_active_seconds / 60) ``` It does not mean: ```text kills / match_duration_minutes ``` ## Source of Truth The current implementation uses connection intervals reconstructed from the materialized RCON AdminLog match model. Reliable interval signals: - `connected` - `disconnected` - `match_start` - `match_end` Supporting evidence still stored in the player fact: - `first_seen_server_time` - `last_seen_server_time` The persisted field is: - `player_active_seconds` The source label is: - `active_time_source = "event_log"` ## Persistence Forward-only persistence now stores on `rcon_match_player_stats`: - `player_active_seconds INTEGER NULL` - `active_time_source TEXT` Legacy rows remain valid. If they were materialized before these columns existed, they keep `player_active_seconds = NULL` until new materialization or new matches populate the field. ## Calculation Observed active time is now: ```text sum(connected_interval_seconds clamped to [match_start, match_end]) ``` Rules: - if the player connects during the match: - open interval at `connected.server_time` - if the player disconnects during the match: - close interval at `disconnected.server_time` - if the player was already connected before `match_start` and there is no later pre-match disconnect: - open interval at `match_start` - if the player is still connected at `match_end`: - close interval at `match_end` - if the player reconnects multiple times: - sum all non-overlapping intervals This is still observed presence, not exact telemetry down to every silent second. KPM is exposed only when: - `player_active_seconds` exists - `player_active_seconds >= HLL_KPM_MIN_ACTIVE_SECONDS` Default: - `HLL_KPM_MIN_ACTIVE_SECONDS = 60` ## Payload Contract Historical match detail player rows may now expose: - `player_active_seconds` - `player_active_minutes` - `kpm` - `kpm_status` - `active_time_source` `active_time_source` values currently used: - `connection_intervals` - `connection_intervals_carryover` - `event_span_fallback` - `unavailable` `kpm_status` values: - `ready` - `missing_active_time` - `insufficient_active_time` - `missing_connection_intervals` Rules: - missing active time: - `kpm = null` - `kpm_status = "missing_active_time"` - fallback event span without reliable connection intervals: - `kpm = null` - `kpm_status = "missing_connection_intervals"` - active time below threshold: - `kpm = null` - `kpm_status = "insufficient_active_time"` - valid active time: - `kpm = round(kills / (player_active_seconds / 60), 2)` - `kpm_status = "ready"` - only when `active_time_source` is `connection_intervals` or `connection_intervals_carryover` ## Historical Matches Already Stored Old matches are not backfilled with fake KPM. For those rows: - `player_active_seconds` can remain `null` - `kpm` stays `null` - frontend must not render `0.00` as if the value were real - rows rematerialized without reliable connection intervals can still expose `event_span_fallback`, but that fallback must not be shown as real KPM ## Frontend Rule `historico-partida.js` renders KPM only when: - `kpm_status == "ready"` If the value is missing or below threshold, the panel stays clean and does not show a fake metric. ## Public Tables The historical match detail now exposes the same real KPM in two public places: - expanded player panel in `historico-partida.html` - main players table in `historico-partida.html` The main table leaves the KPM cell empty when `kpm_status != "ready"`. This avoids false `0.00` values for: - legacy rows without active time - rows below `HLL_KPM_MIN_ACTIVE_SECONDS` - rows that only expose `event_span_fallback` ## Personal Player Profile The public player profile in `stats.html` can now expose real KPM for the selected weekly or monthly window when the lightweight player-period read model already contains enough data for that player and scope. The profile endpoint does not use a runtime fallback over `rcon_match_player_stats` anymore for weekly/monthly public reads. That fallback was disabled because it could degrade public profile response times when the read model was empty or stale. When the read model is available, profile KPM uses: - `SUM(player_active_seconds)` - only over rows with: - `active_time_source = connection_intervals` - `active_time_source = connection_intervals_carryover` - `player_active_seconds >= HLL_KPM_MIN_ACTIVE_SECONDS` Weekly and monthly profile KPM use: ```text sum(eligible_kills) / (sum(eligible_player_active_seconds) / 60) ``` This is intentionally narrower than the window total: - profile `kills` still show all kills in the selected window - profile `KPM` only becomes `ready` when the active-time subset is reliable enough The profile payload exposes: - `player_active_seconds` - `player_active_minutes` - `kpm` - `kpm_status` - `active_time_source` - `active_time_coverage` If the profile read model is empty or unavailable: - the endpoint keeps the profile fast - `kpm` stays `null` - `kpm_status` stays `missing_active_time` - external links and profile identity fields remain available This keeps the page responsive without inventing KPM. ## Kills Per Match Labels Several public tables were showing `kills_per_match` under the visible label `KPM`. That is no longer acceptable because: - `kills_per_match` means kills divided by matches considered - real KPM means kills divided by active minutes from reliable connection intervals Public surfaces that now use `Kills/partida` instead of `KPM` for `kills_per_match`: - historical weekly and monthly leaderboard tables - annual stats summary table - annual stats comparison cards - ranking metric selector and ranking table label ## Aggregated Real KPM This repository does not yet expose a public weekly, monthly or annual aggregate KPM based only on: - `active_time_source = connection_intervals` - `active_time_source = connection_intervals_carryover` That was intentionally left out of this change because the public leaderboard and ranking snapshots would need an explicit coverage contract before mixing: - rows with real active time - older rows without it - fallback rows blocked from `kpm_status = ready` Until that contract exists, public aggregated views keep: - `Kills/partida` for `kills_per_match` - real KPM only at historical match detail player level ## Limitations - This is observed active time from AdminLog connection evidence, not exact join/leave telemetry for every silent second. - Quiet players with kills/chat/team switches but no reliable connection chain can expose `event_span_fallback`; that span is intentionally blocked from KPM. - Legacy matches remain without KPM unless rematerialized from stored AdminLog evidence. - We do not discount time spent without squad/unit/role yet because there is no audited historical source for that dimension in this implementation.