feat(goals): add GoalDecomposer — pure-logic task planner from mission+plugins
Introduces the GoalDecomposer class that bridges mission.strategy + plugin capabilities into concrete, weighted Task objects. Each Task has a target screen, budget, weight, and human-readable intent. Key design decisions: - Pure logic, zero LLM/device dependencies - Strategy weights (aggressive_growth, community_builder, etc.) drive selection - Plugins declare which screens they operate on (multi-screen map) - Screens need BOTH an action route AND active plugin to be viable - Frozen dataclass ensures Task immutability 12/12 TDD tests passing.
This commit is contained in:
212
tests/tdd/test_goal_decomposer.py
Normal file
212
tests/tdd/test_goal_decomposer.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
GoalDecomposer TDD Tests
|
||||
=========================
|
||||
|
||||
The GoalDecomposer takes mission config + enabled plugins and produces
|
||||
a weighted list of concrete Tasks. No LLM, no device, pure logic.
|
||||
|
||||
This is the bridge between "what do I want?" (mission) and
|
||||
"what can I do?" (plugins) → "what should I do now?" (Task).
|
||||
"""
|
||||
|
||||
|
||||
class TestGoalDecomposerGeneratesTasks:
|
||||
"""Tests that GoalDecomposer produces correct tasks from plugin config."""
|
||||
|
||||
def test_generates_feed_task_when_likes_enabled(self):
|
||||
"""If likes plugin is enabled and feed action is configured,
|
||||
the decomposer MUST produce a HomeFeed browsing task."""
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
|
||||
plugins = {
|
||||
"likes": {"percentage": 100, "count": "2-3"},
|
||||
}
|
||||
actions = {"feed": "5-10"}
|
||||
mission = {"strategy": "aggressive_growth"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
feed_tasks = [t for t in tasks if t.target_screen == "HomeFeed"]
|
||||
assert len(feed_tasks) >= 1, "Must generate at least one HomeFeed task when likes + feed are enabled"
|
||||
assert feed_tasks[0].budget_posts > 0
|
||||
assert feed_tasks[0].weight > 0
|
||||
|
||||
def test_generates_explore_task_when_explore_configured(self):
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
|
||||
plugins = {"likes": {"percentage": 100}}
|
||||
actions = {"explore": "5-10"}
|
||||
mission = {"strategy": "aggressive_growth"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
explore_tasks = [t for t in tasks if t.target_screen == "ExploreFeed"]
|
||||
assert len(explore_tasks) >= 1
|
||||
|
||||
def test_generates_story_task_when_story_view_enabled(self):
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
|
||||
plugins = {"story_view": {"percentage": 80, "count": "1-3"}}
|
||||
actions = {"feed": "5-10"}
|
||||
mission = {"strategy": "community_builder"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
story_tasks = [t for t in tasks if t.target_screen == "StoriesFeed"]
|
||||
assert len(story_tasks) >= 1
|
||||
|
||||
def test_generates_no_tasks_when_no_plugins_enabled(self):
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
|
||||
plugins = {}
|
||||
actions = {}
|
||||
mission = {"strategy": "passive_learning"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
assert len(tasks) == 0, "No enabled plugins = no tasks"
|
||||
|
||||
def test_task_weights_reflect_aggressive_growth_strategy(self):
|
||||
"""aggressive_growth should weight explore higher than home feed."""
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
|
||||
plugins = {
|
||||
"likes": {"percentage": 100},
|
||||
"follow": {"percentage": 100},
|
||||
"story_view": {"percentage": 80},
|
||||
}
|
||||
actions = {"feed": "5-10", "explore": "5-10"}
|
||||
mission = {"strategy": "aggressive_growth"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
explore_weight = sum(t.weight for t in tasks if t.target_screen == "ExploreFeed")
|
||||
home_weight = sum(t.weight for t in tasks if t.target_screen == "HomeFeed")
|
||||
|
||||
assert (
|
||||
explore_weight > home_weight
|
||||
), f"aggressive_growth must weight explore ({explore_weight}) > home ({home_weight})"
|
||||
|
||||
def test_community_builder_weights_home_higher(self):
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
|
||||
plugins = {
|
||||
"likes": {"percentage": 100},
|
||||
"story_view": {"percentage": 80},
|
||||
}
|
||||
actions = {"feed": "5-10", "explore": "5-10"}
|
||||
mission = {"strategy": "community_builder"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
home_weight = sum(t.weight for t in tasks if t.target_screen == "HomeFeed")
|
||||
explore_weight = sum(t.weight for t in tasks if t.target_screen == "ExploreFeed")
|
||||
|
||||
assert (
|
||||
home_weight > explore_weight
|
||||
), f"community_builder must weight home ({home_weight}) > explore ({explore_weight})"
|
||||
|
||||
def test_budget_parsed_from_range_string(self):
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
|
||||
plugins = {"likes": {"percentage": 100}}
|
||||
actions = {"feed": "5-10"}
|
||||
mission = {"strategy": "aggressive_growth"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
feed_task = [t for t in tasks if t.target_screen == "HomeFeed"][0]
|
||||
assert 5 <= feed_task.budget_posts <= 10
|
||||
|
||||
def test_dm_task_generated_when_dm_reply_enabled(self):
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
|
||||
plugins = {"dm_reply": {"enabled": True}}
|
||||
actions = {"feed": "5-10"}
|
||||
mission = {"strategy": "aggressive_growth"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
dm_tasks = [t for t in tasks if t.target_screen == "MessageInbox"]
|
||||
assert len(dm_tasks) >= 1
|
||||
|
||||
def test_task_has_required_fields(self):
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer, Task
|
||||
|
||||
plugins = {"likes": {"percentage": 100}}
|
||||
actions = {"feed": "5-10"}
|
||||
mission = {"strategy": "aggressive_growth"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
for task in tasks:
|
||||
assert isinstance(task, Task)
|
||||
assert task.verb, "Task must have a verb"
|
||||
assert task.target_screen, "Task must have a target_screen"
|
||||
assert task.intent, "Task must have a human-readable intent"
|
||||
assert task.weight > 0, "Task must have positive weight"
|
||||
|
||||
def test_disabled_plugin_produces_no_task(self):
|
||||
"""A plugin with enabled: false must not generate tasks."""
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
|
||||
plugins = {
|
||||
"likes": {"percentage": 100},
|
||||
"dm_reply": {"enabled": False},
|
||||
}
|
||||
actions = {"feed": "5-10"}
|
||||
mission = {"strategy": "aggressive_growth"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
dm_tasks = [t for t in tasks if t.target_screen == "MessageInbox"]
|
||||
assert len(dm_tasks) == 0, "Disabled dm_reply must NOT produce MessageInbox task"
|
||||
|
||||
def test_zero_percentage_plugin_produces_no_task(self):
|
||||
"""A plugin with percentage: 0 must not generate tasks."""
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
|
||||
plugins = {"follow": {"percentage": 0}}
|
||||
actions = {"feed": "5-10"}
|
||||
mission = {"strategy": "aggressive_growth"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
# follow with 0% should not create tasks on its own
|
||||
# but likes are not enabled either, so no tasks at all
|
||||
assert len(tasks) == 0
|
||||
|
||||
def test_all_task_target_screens_are_routable(self):
|
||||
"""Every target_screen a Task references must exist in ScreenTopology."""
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
plugins = {
|
||||
"likes": {"percentage": 100},
|
||||
"follow": {"percentage": 100},
|
||||
"comment": {"percentage": 40},
|
||||
"story_view": {"percentage": 80},
|
||||
"dm_reply": {"enabled": True},
|
||||
}
|
||||
actions = {"feed": "5-10", "explore": "5-10"}
|
||||
mission = {"strategy": "aggressive_growth"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
for task in tasks:
|
||||
assert task.target_screen in ScreenTopology.SCREEN_NAME_MAP, (
|
||||
f"Task target_screen '{task.target_screen}' is not in ScreenTopology! "
|
||||
f"The bot cannot navigate there."
|
||||
)
|
||||
Reference in New Issue
Block a user