Skip to content

Commit 1087e45

Browse files
Zaid Salemclaude
authored andcommitted
feat(wb-alerts): Phase 1 — per-channel dynamic trust for checkpoint reports
Checkpoint source_trust was a static {admin:0.9, crowd:0.4} keyed only on last_source_type — every crowd report scored 0.4 regardless of which channel reported it, so the trust number carried no signal (audit leak #1). But the checkpoint channels (ahwalaltreq 0.75, a7walstreet 0.75, road_jehad 0.70, ...) already have learner-tuned reliability weights in channel_reliability. Thread that weight into crowd trust: provenance.source_trust(last_source_type, channel_reliability) — admin stays authoritative (0.9); crowd is scored by the channel's reliability bounded [0.2, 0.85] (never reaches admin, keeps a floor); falls back to the flat 0.4 prior when no weight is known. provenance stays I/O-free; routers/v2 resolves the weights once per request via the new get_channel_reliability_map() (degrades to {} if the table is absent). Tests: +9 (provenance + v2 wiring); suite 147 passed, FP audit 96/96. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 97e6463 commit 1087e45

5 files changed

Lines changed: 119 additions & 6 deletions

File tree

services/westbank-alerts/app/database.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,21 @@ async def get_channel_reliability(channel: Optional[str] = None):
732732
return out
733733

734734

735+
async def get_channel_reliability_map() -> dict:
736+
"""All channel reliability weights as {lower(channel): weight} — one query,
737+
for batch construction of checkpoint envelopes (Phase 1 dynamic trust).
738+
739+
Reliability is enrichment: if the table is unavailable, return {} so callers
740+
fall back to the flat crowd prior rather than failing the checkpoint feed.
741+
"""
742+
try:
743+
async with get_alerts_db() as db:
744+
cur = await db.execute("SELECT lower(channel), weight FROM channel_reliability")
745+
return {row[0]: float(row[1]) for row in await cur.fetchall()}
746+
except aiosqlite.OperationalError:
747+
return {}
748+
749+
735750
# ── Security vocab candidates ─────────────────────────────────────────────────
736751

737752
async def insert_security_vocab_candidate(

services/westbank-alerts/app/routers/v2.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .. import checkpoint_db as cpdb
1515
from .. import incident_db
1616
from ..config import settings
17+
from ..database import get_channel_reliability_map
1718
from ..db_pool import get_alerts_db
1819
from ..serving import provenance as P
1920
from ..serving import gateways as GW
@@ -23,11 +24,16 @@
2324
_STALE = settings.CHECKPOINT_STALE_HOURS
2425

2526

26-
def _cp_envelope(cp: dict) -> dict:
27+
def _cp_envelope(cp: dict, rel_map: Optional[dict] = None) -> dict:
2728
# Use the RAW last_updated (None when never reported) so freshness can return
2829
# band "none" instead of treating a never-reported checkpoint as fresh.
2930
src = {**cp, "last_updated": cp.get("last_updated_iso")}
30-
return P.checkpoint_envelope(src, stale_hours=_STALE)
31+
# Phase 1: a crowd report's trust reflects the reporting channel's observed
32+
# reliability (resolved here, not inside the I/O-free provenance layer).
33+
rel = None
34+
if rel_map:
35+
rel = rel_map.get((cp.get("source_channel") or "").lower())
36+
return P.checkpoint_envelope(src, stale_hours=_STALE, channel_reliability=rel)
3137

3238

3339
@router.get("/checkpoints")
@@ -36,7 +42,8 @@ async def v2_checkpoints(
3642
status: Optional[str] = Query(None, description="filter by effective_status"),
3743
):
3844
cps = await cpdb.get_all_checkpoints(region=region)
39-
envs = [_cp_envelope(c) for c in cps]
45+
rel_map = await get_channel_reliability_map()
46+
envs = [_cp_envelope(c, rel_map) for c in cps]
4047
if status:
4148
envs = [e for e in envs if e["effective_status"] == status]
4249
return {"checkpoints": envs, "total": len(envs),
@@ -46,9 +53,10 @@ async def v2_checkpoints(
4653
@router.get("/checkpoints/geojson")
4754
async def v2_checkpoints_geojson(region: Optional[str] = Query(None)):
4855
cps = await cpdb.get_all_checkpoints(region=region)
56+
rel_map = await get_channel_reliability_map()
4957
features = []
5058
for c in cps:
51-
env = _cp_envelope(c)
59+
env = _cp_envelope(c, rel_map)
5260
lat, lon = env["coordinates"]["lat"], env["coordinates"]["lon"]
5361
if lat is None or lon is None:
5462
continue

services/westbank-alerts/app/serving/provenance.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
LIVE_HOURS = 1.0 # <= this age → "live"
1515

1616
_TRUST = {"admin": 0.9, "crowd": 0.4}
17+
# Phase 1: a crowd report's trust is driven by the reporting channel's observed
18+
# reliability (resolved by the caller), bounded so it keeps a floor of signal
19+
# and never reaches admin (0.9). Falls back to the flat 0.4 prior when the
20+
# caller has no reliability for the channel.
21+
_CROWD_TRUST_FLOOR = 0.2
22+
_CROWD_TRUST_CEIL = 0.85
1723

1824

1925
def _as_datetime(value: Union[datetime, str, None]) -> Optional[datetime]:
@@ -82,7 +88,24 @@ def effective_status(
8288
return cp.get("status") or "unknown"
8389

8490

85-
def source_trust(last_source_type: Optional[str]) -> float:
91+
def source_trust(
92+
last_source_type: Optional[str],
93+
channel_reliability: Optional[float] = None,
94+
) -> float:
95+
"""Trust score for a checkpoint's most recent report.
96+
97+
admin reports stay authoritative (0.9). A crowd report is scored by the
98+
reporting channel's reliability weight when the caller supplies one
99+
(bounded to [_CROWD_TRUST_FLOOR, _CROWD_TRUST_CEIL] so it carries a floor
100+
of signal and never reaches admin); otherwise the flat 0.4 prior is used.
101+
"""
102+
if last_source_type == "admin":
103+
return _TRUST["admin"]
104+
if last_source_type == "crowd":
105+
if channel_reliability is None:
106+
return _TRUST["crowd"]
107+
return round(min(_CROWD_TRUST_CEIL,
108+
max(_CROWD_TRUST_FLOOR, channel_reliability)), 3)
86109
return _TRUST.get(last_source_type or "", 0.0)
87110

88111

@@ -91,6 +114,7 @@ def checkpoint_envelope(
91114
*,
92115
stale_hours: float = DEFAULT_STALE_HOURS,
93116
now: Optional[datetime] = None,
117+
channel_reliability: Optional[float] = None,
94118
) -> dict:
95119
"""Canonical checkpoint record for the /v2 feeds and route endpoints.
96120
@@ -125,7 +149,7 @@ def checkpoint_envelope(
125149
"freshness": fresh,
126150
"source_trust": {
127151
"last_source_type": cp.get("last_source_type"),
128-
"trust": source_trust(cp.get("last_source_type")),
152+
"trust": source_trust(cp.get("last_source_type"), channel_reliability),
129153
},
130154
"provenance": {
131155
"last_msg_id": cp.get("last_msg_id"),

services/westbank-alerts/test_provenance.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,27 @@ def test_trust_admin_beats_crowd_beats_none():
7777
assert P.source_trust("admin") > P.source_trust("crowd") > P.source_trust(None)
7878

7979

80+
def test_trust_crowd_uses_channel_reliability_when_provided():
81+
# Phase 1: a crowd report from a reputable checkpoint channel beats the
82+
# flat 0.4 prior — trust must discriminate by channel.
83+
assert P.source_trust("crowd", channel_reliability=0.75) == 0.75
84+
85+
86+
def test_trust_crowd_defaults_to_flat_prior_without_reliability():
87+
# Backward compatible: when no reliability is resolved, keep the 0.4 prior.
88+
assert P.source_trust("crowd") == 0.4
89+
90+
91+
def test_trust_crowd_capped_below_admin_and_floored():
92+
# Crowd never reaches admin (0.9); a near-zero channel keeps a little signal.
93+
assert P.source_trust("crowd", channel_reliability=0.99) == 0.85
94+
assert P.source_trust("crowd", channel_reliability=0.05) == 0.2
95+
96+
97+
def test_trust_admin_stays_authoritative_regardless_of_channel():
98+
assert P.source_trust("admin", channel_reliability=0.6) == 0.9
99+
100+
80101
# ── checkpoint_envelope ──────────────────────────────────────────────────────
81102

82103
def _cp(**over):
@@ -113,6 +134,14 @@ def test_checkpoint_envelope_stale_row_effective_unknown():
113134
assert env["effective_status"] == "unknown"
114135

115136

137+
def test_checkpoint_envelope_threads_channel_reliability():
138+
# Phase 1: a crowd report's trust reflects the channel's reliability weight,
139+
# resolved by the caller and passed in (provenance stays I/O-free).
140+
env = P.checkpoint_envelope(_cp(last_source_type="crowd"),
141+
channel_reliability=0.75, now=NOW)
142+
assert env["source_trust"]["trust"] == 0.75
143+
144+
116145
def test_checkpoint_envelope_permanent_closure_marks_closed_even_if_stale():
117146
env = P.checkpoint_envelope(
118147
_cp(status="open", permanent_status="closed_since:2023-10",
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Phase 1: the /v2 checkpoint envelope threads per-channel reliability into
2+
the crowd trust score. _cp_envelope is pure given (cp, rel_map) — no DB — so
3+
the rel_map lookup, case-insensitive channel match, and fallback are unit-tested
4+
here; the bulk DB getter (get_channel_reliability_map) is exercised live.
5+
"""
6+
import os
7+
os.environ.setdefault("API_SECRET_KEY", "test-secret-key-0123456789abcdef")
8+
9+
from app.routers import v2
10+
11+
_REL = {"ahwalaltreq": 0.75, "rt_arabic": 0.45}
12+
13+
14+
def _cp(**over):
15+
base = {
16+
"canonical_key": "x", "source_channel": "AhwalAlTreq",
17+
"last_source_type": "crowd", "status": "open", "last_updated_iso": None,
18+
}
19+
base.update(over)
20+
return base
21+
22+
23+
def test_crowd_trust_uses_channel_weight_case_insensitive():
24+
# "AhwalAlTreq" matches the lowercased "ahwalaltreq" key.
25+
assert v2._cp_envelope(_cp(), _REL)["source_trust"]["trust"] == 0.75
26+
27+
28+
def test_crowd_trust_falls_back_for_unknown_channel():
29+
assert v2._cp_envelope(_cp(source_channel="randomchan"), _REL)["source_trust"]["trust"] == 0.4
30+
31+
32+
def test_admin_trust_unaffected_by_channel_weight():
33+
assert v2._cp_envelope(_cp(last_source_type="admin"), _REL)["source_trust"]["trust"] == 0.9
34+
35+
36+
def test_no_rel_map_preserves_legacy_crowd_prior():
37+
assert v2._cp_envelope(_cp())["source_trust"]["trust"] == 0.4

0 commit comments

Comments
 (0)