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:
2026-04-24 22:45:48 +02:00
parent d69da4c974
commit 018b615829
6 changed files with 320 additions and 103 deletions

View 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"

View File

@@ -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