diff --git a/.agent-plan.md b/.agent-plan.md index 1c039b8..4204152 100644 --- a/.agent-plan.md +++ b/.agent-plan.md @@ -120,6 +120,14 @@ Documentation + CI: - [x] `tests/mechanisms/test_mechanisms.py` — 6 tests for `LatentDecayIntensity` - [x] All 737 tests pass; lint + format clean +### WorldSpec task fields (PR #36, closes #6) + +- [x] `leadforge/core/models.py` — `primary_task: str` and `label_window_days: int` fields on `GenerationConfig` (defaults preserve current behavior) +- [x] `leadforge/api/recipes.py` — `label_window_days` optional field on `Recipe`; propagated through `from_dict()` and `resolve_config()` (all 4 precedence layers) +- [x] `leadforge/api/generator.py` — `primary_task` and `label_window_days` kwargs on `Generator.from_recipe()` +- [x] `leadforge/narrative/dataset_card.py` — renders task name and label window from `world_spec.config` instead of hard-coded literals +- [x] 10 new tests (3 dataset card + 7 config resolution); total 750 passing + ### Pipeline refactors (PR #34, closes #31 + #32) - [x] `leadforge/core/rng.py` — `numpy_child()` method on `RNGRoot` returning `np.random.RandomState` diff --git a/leadforge/api/recipes.py b/leadforge/api/recipes.py index 57e8358..2d98e1c 100644 --- a/leadforge/api/recipes.py +++ b/leadforge/api/recipes.py @@ -40,6 +40,7 @@ class Recipe: supported_difficulty: tuple[DifficultyProfile, ...] default_population: dict[str, int] horizon_days: int + label_window_days: int | None = None # ------------------------------------------------------------------ # # Construction @@ -93,6 +94,17 @@ def from_dict(cls, data: dict[str, Any]) -> Recipe: if horizon_days <= 0: raise InvalidRecipeError(f"'horizon_days' must be positive, got {horizon_days}") + label_window_days: int | None = None + raw_lwd = data.get("label_window_days") + if raw_lwd is not None: + if isinstance(raw_lwd, bool) or not isinstance(raw_lwd, int): + raise InvalidRecipeError( + f"'label_window_days' must be a positive int, got {type(raw_lwd).__name__!r}" + ) + if raw_lwd <= 0: + raise InvalidRecipeError(f"'label_window_days' must be positive, got {raw_lwd}") + label_window_days = raw_lwd + return cls( id=data["id"], title=data["title"], @@ -103,6 +115,7 @@ def from_dict(cls, data: dict[str, Any]) -> Recipe: supported_difficulty=supported_difficulty, default_population=dict(pop), horizon_days=horizon_days, + label_window_days=label_window_days, ) # ------------------------------------------------------------------ # @@ -148,6 +161,8 @@ def resolve_config( "n_contacts": pkg["n_contacts"], "n_leads": pkg["n_leads"], "horizon_days": pkg["horizon_days"], + "primary_task": pkg["primary_task"], + "label_window_days": pkg["label_window_days"], } # Layer 3 — recipe defaults @@ -156,6 +171,9 @@ def resolve_config( if key in pop: resolved[key] = pop[key] resolved["horizon_days"] = self.horizon_days + resolved["primary_task"] = self.primary_task + if self.label_window_days is not None: + resolved["label_window_days"] = self.label_window_days # Layer 2 — override dict (beats recipe/package defaults) if override: @@ -164,6 +182,8 @@ def resolve_config( "n_contacts", "n_leads", "horizon_days", + "primary_task", + "label_window_days", "seed", "output_path", "exposure_mode", @@ -226,6 +246,8 @@ def resolve_config( n_contacts=resolved["n_contacts"], n_leads=resolved["n_leads"], horizon_days=resolved["horizon_days"], + primary_task=resolved["primary_task"], + label_window_days=resolved["label_window_days"], output_path=resolved["output_path"], ) diff --git a/leadforge/core/models.py b/leadforge/core/models.py index cd0585b..2674821 100644 --- a/leadforge/core/models.py +++ b/leadforge/core/models.py @@ -45,6 +45,8 @@ class GenerationConfig: n_contacts: int = 4200 n_leads: int = 5000 horizon_days: int = 90 + primary_task: str = "converted_within_90_days" + label_window_days: int = 90 output_path: str = "./out" package_version: str = field(default_factory=lambda: __version__) @@ -57,6 +59,16 @@ def __post_init__(self) -> None: _require_positive_int(self.n_contacts, "n_contacts") _require_positive_int(self.n_leads, "n_leads") _require_positive_int(self.horizon_days, "horizon_days") + _require_positive_int(self.label_window_days, "label_window_days") + if not isinstance(self.primary_task, str) or not self.primary_task: + raise InvalidConfigError( + f"primary_task must be a non-empty string, got {self.primary_task!r}" + ) + if self.label_window_days > self.horizon_days: + raise InvalidConfigError( + f"label_window_days ({self.label_window_days}) must not exceed " + f"horizon_days ({self.horizon_days})" + ) # Coerce string enums supplied as plain strings if not isinstance(self.exposure_mode, ExposureMode): try: diff --git a/leadforge/narrative/dataset_card.py b/leadforge/narrative/dataset_card.py index aa61ffe..6e100d8 100644 --- a/leadforge/narrative/dataset_card.py +++ b/leadforge/narrative/dataset_card.py @@ -97,10 +97,10 @@ def render_dataset_card(world_spec: WorldSpec) -> str: lines += [ "## Primary task", "", - "**Task:** `converted_within_90_days`", + f"**Task:** `{cfg.primary_task}`", "", - "**Label definition:** A lead is considered converted if a `closed_won` event " - "is recorded within 90 days of the lead's snapshot anchor date. " + f"**Label definition:** A lead is considered converted if a `closed_won` event " + f"is recorded within {cfg.label_window_days} days of the lead's snapshot anchor date. " "The label is derived from simulated events — it is never sampled directly.", "", ] diff --git a/tests/api/test_recipes.py b/tests/api/test_recipes.py index d334d4b..7e6ad10 100644 --- a/tests/api/test_recipes.py +++ b/tests/api/test_recipes.py @@ -4,7 +4,7 @@ from leadforge.api.recipes import Recipe from leadforge.core.enums import DifficultyProfile, ExposureMode -from leadforge.core.exceptions import InvalidRecipeError +from leadforge.core.exceptions import InvalidConfigError, InvalidRecipeError from leadforge.core.models import GenerationConfig # --------------------------------------------------------------------------- @@ -128,9 +128,9 @@ def test_resolve_config_recipe_defaults_used() -> None: def test_resolve_config_explicit_kwargs_override_recipe() -> None: """Layer 1: explicit kwargs win over recipe defaults.""" recipe = Recipe.from_dict(VALID_DICT) - config = recipe.resolve_config(n_leads=9999, horizon_days=30) + config = recipe.resolve_config(n_leads=9999, horizon_days=120) assert config.n_leads == 9999 - assert config.horizon_days == 30 + assert config.horizon_days == 120 # Non-overridden values still come from recipe assert config.n_accounts == 100 @@ -208,6 +208,78 @@ def test_resolve_config_unsupported_difficulty_raises() -> None: recipe.resolve_config(difficulty="advanced") +def test_resolve_config_primary_task_defaults_from_recipe() -> None: + recipe = Recipe.from_dict(VALID_DICT) + cfg = recipe.resolve_config() + assert cfg.primary_task == "converted_within_90_days" + + +def test_resolve_config_label_window_days_defaults() -> None: + recipe = Recipe.from_dict(VALID_DICT) + cfg = recipe.resolve_config() + assert cfg.label_window_days == 90 + + +def test_resolve_config_label_window_days_from_recipe() -> None: + data = {**VALID_DICT, "label_window_days": 60} + recipe = Recipe.from_dict(data) + cfg = recipe.resolve_config() + assert cfg.label_window_days == 60 + + +def test_resolve_config_override_dict_applies_task_fields() -> None: + recipe = Recipe.from_dict(VALID_DICT) + cfg = recipe.resolve_config( + override={"primary_task": "upsold_within_90_days", "label_window_days": 60} + ) + assert cfg.primary_task == "upsold_within_90_days" + assert cfg.label_window_days == 60 + + +def test_from_dict_bool_label_window_days_raises() -> None: + """bool label_window_days must be rejected (True → 1 would pass silently).""" + bad = {**VALID_DICT, "label_window_days": True} + with pytest.raises(InvalidRecipeError, match="label_window_days"): + Recipe.from_dict(bad) + + +def test_from_dict_nonpositive_label_window_days_raises() -> None: + bad = {**VALID_DICT, "label_window_days": 0} + with pytest.raises(InvalidRecipeError, match="label_window_days"): + Recipe.from_dict(bad) + + +def test_from_dict_float_label_window_days_raises() -> None: + bad = {**VALID_DICT, "label_window_days": 30.5} + with pytest.raises(InvalidRecipeError, match="label_window_days"): + Recipe.from_dict(bad) + + +# --------------------------------------------------------------------------- +# GenerationConfig validation +# --------------------------------------------------------------------------- + + +def test_config_empty_primary_task_raises() -> None: + with pytest.raises(InvalidConfigError, match="primary_task"): + GenerationConfig(primary_task="") + + +def test_config_non_string_primary_task_raises() -> None: + with pytest.raises(InvalidConfigError, match="primary_task"): + GenerationConfig(primary_task=42) # type: ignore[arg-type] + + +def test_config_label_window_exceeds_horizon_raises() -> None: + with pytest.raises(InvalidConfigError, match="label_window_days.*must not exceed"): + GenerationConfig(horizon_days=30, label_window_days=90) + + +def test_config_label_window_equals_horizon_ok() -> None: + cfg = GenerationConfig(horizon_days=90, label_window_days=90) + assert cfg.label_window_days == 90 + + # --------------------------------------------------------------------------- # Real recipe loading via registry # --------------------------------------------------------------------------- diff --git a/tests/narrative/test_dataset_card.py b/tests/narrative/test_dataset_card.py index 104e46e..c4da70b 100644 --- a/tests/narrative/test_dataset_card.py +++ b/tests/narrative/test_dataset_card.py @@ -46,6 +46,27 @@ def test_card_contains_label_definition() -> None: assert "90 days" in card +def test_card_renders_custom_primary_task() -> None: + spec = _make_world_spec(primary_task="churned_within_60_days") + card = render_dataset_card(spec) + assert "`churned_within_60_days`" in card + assert "converted_within_90_days" not in card + + +def test_card_renders_custom_label_window_days() -> None: + spec = _make_world_spec(label_window_days=60) + card = render_dataset_card(spec) + assert "within 60 days" in card + assert "within 90 days" not in card + + +def test_card_renders_custom_task_and_window() -> None: + spec = _make_world_spec(primary_task="upgraded_within_30_days", label_window_days=30) + card = render_dataset_card(spec) + assert "`upgraded_within_30_days`" in card + assert "30 days" in card + + def test_card_contains_use_cases() -> None: card = render_dataset_card(_make_world_spec()) assert "use cases" in card.lower()