feat(alerts): address review hardening for multi-source road alerts (#31)

This commit is contained in:
Developer 2026-10-10 17:46:01 -04:00
parent bcae4881d6
commit 625999245e
8 changed files with 414 additions and 74 deletions

View File

@ -13,12 +13,35 @@ logger = logging.getLogger(__name__)
class Georgia511Adapter:
"""
Adapter for Georgia 511 (GDOT) official traffic events and road closure feeds.
Strictly rate-limited (10 calls/min) with circuit breaking and zero unauthorized calls.
Experimental Adapter for Georgia 511 (GDOT) official traffic and incident feeds.
=== Upstream Endpoint & Architecture ===
Base Endpoint: https://511ga.org/api/getEvents
Alternative Feeds: GDOT Navigator XML / GeoJSON open feeds.
=== Access Requirements ===
- API Key: Requires registration via the Georgia DOT 511 Developer Portal
or formal open data licensing agreement from GDOT.
- Configuration: Configured via environment variable `GA511_API_KEY`.
- Live Verification Status: UNVERIFIED. Live production access has not yet
been provisioned with GDOT; integration operates in EXPERIMENTAL mode.
- Safe Default: Disabled by default (`GA511_ENABLED=false`). When enabled
without an API key, fixture/mock mode is used to prevent unauthorized calls.
=== Response Parsing Specification ===
Accepts GDOT Event objects:
- `id` / `eventId`: Unique state event identifier.
- `geometry` or `latitude`/`longitude`: WGS84 point coordinates.
- `type` / `event_type`: Categorized into accident, roadClosure, or hazard.
- `headline` / `description`: Roadway incident description.
- `roadwayName`: Canonical highway name (e.g. "I-75 N", "I-285 W").
Rate limit: Strictly throttled with geographic cell caching (0.08°) and
circuit breaking (3 failures = 5-minute trip).
"""
PROVIDER_ID = "ga511_official"
PROVIDER_NAME = "Georgia 511"
PROVIDER_NAME = "Georgia 511 (Experimental)"
BASE_URL = "https://511ga.org/api/getEvents"
def __init__(

View File

@ -1,7 +1,11 @@
import os
import time
from collections import deque
from datetime import datetime, timezone
from threading import Lock
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, status
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from road_alerts.ga511_adapter import Georgia511Adapter
from road_alerts.models import (
@ -19,21 +23,69 @@ router = APIRouter()
zyla_adapter = ZylaWazeAdapter()
ga511_adapter = Georgia511Adapter()
# Geographic boundary constraint: Maximum allowable span in degrees (approx 111 km x 111 km)
MAX_GEOGRAPHIC_SPAN_DEGREES = 1.0
@router.get("/feed", response_model=AlertFeedResponse)
def get_aggregated_feed(
min_lat: float = Query(..., ge=-90.0, le=90.0, description="Minimum latitude (South)"),
min_lng: float = Query(..., ge=-180.0, le=180.0, description="Minimum longitude (West)"),
max_lat: float = Query(..., ge=-90.0, le=90.0, description="Maximum latitude (North)"),
max_lng: float = Query(..., ge=-180.0, le=180.0, description="Maximum longitude (East)"),
include_zyla: bool = Query(default=True, description="Query Zyla Waze Data #4814 if enabled"),
include_ga511: bool = Query(default=True, description="Query Georgia 511 if enabled"),
fixture_override: Optional[str] = Query(default=None, description="Test fixture override name"),
class RateLimiter:
"""
Sliding window in-memory rate limiter for backend gateway endpoints.
Protects upstream budgets and prevents abuse.
"""
def __init__(self, max_requests: int = 60, window_seconds: float = 60.0):
self.max_requests = max_requests
self.window_seconds = window_seconds
self.timestamps = deque()
self.lock = Lock()
def check_and_record(self) -> bool:
now = time.time()
with self.lock:
cutoff = now - self.window_seconds
while self.timestamps and self.timestamps[0] < cutoff:
self.timestamps.popleft()
if len(self.timestamps) >= self.max_requests:
return False
self.timestamps.append(now)
return True
# 60 requests per minute gateway rate limit
gateway_rate_limiter = RateLimiter(max_requests=60, window_seconds=60.0)
def verify_rate_limit():
if not gateway_rate_limiter.check_and_record():
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded. Maximum 60 requests per minute allowed.",
)
def verify_gateway_auth(
x_gateway_key: Optional[str] = Header(None, alias="X-MotorSignal-Gateway-Key"),
):
"""
Retrieves multi-source road alerts within the specified geographic bounding box.
Returns normalized alerts with provider attribution and confidence scores.
"""
required_secret = os.environ.get("MOTORSIGNAL_GATEWAY_SECRET")
if required_secret and x_gateway_key != required_secret:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing MotorSignal Gateway key",
)
def verify_admin_auth(
x_admin_key: Optional[str] = Header(None, alias="X-MotorSignal-Admin-Key"),
):
required_admin = os.environ.get("MOTORSIGNAL_ADMIN_KEY")
if required_admin and x_admin_key != required_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Unauthorized access to Ops switch controls",
)
def validate_bounding_box(min_lat: float, min_lng: float, max_lat: float, max_lng: float):
if min_lat > max_lat:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -44,6 +96,38 @@ def get_aggregated_feed(
status_code=status.HTTP_400_BAD_REQUEST,
detail="min_lng cannot be greater than max_lng",
)
lat_span = max_lat - min_lat
lng_span = max_lng - min_lng
if lat_span > MAX_GEOGRAPHIC_SPAN_DEGREES or lng_span > MAX_GEOGRAPHIC_SPAN_DEGREES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Bounding box exceeds maximum allowed geographic span of "
f"{MAX_GEOGRAPHIC_SPAN_DEGREES} degrees (~111 km). "
f"Requested lat span: {lat_span:.3f}, lng span: {lng_span:.3f}."
),
)
@router.get(
"/feed",
response_model=AlertFeedResponse,
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_auth)],
)
def get_aggregated_feed(
min_lat: float = Query(..., ge=-90.0, le=90.0, description="Minimum latitude (South)"),
min_lng: float = Query(..., ge=-180.0, le=180.0, description="Minimum longitude (West)"),
max_lat: float = Query(..., ge=-90.0, le=90.0, description="Maximum latitude (North)"),
max_lng: float = Query(..., ge=-180.0, le=180.0, description="Maximum longitude (East)"),
include_zyla: bool = Query(default=True, description="Query Zyla Waze Data #4814 if enabled"),
include_ga511: bool = Query(default=True, description="Query Georgia 511 if enabled"),
fixture_override: Optional[str] = Query(default=None, description="Test fixture override name"),
):
"""
Retrieves multi-source road alerts within the bounded geographic box.
Enforces geographic bounds (<= 1.0 degree) and rate limits.
"""
validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
all_alerts: list[NormalizedAlert] = []
providers_polled: list[str] = []
@ -69,9 +153,6 @@ def get_aggregated_feed(
all_alerts.extend(ga_alerts)
providers_polled.append(ga511_adapter.PROVIDER_ID)
# Cross-source deduplication: if two alerts from different sources are in
# the exact same category and within close proximity (< 150m), keep both
# or attribute distinctly without corrupting native alerts.
return AlertFeedResponse(
success=True,
count=len(all_alerts),
@ -81,7 +162,11 @@ def get_aggregated_feed(
)
@router.get("/zyla", response_model=AlertFeedResponse)
@router.get(
"/zyla",
response_model=AlertFeedResponse,
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_auth)],
)
def get_zyla_alerts(
min_lat: float = Query(..., ge=-90.0, le=90.0),
min_lng: float = Query(..., ge=-180.0, le=180.0),
@ -91,7 +176,10 @@ def get_zyla_alerts(
):
"""
Direct endpoint for Zyla Labs Waze Data API #4814.
Enforces geographic box bounds and server-side budget limits.
"""
validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
alerts = zyla_adapter.fetch_alerts(
min_lat=min_lat,
min_lng=min_lng,
@ -108,7 +196,11 @@ def get_zyla_alerts(
)
@router.get("/ga511", response_model=AlertFeedResponse)
@router.get(
"/ga511",
response_model=AlertFeedResponse,
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_auth)],
)
def get_ga511_alerts(
min_lat: float = Query(..., ge=-90.0, le=90.0),
min_lng: float = Query(..., ge=-180.0, le=180.0),
@ -117,7 +209,10 @@ def get_ga511_alerts(
):
"""
Direct endpoint for official Georgia 511 incident feeds.
Enforces geographic box bounds.
"""
validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
alerts = ga511_adapter.fetch_alerts(
min_lat=min_lat,
min_lng=min_lng,
@ -155,10 +250,11 @@ def get_ops_health():
)
@router.post("/ops/switches")
@router.post("/ops/switches", dependencies=[Depends(verify_admin_auth)])
def update_provider_switch(payload: ProviderSwitchRequest):
"""
Runtime enable/disable switch and circuit control for external providers.
Requires admin authorization if configured.
"""
if payload.provider_id == zyla_adapter.PROVIDER_ID:
zyla_adapter.enabled = payload.enabled

View File

@ -11,19 +11,30 @@ from road_alerts.models import NormalizedAlert, ProviderHealth
logger = logging.getLogger(__name__)
# Category TTLs in minutes
# Category and Subtype-aware Freshness & TTL Policies (in minutes)
# Mobile speed traps and traffic jams dissipate quickly;
# infrastructure repairs, potholes, and full road closures persist across days/shifts.
CATEGORY_TTL_MINUTES = {
"police": 60,
"hazard": 120,
"debris": 120,
"pothole": 1440,
"weatherHazard": 120,
"accident": 180,
"roadClosure": 1440,
"traffic": 45,
"police": 45, # Mobile police presence changes within 30-45m
"traffic": 30, # Congestion patterns shift quickly
"debris": 60, # Debris/objects on road cleared or moved
"hazard": 90, # General roadway hazards
"accident": 120, # Average traffic accident duration
"weatherHazard": 120, # Flooding, ice, or dense fog
"pothole": 1440, # Physical pavement damage persists (24 hours)
"construction": 1440, # Planned work zone shifts (24 hours)
"roadClosure": 1440, # Roadway closed for civil works or major events (24 hours)
"other": 60,
}
SUBTYPE_TTL_MINUTES = {
"police_speed_trap": 30, # Stationary radar/laser traps move frequently
"police_visible": 45,
"accident_minor": 60, # Minor incidents clear faster
"accident_major": 180, # Multi-lane highway closures take longer
"construction": 1440,
}
class ZylaWazeAdapter:
"""
@ -169,7 +180,7 @@ class ZylaWazeAdapter:
if "POLICE" in raw_type or "POLICE" in raw_subtype:
category = "police"
if "HIDDEN" in raw_subtype:
if "HIDDEN" in raw_subtype or "HIDING" in raw_subtype:
subtype = "police_speed_trap"
elif "VISIBLE" in raw_subtype:
subtype = "police_visible"
@ -197,8 +208,11 @@ class ZylaWazeAdapter:
category = "hazard"
subtype = "hazard_on_road"
# 4. Freshness and TTL calculation
ttl_minutes = CATEGORY_TTL_MINUTES.get(category, 60)
# 4. Freshness and TTL calculation based on category & subtype
ttl_minutes = (
SUBTYPE_TTL_MINUTES.get(subtype)
or CATEGORY_TTL_MINUTES.get(category, 60)
)
expires_at = reported_at + timedelta(minutes=ttl_minutes)
is_stale = now > expires_at

View File

@ -110,6 +110,39 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
self.assertEqual(pothole.subtype, "pothole")
self.assertEqual(pothole.road_name, "Peachtree Rd NE")
def test_granular_freshness_policies(self):
"""
Verifies that freshness policies enforce category/subtype TTLs:
- Police speed trap (30m) expires after 35 minutes.
- Road closure (24h) remains active after 4 hours.
"""
now = datetime.now(timezone.utc)
thirty_five_mins_ago = int((now.timestamp() - 35 * 60) * 1000)
four_hours_ago = int((now.timestamp() - 4 * 3600) * 1000)
# 1. Police speed trap reported 35 mins ago -> expired
raw_police = {
"uuid": "trap_test_1",
"type": "POLICE",
"subtype": "POLICE_HIDING",
"location": {"x": -85.17, "y": 34.25},
"pubMillis": thirty_five_mins_ago,
}
res_police = self.adapter.normalize_alert(raw_police, now)
self.assertIsNone(res_police)
# 2. Road closure reported 4 hours ago -> still valid (24h TTL)
raw_closure = {
"uuid": "closure_test_1",
"type": "ROAD_CLOSED",
"subtype": "ROAD_CLOSED_CONSTRUCTION",
"location": {"x": -85.17, "y": 34.25},
"pubMillis": four_hours_ago,
}
res_closure = self.adapter.normalize_alert(raw_closure, now)
self.assertIsNotNone(res_closure)
self.assertEqual(res_closure.category, "roadClosure")
def test_zyla_disabled_by_policy_never_requests(self):
"""
Ensures that when Zyla is disabled or API key is absent:
@ -189,10 +222,31 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
self.assertGreaterEqual(len(alerts), 1)
first = alerts[0]
self.assertEqual(first.source, "Georgia 511")
self.assertIn("Georgia 511", first.source)
self.assertEqual(first.source_type, "official_dot")
self.assertAlmostEqual(first.confidence, 0.95)
def test_bounding_box_limit_enforcement(self):
"""
Verifies that geographic queries exceeding 1.0 degree span are rejected.
"""
from road_alerts.router import validate_bounding_box
from fastapi import HTTPException
# Valid box within 1.0 degree span
validate_bounding_box(34.2, -85.2, 34.8, -84.8)
# Inverted coordinates
with self.assertRaises(HTTPException) as ctx:
validate_bounding_box(35.0, -85.0, 34.0, -84.0)
self.assertEqual(ctx.exception.status_code, 400)
# Overly broad box exceeding 1.0 degree
with self.assertRaises(HTTPException) as ctx:
validate_bounding_box(30.0, -90.0, 35.0, -80.0)
self.assertEqual(ctx.exception.status_code, 400)
self.assertIn("maximum allowed geographic span", ctx.exception.detail)
if __name__ == "__main__":
unittest.main()

View File

@ -929,6 +929,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
subtitle:
'Real-time crowdsourced police traps, road debris, and hazard alerts from nearby riders and pack members across the United States.',
status: swarmStatus,
isEnabled: swarmEnabled,
),
const SizedBox(height: 12),
SwitchListTile.adaptive(
@ -999,6 +1000,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
subtitle:
'Offline speed cameras, radar traps & photo enforcement across the United States. 100% operational in mountain passes & cellular dead zones.',
status: osmStatus,
isEnabled: usCamerasEnabled,
),
const SizedBox(height: 12),
SwitchListTile.adaptive(
@ -1050,6 +1052,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
subtitle:
'Commercial licensed Waze Data feed via MotorSignal API backend. Disabled by default; client never holds credentials.',
status: zylaStatus,
isEnabled: zylaEnabled,
),
const SizedBox(height: 12),
SwitchListTile.adaptive(
@ -1163,10 +1166,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
_buildProviderHeader(
icon: Icons.account_balance,
iconColor: Colors.deepOrangeAccent,
title: 'Georgia 511 (Official State Feed)',
title: 'Georgia 511 (Experimental Official Feed)',
subtitle:
'Official GDOT traffic, incident, construction, and closure open data via MotorSignal backend gateway.',
'Experimental GDOT traffic, incident, construction, and closure open data via MotorSignal backend gateway. Live GDOT production credentials unverified.',
status: ga511Status,
isEnabled: ga511Enabled,
),
const SizedBox(height: 12),
SwitchListTile.adaptive(
@ -1203,7 +1207,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
const SizedBox(width: 8),
Expanded(
child: Text(
'Official public agency data feed. High trust, zero third-party subscription costs.',
'Experimental integration. Live GDOT production credentials unverified; uses bounded caching and verified incident datasets.',
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
@ -1284,6 +1288,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
subtitle:
'Optional community hazard & traffic feed inspired by ArgeliusLabs/waze_osint. Disabled by default; zero paid subscriptions.',
status: wazeOsintStatus,
isEnabled: wazeOsintEnabled,
),
const SizedBox(height: 12),
SwitchListTile.adaptive(
@ -1397,6 +1402,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
subtitle:
'Commercial telematics feed for verified mobile speed traps, stationary radar, and red light cameras.',
status: hereStatus,
isEnabled: hereEnabled,
),
const SizedBox(height: 12),
SwitchListTile.adaptive(
@ -1499,6 +1505,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Widget _buildCommunityProviderCard(BuildContext context) {
final colors = context.motorColors;
final communityStatus = ref.watch(communityStatusProvider);
final communityKey = ref.watch(communityApiKeyProvider);
return Card(
clipBehavior: Clip.antiAlias,
@ -1517,6 +1524,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
title: 'Community Traffic Provider',
subtitle: 'Supplies Police, Speed Cameras, and Crowd Hazards.',
status: communityStatus,
isEnabled: communityKey.trim().isNotEmpty,
),
const SizedBox(height: 16),
TextField(
@ -1622,6 +1630,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Widget _buildTomTomProviderCard(BuildContext context) {
final colors = context.motorColors;
final tomtomStatus = ref.watch(tomtomStatusProvider);
final tomtomKey = ref.watch(tomtomApiKeyProvider);
return Card(
clipBehavior: Clip.antiAlias,
@ -1641,6 +1650,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
subtitle:
'Supplies Accidents, Hazards, Closures & Construction data.',
status: tomtomStatus,
isEnabled: tomtomKey.trim().isNotEmpty,
),
const SizedBox(height: 16),
TextField(
@ -2250,6 +2260,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
required String title,
required String subtitle,
required RoadAlertProviderStatus status,
bool isEnabled = true,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
@ -2279,9 +2290,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
softWrap: true,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: _buildStatusBadge(status),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
_buildStateBadge(
label: isEnabled ? 'Enabled' : 'Disabled',
color: isEnabled
? context.motorColors.launchGreen
: context.motorColors.outlineVariant,
icon: isEnabled
? Icons.check_circle_outline
: Icons.pause_circle_outline,
),
_buildStatusBadge(status),
],
),
],
),
@ -2294,49 +2317,73 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final colors = context.motorColors;
String label;
Color color;
IconData icon;
switch (status) {
case RoadAlertProviderStatus.connected:
label = 'Connected';
label = 'Verified & Reachable';
color = colors.launchGreen;
icon = Icons.verified_outlined;
break;
case RoadAlertProviderStatus.configured:
label = 'Configured';
color = colors.cyberCyan;
icon = Icons.tune;
break;
case RoadAlertProviderStatus.authError:
label = 'Auth Error';
color = colors.raceRed;
icon = Icons.lock_outline;
break;
case RoadAlertProviderStatus.rateLimited:
label = 'Rate Limited';
color = colors.warningAmber;
icon = Icons.speed;
break;
case RoadAlertProviderStatus.networkError:
label = 'Network Error';
label = 'Unreachable';
color = colors.warningAmber;
icon = Icons.cloud_off;
break;
case RoadAlertProviderStatus.notConfigured:
label = 'Not Set';
label = 'Not Configured';
color = colors.outlineVariant;
icon = Icons.info_outline;
break;
}
return _buildStateBadge(label: label, color: color, icon: icon);
}
Widget _buildStateBadge({
required String label,
required Color color,
IconData? icon,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
border: Border.all(color: color),
borderRadius: BorderRadius.circular(8),
color: color.withValues(alpha: 0.12),
border: Border.all(color: color.withValues(alpha: 0.7)),
borderRadius: BorderRadius.circular(6),
),
child: Text(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 12, color: color),
const SizedBox(width: 4),
],
Text(
label,
style: TextStyle(
color: color,
fontSize: 11,
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}

View File

@ -24,7 +24,7 @@ class Georgia511RoadAlertProvider implements RoadAlertProvider {
String get id => 'ga511_official';
@override
String get name => 'Georgia 511';
String get name => 'Georgia 511 (Experimental)';
@override
Set<RoadAlertCategory> get supportedCategories => const {
@ -128,7 +128,7 @@ class Georgia511RoadAlertProvider implements RoadAlertProvider {
severity: category == RoadAlertCategory.accident || category == RoadAlertCategory.roadClosure
? RoadAlertSeverity.high
: RoadAlertSeverity.medium,
source: 'Georgia 511',
source: raw['source']?.toString() ?? 'Georgia 511 (Experimental)',
sourceType: RoadAlertSourceType.providerReported,
status: RoadAlertStatus.active,
distanceFromUser: distance,

View File

@ -2,26 +2,68 @@ import 'dart:math' as math;
import 'package:geolocator/geolocator.dart';
import 'road_alert_models.dart';
/// Origin family of an alert source to distinguish independent corroboration
/// from redundant feeds of the same upstream provider network.
enum AlertOriginFamily {
motorSignalSwarm,
waze,
stateDot,
openStreetMap,
hereTechnologies,
tomTom,
other,
}
/// Deduplicates road alerts across multiple heterogeneous sources (Native Swarm,
/// Zyla Waze 4814, Georgia 511, OpenStreetMap speed cameras, etc.).
///
/// Preserves native MotorSignal community reports and their authoritative voting identities.
/// Merges co-located reports within [proximityMeters] that share the same or equivalent category.
/// Strictly enforces that sibling feeds from the same origin family (e.g. Waze OSINT + Zyla Waze)
/// are treated as redundant data rather than independent multi-source corroboration.
class MultiSourceAlertDeduplicator {
/// Maximum distance in meters between two alerts of the same category to be considered duplicates.
static const double defaultProximityMeters = 100.0;
/// Classifies an alert into its underlying origin family.
static AlertOriginFamily getOriginFamily(RoadAlert alert) {
final pid = alert.providerId.toLowerCase();
final src = alert.source.toLowerCase();
if (alert.sourceType == RoadAlertSourceType.userReported ||
pid.contains('swarm')) {
return AlertOriginFamily.motorSignalSwarm;
}
if (pid.contains('waze') || src.contains('waze')) {
return AlertOriginFamily.waze;
}
if (pid.contains('ga511') || pid.contains('dot') || src.contains('511')) {
return AlertOriginFamily.stateDot;
}
if (pid.contains('osm')) {
return AlertOriginFamily.openStreetMap;
}
if (pid.contains('here')) {
return AlertOriginFamily.hereTechnologies;
}
if (pid.contains('tomtom')) {
return AlertOriginFamily.tomTom;
}
return AlertOriginFamily.other;
}
/// Deduplicates a list of [RoadAlert]s.
///
/// Guarantees:
/// 1. Native user-reported swarm alerts (`sourceType == RoadAlertSourceType.userReported`)
/// always take precedence as the canonical alert so Firestore vote identities and
/// moderation status remain intact.
/// 2. If an external alert matches a native swarm alert, the native alert's confidence is
/// boosted (multi-source cross-verification), but its ID and vote state are untouched.
/// 3. Between multiple external provider alerts, the alert with higher confidence, official
/// 2. If an external alert matches a native swarm alert from an INDEPENDENT origin family,
/// the native alert's confidence is boosted (multi-source cross-verification).
/// 3. Sibling feeds sharing the same origin family (e.g. Waze OSINT and Zyla Waze Data)
/// are deduplicated without artificial confidence inflation or false cross-source attribution.
/// 4. Between multiple external provider alerts, the alert with higher confidence, official
/// agency attribution, or more recent timestamp is selected.
/// 4. Dismissed alerts (by ID or composite key) are excluded.
/// 5. Dismissed alerts (by ID or composite key) are excluded.
static List<RoadAlert> deduplicate(
List<RoadAlert> alerts, {
Set<String> dismissedIds = const {},
@ -39,8 +81,9 @@ class MultiSourceAlertDeduplicator {
// Sort by priority order so that the highest-priority alert in each cluster becomes primary:
// 1. Native Swarm userReported first
// 2. Official feeds (e.g. GA511) next
// 3. Higher confidence next
// 4. More recent reportedAt next
// 3. Licensed API (Zyla) over experimental OSINT scraper
// 4. Higher confidence next
// 5. More recent reportedAt next
activeAlerts.sort((a, b) {
final aIsNative =
a.sourceType == RoadAlertSourceType.userReported ||
@ -58,6 +101,11 @@ class MultiSourceAlertDeduplicator {
if (aIsOfficial && !bIsOfficial) return -1;
if (!aIsOfficial && bIsOfficial) return 1;
final aIsZyla = a.providerId.contains('zyla');
final bIsZyla = b.providerId.contains('zyla');
if (aIsZyla && !bIsZyla) return -1;
if (!aIsZyla && bIsZyla) return 1;
final confA = a.confidence ?? 0.5;
final confB = b.confidence ?? 0.5;
if ((confA - confB).abs() > 0.05) {
@ -126,17 +174,33 @@ class MultiSourceAlertDeduplicator {
static RoadAlert _mergeCluster(
RoadAlert primary, List<RoadAlert> duplicates) {
// Boost confidence if corroborated by other sources
final baseConfidence = primary.confidence ?? 0.7;
final boostedConfidence =
math.min(1.0, baseConfidence + (0.1 * duplicates.length));
final primaryFamily = getOriginFamily(primary);
// Combine distinct source names without leaking internal keys
final sources = {primary.source, ...duplicates.map((d) => d.source)}
.where((s) => s.isNotEmpty)
.toList();
final combinedSource =
sources.length > 1 ? sources.join(' + ') : primary.source;
// Identify INDEPENDENT distinct origin families
final otherDistinctFamilies = <AlertOriginFamily>{};
for (final d in duplicates) {
final family = getOriginFamily(d);
if (family != primaryFamily) {
otherDistinctFamilies.add(family);
}
}
// Only boost confidence if confirmed by an independent origin family
final baseConfidence = primary.confidence ?? 0.7;
final boostedConfidence = otherDistinctFamilies.isNotEmpty
? math.min(1.0, baseConfidence + (0.1 * otherDistinctFamilies.length))
: baseConfidence;
// Combine distinct source names ONLY across distinct origin families:
// Sibling feeds from the same origin (e.g. Waze OSINT + Zyla Waze) do not duplicate attribution
final sourceStrings = <String>[primary.source];
for (final family in otherDistinctFamilies) {
final representative = duplicates.firstWhere(
(d) => getOriginFamily(d) == family,
);
sourceStrings.add(representative.source);
}
final combinedSource = sourceStrings.join(' + ');
return primary.copyWith(
confidence: boostedConfidence,

View File

@ -108,6 +108,48 @@ void main() {
expect(merged.first.source, contains('Zyla Waze Data #4814'));
});
test('does NOT treat sibling Waze feeds as independent corroboration', () {
final zylaAlert = RoadAlert(
id: 'zyla_waze_1',
providerId: 'zyla_waze_4814',
category: RoadAlertCategory.police,
latitude: 34.2560,
longitude: -85.1650,
description: 'Police on Turner McCall',
reportedAt: now.subtract(const Duration(minutes: 5)),
lastUpdated: now,
confidence: 0.75,
source: 'Zyla Waze Data #4814',
sourceType: RoadAlertSourceType.providerReported,
);
final wazeOsintAlert = RoadAlert(
id: 'waze_osint_1',
providerId: 'waze_osint',
category: RoadAlertCategory.police,
latitude: 34.2561, // ~15m away
longitude: -85.1651,
description: 'Police report on Turner McCall',
reportedAt: now.subtract(const Duration(minutes: 7)),
lastUpdated: now,
confidence: 0.70,
source: 'Waze Community (Experimental OSINT)',
sourceType: RoadAlertSourceType.providerReported,
);
final merged = MultiSourceAlertDeduplicator.deduplicate([zylaAlert, wazeOsintAlert]);
expect(merged.length, equals(1));
final canonical = merged.first;
// Zyla (licensed feed) is chosen as primary over OSINT scraper
expect(canonical.id, equals('zyla_waze_1'));
// No confidence inflation because they are sibling feeds from the same origin family
expect(canonical.confidence, equals(0.75));
// Attribution remains single authoritative Zyla Waze feed
expect(canonical.source, equals('Zyla Waze Data #4814'));
expect(canonical.source, isNot(contains('Waze Community (Experimental OSINT)')));
});
test('does NOT merge alerts of different categories at the same location', () {
final policeAlert = RoadAlert(
id: 'alert_p1',