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

@@ -725,29 +725,22 @@ class GoalPlanner:
return "force start instagram"
def _is_goal_achieved(self, goal: str, screen_type: ScreenType, context: dict) -> bool:
"""Check if the goal is already satisfied."""
"""Check if the goal is already satisfied. Delegates to ScreenTopology SSOT."""
from GramAddict.core.screen_topology import ScreenTopology
# Interaction goals (context-specific, not navigation)
if "like" in goal and context.get("is_liked") is True:
return True
if "open explore" in goal and screen_type == ScreenType.EXPLORE_GRID:
return True
if "open home" in goal and screen_type == ScreenType.HOME_FEED:
return True
if "open reels" in goal and screen_type == ScreenType.REELS_FEED:
return True
if "open profile" in goal and screen_type == ScreenType.OWN_PROFILE:
return True
if "learn own profile" in goal and screen_type == ScreenType.OWN_PROFILE:
return True
if "open messages" in goal and screen_type == ScreenType.DM_INBOX:
return True
if ("following list" in goal or "followers list" in goal) and screen_type == ScreenType.FOLLOW_LIST:
return True
if "open explore" in goal and screen_type == ScreenType.EXPLORE_GRID:
return True
if "view profile" in goal and (
screen_type == ScreenType.OWN_PROFILE or screen_type == ScreenType.OTHER_PROFILE
if "view profile" in goal and screen_type in (
ScreenType.OWN_PROFILE, ScreenType.OTHER_PROFILE
):
return True
# Navigation goals — delegate to SSOT
target = ScreenTopology.goal_to_target_screen(goal)
if target and screen_type == target:
return True
return False
def _plan_navigation(
@@ -1086,10 +1079,19 @@ class GoalExecutor:
explored_nav_actions.add(action)
if self.action_failures[action] >= MAX_RETRIES:
self.planner.knowledge.learn_trap(screen_type, action, "repeated_failure_or_null_action")
logger.error(
f"💀 [GOAP Execute] Action '{action}' failed {MAX_RETRIES} times. Marked as permanent trap."
)
# ── Topology Guard: Never poison structural HD Map actions ──
from GramAddict.core.screen_topology import ScreenTopology
if ScreenTopology.is_structural_action(screen_type, action):
logger.warning(
f"🛡️ [Topology Guard] NOT burning structural action '{action}'"
f"it's in the HD Map. VLM may have failed, but the route is valid."
)
else:
self.planner.knowledge.learn_trap(screen_type, action, "repeated_failure_or_null_action")
logger.error(
f"💀 [GOAP Execute] Action '{action}' failed {MAX_RETRIES} times. Marked as permanent trap."
)
else:
logger.warning(f"⚠️ [GOAP Execute] Action '{action}' failed. Continuing with replanning...")
@@ -1153,49 +1155,44 @@ class GoalExecutor:
# Verify success via Goal Context + Screen Feedback
post_xml = self.device.dump_hierarchy()
pre_action_screen = self.perceive(xml_dump) # Screen state BEFORE the click
post_screen = self.perceive(post_xml)
post_screen_type = post_screen["screen_type"]
pre_action_screen_type = pre_action_screen["screen_type"]
# Determine if this was a navigation or an interaction
is_navigation = any(k in action.lower() for k in ["tab", "open", "go to", "navigate"])
is_navigation = any(k in action.lower() for k in ["tab", "open", "go to", "navigate", "following list"])
action_success = False
goal_met = False
ui_changed = post_xml != xml_dump
if is_navigation:
if ui_changed:
# ── Goal-Aware Navigation Validation ──
# If we have a goal, verify we landed on the correct screen.
# E.g., "tap messages tab" + goal="open messages" must land on DM_INBOX, not REELS_FEED.
if goal:
goal_screen_map = {
"messages": ScreenType.DM_INBOX,
"explore": ScreenType.EXPLORE_GRID,
"home": ScreenType.HOME_FEED,
"profile": ScreenType.OWN_PROFILE,
"reels": ScreenType.REELS_FEED,
"following": ScreenType.FOLLOW_LIST,
"followers": ScreenType.FOLLOW_LIST,
}
expected_screen = None
for keyword, screen in goal_screen_map.items():
if keyword in goal.lower():
expected_screen = screen
break
# ── Step-Aware Navigation Validation (SSOT) ──
# Validates against the EXPECTED screen for THIS ACTION, not the final goal.
# This enables multi-step routes: "tap profile tab" should land on
# OWN_PROFILE (intermediate), even if the goal is "open following list".
from GramAddict.core.screen_topology import ScreenTopology
if expected_screen and post_screen_type != expected_screen:
expected_step_screen = ScreenTopology.expected_screen_for_action(action, pre_action_screen_type)
if expected_step_screen:
if post_screen_type == expected_step_screen:
action_success = True
logger.info(
f"✅ [GOAP Step] '{action}'{post_screen_type.name} (matches HD Map)"
)
self.planner.knowledge.learn_screen_mapping(action, post_screen_type)
else:
logger.warning(
f"❌ [GOAP Navigation] Wanted {expected_screen.name} but landed on "
f"{post_screen_type.name}. Rejecting navigation '{action}'."
f"❌ [GOAP Step] '{action}' expected {expected_step_screen.name}, "
f"got {post_screen_type.name}. Rejecting."
)
action_success = False
else:
action_success = True
logger.info(f"✅ [GOAP Step] Navigation '{action}' successful -> {post_screen_type.name}.")
self.planner.knowledge.learn_screen_mapping(action, post_screen_type)
else:
# Unknown action (not in HD Map) — accept any screen change as progress
action_success = True
logger.info(f"✅ [GOAP Step] Navigation '{action}' successful -> {post_screen_type.name}.")
logger.info(f"✅ [GOAP Step] Navigation '{action}' {post_screen_type.name} (discovery).")
self.planner.knowledge.learn_screen_mapping(action, post_screen_type)
else:
# Check if we're already on the target screen (no-op is OK)
@@ -1230,22 +1227,13 @@ class GoalExecutor:
logger.warning(f"❌ [GOAP Verify] No UI change detected after interaction '{action}'.")
action_success = None # Inconclusive if UI didn't change at all
# Optional: Log if the overarching goal was miraculously met early
# Optional: Log if the overarching goal was met during this step
if goal and action_success:
required_screens = self.planner.knowledge.get_requirements(goal)
if not required_screens:
if "messages" in goal and post_screen_type == ScreenType.DM_INBOX:
goal_met = True
if "explore" in goal and post_screen_type == ScreenType.EXPLORE_GRID:
goal_met = True
if "home" in goal and post_screen_type == ScreenType.HOME_FEED:
goal_met = True
if "profile" in goal and post_screen_type == ScreenType.OWN_PROFILE:
goal_met = True
if "reels" in goal and post_screen_type == ScreenType.REELS_FEED:
goal_met = True
from GramAddict.core.screen_topology import ScreenTopology
if goal_met or (required_screens and post_screen_type in required_screens):
goal_target = ScreenTopology.goal_to_target_screen(goal.lower() if goal else "")
if goal_target and post_screen_type == goal_target:
goal_met = True
logger.info(f"🎉 [GOAP Verify] OVERARCHING Goal '{goal}' achieved during step '{action}'.")
if action_success is True:
@@ -1287,18 +1275,10 @@ class GoalExecutor:
# ── Convenience methods (backward compatibility with navigate_to) ──
def navigate_to_screen(self, target: str) -> bool:
"""Navigate to a screen by name. Wrapper for achieve()."""
goal_map = {
"HomeFeed": "open home feed",
"ExploreFeed": "open explore feed",
"ReelsFeed": "open reels",
"OwnProfile": "open profile",
"MessageInbox": "open messages",
"StoriesFeed": "open home feed", # Stories are on home feed
"FollowingList": "open following list",
"SearchFeed": "open explore feed",
}
goal = goal_map.get(target, f"navigate to {target}")
"""Navigate to a screen by name. Wrapper for achieve(). Delegates to ScreenTopology SSOT."""
from GramAddict.core.screen_topology import ScreenTopology
goal = ScreenTopology.screen_name_to_goal(target)
return self.achieve(goal)
def get_current_screen_type(self) -> ScreenType:

View File

@@ -45,17 +45,9 @@ class QNavGraph:
# Generate core_nodes from ScreenTopology (single source of truth)
core_nodes = {}
for screen_type, transitions in ScreenTopology.TRANSITIONS.items():
# Map ScreenType enum names to QNavGraph string names
screen_name_map = {
ScreenType.HOME_FEED: "HomeFeed",
ScreenType.EXPLORE_GRID: "ExploreFeed",
ScreenType.REELS_FEED: "ReelsFeed",
ScreenType.OWN_PROFILE: "OwnProfile",
ScreenType.DM_INBOX: "MessageInbox",
ScreenType.FOLLOW_LIST: "FollowingList",
ScreenType.OTHER_PROFILE: "OtherProfile",
ScreenType.UNKNOWN: "UNKNOWN",
}
# Reverse lookup: ScreenType → QNavGraph string name from SSOT
screen_name_map = {v: k for k, v in ScreenTopology.SCREEN_NAME_MAP.items()
if v not in (ScreenType.HOME_FEED, ScreenType.EXPLORE_GRID) or k not in ("StoriesFeed", "SearchFeed")}
node_name = screen_name_map.get(screen_type)
if not node_name:
continue
@@ -153,19 +145,8 @@ class QNavGraph:
def _find_path(self, start: str, end: str):
"""Delegates to ScreenTopology for BFS pathfinding (SSOT)."""
# Map string names to ScreenType for ScreenTopology
name_to_screen = {
"HomeFeed": ScreenType.HOME_FEED,
"ExploreFeed": ScreenType.EXPLORE_GRID,
"ReelsFeed": ScreenType.REELS_FEED,
"OwnProfile": ScreenType.OWN_PROFILE,
"MessageInbox": ScreenType.DM_INBOX,
"FollowingList": ScreenType.FOLLOW_LIST,
"OtherProfile": ScreenType.OTHER_PROFILE,
"UNKNOWN": ScreenType.UNKNOWN,
}
from_screen = name_to_screen.get(start)
to_screen = name_to_screen.get(end)
from_screen = ScreenTopology.SCREEN_NAME_MAP.get(start)
to_screen = ScreenTopology.SCREEN_NAME_MAP.get(end)
if not from_screen or not to_screen:
return None

View File

@@ -133,3 +133,62 @@ class ScreenTopology:
return screen
return None
# ── QNavGraph screen name ↔ ScreenType mapping (SSOT) ──
SCREEN_NAME_MAP: Dict[str, ScreenType] = {
"HomeFeed": ScreenType.HOME_FEED,
"ExploreFeed": ScreenType.EXPLORE_GRID,
"ReelsFeed": ScreenType.REELS_FEED,
"OwnProfile": ScreenType.OWN_PROFILE,
"MessageInbox": ScreenType.DM_INBOX,
"FollowingList": ScreenType.FOLLOW_LIST,
"OtherProfile": ScreenType.OTHER_PROFILE,
"StoriesFeed": ScreenType.HOME_FEED, # Stories are on home feed
"SearchFeed": ScreenType.EXPLORE_GRID, # Search uses explore
"UNKNOWN": ScreenType.UNKNOWN,
}
# ── Reverse map: ScreenType → canonical goal string ──
_SCREEN_TO_GOAL: Dict[ScreenType, str] = {
ScreenType.HOME_FEED: "open home feed",
ScreenType.EXPLORE_GRID: "open explore feed",
ScreenType.REELS_FEED: "open reels",
ScreenType.OWN_PROFILE: "open profile",
ScreenType.DM_INBOX: "open messages",
ScreenType.FOLLOW_LIST: "open following list",
}
@classmethod
def screen_name_to_goal(cls, screen_name: str) -> str:
"""Convert QNavGraph screen name to GOAP goal string.
Returns a canonical goal string for known screens,
or 'navigate to <name>' for unknown ones.
"""
screen_type = cls.SCREEN_NAME_MAP.get(screen_name)
if screen_type and screen_type in cls._SCREEN_TO_GOAL:
return cls._SCREEN_TO_GOAL[screen_type]
return f"navigate to {screen_name}"
@classmethod
def expected_screen_for_action(
cls, action: str, from_screen: ScreenType
) -> Optional[ScreenType]:
"""What screen should we land on after this action from this screen?
Used by _execute_action to validate INTERMEDIATE navigation steps.
Returns None if the action isn't a known transition from this screen.
"""
transitions = cls.TRANSITIONS.get(from_screen, {})
return transitions.get(action)
@classmethod
def is_structural_action(cls, screen: ScreenType, action: str) -> bool:
"""Check if an action is a structural transition in the HD Map.
Structural actions must NEVER be aversively learned as traps —
they are architectural facts about Instagram's UI.
VLM may fail to find the element, but the route itself is valid.
"""
transitions = cls.TRANSITIONS.get(screen, {})
return action in transitions

View File

@@ -697,11 +697,12 @@ class TelepathicEngine:
score *= 0.3 # Heavy penalty
# Thresholding:
# - Navigation intents: Require 100% exact match to avoid feed-cross-talk
# - Tab intents (actual bottom tabs): Require 100% exact match to avoid feed cross-talk
# - Non-tab navigation (following list, DM, etc.): Standard thresholds
# - Short intents (1-2 words): Require at least 50% hit (0.45)
# - Longer intents: Require 75% to avoid false matches on noisy screens.
is_nav_intent = any(k in intent_lower for k in NAV_ZONE_BYPASS_KEYWORDS)
if is_nav_intent:
is_tab_intent = any(k in intent_lower for k in NAV_TAB_KEYWORDS)
if is_tab_intent:
threshold = 1.0
else:
threshold = 0.45 if len(intent_words) <= 2 else 0.75

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