diff --git a/src/benchmark_radar/attention.py b/src/benchmark_radar/attention.py index 67cbd294..1244b5e0 100644 --- a/src/benchmark_radar/attention.py +++ b/src/benchmark_radar/attention.py @@ -2,6 +2,7 @@ from copy import deepcopy from datetime import UTC, datetime +from math import isfinite from typing import Any from urllib.parse import urlsplit @@ -12,6 +13,24 @@ LEGACY_HACKER_NEWS_PRODUCER = "benchmark-social-signal" +def _metrics(values: Any) -> dict[str, float]: + # Public feeds must not turn booleans into measurements or persist Infinity, + # which cannot be read as a JSON number by downstream consumers. + if not isinstance(values, dict): + return {} + normalized = {} + for key, value in values.items(): + if type(value) not in (int, float): + continue + try: + numeric = float(value) + except OverflowError: + continue + if isfinite(numeric) and numeric >= 0: + normalized[str(key)] = numeric + return normalized + + def _date(value: str | None, *, fallback: datetime) -> datetime: if not value: return fallback @@ -47,11 +66,7 @@ def _supporting_observations( "source_id": source_id, "url": url, "published_at": str(published_at), - "metrics": { - str(key): float(metric) - for key, metric in (value.get("metrics") or {}).items() - if isinstance(metric, int | float) and metric >= 0 - }, + "metrics": _metrics(value.get("metrics")), **( {"primary_artifact_url": primary} if (primary := _http_url(value.get("primary_artifact_url"))) @@ -103,11 +118,7 @@ def _normalize_feed( event_kind=str(raw.get("event_kind") or "discussed"), authors=[str(author) for author in raw.get("authors") or []], primary_artifact_url=_http_url(raw.get("primary_artifact_url")), - metrics={ - str(key): float(value) - for key, value in (raw.get("metrics") or {}).items() - if isinstance(value, int | float) and value >= 0 - }, + metrics=_metrics(raw.get("metrics")), categories=sorted( {str(category) for category in raw.get("categories") or [] if category} ), @@ -167,11 +178,7 @@ def _restore_previous( event_kind=str(raw.get("event_kind") or "discussed"), authors=[str(author) for author in raw.get("authors") or []], primary_artifact_url=_http_url(raw.get("primary_artifact_url")), - metrics={ - str(key): float(value) - for key, value in (raw.get("metrics") or {}).items() - if isinstance(value, int | float) and value >= 0 - }, + metrics=_metrics(raw.get("metrics")), categories=[str(value) for value in raw.get("categories") or []], rationale=[str(value) for value in raw.get("rationale") or []], supporting_observations=_supporting_observations( diff --git a/tests/test_attention.py b/tests/test_attention.py index 5827f9b8..c6e4b58e 100644 --- a/tests/test_attention.py +++ b/tests/test_attention.py @@ -1,3 +1,4 @@ +import json from datetime import UTC, datetime from benchmark_radar.attention import fetch_attention_feeds @@ -165,3 +166,58 @@ def test_first_observed_time_survives_reingestion(monkeypatch): ) assert observations[0].observed_at.isoformat() == first + + +def test_feed_metrics_preserve_measured_zero_but_cannot_emit_non_json_numbers(monkeypatch): + # A producer's overflowing JSON number became Infinity in the public snapshot; + # booleans also acquired a fabricated numeric measurement of one. + now = datetime(2026, 7, 27, 12, tzinfo=UTC) + invalid = { + "points": float("inf"), + "flag": True, + "zero": 0, + "valid": 2.5, + "nan": float("nan"), + "huge": 10**400, + "negative": -1, + } + raw = local_observation(now) + raw["metrics"] = invalid + raw["supporting_observations"] = [ + { + "source_id": "2", + "url": "https://news.ycombinator.com/item?id=2", + "published_at": raw["published_at"], + "metrics": invalid, + } + ] + payload = {"schema_version": 1, "producer": "fixture", "observations": [raw], "health": []} + monkeypatch.setattr("benchmark_radar.attention.get_json", lambda url: payload) + observations, health, _, _ = fetch_attention_feeds( + {"feeds": [{"url": "https://example.test/feed.json"}]}, observed_at=now + ) + assert health[0].ok + assert observations[0].metrics == {"zero": 0.0, "valid": 2.5} + assert observations[0].supporting_observations[0]["metrics"] == observations[0].metrics + json.dumps(observations[0].to_dict(), allow_nan=False) + + +def test_failed_collector_sanitizes_metrics_from_legacy_observations(monkeypatch): + now = datetime(2026, 7, 27, 12, tzinfo=UTC) + monkeypatch.setattr( + "benchmark_radar.attention.collect_hacker_news", + lambda *args, **kwargs: ([], {"ok": False, "error": "offline"}), + ) + previous = { + **local_observation(now), + "producer": "benchmark-social-signal", + "observation_id": "benchmark-social-signal:hacker-news:1", + "observed_at": now.isoformat(), + "metrics": {"points": float("inf"), "flag": True, "zero": 0}, + } + restored, health, _, _ = fetch_attention_feeds( + LOCAL_CONFIG, observed_at=now, previous_observations=[previous] + ) + assert not health[0].ok + assert restored[0].metrics == {"zero": 0.0} + json.dumps(restored[0].to_dict(), allow_nan=False)