Files
OpenJarvis/tests/telemetry/test_aggregator.py
T
28ef745384 fix(leaderboard): correct telemetry pipeline and outlier handling (#498)
* fix(leaderboard): correct telemetry pipeline and outlier handling

The public leaderboard at /leaderboard showed a clear bimodal Wh/token
distribution: most users at ~3-5 J/token, ~30% inflated by 1000-4000×.
A 5-agent investigation workflow + 4-agent verification pass traced the
inflation to a cluster of related bugs across telemetry, server, and
display layers. This PR fixes the in-repo half. Backfilling existing
Supabase data is a separate follow-up.

Bug 1 — Dual telemetry recording (`server/routes.py`)
=====================================================

`_handle_direct` wrapped the engine with `instrumented_generate`
unconditionally. When `app.state.engine` was already an
`InstrumentedEngine` (the common case when telemetry is wired in),
BOTH layers published `TELEMETRY_RECORD` — once from the inner
`InstrumentedEngine.generate`, once from the outer wrapper. Every
chat-completion request was counted twice in the leaderboard pipeline.

Fix: detect the InstrumentedEngine and unwrap to `._inner` before
passing to the wrapper so only one layer fires.

Bug 2 — KV-cache fallback over-counts multi-turn (`server/savings.py`)
=====================================================================

`compute_savings` falls back from `prompt_tokens_evaluated` to
`prompt_tokens` when the KV-cache-aware count is missing. But routes.py
aggregates by summing each turn's full prompt — which counts the system
prompt N times for an N-turn conversation. The fallback inflated FLOPs
and energy by N×.

Fix: use 0 (conservative under-count) when the evaluated count is
missing rather than falling back to the inflated `prompt_tokens` sum.

Bug 3 — TelemetryRecord lacked methodology versioning
=====================================================

There was no per-record version tag, so legacy (pre-fix) and current
records were silently aggregated together in the public leaderboard
even though they used different methodologies.

Fix: add `token_counting_version: Optional[int]` field to the
`TelemetryRecord` dataclass + a nullable column to the SQLite schema
(with idempotent migration); the constant moves from `server.savings`
to `core.types` to avoid the server→telemetry layering. New records
write the current version; pre-fix rows remain NULL. The aggregator
gains a `current_methodology_only=True` flag that filters NULL rows out
of leaderboard sums — local dashboards leave it off so historical
aggregates still render.

Bug 4 — Leaderboard JS displayed missing telemetry as legit zeros
=================================================================

Rows with significant token counts but `energy_wh_saved = 0` and
`flops_saved = 0` rendered as `0.00 Wh / 0 FLOPs` — visually identical
to a user who genuinely did almost nothing. The headline totals also
included these rows.

Fix: new `isMissingTelemetry()` detector renders missing-telemetry
energy/FLOPs cells as `—` (with a tooltip explaining why); a new
`.lb-missing` CSS class differentiates the placeholder visually.

Bug 5 — Outlier filter was too generous
=======================================

`MAX_ENERGY_WH_PER_TOKEN = 10` left a 10,000× margin that admitted
every Group B row even though those values are physically impossible
(would imply a space-heater per token). Same for the FLOPs cap at
`1e17`.

Fix: tighten to `0.5` Wh/token (still 500× over a typical consumer GPU)
and `1e15` FLOPs/token (still 10,000× over typical). This removes
existing pre-fix corrupt rows from the public view without touching
Supabase.

Tests
=====

New regression tests (all pass, ruff clean):

- `tests/server/test_routes.py::test_instrumented_engine_unwrapped_to_avoid_dual_telemetry`
  — pins the bug 1 fix; asserts exactly ONE TELEMETRY_RECORD event per
  request when the engine is already an InstrumentedEngine.
- `tests/server/test_savings.py` (NEW file, 3 tests) — pins the bug 2
  fix (FLOPs not inflated via fallback) and the cost-side invariant
  (dollar savings still use full prompt_tokens because cloud providers
  bill per input token even when local KV cache hit).
- `tests/telemetry/test_aggregator.py::TestMethodologyFilter` (3 tests)
  — default behaviour includes legacy rows (local dashboard parity);
  `current_methodology_only=True` excludes them; the summary surface
  honors the same filter.

Unrelated test note
===================

`tests/telemetry/test_energy_wiring.py::TestTelemetryStatsEnergy::test_export_includes_energy_fields`
and `::TestEndToEndPipeline::test_ask_to_export_with_energy` are flaky
on this branch but ALSO flaky on `main` (confirmed via stash + the
banner-only run). Root cause: `_version_check.py:132-135` prints the
"new version of OpenJarvis is available" banner to stdout outside the
throttle window, contaminating `json.loads(result.output)` in those two
tests. Reproducible with `OPENJARVIS_NO_UPDATE_CHECK=1` → all pass.
That's a separate bug (the banner shouldn't write to the same stream
as machine-readable output); not addressing it here to keep this PR
focused on the leaderboard pipeline.

