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
8 changes: 8 additions & 0 deletions .agent-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
22 changes: 22 additions & 0 deletions leadforge/api/recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"],
Expand All @@ -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,
)

# ------------------------------------------------------------------ #
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -164,6 +182,8 @@ def resolve_config(
"n_contacts",
"n_leads",
"horizon_days",
"primary_task",
"label_window_days",
"seed",
"output_path",
"exposure_mode",
Expand Down Expand Up @@ -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"],
)

Expand Down
12 changes: 12 additions & 0 deletions leadforge/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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}"
)
Comment thread
shaypal5 marked this conversation as resolved.
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:
Expand Down
6 changes: 3 additions & 3 deletions leadforge/narrative/dataset_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Comment thread
shaypal5 marked this conversation as resolved.
"",
]
Expand Down
78 changes: 75 additions & 3 deletions tests/api/test_recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
21 changes: 21 additions & 0 deletions tests/narrative/test_dataset_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading