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:
2026-04-29 17:15:17 +02:00
parent 0ed12303ac
commit 6db579f45b
2 changed files with 488 additions and 0 deletions

View 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."
)