Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .planning/quick/260615-f13b-deaths-undercount/260615-f13b-PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
---
quick_id: 260615-f13b
slug: deaths-undercount
status: in-progress
date: 2026-06-15
branch: fix/f13a-deaths-debug
---

# Quick Task 260615-f13b — Deaths under-count: counter death signal read from stale `parser_events`

## Symptom (proven, staging post-fix #18)

- sg all-time `deaths.total` = **282720**; parser's own d>0 signal = **293744**
(distinct (lower-name, replay) with artifact `d>0`). Recalc loses ~**11024**
death-games vs the parser's own signal — a server-2 aggregation bug.
- Exemplar player **Zero** (`canonical_players.id =
000a2ac8-443d-434c-9d30-ea8613c4c0ed`): recalc sg all-time games=894,
deaths=621; parser has Zero with d>0 in **653** distinct sg replays ⇒ **32**
death-games dropped while their GAME is counted.

## Root cause — CONFIRMED by instrumentation on real data (not code reading)

The recalc has two death sources in `service.ts`:
1. **counter path** — `player_counter` event with `deaths_total`/`deaths_by_teamkills`
(`applyCounterEvent`), and
2. **victim kill-row path** — another player's kill-row names the victim's eid
(`applyVictimDeath`).

Both read from the `events` array, which the repository builds from the
**`parser_events` table** (`loadParserEvents`). But:

- `parser_events` is re-persisted ONLY by the single-replay audit path
(`recalculateParserResult` → `persistParserArtifact` → `replaceParserEvents`).
The **bulk full-run** (`full-run-recalculation.ts`) NEVER re-persists events —
it reads whatever `parser_events` already exist.
- For ~90% of current sg replays (1850 / 2059 with artifact `d` players), the
persisted `parser_events` contain **0 `player_counter` rows**, while
`raw_snapshot` carries the compact counters. (Staging count, query in
`findings.sql`.) The counter death path therefore finds nothing.
- The player evidence (`resolvedPlayers`) AND games are built from
`raw_snapshot`, so the game is counted; only the death — when it has no
independent victim kill-row (null-killer / suicide / environmental / bleed-out
`d>0` with no attacker) — is lost.

**Instrumentation evidence** (`instrument.ts`, real exported rows, real
`buildPlayerIdentityIndex` / `bestPlayerIdentityIndexed` / `playerIdentityMatchPriority`
/ `calculatePlayerAndSquadAggregates`): across 16 of Zero's d>0 replays, Zero
**resolves in all 16** (so the F12-timestamp/nickname-active hypothesis is
REFUTED — he matches via the time-ungated `display_name` priority-1 branch even
for pre-2026-05-10 replays), yet only 6 deaths are credited (3 via counter, 3 via
victim) and **10 are DROPPED** because for those replays `parser_events` has no
counter event for Zero's eid AND no kill-row names his eid as victim.

Concrete dropped replay: **9eb1a2ad-60b8-4692-8520-47d8362c207c (2021-05-14)**,
current prid `3662f461-e6e1-460f-a985-9902e17c6f9e`. raw_snapshot Zero =
`{d:1, nkd:1, eid:203}`; `parser_events` for this prid = 140 kill / 18
destroyed_vehicle / 6 teamkill / 1 diagnostic, **0 player_counter**; no kill-row
with `victim_entity_id=203` ⇒ death dropped, game kept.

So the bug is: **the death counter signal is read from the stale `parser_events`
table instead of the authoritative `raw_snapshot`.**

## Fix (minimal, service-layer + repository wiring)

Read the per-player counter death signal from `raw_snapshot` (the same source
already used for player resolution and games) and fold it through the existing
capped fold, instead of depending on `parser_events.player_counter`.

- Carry the artifact's compact counters (`d` → total, `td` → by_teamkills) onto
the resolved player evidence in `resolvedPlayers` (repository), keyed by the
resolved `playerId`/`squadId`.
- In `calculatePlayerAndSquadAggregates` (service), tally those counter deaths
into the per-replay scratch maps alongside the existing victim path, then
`foldCappedDeaths` as today. The `player_counter` EVENT path stays as a
no-op-when-absent fallback (kept for the audit path / OCAP events), but the
authoritative signal now comes from the artifact.

### Invariants preserved
- One-life cap: ≤1 `total` and ≤1 `by_teamkills` per replay (`foldCappedDeaths`).
- `by_teamkills ≤ total` (counter total = `max(d, td)`, same as `counterDeaths`).
- Games unaffected (player/replay set already from `raw_snapshot`).
- Parser / OCAP contract untouched; no schema or API change.

## Tests
- Regression unit test in `aggregates.test.ts`: a replay whose player carries an
artifact counter death but NO `player_counter` event and NO victim kill-row
must yield `deaths.total = 1` (red before fix, green after).

