From cdcbca336623e5cfd4b4a576faf7bc7fa8b529cd Mon Sep 17 00:00:00 2001 From: Shay Palachy Date: Thu, 18 Jun 2026 08:19:03 +0300 Subject: [PATCH] refactor(api): resolve_config carries lifecycle config fields [LTV-Po.2a] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of the split LTV-Po.2 (the config-plumbing enabler; the recipe assets + e2e are Po.2b). Recipe.resolve_config and Generator.from_recipe/generate were lead-scoring-shaped — they only threaded n_accounts/n_contacts/n_leads to GenerationConfig — so a scheme: lifecycle recipe could not size its customer cohort (n_customers always took the package default). - resolve_config now resolves n_customers (from default_population, an explicit kwarg, or the override dict) and early_tenure_weeks / observation_date (from override) and passes all three to GenerationConfig. forward_windows_days is deliberately NOT resolvable — the lifecycle scheme locks it to its exported constant and rejects overrides (LTV-Pn.4c). - Generator.from_recipe and generate gain an n_customers parameter. Lead-scoring config resolution is unchanged: a recipe without these keys leaves the lifecycle fields at their GenerationConfig defaults, which is exactly what the previous construction produced. Verified byte-identical via full-bundle SHA-256 of a lead-scoring bundle (both exposure modes) against main. Tests: n_customers flows from default_population; kwarg + override precedence; lifecycle fields via override; lead-scoring-style recipe keeps the lifecycle defaults. Full suite 1885 passed / 51 skipped; ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 --- .agent-plan.md | 8 +++- docs/ltv/roadmap.md | 19 ++++++-- leadforge/api/generator.py | 5 ++ leadforge/api/recipes.py | 17 ++++++- tests/api/test_resolve_config_lifecycle.py | 55 ++++++++++++++++++++++ 5 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 tests/api/test_resolve_config_lifecycle.py diff --git a/.agent-plan.md b/.agent-plan.md index 52cd1d0..250b2d0 100644 --- a/.agent-plan.md +++ b/.agent-plan.md @@ -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`. diff --git a/docs/ltv/roadmap.md b/docs/ltv/roadmap.md index 1d74e7d..b1a6748 100644 --- a/docs/ltv/roadmap.md +++ b/docs/ltv/roadmap.md @@ -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). diff --git a/leadforge/api/generator.py b/leadforge/api/generator.py index ec6294e..1fb7ab4 100644 --- a/leadforge/api/generator.py +++ b/leadforge/api/generator.py @@ -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, primary_task: str | None = None, label_window_days: int | None = None, @@ -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, @@ -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] **kwargs: Any, ) -> WorldBundle: @@ -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] diff --git a/leadforge/api/recipes.py b/leadforge/api/recipes.py index 7ecad19..93d200c 100644 --- a/leadforge/api/recipes.py +++ b/leadforge/api/recipes.py @@ -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, @@ -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 @@ -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] @@ -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: @@ -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"], ) # ------------------------------------------------------------------ # diff --git a/tests/api/test_resolve_config_lifecycle.py b/tests/api/test_resolve_config_lifecycle.py new file mode 100644 index 0000000..80a05fd --- /dev/null +++ b/tests/api/test_resolve_config_lifecycle.py @@ -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