What this PR explicitly does NOT do
====================================

- Backfill ~30% of Supabase rows that already shipped with inflated
  values from pre-fix clients. That requires Supabase write access and
  is the natural follow-up — see PR comments for proposed dry-run audit
  query and the additive `methodology_version` column migration.
- Distinguish which past submissions came from buggy vs correct clients
  retroactively (no `app_version` tag on existing submissions).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(evals): fix test_energy_scales_linearly under leaderboard fix

CI on #498 failed in the slow `test` job:

    tests/evals/test_use_case_benchmarks.py::TestSavings::
    test_energy_scales_linearly — ZeroDivisionError: float division by zero

Root cause: the test (and `test_energy_wh_matches_direct_formula`)
called `compute_savings(N, 0)` without `prompt_tokens_evaluated`. Under
the OLD buggy fallback, an unset evaluated count silently became N,
energy scaled linearly with N, and the test passed by accident. Under
this branch's conservative fix the fallback is 0, FLOPs collapse to 0,
and the `p10.energy_wh / p1.energy_wh` ratio divides 0 by 0.

The test's own docstring says "evaluated tokens (KV-cache model)" — it
was always meant to exercise the explicit-evaluated path, the API
call just didn't match. Pass `prompt_tokens_evaluated` explicitly so
the test now expresses the invariant it claims to.

Same one-line fix for `test_energy_wh_matches_direct_formula` plus an
explicit `flops > 0` sanity assertion — that test was passing trivially
with `0 == 0` after the conservative fallback fix, masking whether the
formula was actually being exercised.

No production code change in this commit. Verified locally: all 6
TestSavings tests pass; the 5 new regression tests added on this branch
still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: krypticmouse <herumbshandilya123@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 16:59:24 -07:00

309 lines
9.9 KiB
Python

