diff --git a/backend/motorsignal_api/app/road_alerts/ga511_adapter.py b/backend/motorsignal_api/app/road_alerts/ga511_adapter.py index 6e64373..03a85d6 100644 --- a/backend/motorsignal_api/app/road_alerts/ga511_adapter.py +++ b/backend/motorsignal_api/app/road_alerts/ga511_adapter.py @@ -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__( diff --git a/backend/motorsignal_api/app/road_alerts/router.py b/backend/motorsignal_api/app/road_alerts/router.py index 8a41654..c177f67 100644 --- a/backend/motorsignal_api/app/road_alerts/router.py +++ b/backend/motorsignal_api/app/road_alerts/router.py @@ -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 diff --git a/backend/motorsignal_api/app/road_alerts/zyla_adapter.py b/backend/motorsignal_api/app/road_alerts/zyla_adapter.py index 95025a9..6bb750d 100644 --- a/backend/motorsignal_api/app/road_alerts/zyla_adapter.py +++ b/backend/motorsignal_api/app/road_alerts/zyla_adapter.py @@ -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 diff --git a/backend/motorsignal_api/tests/test_road_alerts.py b/backend/motorsignal_api/tests/test_road_alerts.py index ba7abdc..84b7957 100644 --- a/backend/motorsignal_api/tests/test_road_alerts.py +++ b/backend/motorsignal_api/tests/test_road_alerts.py @@ -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() diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 8885533..4a47b51 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -929,6 +929,7 @@ class _SettingsScreenState extends ConsumerState { 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 { 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 { 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 { _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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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,48 +2317,72 @@ class _SettingsScreenState extends ConsumerState { 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( - label, - style: TextStyle( - color: color, - fontSize: 11, - fontWeight: FontWeight.bold, - ), + 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.w600, + ), + ), + ], ), ); } diff --git a/lib/services/alerts/ga511_road_alert_provider.dart b/lib/services/alerts/ga511_road_alert_provider.dart index d45f85c..6a7e58c 100644 --- a/lib/services/alerts/ga511_road_alert_provider.dart +++ b/lib/services/alerts/ga511_road_alert_provider.dart @@ -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 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, diff --git a/lib/services/alerts/multi_source_alert_deduplicator.dart b/lib/services/alerts/multi_source_alert_deduplicator.dart index cf12843..c9aab90 100644 --- a/lib/services/alerts/multi_source_alert_deduplicator.dart +++ b/lib/services/alerts/multi_source_alert_deduplicator.dart @@ -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 deduplicate( List alerts, { Set 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 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 = {}; + 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 = [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, diff --git a/test/multi_source_road_alerts_test.dart b/test/multi_source_road_alerts_test.dart index 9c88f49..8b4a209 100644 --- a/test/multi_source_road_alerts_test.dart +++ b/test/multi_source_road_alerts_test.dart @@ -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',