feat(navigation): harden obstacle guard and intent resolver against keyboard hallucinations

This commit is contained in:
2026-05-05 10:52:38 +02:00
parent 39185593dd
commit 5ada31c77e
40 changed files with 950 additions and 1067 deletions

View File

@@ -32,26 +32,31 @@ class TestContextGateCategoricalBans:
"""P2-2: Every screen/action combination that is structurally impossible
must be categorically banned — not reliant on marker presence."""
def test_like_blocked_on_story_view(self, gate):
"""Cannot like on STORY_VIEW — there's no feed-style like button."""
screen = _make_screen(ScreenType.STORY_VIEW)
assert gate.is_allowed("like", screen) is False
def test_comment_blocked_on_story_view(self, gate):
"""Cannot comment on STORY_VIEW — reply is a different action."""
screen = _make_screen(ScreenType.STORY_VIEW)
assert gate.is_allowed("comment", screen) is False
def test_follow_blocked_on_story_view(self, gate):
"""Cannot follow from STORY_VIEW — no follow button exists."""
screen = _make_screen(ScreenType.STORY_VIEW)
assert gate.is_allowed("follow", screen) is False
def test_save_blocked_on_story_view(self, gate):
"""Cannot save on STORY_VIEW."""
"""Cannot save on STORY_VIEW — no bookmark button exists."""
screen = _make_screen(ScreenType.STORY_VIEW)
assert gate.is_allowed("save", screen) is False
def test_repost_blocked_on_story_view(self, gate):
"""Cannot repost on STORY_VIEW — only share button, no repost."""
screen = _make_screen(ScreenType.STORY_VIEW)
assert gate.is_allowed("repost", screen) is False
def test_like_allowed_on_story_view(self, gate):
"""Stories have toolbar_like_button — like IS valid."""
screen = _make_screen(ScreenType.STORY_VIEW)
assert gate.is_allowed("like", screen) is True
def test_comment_allowed_on_story_view(self, gate):
"""Stories have reel_viewer_comments_button — comment/reply IS valid."""
screen = _make_screen(ScreenType.STORY_VIEW)
assert gate.is_allowed("comment", screen) is True
def test_follow_allowed_on_story_view(self, gate):
"""Stories have reel_header_unconnected_follow_button_stub."""
screen = _make_screen(ScreenType.STORY_VIEW)
assert gate.is_allowed("follow", screen) is True
def test_like_blocked_on_follow_list(self, gate):
"""Cannot like on FOLLOW_LIST — it's a list of users, not posts."""
screen = _make_screen(ScreenType.FOLLOW_LIST)

View File

@@ -0,0 +1,49 @@
from GramAddict.core.perception.intent_resolver import IntentResolver
from GramAddict.core.perception.spatial_parser import SpatialNode
def test_reply_guard_filters_edittext(monkeypatch):
"""
TDD: Prove that the Reply Guard filters out EditText classes
when the intent does not imply typing/messaging.
"""
resolver = IntentResolver()
# Create mock candidates
node_normal = SpatialNode(
bounds=(0, 0, 100, 100),
resource_id="com.instagram.android:id/button_next",
class_name="android.widget.Button",
text="Next",
)
node_edittext = SpatialNode(
bounds=(0, 100, 100, 200),
resource_id="com.instagram.android:id/comment_composer",
class_name="android.widget.EditText",
text="Add a comment...",
)
candidates = [node_normal, node_edittext]
# We will capture the candidates passed to _annotate_screenshot_with_candidates
captured_candidates = []
def fake_annotate(device, cands):
captured_candidates.extend(cands)
# Return a dummy annotation output
return (None, {0: node_normal})
monkeypatch.setattr(resolver, "_annotate_screenshot_with_candidates", fake_annotate)
class DummyDevice:
pass
dummy_device = DummyDevice()
# Attempt to resolve an intent that shouldn't match EditText
resolver._visual_discovery("tap the next button", candidates, dummy_device, screen_height=1000)
assert len(captured_candidates) == 1
assert captured_candidates[0].class_name == "android.widget.Button"
assert node_edittext not in captured_candidates

View File

@@ -35,14 +35,19 @@ class TestMemoryHygiene:
def test_low_confidence_entries_are_pruned(self):
"""Low-confidence (stale/poisoned) entries must be pruned during hygiene."""
import uuid
from GramAddict.core.qdrant_memory import ScreenMemoryDB
db = ScreenMemoryDB()
if not db.is_connected:
pytest.skip("Qdrant not available")
# Use a unique signature to prevent Qdrant state pollution from other tests
unique_sig = f"sig_low_conf_{uuid.uuid4().hex[:8]}"
# Store a low-confidence entry (representing a bad VLM guess)
db.store_screen("sig_low_conf", "MODAL", confidence=0.2)
db.store_screen(unique_sig, "MODAL", confidence=0.2)
# Run hygiene — this SHOULD delete the low-confidence entry
from GramAddict.core.qdrant_memory import memory_hygiene
@@ -50,7 +55,7 @@ class TestMemoryHygiene:
memory_hygiene()
# Verify it was pruned
result = db.get_screen_type("sig_low_conf", similarity_threshold=0.90)
result = db.get_screen_type(unique_sig, similarity_threshold=0.99)
assert result is None, "Low-confidence poisoned entry survived hygiene!"
def test_memory_hygiene_function_exists(self):

View File

@@ -0,0 +1,70 @@
from GramAddict.core.behaviors import BehaviorContext
from GramAddict.core.behaviors.obstacle_guard import ObstacleGuardPlugin
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
class DummyDeviceV2:
def __init__(self):
self.info = {"screenOn": True}
class DummyDevice:
def __init__(self):
self.deviceV2 = DummyDeviceV2()
self.app_id = "com.instagram.android"
self.pressed = []
def press(self, key):
self.pressed.append(key)
def test_sae_detects_obstacle_keyboard():
"""
TDD: Prove that the SAE detects the on-screen keyboard as an obstacle.
"""
real_device = DummyDevice()
sae = SituationalAwarenessEngine.get_instance(real_device)
xml_with_keyboard = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" package="com.instagram.android" resource-id="" text="" content-desc="" />
<node index="1" package="com.google.android.inputmethod.latin" resource-id="com.google.android.inputmethod.latin:id/keyboard_view" text="" content-desc="Keyboard" />
</hierarchy>
"""
situation = sae.perceive(xml_with_keyboard)
assert situation == SituationType.OBSTACLE_KEYBOARD
def test_obstacle_guard_dismisses_keyboard():
"""
TDD: Prove that ObstacleGuardPlugin handles OBSTACLE_KEYBOARD by pressing BACK.
"""
real_device = DummyDevice()
xml_with_keyboard = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" package="com.instagram.android" resource-id="" text="" content-desc="" />
<node index="1" package="com.google.android.inputmethod.latin" resource-id="com.google.android.inputmethod.latin:id/keyboard_view" text="" content-desc="Keyboard" />
</hierarchy>
"""
class DummySessionState:
job_target = "test"
ctx = BehaviorContext(
device=real_device,
session_state=DummySessionState(),
shared_state={},
context_xml=xml_with_keyboard,
configs=None,
cognitive_stack=None,
)
plugin = ObstacleGuardPlugin()
result = plugin.execute(ctx)
# Assert it recognized and handled the keyboard
assert "back" in real_device.pressed
assert result.executed is True
assert result.should_skip is True