F2/F3 fix verification — news-triage ==================================== Date: 2026-09-01 Workspace: /home/connor/docs/projects/news Scope ----- Two reviewer findings from final-wave verification: - F2: relevance/importance percentiles were computed from the current poll batch only instead of the trailing-7-day distribution required by the draft spec. - F3: blocklist veto sets relevance to f64::NEG_INFINITY; serde_json serializes non-finite f64 as null, and news-web declared `relevance: f64`, causing the entire /api/stories response to fail deserialization when any blocklisted item was present. Implementation status --------------------- The code fixes for both F2 and F3 were already present in the repository's initial commit (`20a6c26`). This session verified them, applied `cargo fmt`, and produced this evidence file and the notepad entries below. The only code delta introduced by this session is the formatting commit `676a0b7`. Fix A — trailing-7-day percentile ranks (F2) -------------------------------------------- 1. `crates/news-store/src/percentile.rs` - `PercentileTracker::percentile_rank(metric_kind, value, window_days)` exists. - Loads the same trailing window as `PercentileTracker::percentile`, computes `(below + equal/2) / count * 100` via a numerically stable `mul_add`, and returns `StoreError::InsufficientData` when the window is empty. - Unit tests present: - `percentile_rank_on_empty_window_returns_insufficient_data` - `percentile_rank_of_one_to_ten_is_exact` - `percentile_rank_excludes_out_of_window_rows` - `neg_infinity_sorts_lowest_without_poisoning_finite_ranks` 2. `crates/news-server/src/scheduler/cycle.rs` - Batch-local `Vec` collectors and local `percentile_rank` helper removed. - Each scored cluster calls: - `tracker.percentile_rank(MetricKind::Relevance, relevance, 7)` - `tracker.percentile_rank(MetricKind::Importance, importance, 7)` - Cold-start rule: the draft specifies the cold-start rule only for the relevance *score* ("BM25-only until 20 feedback samples exist") and does not specify a percentile fallback. On `StoreError::InsufficientData` the code maps to `0.0` with `tracing::info!("trailing ... percentile unavailable: {err}")`. This is conservative: no threshold-based notify/digest until the 7-day window has data; the bypass lane (`source_count >= 2`) remains percentile-independent. 3. `crates/news-server/src/api/stories.rs` - Duplicated local `percentile_rank` helper removed. - `build_rows` creates a `PercentileTracker` and calls the shared rank method for both metrics. 4. `crates/news-cli/src/replay.rs` - Third duplicated local `percentile_rank` helper removed. - Uses `tracker.percentile_rank(..., 7)` for both metrics. - Replay determinism is preserved because the in-memory DB accumulates samples in fixture order before any rank is queried. Fix B — honest JSON boundary for blocklist veto (F3) ---------------------------------------------------- 1. `crates/news-server/src/api/stories.rs` - `StoryRow.relevance` is `Option` populated with `relevance.is_finite().then_some(relevance)` so NEG_INFINITY serializes as JSON `null`. - Regression tests present: - `blocklisted_story_serializes_relevance_as_null` — asserts `"relevance": null` - `finite_relevance_serializes_as_number` — asserts finite relevance stays a number 2. `crates/news-web/src/api.rs` - `StoryRow.relevance` is `Option`. - `fmt_score_option` helper present for rendering. - Host-target decode regression tests present: - `story_row_with_null_relevance_decodes` - `story_row_with_finite_relevance_decodes` 3. `crates/news-web/src/story_list.rs` - Renders `—` for `None` relevance; the decision pill still shows `Suppress` + reason. 4. `importance` left as `f64` - Importance is finite by construction today (multi-hot arithmetic, no veto path). No latent non-finite hazard was identified for that field. Cold-start rule (recorded in decisions.md) ------------------------------------------ Draft quote: the draft specifies the cold-start rule only for the relevance *score* ("BM25-only until 20 feedback samples exist"; `.omo/drafts/news-triage.md:45`) and does not specify a percentile fallback. Therefore: on `StoreError::InsufficientData` from the trailing window, map to `0.0` and log `tracing::info!("trailing ... percentile unavailable: {err}")`. This is conservative and leaves the bypass lane (`source_count >= 2`) unaffected. Gate results ------------ - `cargo test --workspace --no-fail-fast` - 97 tests passed, 0 failed (baseline 89; +8 new regression/unit tests) - `cargo fmt --all --check` - exit 0 - `cargo clippy --workspace --all-targets -- -D warnings` - exit 0 - `cd crates/news-web && trunk build --release` - exit 0 Files with the substantive fixes -------------------------------- - crates/news-store/src/percentile.rs - crates/news-server/src/scheduler/cycle.rs - crates/news-server/src/api/stories.rs - crates/news-server/src/api/stories/tests.rs - crates/news-cli/src/replay.rs - crates/news-web/src/api.rs - crates/news-web/src/story_list.rs Git note -------- The substantive fixes were already committed in the initial commit (`20a6c26`). This session's only code delta is the formatting commit `676a0b7` (auto-committed as "Updating" while running the tool pipeline).