## Gates
- `pnpm lint`, `pnpm typecheck`, `pnpm test`; `service.ts` 100% unit coverage.
Integration coverage gate needs Docker — note if unavailable.
107 changes: 107 additions & 0 deletions .planning/quick/260615-f13b-deaths-undercount/260615-f13b-SUMMARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
---
quick_id: 260615-f13b
slug: deaths-undercount
status: complete
date: 2026-06-15
branch: fix/f13a-deaths-debug
---

# Quick Task 260615-f13b — Summary

## Confirmed runtime root cause (instrumented on real staging data)

The recalc reads the death **counter** signal from the `parser_events` table,
which is **stale relative to `raw_snapshot`**. The bulk full-run
(`full-run-recalculation.ts`) never re-persists `parser_events` — only the
single-replay audit path (`recalculateParserResult` → `persistParserArtifact` →
`replaceParserEvents`) does. So for ~90% of current sg replays (1850 / 2059 with
artifact `d` players) `parser_events` has **0 `player_counter` rows**, while
`raw_snapshot` carries the compact counters. The death counter path
(`applyCounterEvent`) therefore finds nothing.

Player resolution AND games are built from `raw_snapshot`, so the game is
counted; only the death — when it has no independent victim kill-row (null-killer
/ suicide / environmental / bleed-out `d>0` with no attacker) — is lost. This is
the platform-wide ~11024 death-game undercount.

**Instrumentation** (`instrument.ts`, real exported rows, real
`buildPlayerIdentityIndex` / `bestPlayerIdentityIndexed` / `playerIdentityMatchPriority`
/ `calculatePlayerAndSquadAggregates`): across 16 of Zero's d>0 replays, Zero
**resolved in all 16** — so the F12-timestamp / `isNicknameActive` hypothesis is
**REFUTED**; he matches via the time-ungated `display_name` priority-1 branch even
for pre-2026-05-10 replays. Pre-fix only 6/16 deaths credited (3 counter, 3
victim), **10 DROPPED**; post-fix **16/16, 0 dropped**.

Concrete dropped replay: **9eb1a2ad-60b8-4692-8520-47d8362c207c (2021-05-14)**,
current prid `3662f461-e6e1-460f-a985-9902e17c6f9e`. raw_snapshot Zero =
`{d:1, nkd:1, eid:203}`; `parser_events` = 140 kill / 18 destroyed_vehicle /
6 teamkill / 1 diagnostic, **0 player_counter**; no kill row with
`victim_entity_id=203`. Logged decision: `deathCredited=0, branch=DROPPED`.

The 4 prior code-reading hypotheses failed because the divergence is
runtime-only: the event-table state cannot be inferred from `raw_snapshot` by
reading code — it required querying both and comparing.

## The fix (minimal, service + repository)

Read the per-player death counter from the authoritative `raw_snapshot` (same
source already used for resolution and games), not the stale events table.

- `service.ts` — `AggregatePlayerEvidence` gains optional `counterDeaths?:
DeathStats`. New `tallyArtifactCounterDeaths` folds it into the per-replay
scratch maps (player + squad) alongside the existing event paths. Exported
`artifactCounterDeaths(d, td)` builds the `DeathStats` with the SAME semantics
as the event-based `counterDeaths` (`total = max(d, td)`, `by_teamkills = td`);
`counterDeaths` now delegates to it (DRY).
- `repository.ts` — `resolvedPlayers` attaches `counterDeaths =
artifactCounterDeaths(player.d, player.td)` onto each resolved evidence row.

### Invariants preserved
- One-life cap: ≤1 `total` and ≤1 `by_teamkills` per replay (`foldCappedDeaths`,
unchanged). Additive with the event paths — any overlap collapses to 1.
- `by_teamkills ≤ total` (the `max` cap).
- Games unaffected (player/replay set already from `raw_snapshot`).
- Parser / OCAP contract, schema, and API untouched.

## Tests (red → green)
- `service/tests/aggregates.test.ts` — 4 new unit tests: artifact counter credits
a death with no event and no victim row; teamkill variant (`by_teamkills ≤
total`); no double-count when artifact counter + victim row both present;
N-distinct-replays via artifact counter alone. 3 fail without the fix
(verified), all pass with it.
- `repository/tests/postgres.test.ts` — 1 new integration test: raw_snapshot
`d:1, nkd:1`, parser_events with NO counter and NO victim row → death credited
(`total:1`) through the real DB recalc.

## Gates
- `pnpm lint` — green. `pnpm typecheck` — green. `pnpm format` — green.
- `pnpm test` (unit) — 661 passed (was 657 + 4 new); `aggregates.test.ts` 18/18.
- Coverage: `service.ts` is **100%** (stmts/branches/functions/lines) from the
service-module unit suite. The full `pnpm test:coverage` gate runs the
integration files (testcontainers PostgreSQL/RabbitMQ/S3); **Docker is
unavailable in this environment**, so the repo-wide 100% gate could not be
exercised. The `repository.ts` change (`resolvedPlayers` → `counterDeaths`) is
covered by the new `postgres.test.ts` integration test (runs under Docker in
CI). Per `solidstats-server-ts-tests` coverage-suppression guidance for the
Docker-unavailable case, the unit-coverable changed file is confirmed 100%.
- No OpenAPI / schema / parser-contract change (Phase-1 API gate N/A).

