From 46dd0df9493358d91cb5cb39bc3bb11633d8fe59 Mon Sep 17 00:00:00 2001 From: Dom <168790843+firestar5683@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:48:58 -0600 Subject: [PATCH] Reuse exact Ioniq native structural admission per observation Retain all live freshness, session, permission and original lease checks. Reuse only successful structural validation for exact payload, timestamp and complete CP inputs within each owner. Unsupported profiles fall back to full validation. Add 50 invalidation and fallback regressions. Validated affected AOL, SelfdriveD and Controls suites, unchanged Card fixture failures, full lint/type checks and deterministic owner outputs. Vehicle-level dropout resolution remains unverified. --- openpilot/starpilot/aol/runtime.py | 42 ++- .../test_native_observation_admission.py | 293 ++++++++++++++++++ openpilot/starpilot/aol/vehicle.py | 8 + openpilot/starpilot/car/hyundai/aol.py | 14 + 4 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 openpilot/starpilot/aol/tests/test_native_observation_admission.py diff --git a/openpilot/starpilot/aol/runtime.py b/openpilot/starpilot/aol/runtime.py index 74714ae709..7824f29573 100644 --- a/openpilot/starpilot/aol/runtime.py +++ b/openpilot/starpilot/aol/runtime.py @@ -9,7 +9,7 @@ from __future__ import annotations from dataclasses import dataclass from opendbc.car.structs import car -from openpilot.starpilot.aol.vehicle import native_matches_cp, native_profile_supported +from openpilot.starpilot.aol.vehicle import native_admission_key, native_matches_cp, native_profile_supported from openpilot.starpilot.aol.wire import INTENT_SERVICE, SAFETY_SERVICE, IntentState, decode_intent, decode_safety INTENT_MAX_AGE_NS = 30_000_000 @@ -105,23 +105,57 @@ def native_inventory_matches_cp(state, CP) -> bool: return False +def _clear_native_admission(sm): + try: + sm._aol_native_admission = None + except Exception: + # Custom/slotted owners may not support this optional optimization. + pass + + +def _native_admission_matches(sm, CP, state, raw): + key = None + try: + cp_key = native_admission_key(CP) if state.pandaInventory else None + if cp_key is not None and isinstance(raw, bytes): + key = (raw, int(sm.logMonoTime[SAFETY_SERVICE]), cp_key) + if getattr(sm, '_aol_native_admission', None) == key: + return True + except Exception: + # Cache plumbing may never replace or prevent the original validation. + key = None + matches = (native_matches_cp(CP, int(state.safetyModel), int(state.safetyParam)) and + native_inventory_matches_cp(state, CP)) + try: + # Re-read mutable CP on a miss before binding the successful result. + sm._aol_native_admission = key if matches and key is not None and native_admission_key(CP) == key[2] else None + except Exception: + _clear_native_admission(sm) + return matches + + def current_native(sm, CP, *, now_ns: int, axis_session_id: str | None = None): if not (sm.valid[SAFETY_SERVICE] and sm.alive[SAFETY_SERVICE] and sm.seen[SAFETY_SERVICE]): + _clear_native_admission(sm) return None - state = decode_safety(sm[SAFETY_SERVICE]) + raw = sm[SAFETY_SERVICE] + raw = bytes(raw) if isinstance(raw, (bytes, bytearray, memoryview)) else raw + state = decode_safety(raw) if state is None: + _clear_native_admission(sm) return None - if (not native_matches_cp(CP, int(state.safetyModel), int(state.safetyParam)) or - not native_inventory_matches_cp(state, CP) or + if (not _native_admission_matches(sm, CP, state, raw) or not state.compatible or int(state.protocolVersion) != 1 or not state.pandaSerial or not state.axisSessionId or (axis_session_id is not None and str(state.axisSessionId) != axis_session_id) or int(sm.logMonoTime[SAFETY_SERVICE]) > now_ns or now_ns - int(sm.logMonoTime[SAFETY_SERVICE]) > SAFETY_MAX_AGE_NS or int(state.observedMonoTime) > now_ns or now_ns > int(state.validUntilMonoTime)): + _clear_native_admission(sm) return None if state.pandaInventory and (int(sm.logMonoTime[SAFETY_SERVICE]) != state.observedMonoTime or state.observedMonoTime <= 0 or state.validUntilMonoTime - state.observedMonoTime > SAFETY_MAX_AGE_NS): + _clear_native_admission(sm) return None return state diff --git a/openpilot/starpilot/aol/tests/test_native_observation_admission.py b/openpilot/starpilot/aol/tests/test_native_observation_admission.py new file mode 100644 index 0000000000..e1ac8cac86 --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_native_observation_admission.py @@ -0,0 +1,293 @@ +"""Reuse structural admission only; every live native gate remains per call.""" +from dataclasses import replace +from unittest import mock + +import pytest + +from openpilot.cereal import messaging +from openpilot.starpilot.aol import runtime, transport_pause +from openpilot.starpilot.aol.tests.test_panda_inventory import NOW, bound_state, configured_cp, sm_for +from openpilot.starpilot.aol.tests.test_runtime import car_state +from openpilot.starpilot.aol.wire import encode_safety + + +def receive(sm, state, *, stamp=None, valid=True, raw=None): + stamp = state.observedMonoTime if stamp is None else stamp + message = messaging.new_message('aolSafetyWire', 0, valid=valid, logMonoTime=stamp) + message.aolSafetyWire = encode_safety(state) if raw is None else raw + sm.update_msgs(max(NOW, stamp) / 1e9, [message.as_reader()]) + + +def current(sm, cp, now=NOW, session=None): + return runtime.current_native(sm, cp, now_ns=now, axis_session_id=session) + + +def test_twenty_observations_per_two_hundred_live_checks_and_owner_isolation(): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + other = sm_for(cp, state) + with mock.patch.object(runtime, 'native_matches_cp', wraps=runtime.native_matches_cp) as profile, \ + mock.patch.object(runtime, 'native_inventory_matches_cp', wraps=runtime.native_inventory_matches_cp) as inventory: + for tick in range(200): + now = NOW + tick * 10_000_000 + if tick % 10 == 0: + state = bound_state(cp, now=now) + receive(sm, state) + assert current(sm, cp, now, state.axisSessionId if tick % 2 else None) == state + assert profile.call_count == inventory.call_count == 20 + assert current(other, cp) is not None + assert profile.call_count == inventory.call_count == 21 + assert len(sm._aol_native_admission) == 3 + + +@pytest.mark.parametrize('field,value', [ + ('brand', 'mock'), ('carFingerprint', 'mock'), ('flags', 0), ('radarUnavailable', True), + ('notCar', True), ('passive', True), ('dashcamOnly', True), + ('openpilotLongitudinalControl', False), ('pcmCruise', True), ('alternativeExperience', 32), + ('safetyModel', 0), ('safetyParam', 0), ('extra_slot', True), +]) +def test_every_cp_admission_input_invalidates_a_hot_entry(field, value): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + assert current(sm, cp) is not None + original = cp.as_reader().as_builder() + if field == 'extra_slot': + cp.safetyConfigs = [original.safetyConfigs[0], original.safetyConfigs[0]] + elif field in ('safetyModel', 'safetyParam'): + setattr(cp.safetyConfigs[0], field, value) + else: + setattr(cp, field, value) + assert current(sm, cp) is None + assert sm._aol_native_admission is None + if field == 'extra_slot': + cp.safetyConfigs = [original.safetyConfigs[0]] + elif field in ('safetyModel', 'safetyParam'): + setattr(cp.safetyConfigs[0], field, getattr(original.safetyConfigs[0], field)) + else: + setattr(cp, field, getattr(original, field)) + with mock.patch.object(runtime, 'native_matches_cp', wraps=runtime.native_matches_cp) as profile: + assert current(sm, cp) is not None + assert profile.call_count == 1 + + +@pytest.mark.parametrize('field', ['valid', 'alive', 'seen']) +def test_live_envelope_health_invalidates_hot_entry(field): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + assert current(sm, cp) is not None + getattr(sm, field)['aolSafetyWire'] = False + assert current(sm, cp) is None + assert sm._aol_native_admission is None + getattr(sm, field)['aolSafetyWire'] = True + with mock.patch.object(runtime, 'native_matches_cp', wraps=runtime.native_matches_cp) as profile: + assert current(sm, cp) is not None + assert profile.call_count == 1 + + +@pytest.mark.parametrize('change', ['session', 'future_clock', 'expired', 'future_source', 'changed_source', 'bad_byte']) +def test_live_identity_and_original_lease_reject_hot_entry(change): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + assert current(sm, cp) is not None + now, session = NOW, state.axisSessionId + if change == 'session': + session = 'foreign' + elif change == 'future_clock': + now -= 1 + elif change == 'expired': + now += 200_000_001 + elif change in ('future_source', 'changed_source'): + sm.logMonoTime['aolSafetyWire'] += 1 if change == 'future_source' else -1 + else: + raw = bytearray(encode_safety(state)) + raw[0] ^= 1 + receive(sm, state, raw=bytes(raw)) + assert current(sm, cp, now, session) is None + assert sm._aol_native_admission is None + + +@pytest.mark.parametrize('change', ['lateral', 'longitudinal', 'rx', 'heartbeat', 'fault', 'serial', 'inventory', 'compatible', 'protocol']) +def test_changed_wire_cannot_borrow_previous_native_permission_or_health(change): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + assert current(sm, cp) is not None + values = {'lateral': {'lateralAllowed': False}, 'longitudinal': {'longitudinalAllowed': False}, + 'serial': {'pandaSerial': 'foreign'}, 'compatible': {'compatible': False}, 'protocol': {'protocolVersion': 2}} + if change in values: + updated = replace(state, **values[change]) + else: + slot = state.pandaInventory[0] + fields = {'rx': {'safetyRxChecksInvalid': True}, 'heartbeat': {'heartbeatLost': True}, + 'fault': {'faults': 1}, 'inventory': {'safetyParam': 0}} + updated = replace(state, pandaInventory=(replace(slot, **fields[change]),)) + receive(sm, updated) + result = current(sm, cp) + if change in ('lateral', 'longitudinal'): + assert result == updated + assert not getattr(result, 'lateralAllowed' if change == 'lateral' else 'longitudinalAllowed') + else: + assert result is None + assert sm._aol_native_admission is None + + +def test_latest_panda_fault_is_still_checked_on_a_native_cache_hit(): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + assert transport_pause.healthy_transport(sm, cp, car_state(), state.axisSessionId, NOW) + pandas = messaging.new_message('pandaStates', 1, valid=True, logMonoTime=state.sourcePandaStatesMonoTime) + pandas.pandaStates[0] = sm['pandaStates'][0] + pandas.pandaStates[0].heartbeatLost = True + sm.update_msgs(NOW / 1e9, [pandas.as_reader()]) + assert current(sm, cp) is not None + assert not transport_pause.healthy_transport(sm, cp, car_state(), state.axisSessionId, NOW) + + +def test_same_builder_mutation_during_miss_cannot_store_an_old_key(): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + original = runtime.native_inventory_matches_cp + + def validate_then_mutate(native, params): + result = original(native, params) + params.radarUnavailable = True + return result + + with mock.patch.object(runtime, 'native_inventory_matches_cp', side_effect=validate_then_mutate): + assert current(sm, cp) is not None # Same result as the uncached validation call. + assert sm._aol_native_admission is None + assert current(sm, cp) is None + + +@pytest.mark.parametrize('legacy', [False, True]) +def test_unsupported_owner_or_legacy_wire_keeps_full_validation(legacy): + cp = configured_cp() + state = bound_state(cp) + if legacy: + state = replace(state, sourcePandaStatesMonoTime=0, pandaInventory=()) + original = sm_for(cp, state) + + class SlottedOwner: + __slots__ = ('valid', 'alive', 'seen', 'logMonoTime') + + def __init__(self): + for name in self.__slots__: + setattr(self, name, getattr(original, name)) + + def __getitem__(self, name): + return original[name] + + sm = original if legacy else SlottedOwner() + with mock.patch.object(runtime, 'native_matches_cp', wraps=runtime.native_matches_cp) as profile: + assert current(sm, cp) is not None + assert current(sm, cp) is not None + assert profile.call_count == 2 + + +@pytest.mark.parametrize('profile_name', ['honda', 'ev6_two_pandas']) +def test_other_admitted_profiles_keep_full_validation(profile_name): + if profile_name == 'honda': + from opendbc.car import gen_empty_fingerprint + from opendbc.car.honda.interface import CarInterface + from opendbc.car.honda.values import CAR + fingerprint = gen_empty_fingerprint() + fingerprint[1][0x1A3] = 8 + cp = CarInterface.get_params(CAR.HONDA_ACCORD, fingerprint, [], False, False, False) + cp.alternativeExperience = 32 + else: + from openpilot.starpilot.tests.test_ev6_startup import params + cp = params() + cp.openpilotLongitudinalControl, cp.pcmCruise, cp.alternativeExperience = True, False, 32 + cp.safetyConfigs = [{'safetyModel': 'noOutput', 'safetyParam': 0}, + {'safetyModel': 'hyundaiCanfd', 'safetyParam': 0x815}] + state = bound_state(cp) + sm = sm_for(cp, state) + with mock.patch.object(runtime, 'native_matches_cp', wraps=runtime.native_matches_cp) as profile: + assert current(sm, cp) == state + assert current(sm, cp) == state + assert profile.call_count == 2 + assert sm._aol_native_admission is None + + +def test_rejected_observation_then_valid_recovery_cannot_reuse_a_failed_verdict(): + cp = configured_cp() + good = bound_state(cp) + bad = replace(good, pandaSerial='other') + sm = sm_for(cp, bad) + with mock.patch.object(runtime, 'native_matches_cp', wraps=runtime.native_matches_cp) as profile: + assert current(sm, cp) is None + assert current(sm, cp) is None + assert profile.call_count == 2 + assert sm._aol_native_admission is None + receive(sm, good) + assert current(sm, cp) == good + assert current(sm, cp) == good + assert profile.call_count == 3 + + +def test_cache_key_failure_falls_back_to_complete_validation(): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + assert current(sm, cp) == state + with mock.patch.object(runtime, 'native_admission_key', side_effect=RuntimeError('cache failure')), \ + mock.patch.object(runtime, 'native_matches_cp', wraps=runtime.native_matches_cp) as profile: + assert current(sm, cp) == state + assert current(sm, cp) == state + assert profile.call_count == 2 + assert sm._aol_native_admission is None + + +@pytest.mark.parametrize('field,value', [('canValid', False), ('canTimeout', True), + ('steerFaultTemporary', True), ('steerFaultPermanent', True)]) +def test_live_car_state_veto_remains_after_native_cache_hit(field, value): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + cs = car_state() + assert transport_pause.healthy_transport(sm, cp, cs, state.axisSessionId, NOW) + setattr(cs, field, value) + assert current(sm, cp) == state + assert not transport_pause.healthy_transport(sm, cp, cs, state.axisSessionId, NOW) + + +@pytest.mark.parametrize('field,value', [('controlsAllowed', False), ('safetyParam', 0), + ('alternativeExperience', 32), ('safetyRxChecksInvalid', True)]) +def test_latest_panda_permission_and_configuration_veto_a_native_cache_hit(field, value): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + assert transport_pause.healthy_transport(sm, cp, car_state(), state.axisSessionId, NOW) + pandas = messaging.new_message('pandaStates', 1, valid=True, logMonoTime=state.sourcePandaStatesMonoTime) + pandas.pandaStates[0] = sm['pandaStates'][0] + setattr(pandas.pandaStates[0], field, value) + sm.update_msgs(NOW / 1e9, [pandas.as_reader()]) + assert current(sm, cp) == state + assert not transport_pause.healthy_transport(sm, cp, car_state(), state.axisSessionId, NOW) + + +def test_cache_hit_uses_exact_original_inclusive_deadline(): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + with mock.patch.object(runtime, 'native_matches_cp', wraps=runtime.native_matches_cp) as profile: + assert current(sm, cp) == state + assert current(sm, cp, state.validUntilMonoTime) == state + assert profile.call_count == 1 + assert current(sm, cp, state.validUntilMonoTime + 1) is None + assert sm._aol_native_admission is None + + +def test_invalid_profile_keeps_original_short_circuit_with_missing_owner_stamp(): + cp = configured_cp() + state = bound_state(cp) + sm = sm_for(cp, state) + cp.radarUnavailable = True + sm.logMonoTime.pop('aolSafetyWire') + assert current(sm, cp) is None diff --git a/openpilot/starpilot/aol/vehicle.py b/openpilot/starpilot/aol/vehicle.py index aacbd72948..d82d253e7f 100644 --- a/openpilot/starpilot/aol/vehicle.py +++ b/openpilot/starpilot/aol/vehicle.py @@ -38,6 +38,14 @@ def native_matches_cp(CP, model: int, param: int) -> bool: return False +def native_admission_key(CP): + """Observation reuse is scoped to the exact Ioniq single-Panda contract.""" + try: + return hyundai.native_admission_key(CP) if CP.brand == 'hyundai' else None + except (AttributeError, TypeError, ValueError, OverflowError, IndexError): + return None + + def native_latch_rejected(CP, native, *, state=None) -> bool: if native is None: return False diff --git a/openpilot/starpilot/car/hyundai/aol.py b/openpilot/starpilot/car/hyundai/aol.py index c8c55a4a60..d97db58dcd 100644 --- a/openpilot/starpilot/car/hyundai/aol.py +++ b/openpilot/starpilot/car/hyundai/aol.py @@ -73,6 +73,20 @@ def qualified_ioniq6(CP) -> bool: return raw == (0x8895 if flags & int(HyundaiFlags.CANFD_LKA_STEER_MSG_ALT) else 0x8815) +def native_admission_key(CP): + """Complete mutable CP inputs to exact one-slot Ioniq native admission. + + Keep this in sync with qualified_ioniq6 and native inventory qualification. + This key is not an admission decision and never contains live authority. + """ + if CP.brand != 'hyundai' or CP.carFingerprint != HyundaiCAR.HYUNDAI_IONIQ_6 or len(CP.safetyConfigs) != 1: + return None + return (str(CP.brand), str(CP.carFingerprint), int(CP.flags), bool(CP.radarUnavailable), + bool(CP.notCar), bool(CP.passive), bool(CP.dashcamOnly), + bool(CP.openpilotLongitudinalControl), bool(CP.pcmCruise), int(CP.alternativeExperience), + tuple((int(c.safetyModel.raw), int(c.safetyParam)) for c in CP.safetyConfigs)) + + def ioniq6_settings_capable(CP) -> bool: # Stock ACC lateral settings are independent of the experimental LONG opt-in. return _stock_ioniq6(CP) or (IONIQ6_LONG_PREARM_ENABLED and ioniq6_long_eligible(CP))