Skip to content
Open
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: 6 additions & 2 deletions .agent-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,12 @@ byte-identical) opened as **#128** — **completes LTV-Pn.4**. `LTV-Po` split in
firmographics; build_world threads narrative; no-narrative path byte-identical
vs main) opened as **#130**. Decisions locked: narrative DRIVES firmographics;
public early-pLTV stays calendar-only (Option A); difficulty = distortion tiers
now + simulation-level scaling deferred (issue #129). Next: `LTV-Po.2`
(b2b_saas_ltv_v1 recipe YAMLs + difficulty_params resolution + e2e round-trip).
now + simulation-level scaling deferred (issue #129). `LTV-Po.2` split into Po.2a
(config plumbing) + Po.2b (recipe + e2e). `LTV-Po.2a` (`resolve_config` +
`Generator` carry `n_customers` / `early_tenure_weeks` / `observation_date`;
lead-scoring byte-identical) opened as **#131**. Next: `LTV-Po.2b`
(b2b_saas_ltv_v1 recipe YAMLs + difficulty_params resolution + e2e round-trip —
**completes M6**).
Note: `validate_bundle` is lead-scoring-coupled — scheme-aware validation is
`LTV-Pp`.

Expand Down
19 changes: 15 additions & 4 deletions docs/ltv/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,10 +388,21 @@ methods, then public-safety, then the carried orchestrator cleanup:
vs `main`, both modes). `build_world` threads `narrative` through. Decision
(locked): the recipe narrative **drives** firmographics (not scheme-internal).
- Labels: `type: feature`, `layer: narrative`
- [ ] **`LTV-Po.2`** — `feat(recipes): b2b_saas_ltv_v1 recipe assets + e2e`. The
three recipe YAMLs (`scheme: lifecycle`; `narrative.yaml` with the lifecycle
vertical's firmographics; `difficulty_profiles.yaml`); register in the recipe
registry; resolve `difficulty_params` from the active profile in `build_world`
`LTV-Po.2` is split: the config-plumbing enabler, then the recipe + e2e.

- [x] **`LTV-Po.2a`** — `refactor(api): resolve_config carries lifecycle config`
(**PR #131**). `Recipe.resolve_config` + `Generator.from_recipe`/`generate`
now carry `n_customers` (from `default_population` / kwarg / override) and
`early_tenure_weeks` / `observation_date` (override) through to
`GenerationConfig`, so a `scheme: lifecycle` recipe can size its cohort.
`forward_windows_days` is intentionally **not** resolvable (the scheme locks
it). Lead-scoring config resolution is byte-identical (the lifecycle fields
default-match; verified via full-bundle SHA-256 vs `main`, both modes).
- Labels: `type: refactor`, `layer: api`
- [ ] **`LTV-Po.2b`** — `feat(recipes): b2b_saas_ltv_v1 recipe assets + e2e`. The
three recipe YAMLs (`scheme: lifecycle`; `narrative.yaml` with ≥2 industries +
≥2 geographies; `difficulty_profiles.yaml`); register in the recipe registry;
resolve `difficulty_params` from the active profile in `build_world`
(mirroring lead-scoring `_resolve_difficulty`) so snapshot distortions fire
per tier; end-to-end `Generator.from_recipe("b2b_saas_ltv_v1").generate()`
round-trip. Public mode stays calendar-only (Option A, locked).
Expand Down
5 changes: 5 additions & 0 deletions leadforge/api/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def from_recipe(
n_accounts: int | None = None,
n_contacts: int | None = None,
n_leads: int | None = None,
n_customers: int | None = None,
horizon_days: int | None = None,
Comment on lines 51 to 55
primary_task: str | None = None,
label_window_days: int | None = None,
Expand Down Expand Up @@ -107,6 +108,7 @@ def from_recipe(
n_accounts=n_accounts,
n_contacts=n_contacts,
n_leads=n_leads,
n_customers=n_customers,
horizon_days=horizon_days,
primary_task=primary_task,
label_window_days=label_window_days,
Expand All @@ -127,6 +129,7 @@ def generate(
n_accounts: int | None = None,
n_contacts: int | None = None,
n_leads: int | None = None,
n_customers: int | None = None,
difficulty: str | DifficultyProfile = _MISSING, # type: ignore[assignment]
Comment on lines 129 to 133
**kwargs: Any,
) -> WorldBundle:
Expand Down Expand Up @@ -163,6 +166,8 @@ def generate(
overrides["n_contacts"] = n_contacts
if n_leads is not None:
overrides["n_leads"] = n_leads
if n_customers is not None:
overrides["n_customers"] = n_customers
if difficulty is not _MISSING:
if not isinstance(difficulty, DifficultyProfile):
difficulty = DifficultyProfile(difficulty) # type: ignore[arg-type]
Expand Down
17 changes: 16 additions & 1 deletion leadforge/api/recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ def resolve_config(
n_accounts: int | None = None,
n_contacts: int | None = None,
n_leads: int | None = None,
n_customers: int | None = None,
horizon_days: int | None = None,
primary_task: str | None = None,
label_window_days: int | None = None,
Expand Down Expand Up @@ -191,11 +192,17 @@ def resolve_config(
"primary_task": pkg["primary_task"],
"label_window_days": pkg["label_window_days"],
"snapshot_day": pkg["snapshot_day"],
# Lifecycle-scheme fields (ignored by lead-scoring). forward_windows_days
# is intentionally NOT resolvable here — the lifecycle scheme locks it to
# its exported constant and rejects overrides.
"n_customers": pkg["n_customers"],
"early_tenure_weeks": pkg["early_tenure_weeks"],
"observation_date": pkg["observation_date"],
}

# Layer 3 — recipe defaults
pop = self.default_population
for key in ("n_accounts", "n_contacts", "n_leads"):
for key in ("n_accounts", "n_contacts", "n_leads", "n_customers"):
if key in pop:
resolved[key] = pop[key]
resolved["horizon_days"] = self.horizon_days
Expand All @@ -219,6 +226,9 @@ def resolve_config(
"output_path",
"exposure_mode",
"difficulty",
"n_customers",
"early_tenure_weeks",
"observation_date",
):
if key in override:
resolved[key] = override[key]
Expand All @@ -239,6 +249,8 @@ def resolve_config(
resolved["n_contacts"] = n_contacts
if n_leads is not None:
resolved["n_leads"] = n_leads
if n_customers is not None:
resolved["n_customers"] = n_customers
if horizon_days is not None:
resolved["horizon_days"] = horizon_days
if primary_task is not None:
Expand Down Expand Up @@ -287,6 +299,9 @@ def resolve_config(
label_window_days=resolved["label_window_days"],
snapshot_day=resolved["snapshot_day"],
output_path=resolved["output_path"],
n_customers=resolved["n_customers"],
early_tenure_weeks=resolved["early_tenure_weeks"],
observation_date=resolved["observation_date"],
)

# ------------------------------------------------------------------ #
Expand Down
55 changes: 55 additions & 0 deletions tests/api/test_resolve_config_lifecycle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""resolve_config carries the lifecycle config fields (LTV-Po.2a)."""

from __future__ import annotations

from leadforge.api.recipes import Recipe
from leadforge.core.enums import DifficultyProfile, ExposureMode


def _recipe(**pop) -> Recipe:
return Recipe(
id="tmp_lifecycle",
title="t",
vertical="v",
scheme="lifecycle",
description="d",
primary_task="ltv_revenue_365d",
supported_modes=(ExposureMode.student_public, ExposureMode.research_instructor),
supported_difficulty=(DifficultyProfile.intro,),
default_population=dict(pop),
horizon_days=90,
label_window_days=None,
snapshot_day=None,
)


def test_n_customers_flows_from_default_population() -> None:
cfg = _recipe(n_customers=2500).resolve_config(seed=1, difficulty="intro")
assert cfg.n_customers == 2500


def test_n_customers_kwarg_overrides_recipe() -> None:
cfg = _recipe(n_customers=2500).resolve_config(seed=1, difficulty="intro", n_customers=300)
assert cfg.n_customers == 300


def test_lifecycle_fields_via_override() -> None:
cfg = _recipe(n_customers=100).resolve_config(
seed=1,
difficulty="intro",
override={"early_tenure_weeks": 8, "observation_date": "2026-06-01"},
)
assert cfg.early_tenure_weeks == 8
assert cfg.observation_date == "2026-06-01"


def test_defaults_when_recipe_omits_lifecycle_fields() -> None:
# A lead-scoring-style recipe (no n_customers) → lifecycle fields keep their
# GenerationConfig defaults; this is why lead-scoring resolution is unchanged.
cfg = _recipe(n_accounts=10, n_contacts=30, n_leads=30).resolve_config(
seed=1, difficulty="intro"
)
assert cfg.n_customers == 1500 # GenerationConfig default
assert cfg.forward_windows_days == (90, 365, 730)
assert cfg.early_tenure_weeks == 4
assert cfg.observation_date is None
Comment on lines +46 to +55
Loading