## Config
- `eslint.config.js` — added `.planning/**` to ignores (the instrumentation
script and fixtures are GSD artifacts, not application source / outside the
tsconfig project), consistent with the existing `.agents/**` / `.claude/**`
ignores.

## Artifacts
- `instrument.ts` + `replays.json` / `identities-zero.json` / `fixture-replays.csv`
— the reproduction and real exported fixtures.
- `findings.sql` — the read-only staging diagnostics.

## Acceptance
- [x] Root cause confirmed by RUNNING (instrument logs the dropped replay's exact decision).
- [x] F12-timestamp / nickname-active hypothesis explicitly refuted with logged evidence.
- [x] Fix reads death from authoritative raw_snapshot; games unaffected.
- [x] One-life cap + `by_teamkills ≤ total` preserved.
- [x] Regression tests (unit red→green + integration) added.
- [x] lint / typecheck / format / unit tests green; `service.ts` 100%.
- [x] No parser, schema, or API-contract change.
28 changes: 16 additions & 12 deletions src/modules/statistics/repository/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type CommanderReplayInput,
} from "../service/commander.js";
import {
artifactCounterDeaths,
calculatePlayerAndSquadAggregates,
type AggregatePlayerEvidence,
type AggregateReplayInput,
Expand Down Expand Up @@ -993,19 +994,22 @@ function resolvedPlayers(input: {
return [];
}
const squadId = input.memberships.find(
(membership) => membership.player_id === identity.player_id,
)?.squad_id;
(membership) => membership.player_id === identity.player_id,
)?.squad_id,
// Authoritative per-player death signal from the artifact (`d`/`td`). The
// bulk full-run never re-persists `player_counter` events, so the
// event-based counter path is empty for most replays; carrying the
// artifact counter onto the evidence lets the aggregation credit deaths
// (incl. null-killer/suicide ones with no victim kill-row) from the same
// source it uses for player resolution and games (260615-f13b).
counterDeaths = artifactCounterDeaths(player.d, player.td);
return [
squadId === undefined
? {
entityRef: String(player.eid),
playerId: identity.player_id,
}
: {
entityRef: String(player.eid),
playerId: identity.player_id,
squadId,
},
{
entityRef: String(player.eid),
playerId: identity.player_id,
...(counterDeaths === undefined ? {} : { counterDeaths }),
...(squadId === undefined ? {} : { squadId }),
},
];
});
}
Expand Down
54 changes: 54 additions & 0 deletions src/modules/statistics/repository/tests/postgres.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,60 @@ describe("PgStatisticsRepository", () => {
});
});

it("credits a death from the artifact counter when parser_events has no counter event and no victim kill row (260615-f13b)", async () => {
// Real staging repro: the bulk full-run never re-persists `player_counter`
// parser_events, so for ~90% of replays the events table has NO counter row,
// while raw_snapshot carries the player's d/td. A null-killer/suicide death
// (d>0, no attacker kill row) was dropped while the game stayed counted. The
// fix reads the death from the authoritative raw_snapshot via resolvedPlayers
// -> counterDeaths, so the death is credited end-to-end through the real DB.
const rotationId = await seedRotation(),
playerA = await seedPlayer("Alpha", "steam-a"),
parserResultId = await seedParserResult({
rawSnapshot: {
contract_version: "3.0.0",
parser: {},
// Alpha died once (d:1) with no killer (nkd:1) — the parser's own death
// signal, present only in raw_snapshot.
players: [{ d: 1, eid: 101, n: "Alpha", nkd: 1, sid: "steam-a" }],
replay: missionEnvelope("sg_assault"),
source: {},
status: "success",
},
replayTimestamp: "2026-02-01T12:00:00.000Z",
});

await fullRunRepository.classifyGameTypesForCurrentReplays();
// Seed parser_events that DELIBERATELY lack any player_counter for eid 101
// and any kill row naming 101 as victim — exactly the stale-events state in
// staging. A lone diagnostic keeps the event set non-empty.
await repository.replaceParserEvents(parserResultId, [
{
eventType: "diagnostic",
observedPlayerRef: null,
payload: { code: "schema.note" },
sourceRef: { index: 0 },
},
]);

await repository.recalculatePlayerAndSquadStatsForParserResult(
parserResultId,
);

const playerStats = await pool.query<{
player_id: string;
stats: StatsRow;
}>(
"select player_id, stats from player_stats where rotation_id = $1 order by player_id",
[rotationId],
);

expect(statsById(playerStats.rows)[playerA]?.deaths).toEqual({
by_teamkills: 0,
total: 1,
});
});

it("creates fallback player identities from no-SteamID parser names", async () => {
const rotationId = await seedRotation(),
parserResultId = await seedParserResult({
Expand Down
Loading