diff --git a/backend/app/api/sentiment.py b/backend/app/api/sentiment.py index dca27e8..99d89b4 100644 --- a/backend/app/api/sentiment.py +++ b/backend/app/api/sentiment.py @@ -92,6 +92,8 @@ def create_campaign(): competitive_event_type: str (optional, default 'price_drop') crisis_event_type: str (optional, default 'security_breach') total_agents: int (optional, default 50) + scenario_b_inject_round: int (optional, default 7) + scenario_c_inject_round: int (optional, default 14) enable_twitter: bool (optional, default true) enable_reddit: bool (optional, default true) """ @@ -122,12 +124,16 @@ def create_campaign(): competitive_event_weights=competitive_event_weights, crisis_event_weights=crisis_event_weights, total_agents=int(body.get("total_agents", 50)), + scenario_b_inject_round=body.get("scenario_b_inject_round"), + scenario_c_inject_round=body.get("scenario_c_inject_round"), enable_twitter=bool(body.get("enable_twitter", True)), enable_reddit=bool(body.get("enable_reddit", True)), ) return jsonify({"success": True, "campaign": campaign.to_dict()}) + except ValueError as e: + return jsonify({"success": False, "error": str(e)}), 400 except Exception as e: logger.error(f"Create campaign error: {traceback.format_exc()}") return jsonify({"success": False, "error": str(e)}), 500 diff --git a/backend/app/services/campaign_manager.py b/backend/app/services/campaign_manager.py index e0f790e..c720ebe 100644 --- a/backend/app/services/campaign_manager.py +++ b/backend/app/services/campaign_manager.py @@ -3,8 +3,8 @@ Orchestrates three parallel simulation scenarios for a single product: - Scenario A: Baseline launch (no disruptions) - - Scenario B: Competitive pressure injected at round 7 - - Scenario C: Crisis stress test injected at round 14 + - Scenario B: Competitive pressure injected at a configurable round (default 7) + - Scenario C: Crisis stress test injected at a configurable round (default 14) Uses existing SimulationManager + SimulationRunner infrastructure. """ @@ -72,6 +72,29 @@ SCENARIO_B_INJECT_ROUND = Config.SENTIMENT_SCENARIO_B_INJECT_ROUND SCENARIO_C_INJECT_ROUND = Config.SENTIMENT_SCENARIO_C_INJECT_ROUND +# Upper sanity bound for a configurable injection round. The effective ceiling +# is the campaign's max_rounds (set at start); an injection scheduled beyond the +# run length simply never fires. This bound just rejects nonsense values early. +MAX_INJECT_ROUND = 1000 + + +def validate_inject_round(value: Any, label: str = "inject_round") -> int: + """Coerce ``value`` to an event-injection round and range-check it. + + Rounds are 1-indexed; an injection fires once the simulation reaches the + configured round. Raises ``ValueError`` (which the API surfaces as a 400) for + non-integers or values outside ``[1, MAX_INJECT_ROUND]``. + """ + try: + rounds = int(value) + except (TypeError, ValueError): + raise ValueError(f"{label} must be an integer, got {value!r}") + if not 1 <= rounds <= MAX_INJECT_ROUND: + raise ValueError( + f"{label} must be between 1 and {MAX_INJECT_ROUND}, got {rounds}" + ) + return rounds + class CampaignStatus(str, Enum): CREATED = "created" @@ -107,6 +130,11 @@ class CampaignState: "security_breach": 34.0, "harsh_review": 33.0, "misleading_comparison": 33.0 }) + # Round at which each scenario's event is injected. Defaults preserve the + # historical 7/14 cadence; configurable per campaign. + scenario_b_inject_round: int = SCENARIO_B_INJECT_ROUND + scenario_c_inject_round: int = SCENARIO_C_INJECT_ROUND + scenario_b_injected: bool = False scenario_c_injected: bool = False @@ -136,8 +164,8 @@ def to_dict(self) -> Dict[str, Any]: "crisis_event_type": self.crisis_event_type, "competitive_event_weights": self.competitive_event_weights, "crisis_event_weights": self.crisis_event_weights, - "scenario_b_inject_round": SCENARIO_B_INJECT_ROUND, - "scenario_c_inject_round": SCENARIO_C_INJECT_ROUND, + "scenario_b_inject_round": self.scenario_b_inject_round, + "scenario_c_inject_round": self.scenario_c_inject_round, "scenario_b_injected": self.scenario_b_injected, "scenario_c_injected": self.scenario_c_injected, "total_agents": self.total_agents, @@ -205,6 +233,8 @@ def _load(self, campaign_id: str) -> Optional[CampaignState]: crisis_event_weights=d.get("crisis_event_weights", { "security_breach": 34.0, "harsh_review": 33.0, "misleading_comparison": 33.0 }), + scenario_b_inject_round=d.get("scenario_b_inject_round", SCENARIO_B_INJECT_ROUND), + scenario_c_inject_round=d.get("scenario_c_inject_round", SCENARIO_C_INJECT_ROUND), scenario_b_injected=d.get("scenario_b_injected", False), scenario_c_injected=d.get("scenario_c_injected", False), total_agents=d.get("total_agents", DEFAULT_TOTAL_AGENTS), @@ -229,13 +259,28 @@ def create_campaign( competitive_event_weights: Optional[Dict[str, float]] = None, crisis_event_weights: Optional[Dict[str, float]] = None, total_agents: int = DEFAULT_TOTAL_AGENTS, + scenario_b_inject_round: Optional[int] = None, + scenario_c_inject_round: Optional[int] = None, enable_twitter: bool = True, enable_reddit: bool = True, ) -> CampaignState: """ Create a new campaign and provision three SimulationManager simulations. Returns the campaign state with sim IDs assigned. + + ``scenario_b_inject_round`` / ``scenario_c_inject_round`` control when the + competitive / crisis events fire. They default to the configured 7 / 14 + cadence and are range-checked (``ValueError`` on invalid input). """ + b_inject_round = ( + SCENARIO_B_INJECT_ROUND if scenario_b_inject_round is None + else validate_inject_round(scenario_b_inject_round, "scenario_b_inject_round") + ) + c_inject_round = ( + SCENARIO_C_INJECT_ROUND if scenario_c_inject_round is None + else validate_inject_round(scenario_c_inject_round, "scenario_c_inject_round") + ) + campaign_id = f"camp_{uuid.uuid4().hex[:12]}" # Create three simulations @@ -270,6 +315,8 @@ def create_campaign( "security_breach": 34.0, "harsh_review": 33.0, "misleading_comparison": 33.0 }, total_agents=total_agents, + scenario_b_inject_round=b_inject_round, + scenario_c_inject_round=c_inject_round, ) self._save(campaign) logger.info( @@ -924,8 +971,8 @@ def _run_campaign_background( scenarios = [ ("A", campaign.sim_id_a, None, None), - ("B", campaign.sim_id_b, SCENARIO_B_INJECT_ROUND, "b"), - ("C", campaign.sim_id_c, SCENARIO_C_INJECT_ROUND, "c"), + ("B", campaign.sim_id_b, campaign.scenario_b_inject_round, "b"), + ("C", campaign.sim_id_c, campaign.scenario_c_inject_round, "c"), ] results = {} diff --git a/backend/tests/test_campaign_inject_rounds.py b/backend/tests/test_campaign_inject_rounds.py new file mode 100644 index 0000000..49b99df --- /dev/null +++ b/backend/tests/test_campaign_inject_rounds.py @@ -0,0 +1,116 @@ +"""Tests for configurable event-injection rounds (issue #3). + +Covers the pure validator, CampaignState round-tripping, and create_campaign +wiring (defaults preserve the 7/14 cadence; custom rounds are honoured; invalid +rounds raise before any simulation is provisioned). +""" + +import types +from unittest.mock import MagicMock + +import pytest + +from app.services.campaign_manager import ( + CampaignManager, + CampaignState, + MAX_INJECT_ROUND, + SCENARIO_B_INJECT_ROUND, + SCENARIO_C_INJECT_ROUND, + validate_inject_round, +) + + +# --- validate_inject_round --------------------------------------------------- + +def test_validate_inject_round_accepts_valid_int(): + assert validate_inject_round(5) == 5 + + +def test_validate_inject_round_coerces_numeric_string(): + assert validate_inject_round("12") == 12 + + +@pytest.mark.parametrize("bad", [0, -1, MAX_INJECT_ROUND + 1]) +def test_validate_inject_round_rejects_out_of_range(bad): + with pytest.raises(ValueError): + validate_inject_round(bad) + + +@pytest.mark.parametrize("bad", ["abc", None, [1]]) +def test_validate_inject_round_rejects_non_integer(bad): + with pytest.raises(ValueError): + validate_inject_round(bad) + + +# --- CampaignState ----------------------------------------------------------- + +def _state(**overrides): + base = dict(campaign_id="c1", project_id="p1", graph_id="g1", seed_data={}) + base.update(overrides) + return CampaignState(**base) + + +def test_state_defaults_preserve_configured_cadence(): + s = _state() + assert s.scenario_b_inject_round == SCENARIO_B_INJECT_ROUND + assert s.scenario_c_inject_round == SCENARIO_C_INJECT_ROUND + d = s.to_dict() + assert d["scenario_b_inject_round"] == SCENARIO_B_INJECT_ROUND + assert d["scenario_c_inject_round"] == SCENARIO_C_INJECT_ROUND + + +def test_state_to_dict_emits_custom_rounds(): + s = _state(scenario_b_inject_round=3, scenario_c_inject_round=9) + d = s.to_dict() + assert d["scenario_b_inject_round"] == 3 + assert d["scenario_c_inject_round"] == 9 + + +# --- CampaignManager.create_campaign ----------------------------------------- + +def _manager(): + """Bare manager with provisioning + persistence stubbed out.""" + mgr = CampaignManager.__new__(CampaignManager) + sim = MagicMock() + sim.create_simulation.side_effect = [ + types.SimpleNamespace(simulation_id="sim_a"), + types.SimpleNamespace(simulation_id="sim_b"), + types.SimpleNamespace(simulation_id="sim_c"), + ] + mgr._sim_manager = sim + mgr._save = MagicMock() + return mgr + + +def test_create_campaign_defaults_to_configured_cadence(): + mgr = _manager() + camp = mgr.create_campaign(project_id="p", graph_id="g", seed_data={}) + assert camp.scenario_b_inject_round == SCENARIO_B_INJECT_ROUND + assert camp.scenario_c_inject_round == SCENARIO_C_INJECT_ROUND + mgr._save.assert_called_once() + + +def test_create_campaign_honours_custom_rounds(): + mgr = _manager() + camp = mgr.create_campaign( + project_id="p", graph_id="g", seed_data={}, + scenario_b_inject_round=4, scenario_c_inject_round=11, + ) + assert camp.scenario_b_inject_round == 4 + assert camp.scenario_c_inject_round == 11 + assert camp.to_dict()["scenario_b_inject_round"] == 4 + + +@pytest.mark.parametrize("kwargs", [ + {"scenario_b_inject_round": 0}, + {"scenario_c_inject_round": -3}, + {"scenario_b_inject_round": "oops"}, + {"scenario_c_inject_round": MAX_INJECT_ROUND + 1}, +]) +def test_create_campaign_rejects_invalid_rounds_before_provisioning(kwargs): + mgr = _manager() + with pytest.raises(ValueError): + mgr.create_campaign(project_id="p", graph_id="g", seed_data={}, **kwargs) + # Validation must happen before any simulation is provisioned or persisted. + mgr._sim_manager.create_simulation.assert_not_called() + mgr._save.assert_not_called()