fix(navigation): Resolve planner loop, semantic mapping, and structural verification
This commit is contained in:
@@ -1,39 +1,208 @@
|
||||
import pytest
|
||||
"""
|
||||
TDD Tests: Profile Screen Identity Classification
|
||||
|
||||
Reproduces the EXACT production bug from 2026-05-05 where ScreenMemory (Qdrant)
|
||||
poisoned OWN_PROFILE classification as OTHER_PROFILE, causing an infinite navigation loop.
|
||||
|
||||
The fix ensures:
|
||||
1. profile_tab selected=true → OWN_PROFILE (absolute structural anchor)
|
||||
2. profile_header markers → OWN vs OTHER (deterministic disambiguation)
|
||||
3. Qdrant cache CANNOT override structurally-gated screen types
|
||||
"""
|
||||
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
|
||||
def test_screen_identity_own_profile_vs_other_profile():
|
||||
identity = ScreenIdentity("marisaundmarc")
|
||||
|
||||
# When we are on our OWN profile, 'profile_tab' is selected,
|
||||
# but 'profile_header_container' is ALSO present.
|
||||
# The bug is that 'profile_header_container' shadows 'profile_tab' selected=True.
|
||||
|
||||
# Let's create an XML dump that mimics this scenario:
|
||||
own_profile_xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_container" text="" content-desc="" clickable="false" bounds="[0,0][100,100]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tab" selected="true" text="" content-desc="Profile" clickable="true" bounds="[0,0][100,100]" />
|
||||
</hierarchy>
|
||||
def _make_identity():
|
||||
"""Create ScreenIdentity with Qdrant disabled (no mock needed — constructor guards)."""
|
||||
si = ScreenIdentity.__new__(ScreenIdentity)
|
||||
si.bot_username = "testuser"
|
||||
si.screen_memory = None
|
||||
return si
|
||||
|
||||
|
||||
class TestProfileClassificationInvariants:
|
||||
"""
|
||||
|
||||
result = identity.identify(own_profile_xml)
|
||||
|
||||
assert result["screen_type"] == ScreenType.OWN_PROFILE, "Failed! own profile was classified as OTHER_PROFILE because profile_header_container shadowed it."
|
||||
|
||||
def test_screen_identity_other_profile_vs_own_profile():
|
||||
identity = ScreenIdentity("marisaundmarc")
|
||||
|
||||
# When we are on someone ELSE's profile, 'profile_tab' is NOT selected
|
||||
# (or maybe 'feed_tab' or 'search_tab' is selected, or none).
|
||||
# And 'profile_header_container' is present.
|
||||
|
||||
other_profile_xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_container" text="" content-desc="" clickable="false" bounds="[0,0][100,100]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/feed_tab" selected="true" text="" content-desc="Home" clickable="true" bounds="[0,0][100,100]" />
|
||||
</hierarchy>
|
||||
These tests enforce absolute structural invariants that prevent
|
||||
Qdrant cache poisoning from ever breaking profile navigation again.
|
||||
"""
|
||||
|
||||
result = identity.identify(other_profile_xml)
|
||||
|
||||
assert result["screen_type"] == ScreenType.OTHER_PROFILE, "Failed! other profile was not classified as OTHER_PROFILE."
|
||||
|
||||
def test_profile_tab_selected_always_means_own_profile(self):
|
||||
"""INVARIANT: profile_tab selected=true → OWN_PROFILE. No exceptions."""
|
||||
si = _make_identity()
|
||||
xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tab"
|
||||
selected="true" text="" content-desc="Profile" clickable="true" bounds="[864,2268][1080,2400]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/feed_tab"
|
||||
selected="false" text="" content-desc="Home" clickable="true" bounds="[0,2268][216,2400]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_container"
|
||||
text="" content-desc="" clickable="false" bounds="[0,100][1080,600]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
result = si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.OWN_PROFILE
|
||||
|
||||
def test_own_profile_with_edit_button_no_tab_selected(self):
|
||||
"""OWN_PROFILE detected via edit_profile button even without selected tab."""
|
||||
si = _make_identity()
|
||||
xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header"
|
||||
text="" content-desc="" clickable="false" bounds="[0,100][1080,600]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_edit_profile_button"
|
||||
text="" content-desc="Edit Profile" clickable="true" bounds="[50,500][1030,570]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tab"
|
||||
selected="false" text="" content-desc="Profile" clickable="true" bounds="[864,2268][1080,2400]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
result = si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.OWN_PROFILE
|
||||
|
||||
def test_other_profile_with_follow_button(self):
|
||||
"""OTHER_PROFILE detected via follow button."""
|
||||
si = _make_identity()
|
||||
xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header"
|
||||
text="" content-desc="" clickable="false" bounds="[0,100][1080,600]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_follow_button"
|
||||
text="" content-desc="Follow" clickable="true" bounds="[50,500][1030,570]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_message_button"
|
||||
text="" content-desc="Message" clickable="true" bounds="[50,580][1030,650]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tab"
|
||||
selected="false" text="" content-desc="Profile" clickable="true" bounds="[864,2268][1080,2400]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
result = si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.OTHER_PROFILE
|
||||
|
||||
def test_profile_header_without_markers_defaults_to_own_profile(self):
|
||||
"""
|
||||
Edge case: profile_header exists but neither edit nor follow button.
|
||||
Must default to OWN_PROFILE (safer than letting Qdrant guess OTHER_PROFILE).
|
||||
"""
|
||||
si = _make_identity()
|
||||
xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header"
|
||||
text="" content-desc="" clickable="false" bounds="[0,100][1080,600]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tab"
|
||||
selected="false" text="" content-desc="Profile" clickable="true" bounds="[864,2268][1080,2400]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
result = si.identify(xml)
|
||||
assert (
|
||||
result["screen_type"] == ScreenType.OWN_PROFILE
|
||||
), "Profile without markers must default to OWN_PROFILE, not fall to Qdrant"
|
||||
|
||||
|
||||
class TestQdrantCachePoisoningGuard:
|
||||
"""
|
||||
Reproduces the exact production failure: Qdrant cached a profile screen
|
||||
as OTHER_PROFILE, and every subsequent identification returned OTHER_PROFILE
|
||||
even when on OWN_PROFILE. This must be prevented architecturally.
|
||||
"""
|
||||
|
||||
def test_poisoned_qdrant_cache_cannot_override_own_profile(self, monkeypatch):
|
||||
"""
|
||||
PRODUCTION BUG REPRODUCTION:
|
||||
1. Qdrant has a cached entry mapping this XML signature → OTHER_PROFILE
|
||||
2. The XML has profile_tab selected=true → should be OWN_PROFILE
|
||||
3. Qdrant MUST NOT override the structural fast-path
|
||||
"""
|
||||
si = _make_identity()
|
||||
|
||||
# Simulate a poisoned Qdrant cache by attaching a fake screen_memory
|
||||
class FakePoisonedCache:
|
||||
is_connected = True
|
||||
|
||||
def get_screen_type(self, sig, similarity_threshold=0.95):
|
||||
return "OTHER_PROFILE"
|
||||
|
||||
def purge_stale_screens(self):
|
||||
pass
|
||||
|
||||
def store_screen(self, sig, t):
|
||||
pass
|
||||
|
||||
si.screen_memory = FakePoisonedCache()
|
||||
|
||||
xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tab"
|
||||
selected="true" text="" content-desc="Profile" clickable="true" bounds="[864,2268][1080,2400]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header"
|
||||
text="" content-desc="" clickable="false" bounds="[0,100][1080,600]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_edit_profile_button"
|
||||
text="" content-desc="Edit Profile" clickable="true" bounds="[50,500][1030,570]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
result = si.identify(xml)
|
||||
assert (
|
||||
result["screen_type"] == ScreenType.OWN_PROFILE
|
||||
), "Poisoned Qdrant cache returned OTHER_PROFILE but structural fast-path should override!"
|
||||
|
||||
def test_poisoned_qdrant_cache_cannot_override_other_profile(self, monkeypatch):
|
||||
"""Qdrant cached OWN_PROFILE but XML shows follow button → OTHER_PROFILE."""
|
||||
si = _make_identity()
|
||||
|
||||
class FakePoisonedCache:
|
||||
is_connected = True
|
||||
|
||||
def get_screen_type(self, sig, similarity_threshold=0.95):
|
||||
return "OWN_PROFILE"
|
||||
|
||||
def purge_stale_screens(self):
|
||||
pass
|
||||
|
||||
def store_screen(self, sig, t):
|
||||
pass
|
||||
|
||||
si.screen_memory = FakePoisonedCache()
|
||||
|
||||
xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header"
|
||||
text="" content-desc="" clickable="false" bounds="[0,100][1080,600]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_follow_button"
|
||||
text="" content-desc="Follow" clickable="true" bounds="[50,500][1030,570]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tab"
|
||||
selected="false" text="" content-desc="Profile" clickable="true" bounds="[864,2268][1080,2400]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
result = si.identify(xml)
|
||||
assert (
|
||||
result["screen_type"] == ScreenType.OTHER_PROFILE
|
||||
), "Poisoned Qdrant cache returned OWN_PROFILE but structural fast-path should override!"
|
||||
|
||||
def test_qdrant_profile_cache_rejected_when_no_structural_match(self, monkeypatch):
|
||||
"""If Qdrant says OTHER_PROFILE but no profile markers exist, it must be rejected."""
|
||||
si = _make_identity()
|
||||
|
||||
class FakePoisonedCache:
|
||||
is_connected = True
|
||||
|
||||
def get_screen_type(self, sig, similarity_threshold=0.95):
|
||||
return "OTHER_PROFILE"
|
||||
|
||||
def purge_stale_screens(self):
|
||||
pass
|
||||
|
||||
def store_screen(self, sig, t):
|
||||
pass
|
||||
|
||||
si.screen_memory = FakePoisonedCache()
|
||||
|
||||
xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/feed_tab"
|
||||
selected="true" text="" content-desc="Home" clickable="true" bounds="[0,2268][216,2400]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
result = si.identify(xml)
|
||||
# Qdrant said OTHER_PROFILE, but feed_tab is selected → HOME_FEED
|
||||
# The cache for profile types must be rejected by _STRUCTURALLY_GATED_TYPES guard
|
||||
assert (
|
||||
result["screen_type"] != ScreenType.OTHER_PROFILE
|
||||
), "Qdrant cache for OTHER_PROFILE must be rejected when no profile markers exist!"
|
||||
|
||||
@@ -12,10 +12,19 @@ class TestVerifySuccessGridReels:
|
||||
|
||||
def setup_method(self):
|
||||
self.engine = TelepathicEngine()
|
||||
grid_xml = """
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/recycler_view" />
|
||||
<node resource-id="com.instagram.android:id/image_button" />
|
||||
<node resource-id="com.instagram.android:id/explore_action_bar" />
|
||||
<node text="Search" resource-id="com.instagram.android:id/action_bar_search_edit_text" />
|
||||
</hierarchy>
|
||||
"""
|
||||
# Simulate a click context so verify_success has something to check against
|
||||
TelepathicEngine._last_click_context = {
|
||||
self.engine._memory._last_click_context = {
|
||||
"intent": "view a post",
|
||||
"semantic_string": "id context: 'image button'",
|
||||
"xml_context": grid_xml,
|
||||
"x": 178,
|
||||
"y": 558,
|
||||
"timestamp": 0,
|
||||
@@ -62,7 +71,7 @@ class TestVerifySuccessGridReels:
|
||||
|
||||
def test_profile_grid_reel_accepted(self):
|
||||
"""Profile grid → Reel must also be accepted."""
|
||||
TelepathicEngine._last_click_context["intent"] = "view a post"
|
||||
self.engine._memory._last_click_context["intent"] = "view a post"
|
||||
reel_xml = """
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/clips_viewer_view_pager" />
|
||||
|
||||
Reference in New Issue
Block a user