diff --git a/GramAddict/core/behaviors/close_friends_guard.py b/GramAddict/core/behaviors/close_friends_guard.py
index 6c99bb5..7988ed8 100644
--- a/GramAddict/core/behaviors/close_friends_guard.py
+++ b/GramAddict/core/behaviors/close_friends_guard.py
@@ -35,7 +35,21 @@ class CloseFriendsGuardPlugin(BehaviorPlugin):
return False
xml = ctx.context_xml if ctx.context_xml else ctx.device.dump_hierarchy()
- return "enge freunde" in xml.lower() or "close friend" in xml.lower()
+ xml_lower = xml.lower()
+
+ # Structural resource-id markers (language-agnostic, O(1) detection)
+ structural_markers = (
+ "friendly_bubbles_component", # Close friend post in feed
+ "profile_header_close_friend", # Close friend badge on profile
+ "close_friends_badge", # Close friend badge in Reels
+ "close_friends_star", # Green star indicator
+ )
+ for marker in structural_markers:
+ if marker in xml_lower:
+ return True
+
+ # Legacy text fallback for edge cases
+ return "enge freunde" in xml_lower or "close friend" in xml_lower
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
logger.info("💚 [CloseFriendsGuard] Close friends post detected. Skipping...")
diff --git a/GramAddict/core/qdrant_memory.py b/GramAddict/core/qdrant_memory.py
index 1f65796..94bc4a2 100644
--- a/GramAddict/core/qdrant_memory.py
+++ b/GramAddict/core/qdrant_memory.py
@@ -1499,13 +1499,17 @@ class ContextMemoryDB(QdrantBase):
def is_allowed(self, intent: str, screen_type: str) -> bool:
"""
- Exploration vs Exploitation:
- If we don't know (no entry or neutral confidence), allow it!
- Only block if we have learned it fails consistently (< 0.2).
+ Exploration vs Exploitation with time-based decay:
+ - If we don't know (no entry), allow it (exploration).
+ - Only block if we have RECENTLY learned it fails (< 0.2 confidence AND < 1 hour old).
+ - Old failures decay: entries older than 1 hour are auto-forgiven and deleted,
+ preventing permanent poisoning of the action space.
"""
if not self.is_connected:
return True # Fail-open for exploration
+ DECAY_THRESHOLD_SECONDS = 3600 # 1 hour: old failures are forgiven
+
key = self._get_key(intent, screen_type)
try:
point_id = self.generate_uuid(key)
@@ -1513,11 +1517,25 @@ class ContextMemoryDB(QdrantBase):
collection_name=self.collection_name, ids=[point_id], with_payload=True, with_vectors=False
)
if points:
- conf = points[0].payload.get("confidence", 0.5)
+ payload = points[0].payload
+ conf = payload.get("confidence", 0.5)
+ updated_at = payload.get("updated_at", 0)
+ age_seconds = time.time() - updated_at
+
+ # Time-based decay: old failures are forgiven
+ if conf < 0.2 and age_seconds > DECAY_THRESHOLD_SECONDS:
+ logger.info(
+ f"🔄 [ContextMemory] Forgave old failure for '{intent}' on '{screen_type}' "
+ f"(age: {age_seconds/60:.0f}min, confidence: {conf:.2f}). Allowing re-exploration."
+ )
+ # Delete the stale entry so the bot can re-learn
+ self.delete_point(key)
+ return True
+
if conf < 0.2:
logger.warning(
f"🛡️ [ContextMemory] Circuit Breaker: Blocked '{intent}' on '{screen_type}' "
- f"(learned confidence {conf:.2f} < 0.2)"
+ f"(learned confidence {conf:.2f} < 0.2, age: {age_seconds/60:.0f}min)"
)
return False
except Exception:
diff --git a/tests/tdd/test_close_friends_and_memory.py b/tests/tdd/test_close_friends_and_memory.py
new file mode 100644
index 0000000..9352ff3
--- /dev/null
+++ b/tests/tdd/test_close_friends_and_memory.py
@@ -0,0 +1,213 @@
+"""
+TDD: Close Friends Guard & ContextMemory Circuit Breaker
+
+1. Close Friends Guard must detect structural resource-id markers, not just text.
+2. ContextMemory must have a decay/recovery mechanism — a single failure must NOT
+ permanently block fundamental actions like 'tap like button' on POST_DETAIL.
+3. The circuit breaker threshold must be LOWER for core actions.
+"""
+
+import time
+
+import pytest
+
+from GramAddict.core.behaviors import BehaviorContext, BehaviorResult
+from GramAddict.core.behaviors.close_friends_guard import CloseFriendsGuardPlugin
+
+
+# ── Fixtures ──
+
+
+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 = []
+ self.clicks = []
+ self._display_info = {"width": 1080, "height": 2424}
+
+ def press(self, key):
+ self.pressed.append(key)
+
+ def click(self, x, y):
+ self.clicks.append((x, y))
+
+
+# Instagram shows close friends via structural markers, not text
+FEED_WITH_CLOSE_FRIEND_POST = """
+
+
+
+
+
+
+
+"""
+
+FEED_NORMAL_POST = """
+
+
+
+
+
+"""
+
+PROFILE_WITH_CLOSE_FRIEND_BADGE = """
+
+
+
+
+
+
+"""
+
+REELS_WITH_GREEN_STAR = """
+
+
+
+
+
+
+"""
+
+
+# ── Tests: Close Friends Guard ──
+
+
+class TestCloseFriendsGuard:
+ """The guard must detect close friends via STRUCTURAL markers, not just text."""
+
+ def test_detects_friendly_bubbles_component(self):
+ """RED: Guard must detect friendly_bubbles_component resource-id."""
+ plugin = CloseFriendsGuardPlugin()
+ ctx = BehaviorContext(
+ device=DummyDevice(),
+ session_state=type("S", (), {"job_target": "test"})(),
+ shared_state={},
+ context_xml=FEED_WITH_CLOSE_FRIEND_POST,
+ configs=None,
+ cognitive_stack=None,
+ )
+ assert plugin.can_activate(ctx), (
+ "CloseFriendsGuard must detect 'friendly_bubbles_component' as a close friend indicator"
+ )
+
+ def test_does_not_fire_on_normal_post(self):
+ """Guard must NOT fire on normal posts without close friend markers."""
+ plugin = CloseFriendsGuardPlugin()
+ ctx = BehaviorContext(
+ device=DummyDevice(),
+ session_state=type("S", (), {"job_target": "test"})(),
+ shared_state={},
+ context_xml=FEED_NORMAL_POST,
+ configs=None,
+ cognitive_stack=None,
+ )
+ assert not plugin.can_activate(ctx), "Guard must not fire on normal posts"
+
+ def test_detects_profile_header_close_friend(self):
+ """RED: Guard must detect profile_header_close_friend resource-id on profiles."""
+ plugin = CloseFriendsGuardPlugin()
+ ctx = BehaviorContext(
+ device=DummyDevice(),
+ session_state=type("S", (), {"job_target": "test"})(),
+ shared_state={},
+ context_xml=PROFILE_WITH_CLOSE_FRIEND_BADGE,
+ configs=None,
+ cognitive_stack=None,
+ )
+ assert plugin.can_activate(ctx), (
+ "CloseFriendsGuard must detect 'profile_header_close_friend' resource-id"
+ )
+
+ def test_detects_close_friends_badge_in_reels(self):
+ """RED: Guard must detect close_friends_badge resource-id in Reels."""
+ plugin = CloseFriendsGuardPlugin()
+ ctx = BehaviorContext(
+ device=DummyDevice(),
+ session_state=type("S", (), {"job_target": "test"})(),
+ shared_state={},
+ context_xml=REELS_WITH_GREEN_STAR,
+ configs=None,
+ cognitive_stack=None,
+ )
+ assert plugin.can_activate(ctx), (
+ "CloseFriendsGuard must detect 'close_friends_badge' resource-id in Reels"
+ )
+
+ def test_no_hardcoded_text_matching(self):
+ """
+ META-TEST: The guard must NOT rely solely on text matching.
+ It must use structural resource-id markers.
+ """
+ import inspect
+ source = inspect.getsource(CloseFriendsGuardPlugin.can_activate)
+ # Must contain resource-id based detection
+ assert "resource-id" in source.lower() or "resource_id" in source.lower() or \
+ "friendly_bubbles" in source or "close_friends_badge" in source or \
+ "profile_header_close_friend" in source, (
+ "can_activate must use structural resource-id markers, not just text matching"
+ )
+
+
+# ── Tests: ContextMemory Circuit Breaker ──
+
+
+class TestContextMemoryCircuitBreaker:
+ """
+ The circuit breaker must NOT permanently block core actions.
+ A single failure must decay over time, allowing re-exploration.
+ """
+
+ def test_circuit_breaker_has_time_decay(self):
+ """
+ META-TEST: ContextMemory.is_allowed must consider time since last update.
+ Old failures (> 1 hour) must be forgiven to allow re-exploration.
+ """
+ import inspect
+ from GramAddict.core.qdrant_memory import ContextMemoryDB
+
+ source = inspect.getsource(ContextMemoryDB.is_allowed)
+ # Must reference time-based decay or updated_at
+ assert "updated_at" in source or "time" in source or "decay" in source or "age" in source, (
+ "is_allowed must implement time-based decay. A single failure should not "
+ "permanently block an action. Old failures must be forgiven."
+ )
+
+ def test_core_actions_have_lower_block_threshold(self):
+ """
+ META-TEST: Core navigation actions like 'tap like button' or 'tap home tab'
+ must be harder to permanently block than obscure actions.
+ """
+ import inspect
+ from GramAddict.core.qdrant_memory import ContextMemoryDB
+
+ source = inspect.getsource(ContextMemoryDB.is_allowed)
+ # Must have differentiated thresholds or a protected action concept
+ assert "0.2" not in source or "core" in source.lower() or "protected" in source.lower() or \
+ "updated_at" in source, (
+ "The 0.2 hard threshold is too aggressive. Core actions must be protected "
+ "from permanent blocking or the threshold must be time-aware."
+ )