"""Tests for TelemetryAggregator."""
from __future__ import annotations
import time
from pathlib import Path
import pytest
from openjarvis.core.types import TelemetryRecord
from openjarvis.telemetry.aggregator import (
AggregatedStats,
EngineStats,
ModelStats,
TelemetryAggregator,
)
from openjarvis.telemetry.store import TelemetryStore
def _make_record(
model_id: str = "test-model",
engine: str = "ollama",
prompt_tokens: int = 10,
completion_tokens: int = 5,
latency: float = 1.0,
cost: float = 0.001,
ts: float | None = None,
) -> TelemetryRecord:
return TelemetryRecord(
timestamp=ts or time.time(),
model_id=model_id,
engine=engine,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
latency_seconds=latency,
cost_usd=cost,
)
def _setup(tmp_path: Path, records: list[TelemetryRecord] | None = None):
db = tmp_path / "telemetry.db"
store = TelemetryStore(db)
for rec in records or []:
store.record(rec)
store.close()
agg = TelemetryAggregator(db)
return agg
class TestTelemetryAggregator:
def test_empty_db_summary(self, tmp_path: Path) -> None:
agg = _setup(tmp_path)
s = agg.summary()
assert s.total_calls == 0
assert s.total_tokens == 0
assert s.total_cost == 0.0
agg.close()
def test_record_count(self, tmp_path: Path) -> None:
agg = _setup(tmp_path, [_make_record(), _make_record()])
assert agg.record_count() == 2
agg.close()
def test_single_model_stats(self, tmp_path: Path) -> None:
agg = _setup(tmp_path, [_make_record(model_id="m1")])
stats = agg.per_model_stats()
assert len(stats) == 1
assert stats[0].model_id == "m1"
assert stats[0].call_count == 1
agg.close()
def test_multiple_models_grouped(self, tmp_path: Path) -> None:
agg = _setup(
tmp_path,
[
_make_record(model_id="m1"),
_make_record(model_id="m1"),
_make_record(model_id="m2"),
],
)
stats = agg.per_model_stats()
assert len(stats) == 2
# Ordered by call_count DESC
assert stats[0].model_id == "m1"
assert stats[0].call_count == 2
assert stats[1].model_id == "m2"
assert stats[1].call_count == 1
agg.close()
def test_per_engine_stats(self, tmp_path: Path) -> None:
agg = _setup(
tmp_path,
[
_make_record(engine="ollama"),
_make_record(engine="vllm"),
_make_record(engine="vllm"),
],
)
stats = agg.per_engine_stats()
assert len(stats) == 2
assert stats[0].engine == "vllm"
assert stats[0].call_count == 2
agg.close()
def test_top_models_limit(self, tmp_path: Path) -> None:
records = [_make_record(model_id=f"m{i}") for i in range(10)]
agg = _setup(tmp_path, records)
top = agg.top_models(n=3)
assert len(top) == 3
agg.close()
def test_top_models_ordering(self, tmp_path: Path) -> None:
agg = _setup(
tmp_path,
[
_make_record(model_id="rare"),
_make_record(model_id="popular"),
_make_record(model_id="popular"),
_make_record(model_id="popular"),
],
)
top = agg.top_models(n=2)
assert top[0].model_id == "popular"
assert top[0].call_count == 3
agg.close()
def test_summary_totals(self, tmp_path: Path) -> None:
agg = _setup(
tmp_path,
[
_make_record(prompt_tokens=10, completion_tokens=5, cost=0.001),
_make_record(prompt_tokens=20, completion_tokens=10, cost=0.002),
],
)
s = agg.summary()
assert s.total_calls == 2
assert s.total_tokens == 45 # (10+5) + (20+10)
assert s.total_cost == pytest.approx(0.003)
agg.close()
def test_summary_includes_sub_stats(self, tmp_path: Path) -> None:
agg = _setup(tmp_path, [_make_record()])
s = agg.summary()
assert len(s.per_model) >= 1
assert len(s.per_engine) >= 1
agg.close()
def test_time_range_since(self, tmp_path: Path) -> None:
now = time.time()
agg = _setup(
tmp_path,
[
_make_record(ts=now - 100),
_make_record(ts=now - 10),
_make_record(ts=now),
],
)
stats = agg.per_model_stats(since=now - 50)
total = sum(s.call_count for s in stats)
assert total == 2
agg.close()
def test_time_range_until(self, tmp_path: Path) -> None:
now = time.time()
agg = _setup(
tmp_path,
[
_make_record(ts=now - 100),
_make_record(ts=now),
],
)
stats = agg.per_model_stats(until=now - 50)
total = sum(s.call_count for s in stats)
assert total == 1
agg.close()
def test_time_range_since_and_until(self, tmp_path: Path) -> None:
now = time.time()
agg = _setup(
tmp_path,
[
_make_record(ts=now - 200),
_make_record(ts=now - 100),
_make_record(ts=now),
],
)
stats = agg.per_model_stats(since=now - 150, until=now - 50)
total = sum(s.call_count for s in stats)
assert total == 1
agg.close()
def test_export_records_all(self, tmp_path: Path) -> None:
agg = _setup(tmp_path, [_make_record(), _make_record()])
records = agg.export_records()
assert len(records) == 2
assert "model_id" in records[0]
agg.close()
def test_export_records_filtered(self, tmp_path: Path) -> None:
now = time.time()
agg = _setup(
tmp_path,
[
_make_record(ts=now - 100),
_make_record(ts=now),
],
)
records = agg.export_records(since=now - 50)
assert len(records) == 1
agg.close()
def test_clear_removes_all(self, tmp_path: Path) -> None:
agg = _setup(tmp_path, [_make_record(), _make_record(), _make_record()])
deleted = agg.clear()
assert deleted == 3
assert agg.record_count() == 0
agg.close()
def test_clear_empty_db(self, tmp_path: Path) -> None:
agg = _setup(tmp_path)
deleted = agg.clear()
assert deleted == 0
agg.close()
def test_close(self, tmp_path: Path) -> None:
agg = _setup(tmp_path)
agg.close()
# After close, operations should raise
with pytest.raises(Exception):
agg.record_count()
class TestDataclassDefaults:
def test_model_stats_defaults(self) -> None:
ms = ModelStats()
assert ms.model_id == ""
assert ms.call_count == 0
assert ms.total_tokens == 0
def test_engine_stats_defaults(self) -> None:
es = EngineStats()
assert es.engine == ""
assert es.call_count == 0
def test_aggregated_stats_defaults(self) -> None:
a = AggregatedStats()
assert a.total_calls == 0
assert a.per_model == []
assert a.per_engine == []
# ---------------------------------------------------------------------------
# Token-counting-version filter (leaderboard correctness)
# ---------------------------------------------------------------------------
class TestMethodologyFilter:
"""When the aggregator is asked to honour the methodology version
(the leaderboard ingest path does), legacy rows that predate the
per-record version stamp must be excluded — they were the dominant
source of the bimodal Wh/token distribution on the public
leaderboard. Local dashboard callers leave the flag off so they
still see the full history."""
def test_default_includes_legacy_rows(self, tmp_path: Path) -> None:
from openjarvis.core.types import TOKEN_COUNTING_VERSION
legacy = _make_record(model_id="m1")
legacy.token_counting_version = None # pre-fix row
current = _make_record(model_id="m1")
current.token_counting_version = TOKEN_COUNTING_VERSION
agg = _setup(tmp_path, [legacy, current])
stats = agg.per_model_stats() # default: include everything
assert len(stats) == 1
assert stats[0].call_count == 2
agg.close()
def test_methodology_filter_drops_legacy_rows(self, tmp_path: Path) -> None:
from openjarvis.core.types import TOKEN_COUNTING_VERSION
legacy = _make_record(model_id="m1")
legacy.token_counting_version = None
current = _make_record(model_id="m1")
current.token_counting_version = TOKEN_COUNTING_VERSION
agg = _setup(tmp_path, [legacy, current])
stats = agg.per_model_stats(current_methodology_only=True)
assert len(stats) == 1
# Only the current-version row counts toward the leaderboard sum.
assert stats[0].call_count == 1
agg.close()
def test_methodology_filter_drops_legacy_in_summary(self, tmp_path: Path) -> None:
from openjarvis.core.types import TOKEN_COUNTING_VERSION
legacy = _make_record(model_id="m1", completion_tokens=99)
legacy.token_counting_version = None
current = _make_record(model_id="m1", completion_tokens=7)
current.token_counting_version = TOKEN_COUNTING_VERSION
agg = _setup(tmp_path, [legacy, current])
summary = agg.summary(current_methodology_only=True)
# 99-token legacy row excluded; only the 7-completion-token current
# row contributes to total_tokens.
assert sum(m.completion_tokens for m in summary.per_model) == 7
agg.close()