Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/app/api/sentiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
"""
Expand Down Expand Up @@ -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
Expand Down
59 changes: 53 additions & 6 deletions backend/app/services/campaign_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 = {}
Expand Down
116 changes: 116 additions & 0 deletions backend/tests/test_campaign_inject_rounds.py
Original file line number Diff line number Diff line change
@@ -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()
Loading