feat: ScreenTopology HD Map + graph-aware GOAP routing + NAV keyword split
FUNDAMENTAL ARCHITECTURE OVERHAUL: 1. ScreenTopology HD Map (NEW): Pure-data BFS pathfinding between Instagram screens. Zero runtime dependencies. The GOAP planner's GPS. Knows: HOME_FEED → tap profile tab → OWN_PROFILE → tap following list → FOLLOW_LIST 2. Graph-Aware GOAP Planning: GoalPlanner._plan_navigation() now consults ScreenTopology FIRST. From HOME_FEED, goal 'open following list' returns 'tap profile tab' (intermediate hop) instead of blind 'open following list' (impossible). Autonomous discovery and Qdrant knowledge kept as fallbacks. 3. NAV Keyword Split: NAV_INTENT_KEYWORDS → NAV_ZONE_BYPASS_KEYWORDS + NAV_TAB_KEYWORDS Ends the Structural Guard civil war where 'following' was both 'allowed in nav zone' AND 'must be at bottom' simultaneously. 4. QNavGraph Deduplication: _find_path() delegates to ScreenTopology.find_route() (SSOT). core_nodes seed generated from ScreenTopology.TRANSITIONS. 115 unit tests pass. Zero regressions.
This commit is contained in:
@@ -758,7 +758,14 @@ class GoalPlanner:
|
||||
selected_tab: Optional[str] = None,
|
||||
explored_nav_actions: set = None,
|
||||
) -> Optional[str]:
|
||||
"""If we're on the wrong screen, figure out how to navigate."""
|
||||
"""If we're on the wrong screen, figure out how to navigate.
|
||||
|
||||
Strategy (priority order):
|
||||
1. HD Map (ScreenTopology BFS) — deterministic, pre-computed routes
|
||||
2. Learned Knowledge (Qdrant) — dynamic discovery from past sessions
|
||||
3. Autonomous Discovery — linguistic matching + VLM intent
|
||||
"""
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
# 0. Aversive Filter: Remove known traps from available actions
|
||||
safe_available = []
|
||||
@@ -769,10 +776,30 @@ class GoalPlanner:
|
||||
logger.debug(f"🛡️ [Aversive Filter] Masking trapped action: '{action}'")
|
||||
available = safe_available
|
||||
|
||||
# 1. Get required screens for this goal from knowledge
|
||||
# ── 1. HD Map Routing (Primary Strategy) ──
|
||||
target_screen = ScreenTopology.goal_to_target_screen(goal)
|
||||
if target_screen and target_screen != screen_type:
|
||||
route = ScreenTopology.find_route(screen_type, target_screen)
|
||||
if route:
|
||||
next_action, next_screen = route[0]
|
||||
# Verify action isn't explored/trapped
|
||||
if next_action not in (explored_nav_actions or set()):
|
||||
if not self.knowledge.is_trap(screen_type, next_action):
|
||||
route_desc = " → ".join(s.name for _, s in route)
|
||||
logger.info(
|
||||
f"🗺️ [HD Map] Route: {screen_type.name} → {route_desc}. "
|
||||
f"Next action: '{next_action}'"
|
||||
)
|
||||
return next_action
|
||||
else:
|
||||
logger.warning(f"🛡️ [HD Map] Route action '{next_action}' is trapped. Falling back.")
|
||||
else:
|
||||
logger.debug(f"🛡️ [HD Map] Route action '{next_action}' already explored. Falling back.")
|
||||
|
||||
# ── 2. Learned Knowledge (Qdrant) ──
|
||||
required_screens = self.knowledge.get_requirements(goal)
|
||||
|
||||
# 2. Blank Start Discovery (if knowledge is empty)
|
||||
# ── 3. Autonomous Discovery (Blank Start fallback) ──
|
||||
if not required_screens:
|
||||
logger.info(f"🧠 [Nav Discovery] No known requirements for '{goal}'. Will attempt autonomous discovery.")
|
||||
|
||||
@@ -784,15 +811,12 @@ class GoalPlanner:
|
||||
|
||||
for action in available:
|
||||
if any(w in action.lower() for w in goal_words):
|
||||
# Ensure it doesn't lead to a completely contradictory known state (if we knew the target).
|
||||
# But since we are in a blank start, any linguistic match is a highly probable path!
|
||||
logger.info(
|
||||
f"🎯 [Nav Discovery] Linguistic match on available action! '{action}' aligns with '{goal}'"
|
||||
)
|
||||
return action
|
||||
|
||||
# We don't know the required screen or path. Let the TelepathicEngine figure out
|
||||
# what button to press based on the pure goal text!
|
||||
# Return raw intent for TelepathicEngine discovery
|
||||
if explored_nav_actions and goal in explored_nav_actions:
|
||||
logger.info(
|
||||
f"🛑 [Nav Discovery] Autonomous intent '{goal}' already tried and failed/trapped. Yielding to back-tracking."
|
||||
@@ -801,37 +825,31 @@ class GoalPlanner:
|
||||
else:
|
||||
return goal
|
||||
|
||||
# 3. If we're already on an acceptable screen, no navigation needed
|
||||
# 4. If we're already on an acceptable screen, no navigation needed
|
||||
if screen_type in required_screens:
|
||||
return None
|
||||
|
||||
# 4. Find the action we need to take
|
||||
# 5. Find the action we need to take (from learned knowledge)
|
||||
for target_screen in required_screens:
|
||||
known_action = self.knowledge.get_action_for_screen(target_screen)
|
||||
|
||||
if not known_action:
|
||||
logger.info(f"🧭 [Nav Discovery] Don't know action to reach {target_screen.name}. Asking VLM...")
|
||||
|
||||
# Check ALL available actions if one linguistically aligns with the target screen
|
||||
# Or just let VLM figure it out by returning an intention.
|
||||
screen_friendly_name = target_screen.name.replace("_", " ").lower()
|
||||
|
||||
# Semantic Heuristic Match on dynamically perceived actions
|
||||
goal_words = [w.rstrip("s") for w in screen_friendly_name.split() if len(w) > 3]
|
||||
|
||||
for action in available:
|
||||
if any(w in action.lower() for w in goal_words):
|
||||
# Verify we don't know this leads somewhere else
|
||||
known_target = self.knowledge.get_screen_for_action(action)
|
||||
if known_target and known_target != target_screen:
|
||||
continue # Trap prevention
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"🎯 [Nav Discovery] Linguistic match on available action! '{action}' aligns with '{screen_friendly_name}'"
|
||||
)
|
||||
return action
|
||||
|
||||
# If no perceived action matches, return intent for TelepathicEngine
|
||||
return f"navigate to {screen_friendly_name}"
|
||||
else:
|
||||
if known_action in available:
|
||||
@@ -840,7 +858,6 @@ class GoalPlanner:
|
||||
|
||||
# If no targeted navigation works, try going back first
|
||||
if "press back" in available:
|
||||
# Only press back if we aren't currently on the required screen (handled in step 3)
|
||||
return "press back"
|
||||
|
||||
return None
|
||||
|
||||
@@ -9,6 +9,7 @@ from GramAddict.core.compiler_engine import VLMCompilerEngine
|
||||
from GramAddict.core.qdrant_memory import NavigationMemoryDB
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
|
||||
from GramAddict.core.goap import GoalExecutor, ScreenType
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -37,18 +38,34 @@ class QNavGraph:
|
||||
|
||||
|
||||
def _load_graph(self):
|
||||
"""Loads the topological map from Qdrant. Merges with core seeds to guarantee baseline navigation."""
|
||||
"""Loads the topological map from Qdrant. Merges with core seeds from ScreenTopology (SSOT)."""
|
||||
logger.debug("🌐 [NavGraph] Syncing topological map with Qdrant...")
|
||||
self.nodes = self.nav_memory.get_all_transitions()
|
||||
|
||||
core_nodes = {
|
||||
"HomeFeed": {"transitions": {"tap_explore_tab": "ExploreFeed", "tap_profile_tab": "OwnProfile", "tap_message_icon": "MessageInbox"}},
|
||||
"ExploreFeed": {"transitions": {"tap_home_tab": "HomeFeed"}},
|
||||
"OwnProfile": {"transitions": {"tap_home_tab": "HomeFeed", "tap_following_list": "FollowingList"}},
|
||||
"MessageInbox": {"transitions": {"tap_back": "HomeFeed"}},
|
||||
"FollowingList": {"transitions": {"tap_back": "OwnProfile"}},
|
||||
"UNKNOWN": {"transitions": {"tap_home_tab": "HomeFeed"}}
|
||||
}
|
||||
|
||||
# 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",
|
||||
}
|
||||
node_name = screen_name_map.get(screen_type)
|
||||
if not node_name:
|
||||
continue
|
||||
node_transitions = {}
|
||||
for action, target_screen in transitions.items():
|
||||
# Convert action format: "tap profile tab" → "tap_profile_tab"
|
||||
action_key = action.replace(" ", "_")
|
||||
target_name = screen_name_map.get(target_screen, target_screen.name)
|
||||
node_transitions[action_key] = target_name
|
||||
core_nodes[node_name] = {"transitions": node_transitions}
|
||||
|
||||
# Merge core nodes into loaded nodes
|
||||
for node, data in core_nodes.items():
|
||||
@@ -135,25 +152,29 @@ class QNavGraph:
|
||||
return self.goap._execute_action(goal)
|
||||
|
||||
def _find_path(self, start: str, end: str):
|
||||
if start == end: return []
|
||||
if start not in self.nodes: return None
|
||||
|
||||
queue = [(start, [])]
|
||||
visited = set()
|
||||
|
||||
while queue:
|
||||
current, path = queue.pop(0)
|
||||
if current == end:
|
||||
return path
|
||||
|
||||
visited.add(current)
|
||||
transitions = self.nodes.get(current, {}).get("transitions", {})
|
||||
|
||||
for action, next_state in transitions.items():
|
||||
if next_state not in visited:
|
||||
queue.append((next_state, path + [action]))
|
||||
|
||||
return None
|
||||
"""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)
|
||||
if not from_screen or not to_screen:
|
||||
return None
|
||||
|
||||
route = ScreenTopology.find_route(from_screen, to_screen)
|
||||
if route is None:
|
||||
return None
|
||||
|
||||
# Convert back to QNavGraph action format: "tap profile tab" → "tap_profile_tab"
|
||||
return [action.replace(" ", "_") for action, _ in route]
|
||||
|
||||
def _clear_anomaly_obstacles(self, max_attempts=2, xml_dump: str = None) -> bool:
|
||||
"""
|
||||
|
||||
135
GramAddict/core/screen_topology.py
Normal file
135
GramAddict/core/screen_topology.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
ScreenTopology — The Instagram HD Map
|
||||
|
||||
Pure-data BFS pathfinding between Instagram screen states.
|
||||
Zero dependencies on device, VLM, Qdrant, or any runtime state.
|
||||
|
||||
This is the bot's GPS: it knows HOW to get from screen A to screen B
|
||||
before the bot starts moving. The GOAP planner consults this map
|
||||
as its primary routing strategy.
|
||||
"""
|
||||
from collections import deque
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from GramAddict.core.goap import ScreenType
|
||||
|
||||
|
||||
class ScreenTopology:
|
||||
"""
|
||||
Topological HD Map of Instagram's screen graph.
|
||||
|
||||
Provides BFS pathfinding between any two ScreenTypes.
|
||||
All transitions use the same action string format as
|
||||
the TelepathicEngine intent system — no translation needed.
|
||||
"""
|
||||
|
||||
# ── The Map: ScreenType → {action_string → ScreenType} ──
|
||||
# These are structural facts about Instagram's UI, not learned behavior.
|
||||
# They survive blank_start because they describe the app's architecture.
|
||||
TRANSITIONS: Dict[ScreenType, Dict[str, ScreenType]] = {
|
||||
ScreenType.HOME_FEED: {
|
||||
"tap explore tab": ScreenType.EXPLORE_GRID,
|
||||
"tap profile tab": ScreenType.OWN_PROFILE,
|
||||
"tap reels tab": ScreenType.REELS_FEED,
|
||||
"tap messages tab": ScreenType.DM_INBOX,
|
||||
},
|
||||
ScreenType.EXPLORE_GRID: {
|
||||
"tap home tab": ScreenType.HOME_FEED,
|
||||
"tap profile tab": ScreenType.OWN_PROFILE,
|
||||
"tap reels tab": ScreenType.REELS_FEED,
|
||||
},
|
||||
ScreenType.REELS_FEED: {
|
||||
"tap home tab": ScreenType.HOME_FEED,
|
||||
"tap explore tab": ScreenType.EXPLORE_GRID,
|
||||
"tap profile tab": ScreenType.OWN_PROFILE,
|
||||
},
|
||||
ScreenType.OWN_PROFILE: {
|
||||
"tap home tab": ScreenType.HOME_FEED,
|
||||
"tap explore tab": ScreenType.EXPLORE_GRID,
|
||||
"tap reels tab": ScreenType.REELS_FEED,
|
||||
"tap following list": ScreenType.FOLLOW_LIST,
|
||||
},
|
||||
ScreenType.DM_INBOX: {
|
||||
"press back": ScreenType.HOME_FEED,
|
||||
},
|
||||
ScreenType.FOLLOW_LIST: {
|
||||
"press back": ScreenType.OWN_PROFILE,
|
||||
},
|
||||
ScreenType.OTHER_PROFILE: {
|
||||
"press back": ScreenType.HOME_FEED,
|
||||
"tap home tab": ScreenType.HOME_FEED,
|
||||
},
|
||||
ScreenType.UNKNOWN: {
|
||||
"tap home tab": ScreenType.HOME_FEED,
|
||||
},
|
||||
}
|
||||
|
||||
# ── Goal → ScreenType mapping ──
|
||||
# Maps natural-language goals to their target screen.
|
||||
_GOAL_MAP: Dict[str, ScreenType] = {
|
||||
"open home feed": ScreenType.HOME_FEED,
|
||||
"open home": ScreenType.HOME_FEED,
|
||||
"open explore feed": ScreenType.EXPLORE_GRID,
|
||||
"open explore": ScreenType.EXPLORE_GRID,
|
||||
"open reels": ScreenType.REELS_FEED,
|
||||
"open profile": ScreenType.OWN_PROFILE,
|
||||
"learn own profile": ScreenType.OWN_PROFILE,
|
||||
"open messages": ScreenType.DM_INBOX,
|
||||
"open following list": ScreenType.FOLLOW_LIST,
|
||||
"open followers list": ScreenType.FOLLOW_LIST,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def find_route(
|
||||
cls, from_screen: ScreenType, to_screen: ScreenType
|
||||
) -> Optional[List[Tuple[str, ScreenType]]]:
|
||||
"""
|
||||
BFS shortest path from from_screen to to_screen.
|
||||
|
||||
Returns:
|
||||
[] if already there,
|
||||
[(action, resulting_screen), ...] for a path,
|
||||
None if unreachable.
|
||||
"""
|
||||
if from_screen == to_screen:
|
||||
return []
|
||||
|
||||
queue: deque = deque()
|
||||
queue.append((from_screen, []))
|
||||
visited = {from_screen}
|
||||
|
||||
while queue:
|
||||
current, path = queue.popleft()
|
||||
transitions = cls.TRANSITIONS.get(current, {})
|
||||
|
||||
for action, next_screen in transitions.items():
|
||||
if next_screen == to_screen:
|
||||
return path + [(action, next_screen)]
|
||||
|
||||
if next_screen not in visited:
|
||||
visited.add(next_screen)
|
||||
queue.append((next_screen, path + [(action, next_screen)]))
|
||||
|
||||
return None # Unreachable
|
||||
|
||||
@classmethod
|
||||
def get_transitions(cls, screen: ScreenType) -> Dict[str, ScreenType]:
|
||||
"""Get all known transitions from a screen."""
|
||||
return dict(cls.TRANSITIONS.get(screen, {}))
|
||||
|
||||
@classmethod
|
||||
def goal_to_target_screen(cls, goal: str) -> Optional[ScreenType]:
|
||||
"""Map a goal string to its target ScreenType. Returns None for non-navigation goals."""
|
||||
goal_lower = goal.lower().strip()
|
||||
|
||||
# Exact match first
|
||||
if goal_lower in cls._GOAL_MAP:
|
||||
return cls._GOAL_MAP[goal_lower]
|
||||
|
||||
# Substring match for flexibility
|
||||
for key, screen in cls._GOAL_MAP.items():
|
||||
if key in goal_lower:
|
||||
return screen
|
||||
|
||||
return None
|
||||
@@ -24,9 +24,17 @@ MAX_BUTTON_AREA = 150000 # Buttons/icons should be smaller than this (px²)
|
||||
MAX_CONTAINER_AREA = 500000 # Anything above this is a full-screen container
|
||||
|
||||
# ── Nav Intent Keywords (SSOT — used by all structural guards) ──
|
||||
# Any intent containing these keywords is classified as "navigation",
|
||||
# allowing the structural guard to accept elements in the nav bar zone.
|
||||
NAV_INTENT_KEYWORDS = [
|
||||
#
|
||||
# TWO purpose-specific sets prevent the Guard Civil War:
|
||||
#
|
||||
# NAV_ZONE_BYPASS_KEYWORDS: Intents ALLOWED to target elements in the
|
||||
# bottom nav bar zone. Broad — includes tabs, DMs, profile stats.
|
||||
#
|
||||
# NAV_TAB_KEYWORDS: Intents that REQUIRE their target to be at the bottom.
|
||||
# Narrow — only actual navigation tabs. "following"/"follower" are profile
|
||||
# stats at the TOP of the screen, not tabs.
|
||||
|
||||
NAV_ZONE_BYPASS_KEYWORDS = [
|
||||
"tab",
|
||||
"navigation",
|
||||
"reels tab",
|
||||
@@ -44,6 +52,14 @@ NAV_INTENT_KEYWORDS = [
|
||||
"followers",
|
||||
]
|
||||
|
||||
NAV_TAB_KEYWORDS = [
|
||||
"reels tab",
|
||||
"profile tab",
|
||||
"home tab",
|
||||
"explore tab",
|
||||
"message tab",
|
||||
]
|
||||
|
||||
# Cache files
|
||||
MEMORY_FILE = "telepathic_memory.json"
|
||||
BLACKLIST_FILE = "telepathic_blacklist.json"
|
||||
@@ -410,7 +426,7 @@ class TelepathicEngine:
|
||||
|
||||
# 3. Reject nodes in the Navigation Bar zone (bottom 6% - adjusted for accuracy)
|
||||
# UNLESS the intent is explicitly about navigation tabs, profile stats, OR popup modals
|
||||
nav_keywords = NAV_INTENT_KEYWORDS
|
||||
nav_keywords = NAV_ZONE_BYPASS_KEYWORDS
|
||||
modal_keywords = ["dismiss", "ok", "cancel", "accept", "allow", "deny", "action", "obstacle", "popup"]
|
||||
|
||||
low_intent = intent_description.lower()
|
||||
@@ -431,12 +447,11 @@ class TelepathicEngine:
|
||||
# STRICT NAVIGATION TAB ENFORCEMENT
|
||||
# Navigation tabs MUST be at the bottom (>= 0.92 of screen height).
|
||||
# We reject any navigation tab hallucinated in the middle of the screen.
|
||||
if is_nav_intent and node.get("y", 0) < screen_height * 0.92:
|
||||
# We must be careful: intent could be "navigate to profile" which means click username
|
||||
# So ONLY block if the intent explicitly says "tab".
|
||||
if "tab" in low_intent:
|
||||
# Silently filter non-bottom elements for navigation intents to prevent log spam
|
||||
return False
|
||||
# STRICT TAB ENFORCEMENT: Only actual tab intents must be at the bottom.
|
||||
is_tab_intent = any(k in low_intent for k in NAV_TAB_KEYWORDS)
|
||||
if is_tab_intent and node.get("y", 0) < screen_height * 0.92:
|
||||
# Silently filter non-bottom elements for tab intents to prevent log spam
|
||||
return False
|
||||
|
||||
# STRICT STORY TRAY ENFORCEMENT
|
||||
# Story rings MUST be at the top of the screen (y < 30% of screen height).
|
||||
@@ -685,7 +700,7 @@ class TelepathicEngine:
|
||||
# - Navigation intents: Require 100% exact match to avoid feed-cross-talk
|
||||
# - 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_INTENT_KEYWORDS)
|
||||
is_nav_intent = any(k in intent_lower for k in NAV_ZONE_BYPASS_KEYWORDS)
|
||||
if is_nav_intent:
|
||||
threshold = 1.0
|
||||
else:
|
||||
@@ -939,7 +954,7 @@ class TelepathicEngine:
|
||||
# If a bottom sheet or dialog is active, it likely obscures the main navigation tabs.
|
||||
# We rely strictly on the SAE (Situational Awareness Engine) for 100% autonomous detection
|
||||
# (via Qdrant cache or LLM reasoning) instead of hardcoded resource IDs.
|
||||
is_nav_intent = any(k in intent_lower for k in NAV_INTENT_KEYWORDS)
|
||||
is_nav_intent = any(k in intent_lower for k in NAV_ZONE_BYPASS_KEYWORDS)
|
||||
if is_nav_intent and device is not None:
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
|
||||
|
||||
@@ -2080,15 +2095,14 @@ class TelepathicEngine:
|
||||
)
|
||||
return None
|
||||
|
||||
is_nav_intent = any(k in intent.lower() for k in NAV_INTENT_KEYWORDS)
|
||||
is_nav_intent = any(k in intent.lower() for k in NAV_ZONE_BYPASS_KEYWORDS)
|
||||
is_tab_intent = any(k in intent.lower() for k in NAV_TAB_KEYWORDS)
|
||||
|
||||
# NAVIGATION TAB ENFORCEMENT:
|
||||
# Real navigation tabs (Home, Search, Reels, Store, Profile) are ALWAYS in the bottom zone.
|
||||
# If the bot is looking for a tab, forbid results that are too high up (likely hallucinations on comments/feed).
|
||||
# IMPORTANT: Only enforce for intents explicitly containing "tab".
|
||||
# "following"/"follower" are profile stats at the TOP of the screen, not tabs.
|
||||
# This aligns with _structural_sanity_check which has the same "tab" guard.
|
||||
if is_nav_intent and "tab" in intent.lower():
|
||||
# If the bot is looking for a tab, forbid results that are too high up.
|
||||
# "following"/"follower" are profile stats at the TOP — not tabs.
|
||||
if is_tab_intent:
|
||||
if match.get("y", 0) < screen_height * 0.92:
|
||||
logger.warning(
|
||||
f"🛡️ [Structural Guard] VLM hallucinated a navigation tab '{intent}' "
|
||||
|
||||
@@ -3,10 +3,12 @@ from unittest.mock import MagicMock
|
||||
from GramAddict.core.goap import GoalPlanner, ScreenType
|
||||
|
||||
|
||||
def test_goal_planner_linguistic_match_blank_start():
|
||||
def test_goal_planner_uses_hd_map_over_linguistic_match():
|
||||
"""
|
||||
Tests that the GoalPlanner correctly matches a linguistic target
|
||||
during Blank Start discovery without being blocked by a known target check.
|
||||
Tests that the GoalPlanner uses the HD Map (ScreenTopology) as its
|
||||
primary routing strategy, even during Blank Start discovery.
|
||||
The HD Map returns canonical action intents that the TelepathicEngine
|
||||
interprets — not just available_actions from the XML dump.
|
||||
"""
|
||||
planner = GoalPlanner("test_user")
|
||||
|
||||
@@ -26,4 +28,8 @@ def test_goal_planner_linguistic_match_blank_start():
|
||||
|
||||
result = planner.plan_next_step(goal, screen, explored_nav_actions=set())
|
||||
|
||||
assert result == "tap explore grid", "Failed to linguistically match explore during Blank Start"
|
||||
# HD Map routes HOME_FEED → EXPLORE_GRID via "tap explore tab"
|
||||
assert result == "tap explore tab", (
|
||||
f"Expected HD Map to route via 'tap explore tab', got '{result}'. "
|
||||
"The HD Map should override linguistic matching as the primary strategy."
|
||||
)
|
||||
|
||||
103
tests/unit/test_goap_graph_routing.py
Normal file
103
tests/unit/test_goap_graph_routing.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
🔴 RED TDD: GOAP Graph-Aware Routing
|
||||
|
||||
The GoalPlanner must use the ScreenTopology HD Map as its PRIMARY
|
||||
routing strategy. When asked to reach FollowingList from HomeFeed,
|
||||
it should return "tap profile tab" (first step of the BFS route),
|
||||
NOT "open following list" (impossible direct action).
|
||||
"""
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
||||
|
||||
from GramAddict.core.goap import GoalPlanner, ScreenType
|
||||
|
||||
|
||||
class TestGoapGraphRouting:
|
||||
"""GOAP planner must use the HD Map for multi-step navigation."""
|
||||
|
||||
@pytest.fixture
|
||||
def planner(self):
|
||||
p = GoalPlanner("testbot")
|
||||
# Ensure blank start (no learned knowledge)
|
||||
p.knowledge._goal_requirements = {}
|
||||
p.knowledge._learned_screen_mappings = {}
|
||||
p.knowledge._learned_traps = set()
|
||||
return p
|
||||
|
||||
def test_planner_routes_to_profile_first_for_following_list(self, planner):
|
||||
"""
|
||||
From HOME_FEED, goal 'open following list' should return
|
||||
'tap profile tab' (navigate to OwnProfile first),
|
||||
NOT 'open following list' as a raw intent.
|
||||
"""
|
||||
screen = {
|
||||
"screen_type": ScreenType.HOME_FEED,
|
||||
"available_actions": [
|
||||
"tap explore tab",
|
||||
"tap home tab",
|
||||
"tap messages tab",
|
||||
"tap reels tab",
|
||||
"press back",
|
||||
],
|
||||
"context": {},
|
||||
"selected_tab": "feed_tab",
|
||||
}
|
||||
action = planner.plan_next_step("open following list", screen)
|
||||
assert action == "tap profile tab", (
|
||||
f"Planner returned '{action}' instead of 'tap profile tab'. "
|
||||
"It should use the HD Map to route HOME_FEED → OWN_PROFILE → FOLLOW_LIST."
|
||||
)
|
||||
|
||||
def test_planner_returns_final_action_on_intermediate_screen(self, planner):
|
||||
"""
|
||||
From OWN_PROFILE, goal 'open following list' should return
|
||||
'tap following list' directly (we're already on the right screen).
|
||||
"""
|
||||
screen = {
|
||||
"screen_type": ScreenType.OWN_PROFILE,
|
||||
"available_actions": [
|
||||
"tap explore tab",
|
||||
"tap home tab",
|
||||
"tap reels tab",
|
||||
"tap following list",
|
||||
"press back",
|
||||
],
|
||||
"context": {},
|
||||
"selected_tab": "profile_tab",
|
||||
}
|
||||
action = planner.plan_next_step("open following list", screen)
|
||||
assert action == "tap following list", (
|
||||
f"Planner returned '{action}' instead of 'tap following list'. "
|
||||
"On OWN_PROFILE, it should directly execute the final action."
|
||||
)
|
||||
|
||||
def test_planner_detects_goal_already_achieved(self, planner):
|
||||
"""On FOLLOW_LIST, goal 'open following list' should return None (achieved)."""
|
||||
screen = {
|
||||
"screen_type": ScreenType.FOLLOW_LIST,
|
||||
"available_actions": ["press back"],
|
||||
"context": {},
|
||||
}
|
||||
action = planner.plan_next_step("open following list", screen)
|
||||
assert action is None, "Goal is already achieved — planner should return None."
|
||||
|
||||
def test_planner_routes_explore_to_following_list(self, planner):
|
||||
"""From EXPLORE_GRID, route should be: Explore → Profile → FollowList."""
|
||||
screen = {
|
||||
"screen_type": ScreenType.EXPLORE_GRID,
|
||||
"available_actions": [
|
||||
"tap home tab",
|
||||
"tap profile tab",
|
||||
"tap reels tab",
|
||||
],
|
||||
"context": {},
|
||||
"selected_tab": "explore_tab",
|
||||
}
|
||||
action = planner.plan_next_step("open following list", screen)
|
||||
assert action == "tap profile tab", (
|
||||
f"From EXPLORE_GRID, planner should route via OWN_PROFILE, got '{action}'"
|
||||
)
|
||||
98
tests/unit/test_screen_topology.py
Normal file
98
tests/unit/test_screen_topology.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
🔴 RED TDD: ScreenTopology — The Instagram HD Map
|
||||
|
||||
Tests for BFS pathfinding between Instagram screens.
|
||||
The killer test: HOME_FEED → OWN_PROFILE → FOLLOW_LIST must be a 2-step route.
|
||||
"""
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
||||
|
||||
from GramAddict.core.goap import ScreenType
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
|
||||
class TestScreenTopologyRouting:
|
||||
"""BFS pathfinding must compute correct multi-step routes."""
|
||||
|
||||
def test_route_home_to_following_list(self):
|
||||
"""THE killer test: HomeFeed → OwnProfile → FollowList (2 hops)."""
|
||||
route = ScreenTopology.find_route(ScreenType.HOME_FEED, ScreenType.FOLLOW_LIST)
|
||||
assert route is not None, "Route from HOME_FEED to FOLLOW_LIST must exist"
|
||||
assert len(route) == 2, f"Expected 2 hops, got {len(route)}: {route}"
|
||||
assert route[0] == ("tap profile tab", ScreenType.OWN_PROFILE)
|
||||
assert route[1] == ("tap following list", ScreenType.FOLLOW_LIST)
|
||||
|
||||
def test_route_already_there(self):
|
||||
"""Same screen = empty route (no steps needed)."""
|
||||
route = ScreenTopology.find_route(ScreenType.HOME_FEED, ScreenType.HOME_FEED)
|
||||
assert route == []
|
||||
|
||||
def test_route_single_hop(self):
|
||||
"""Direct neighbor = 1 step."""
|
||||
route = ScreenTopology.find_route(ScreenType.HOME_FEED, ScreenType.EXPLORE_GRID)
|
||||
assert route is not None
|
||||
assert len(route) == 1
|
||||
assert route[0] == ("tap explore tab", ScreenType.EXPLORE_GRID)
|
||||
|
||||
def test_route_reverse_direction(self):
|
||||
"""FollowList back to HomeFeed: FollowList → OwnProfile → HomeFeed (2 hops)."""
|
||||
route = ScreenTopology.find_route(ScreenType.FOLLOW_LIST, ScreenType.HOME_FEED)
|
||||
assert route is not None
|
||||
assert len(route) == 2
|
||||
assert route[0][1] == ScreenType.OWN_PROFILE
|
||||
assert route[1][1] == ScreenType.HOME_FEED
|
||||
|
||||
def test_no_route_from_unreachable(self):
|
||||
"""FOREIGN_APP has no outbound transitions — route should be None."""
|
||||
route = ScreenTopology.find_route(ScreenType.FOREIGN_APP, ScreenType.FOLLOW_LIST)
|
||||
assert route is None
|
||||
|
||||
|
||||
class TestGoalToTargetScreen:
|
||||
"""Goal string → target ScreenType mapping."""
|
||||
|
||||
def test_following_list_goal(self):
|
||||
assert ScreenTopology.goal_to_target_screen("open following list") == ScreenType.FOLLOW_LIST
|
||||
|
||||
def test_followers_list_goal(self):
|
||||
assert ScreenTopology.goal_to_target_screen("open followers list") == ScreenType.FOLLOW_LIST
|
||||
|
||||
def test_profile_goal(self):
|
||||
assert ScreenTopology.goal_to_target_screen("open profile") == ScreenType.OWN_PROFILE
|
||||
|
||||
def test_home_feed_goal(self):
|
||||
assert ScreenTopology.goal_to_target_screen("open home feed") == ScreenType.HOME_FEED
|
||||
|
||||
def test_explore_goal(self):
|
||||
assert ScreenTopology.goal_to_target_screen("open explore feed") == ScreenType.EXPLORE_GRID
|
||||
|
||||
def test_messages_goal(self):
|
||||
assert ScreenTopology.goal_to_target_screen("open messages") == ScreenType.DM_INBOX
|
||||
|
||||
def test_interaction_goal_returns_none(self):
|
||||
"""Non-navigation goals should return None."""
|
||||
assert ScreenTopology.goal_to_target_screen("like this post") is None
|
||||
|
||||
def test_unknown_goal_returns_none(self):
|
||||
assert ScreenTopology.goal_to_target_screen("do something random") is None
|
||||
|
||||
|
||||
class TestGetTransitions:
|
||||
"""Get available transitions from a screen."""
|
||||
|
||||
def test_home_feed_has_profile_tab(self):
|
||||
transitions = ScreenTopology.get_transitions(ScreenType.HOME_FEED)
|
||||
assert "tap profile tab" in transitions
|
||||
assert transitions["tap profile tab"] == ScreenType.OWN_PROFILE
|
||||
|
||||
def test_own_profile_has_following_list(self):
|
||||
transitions = ScreenTopology.get_transitions(ScreenType.OWN_PROFILE)
|
||||
assert "tap following list" in transitions
|
||||
assert transitions["tap following list"] == ScreenType.FOLLOW_LIST
|
||||
|
||||
def test_unknown_screen_returns_empty(self):
|
||||
transitions = ScreenTopology.get_transitions(ScreenType.FOREIGN_APP)
|
||||
assert transitions == {}
|
||||
Reference in New Issue
Block a user