fix: systemic GOAP self-sabotage — SSOT consolidation + step-aware validation
ROOT CAUSE: The bot was systematically sabotaging its own navigation. The HD Map planned correct 2-step routes (Home→Profile→FollowList), but _execute_action() validated each INTERMEDIATE step against the FINAL goal. Step 1 (tap profile tab → OWN_PROFILE) got rejected because the goal said 'following', triggering aversive learning that permanently burned the only valid route. SIX COMPOUNDING FIXES: 1. SSOT Consolidation (_is_goal_achieved): Replaced 12-line hardcoded if-chain with ScreenTopology.goal_to_target_screen(). Single source of truth for all goal→screen mappings. 2. Step-Aware Navigation Validation (_execute_action): Replaced goal_screen_map keyword-scan with ScreenTopology.expected_screen_for_action(action, pre_action_screen). Now validates: 'did this step land where IT should?' not 'did I reach my goal yet?' 3. Topology Guard (aversive learning): ScreenTopology.is_structural_action() prevents burning HD Map actions. VLM may fail to find the element, but the route itself is structurally valid. 4. Fast-Path Threshold Fix (telepathic_engine): 100% match threshold now applies only to NAV_TAB_KEYWORDS (actual tabs), not NAV_ZONE_BYPASS_KEYWORDS. 'tap following list' was blocked because 'following' triggered the tab threshold on a non-tab action. 5. navigate_to_screen SSOT: Replaced 8-entry goal_map dict with ScreenTopology.screen_name_to_goal(). 6. QNavGraph Name Map Dedup: Replaced 2 inline screen_name_map/name_to_screen dicts with ScreenTopology.SCREEN_NAME_MAP reverse lookups. Before: 4 competing goal→screen mappings. Self-sabotage loop. After: 1 SSOT. Step-aware validation. Topology-protected routing. 141 unit tests pass. Zero regressions.
This commit is contained in:
116
tests/unit/test_goap_step_validation.py
Normal file
116
tests/unit/test_goap_step_validation.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
🔴 RED TDD: Step-Aware Navigation Validation + Topology Guard
|
||||
|
||||
Tests that validate the GOAP planner correctly handles multi-step navigation:
|
||||
- Intermediate steps (tap profile tab → OWN_PROFILE) must be ACCEPTED
|
||||
- Wrong screens (tap profile tab → REELS_FEED) must be REJECTED
|
||||
- Structural HD Map actions must NEVER be aversively learned as traps
|
||||
"""
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
||||
|
||||
from GramAddict.core.goap import GoalExecutor, GoalPlanner, ScreenType, NavigationKnowledge
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
|
||||
class TestStepAwareValidation:
|
||||
"""_execute_action must validate intermediate steps, not just final goals."""
|
||||
|
||||
@pytest.fixture
|
||||
def executor(self):
|
||||
device = MagicMock()
|
||||
device.dump_hierarchy.return_value = "<xml>mock</xml>"
|
||||
device.click = MagicMock()
|
||||
executor = GoalExecutor(device, bot_username="testbot")
|
||||
executor.action_failures = {}
|
||||
return executor
|
||||
|
||||
def test_intermediate_step_accepted(self, executor):
|
||||
"""
|
||||
Action: 'tap profile tab' from HOME_FEED
|
||||
Goal: 'open following list'
|
||||
Landing: OWN_PROFILE (intermediate step)
|
||||
|
||||
The validator must ACCEPT this — OWN_PROFILE is the correct
|
||||
intermediate screen for 'tap profile tab' from HOME_FEED.
|
||||
It must NOT reject it because the goal says 'following'.
|
||||
"""
|
||||
# The critical question: does expected_screen_for_action return OWN_PROFILE?
|
||||
expected = ScreenTopology.expected_screen_for_action("tap profile tab", ScreenType.HOME_FEED)
|
||||
assert expected == ScreenType.OWN_PROFILE, "HD Map must know 'tap profile tab' → OWN_PROFILE"
|
||||
|
||||
# And the goal target is FOLLOW_LIST, which is DIFFERENT from where we landed
|
||||
goal_target = ScreenTopology.goal_to_target_screen("open following list")
|
||||
assert goal_target == ScreenType.FOLLOW_LIST
|
||||
|
||||
# The old code would fail here because it checks goal_target against landing screen.
|
||||
# The new code checks expected (from action) against landing screen.
|
||||
landing = ScreenType.OWN_PROFILE
|
||||
assert landing == expected, "Step validation: landing matches expected → ACCEPT"
|
||||
assert landing != goal_target, "Goal not yet achieved — but step is valid"
|
||||
|
||||
def test_wrong_screen_rejected(self, executor):
|
||||
"""
|
||||
Action: 'tap profile tab' from HOME_FEED
|
||||
Landing: REELS_FEED (wrong!)
|
||||
|
||||
The validator must REJECT this — expected was OWN_PROFILE.
|
||||
"""
|
||||
expected = ScreenTopology.expected_screen_for_action("tap profile tab", ScreenType.HOME_FEED)
|
||||
landing = ScreenType.REELS_FEED
|
||||
assert landing != expected, "Wrong screen: REELS_FEED ≠ OWN_PROFILE → REJECT"
|
||||
|
||||
def test_final_step_accepted(self, executor):
|
||||
"""
|
||||
Action: 'tap following list' from OWN_PROFILE
|
||||
Goal: 'open following list'
|
||||
Landing: FOLLOW_LIST (final step!)
|
||||
|
||||
The validator must ACCEPT this.
|
||||
"""
|
||||
expected = ScreenTopology.expected_screen_for_action("tap following list", ScreenType.OWN_PROFILE)
|
||||
assert expected == ScreenType.FOLLOW_LIST
|
||||
landing = ScreenType.FOLLOW_LIST
|
||||
assert landing == expected
|
||||
|
||||
|
||||
class TestTopologyGuard:
|
||||
"""Structural HD Map actions must NEVER be aversively learned as traps."""
|
||||
|
||||
def test_structural_action_not_burned(self):
|
||||
"""'tap profile tab' on HOME_FEED is structural — must not be burned."""
|
||||
assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "tap profile tab") is True
|
||||
|
||||
def test_non_structural_action_can_be_burned(self):
|
||||
"""'open following list' on HOME_FEED is NOT structural — can be burned."""
|
||||
assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "open following list") is False
|
||||
|
||||
|
||||
class TestSSOTConsolidation:
|
||||
"""All goal→screen mappings must use ScreenTopology as SSOT."""
|
||||
|
||||
def test_is_goal_achieved_uses_topology_for_following(self):
|
||||
"""_is_goal_achieved must recognize FOLLOW_LIST for 'open following list'."""
|
||||
planner = GoalPlanner("testbot")
|
||||
planner.knowledge = MagicMock()
|
||||
planner.knowledge.is_trap.return_value = False
|
||||
planner.knowledge.get_requirements.return_value = None
|
||||
|
||||
# On FOLLOW_LIST with goal "open following list" → achieved
|
||||
result = planner._is_goal_achieved("open following list", ScreenType.FOLLOW_LIST, {})
|
||||
assert result is True, "Goal must be achieved when on target screen"
|
||||
|
||||
def test_is_goal_achieved_not_achieved_on_wrong_screen(self):
|
||||
planner = GoalPlanner("testbot")
|
||||
result = planner._is_goal_achieved("open following list", ScreenType.HOME_FEED, {})
|
||||
assert result is False
|
||||
|
||||
def test_navigate_to_screen_uses_topology(self):
|
||||
"""navigate_to_screen must use ScreenTopology.screen_name_to_goal()."""
|
||||
assert ScreenTopology.screen_name_to_goal("FollowingList") == "open following list"
|
||||
assert ScreenTopology.screen_name_to_goal("ExploreFeed") == "open explore feed"
|
||||
assert ScreenTopology.screen_name_to_goal("OwnProfile") == "open profile"
|
||||
@@ -96,3 +96,83 @@ class TestGetTransitions:
|
||||
def test_unknown_screen_returns_empty(self):
|
||||
transitions = ScreenTopology.get_transitions(ScreenType.FOREIGN_APP)
|
||||
assert transitions == {}
|
||||
|
||||
|
||||
class TestScreenNameMap:
|
||||
"""Bidirectional QNavGraph string ↔ ScreenType mapping."""
|
||||
|
||||
def test_following_list_maps(self):
|
||||
assert ScreenTopology.SCREEN_NAME_MAP["FollowingList"] == ScreenType.FOLLOW_LIST
|
||||
|
||||
def test_home_feed_maps(self):
|
||||
assert ScreenTopology.SCREEN_NAME_MAP["HomeFeed"] == ScreenType.HOME_FEED
|
||||
|
||||
def test_stories_feed_maps_to_home(self):
|
||||
"""StoriesFeed is a virtual target — it maps to HOME_FEED."""
|
||||
assert ScreenTopology.SCREEN_NAME_MAP["StoriesFeed"] == ScreenType.HOME_FEED
|
||||
|
||||
def test_search_feed_maps_to_explore(self):
|
||||
"""SearchFeed is a virtual target — it maps to EXPLORE_GRID."""
|
||||
assert ScreenTopology.SCREEN_NAME_MAP["SearchFeed"] == ScreenType.EXPLORE_GRID
|
||||
|
||||
|
||||
class TestScreenNameToGoal:
|
||||
"""Convert QNavGraph screen name to GOAP goal string."""
|
||||
|
||||
def test_following_list(self):
|
||||
assert ScreenTopology.screen_name_to_goal("FollowingList") == "open following list"
|
||||
|
||||
def test_home_feed(self):
|
||||
assert ScreenTopology.screen_name_to_goal("HomeFeed") == "open home feed"
|
||||
|
||||
def test_explore_feed(self):
|
||||
assert ScreenTopology.screen_name_to_goal("ExploreFeed") == "open explore feed"
|
||||
|
||||
def test_stories_feed(self):
|
||||
"""StoriesFeed → open home feed (stories are on home)."""
|
||||
assert ScreenTopology.screen_name_to_goal("StoriesFeed") == "open home feed"
|
||||
|
||||
def test_unknown_target(self):
|
||||
assert ScreenTopology.screen_name_to_goal("BogusScreen") == "navigate to BogusScreen"
|
||||
|
||||
|
||||
class TestExpectedScreenForAction:
|
||||
"""Step-aware validation: what screen should an action land on?"""
|
||||
|
||||
def test_tap_profile_tab_from_home(self):
|
||||
result = ScreenTopology.expected_screen_for_action("tap profile tab", ScreenType.HOME_FEED)
|
||||
assert result == ScreenType.OWN_PROFILE
|
||||
|
||||
def test_tap_following_list_from_profile(self):
|
||||
result = ScreenTopology.expected_screen_for_action("tap following list", ScreenType.OWN_PROFILE)
|
||||
assert result == ScreenType.FOLLOW_LIST
|
||||
|
||||
def test_press_back_from_follow_list(self):
|
||||
result = ScreenTopology.expected_screen_for_action("press back", ScreenType.FOLLOW_LIST)
|
||||
assert result == ScreenType.OWN_PROFILE
|
||||
|
||||
def test_unknown_action_returns_none(self):
|
||||
result = ScreenTopology.expected_screen_for_action("do something random", ScreenType.HOME_FEED)
|
||||
assert result is None
|
||||
|
||||
def test_action_not_available_on_screen(self):
|
||||
"""'tap following list' from HOME_FEED should return None (not a valid transition)."""
|
||||
result = ScreenTopology.expected_screen_for_action("tap following list", ScreenType.HOME_FEED)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestIsStructuralAction:
|
||||
"""Topology guard: protect HD Map actions from aversive learning."""
|
||||
|
||||
def test_tap_profile_tab_is_structural(self):
|
||||
assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "tap profile tab") is True
|
||||
|
||||
def test_tap_following_list_is_structural(self):
|
||||
assert ScreenTopology.is_structural_action(ScreenType.OWN_PROFILE, "tap following list") is True
|
||||
|
||||
def test_random_action_is_not_structural(self):
|
||||
assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "open following list") is False
|
||||
|
||||
def test_action_on_wrong_screen_is_not_structural(self):
|
||||
"""'tap following list' on HOME_FEED is NOT structural (not in that screen's transitions)."""
|
||||
assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "tap following list") is False
|
||||
|
||||
Reference in New Issue
Block a user