diff --git a/.agent-plan.md b/.agent-plan.md index cc610a6..3b58f50 100644 --- a/.agent-plan.md +++ b/.agent-plan.md @@ -82,10 +82,14 @@ split writer + `schemes/lifecycle/tasks.py` task families; discharges the `LTV-Pc` regression-task-spec leftover) opened as **#124** (merged). `LTV-Pn.4` split into four (build → write → public-safety → orchestrator): `LTV-Pn.4a` (`LifecycleScheme.build_world` — deterministic motif sampling + population + sim + `LifecycleArtifacts`; lifecycle relational -`to_dataframes`; consumes the Pn.3 config fields) opened as **#125**. Next: -`Pn.4b` (instructor `write_bundle` + tasks), `Pn.4c` (student_public -snapshot-safety + CLAUDE.md), `Pn.4d` (shared bundle orchestrator), `LTV-Po` -(recipe). +`to_dataframes`; consumes the Pn.3 config fields) opened as **#125** (merged). `LTV-Pn.4b` (instructor-mode `write_bundle` — +first on-disk lifecycle bundle: 6 relational tables + 8 task dirs (both +regimes) + lifecycle dataset card + manifest extra_fields + hidden-truth +metadata; difficulty_params threaded; student_public refused until 4c) opened +as **#126**. Next: `Pn.4c` (student_public snapshot-safety + CLAUDE.md + +recipe-driven difficulty resolution), `Pn.4d` (shared bundle orchestrator), +`LTV-Po` (recipe). 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 085b28c..bd65168 100644 --- a/docs/ltv/roadmap.md +++ b/docs/ltv/roadmap.md @@ -46,7 +46,7 @@ protocol + registry, with the package physically reorganized into | `LTV-M3` | Customer population + lifecycle world | `LTV-Ph`, `LTV-Pi` | #113 (Ph) | | `LTV-M4` | Lifecycle simulation engine | `LTV-Pj`, `LTV-Pk` | #117 (Pj), #118 (Pk) | | `LTV-M5` | Customer snapshots + pLTV targets (both regimes) | `LTV-Pl`, `LTV-Pm` | #119 (Pl), #120 (Pm) | -| `LTV-M6` | Register LifecycleScheme + recipe + manifest/version | `LTV-Pn.1…4`, `LTV-Po` | #121 (Pn.1), #122 (Pn.2), #124 (Pn.3), #125 (Pn.4a) | +| `LTV-M6` | Register LifecycleScheme + recipe + manifest/version | `LTV-Pn.1…4`, `LTV-Po` | #121 (Pn.1), #122 (Pn.2), #124 (Pn.3), #125 (Pn.4a), #126 (Pn.4b) | | `LTV-M7` | Validation + regression-metric calibration | `LTV-Pp` | | | `LTV-M8` | CLI, notebooks, publish | `LTV-Pq`, `LTV-Pr`, `LTV-Ps` | | @@ -325,15 +325,22 @@ methods, then public-safety, then the carried orchestrator cleanup: `write_bundle` still stubbed. - Tests: determinism, cross-seed motif variability, FK integrity, table shapes. - Labels: `type: feature`, `layer: api`, `layer: render` -- [ ] **`LTV-Pn.4b`** — `feat(lifecycle): write_bundle (instructor) + tasks`. - Instructor-mode `write_bundle`: relational tables; both regime snapshots → - 8 task dirs (3 pLTV regression + churn, × 2 regimes) via the shared writer; - dataset card; feature dictionary; manifest with `generation_scheme` + - `observation_date` + windows (`extra_fields`); lifecycle `write_metadata` - hidden-truth hook (latent registry + mechanism summary). First on-disk - lifecycle bundle. **Must resolve `difficulty_params` from the active profile - and thread it into `build_customer_snapshot` (Pn.4a's `build_world` does not — - without this the snapshot distortions never fire and every tier is identical).** +- [x] **`LTV-Pn.4b`** — `feat(lifecycle): write_bundle (instructor) + tasks` + (**PR #126**). Instructor-mode `write_bundle` produces the first on-disk + lifecycle bundle: six relational tables; both regime snapshots → 8 task dirs + (3 pLTV regression + churn, × 2 regimes) via the shared writer; a lifecycle + dataset card (`render/dataset_card.py` — the lead-scoring card is too + coupled to reuse); feature dictionary; manifest with `generation_scheme` + + `observation_date` + `forward_windows_days` (`extra_fields`); lifecycle + `write_metadata` hidden-truth hook (latent registry + mechanism summary; + no graph). `config.difficulty_params` is **threaded** into both snapshot + builders (tested), so recipe-resolved difficulty will drive distortions; + recipe-driven *resolution* of `difficulty_params` lands in `LTV-Po`. + `student_public` is **refused** (raises) until `LTV-Pn.4c` adds the + snapshot-safe export — never emit an unsafe public bundle. + - **Flagged:** `validation.bundle_checks.validate_bundle` is lead-scoring- + coupled (applies lead-scoring FK/table/task checks) and errors on a + lifecycle bundle; scheme-aware validation is `LTV-Pp`. - Labels: `type: feature`, `layer: api`, `layer: render` - [ ] **`LTV-Pn.4c`** — `feat(lifecycle): student_public snapshot-safety`. Public relational filtering (event tables ≤ cutoff; drop terminal diff --git a/leadforge/schemes/lifecycle/__init__.py b/leadforge/schemes/lifecycle/__init__.py index ebbbaf8..5a60b8a 100644 --- a/leadforge/schemes/lifecycle/__init__.py +++ b/leadforge/schemes/lifecycle/__init__.py @@ -2,9 +2,9 @@ The second peer scheme alongside ``lead_scoring``. Its entity rows and FK constraints live here (``entities`` / ``relationships``); the snapshot, feature, -and task definitions live in sibling modules. :meth:`LifecycleScheme.build_world` -is implemented (LTV-Pn.4a); :meth:`write_bundle` / :meth:`write_metadata` are -built out in LTV-Pn.4b–c and currently raise :class:`NotImplementedError`. +and task definitions live in sibling modules. ``build_world`` (LTV-Pn.4a) and +the instructor-mode ``write_bundle`` / ``write_metadata`` (LTV-Pn.4b) are +implemented; the ``student_public`` snapshot-safe export lands in LTV-Pn.4c. """ from __future__ import annotations @@ -20,11 +20,6 @@ from leadforge.core.models import GenerationConfig, WorldBundle from leadforge.narrative.spec import NarrativeSpec -_NOT_IMPLEMENTED = ( - "the lifecycle (b2b_saas_ltv_v1) write path is not implemented yet; " - "it is built across LTV-Pn.4b–c" -) - def _sample_motif_family(rng: random.Random) -> str: """Deterministically pick a retention motif family for this world. @@ -74,11 +69,26 @@ def build_world( ``narrative.yaml`` will not drive them until ``LTV-Po`` decides whether the lifecycle scheme should consume the narrative spec. """ + from leadforge.core.exceptions import InvalidConfigError from leadforge.core.models import WorldBundle, WorldSpec from leadforge.core.rng import RNGRoot from leadforge.schemes.lifecycle.artifacts import LifecycleArtifacts from leadforge.schemes.lifecycle.engine import simulate_lifecycle from leadforge.schemes.lifecycle.population import build_customer_population + from leadforge.schemes.lifecycle.snapshots import FORWARD_WINDOWS_DAYS + + # config.forward_windows_days is not yet threaded into the snapshot + # builder, which exports the fixed FORWARD_WINDOWS_DAYS targets. Reject + # an override now (clear, early) rather than emit a bundle whose manifest + # disagrees with its task dirs, or under-simulate and fail opaquely later. + # Threading config-driven windows through is tracked for a later step. + if tuple(config.forward_windows_days) != tuple(FORWARD_WINDOWS_DAYS): + raise InvalidConfigError( + f"config.forward_windows_days={tuple(config.forward_windows_days)} differs " + f"from the lifecycle scheme's exported windows {tuple(FORWARD_WINDOWS_DAYS)}; " + "config-driven forward windows are not yet supported (the snapshot builder " + "exports the fixed set). Use the default until that wiring lands." + ) motif_rng = RNGRoot(config.seed).child("lifecycle_motif") motif_family = _sample_motif_family(motif_rng) @@ -112,10 +122,163 @@ def write_bundle( path: str, generation_timestamp: str | None = None, ) -> None: - raise NotImplementedError(_NOT_IMPLEMENTED) + """Serialise a lifecycle *bundle* to *path* (instructor mode). + + Writes the six relational tables, both observation regimes' snapshots + split into 8 task directories (3 pLTV regression + 1 churn + classification per regime, the early regime prefixed ``early_``), a + dataset card, the feature dictionary, the hidden-truth ``metadata/`` + (via :meth:`write_metadata`), and the manifest (recording + ``generation_scheme`` + ``observation_date`` + the forward windows). + + ``config.difficulty_params`` is threaded into both snapshot builders — + when set (LTV-Po resolves it from the recipe profile), it drives the + snapshot distortions. + + Only ``research_instructor`` mode is supported here. The + ``student_public`` snapshot-safety projection (event-table cutoff + filtering, terminal-column drops, per-task target projection) lands in + LTV-Pn.4c; until then this refuses to write a public bundle rather than + emit one that is not snapshot-safe. + """ + from pathlib import Path + + from leadforge.core.enums import ExposureMode + from leadforge.exposure.modes import apply_exposure + from leadforge.render.manifests import build_manifest, write_manifest + from leadforge.render.relational_io import write_relational_tables + from leadforge.render.tasks import write_task_splits + from leadforge.schema.dictionaries import write_feature_dictionary + from leadforge.schemes.lifecycle.artifacts import LifecycleArtifacts + from leadforge.schemes.lifecycle.features import CUSTOMER_SNAPSHOT_FEATURES + from leadforge.schemes.lifecycle.render.dataset_card import render_lifecycle_dataset_card + from leadforge.schemes.lifecycle.render.relational import to_dataframes + from leadforge.schemes.lifecycle.snapshots import ( + FORWARD_WINDOWS_DAYS, + build_customer_snapshot, + build_early_pltv_snapshot, + ) + from leadforge.schemes.lifecycle.tasks import ( + CALENDAR_REGIME, + EARLY_REGIME, + lifecycle_task_manifests, + ) + + artifacts = bundle.artifacts + if not isinstance(artifacts, LifecycleArtifacts): + raise RuntimeError( + "WorldBundle is not populated with lifecycle artifacts. " + "Call Generator.generate() / build_world() first." + ) + config = bundle.spec.config + if config.exposure_mode is not ExposureMode.research_instructor: + raise NotImplementedError( + f"lifecycle write_bundle currently supports only " + f"research_instructor; {config.exposure_mode.value!r} (snapshot-safe " + "public export) lands in LTV-Pn.4c" + ) + + population = artifacts.population + sim = artifacts.simulation_result + root = Path(path) + root.mkdir(parents=True, exist_ok=True) + + # 1. Relational tables → tables/ + dfs = to_dataframes(sim, population) + table_row_counts = write_relational_tables(dfs, root / "tables") + + # 2. Both regime snapshots → 8 task directories. + # difficulty_params (None until LTV-Po resolves it) drives distortions. + snapshots = { + CALENDAR_REGIME: build_customer_snapshot( + population, sim, difficulty_params=config.difficulty_params, seed=config.seed + ), + EARLY_REGIME: build_early_pltv_snapshot( + population, + sim, + early_tenure_weeks=config.early_tenure_weeks, + difficulty_params=config.difficulty_params, + seed=config.seed, + ), + } + # Each task is a standalone single-target split: drop every OTHER + # target column so a task's parquet cannot leak the answer's siblings + # (e.g. ltv_revenue_730d ⊇ ltv_revenue_90d). The deliberate + # mrr_change_full_period trap (leakage_risk but not a target) is kept. + all_target_cols = {f.name for f in CUSTOMER_SNAPSHOT_FEATURES if f.is_target} + task_row_counts: dict[str, dict[str, int]] = {} + all_tasks = [] + for regime, snapshot in snapshots.items(): + for task in lifecycle_task_manifests(regime): + other_targets = [ + c for c in all_target_cols - {task.label_column} if c in snapshot.columns + ] + task_df = snapshot.drop(columns=other_targets) + counts = write_task_splits(task_df, root / "tasks", seed=config.seed, task=task) + task_row_counts[task.task_id] = counts + all_tasks.append(task) + + # 3. Dataset card + feature dictionary + (root / "dataset_card.md").write_text( + render_lifecycle_dataset_card( + bundle.spec, + table_counts=table_row_counts, + tasks=tuple(all_tasks), + observation_date=population.observation_date, + ) + ) + write_feature_dictionary( + root / "feature_dictionary.csv", features=tuple(CUSTOMER_SNAPSHOT_FEATURES) + ) + + # 4. Exposure metadata (delegates hidden truth to write_metadata) + apply_exposure(bundle, root, config.exposure_mode) + + # 5. Manifest + manifest = build_manifest( + config=config, + generation_scheme=self.name, + motif_family=artifacts.motif_family, + table_row_counts=table_row_counts, + task_row_counts=task_row_counts, + bundle_root=root, + generation_timestamp=generation_timestamp, + extra_fields={ + "observation_date": population.observation_date, + # The actual exported target windows (source of truth), not + # config.forward_windows_days — build_world rejects any mismatch. + "forward_windows_days": list(FORWARD_WINDOWS_DAYS), + "early_tenure_weeks": config.early_tenure_weeks, + }, + ) + write_manifest(manifest, root) def write_metadata(self, bundle: WorldBundle, meta_dir: Path) -> None: - raise NotImplementedError(_NOT_IMPLEMENTED) + """Write the lifecycle hidden-truth files into *meta_dir*. + + Called by :func:`leadforge.exposure.modes.apply_exposure` after the + shared ``world_spec.json``. The lifecycle scheme has no hidden graph; + its latent truth is the per-entity latent registry and the + motif-derived mechanism parameters. + """ + import json + + from leadforge.schemes.lifecycle.artifacts import LifecycleArtifacts + from leadforge.schemes.lifecycle.render.metadata import ( + latent_registry_dict, + mechanism_summary_dict, + ) + + artifacts = bundle.artifacts + if not isinstance(artifacts, LifecycleArtifacts): + raise RuntimeError("WorldBundle is not populated with lifecycle artifacts.") + + (meta_dir / "latent_registry.json").write_text( + json.dumps(latent_registry_dict(artifacts.population.latent_state), indent=2) + ) + (meta_dir / "mechanism_summary.json").write_text( + json.dumps(mechanism_summary_dict(artifacts.motif_family), indent=2) + ) LIFECYCLE_SCHEME = LifecycleScheme() diff --git a/leadforge/schemes/lifecycle/render/dataset_card.py b/leadforge/schemes/lifecycle/render/dataset_card.py new file mode 100644 index 0000000..8ca9b45 --- /dev/null +++ b/leadforge/schemes/lifecycle/render/dataset_card.py @@ -0,0 +1,89 @@ +"""Dataset-card renderer for the lifecycle (pLTV) scheme. + +The lead-scoring card (:func:`leadforge.narrative.dataset_card.render_dataset_card`) +is hard-coupled to the lead-scoring framing (binary conversion label, single +task, narrative-driven firmographics), so the lifecycle scheme renders its own. +Kept deliberately concise for LTV-Pn.4b; richer prose can follow. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from leadforge.core.models import WorldSpec + from leadforge.schema.tasks import TaskManifest + +__all__ = ["render_lifecycle_dataset_card"] + + +def render_lifecycle_dataset_card( + world_spec: WorldSpec, + *, + table_counts: dict[str, int], + tasks: tuple[TaskManifest, ...], + observation_date: str, +) -> str: + """Return a Markdown dataset card for a lifecycle (pLTV) bundle.""" + cfg = world_spec.config + tier = (str(cfg.difficulty) if cfg.difficulty else "unknown").capitalize() + + lines: list[str] = [ + f"# B2B SaaS pLTV Dataset — {tier} Tier", + "", + "## What this is", + "", + "A synthetic B2B SaaS customer base simulated week by week from " + "acquisition through retention, expansion, and churn. The prediction " + "task is **predicted lifetime value (pLTV)**: a continuous, " + "zero-inflated, right-skewed regression target — forecast each " + "customer's future gross revenue over a fixed forward window. Customer " + "churn is provided as a secondary classification label.", + "", + "## Two observation regimes", + "", + "- **Calendar-anchored (standard)** — every customer observed at the " + f"fixed observation date (`{observation_date}`); tenure varies from " + "cold to mature. Task ids: `pltv_revenue_*`, `churned_within_180d`.", + "- **Tenure-anchored (early-pLTV)** — every customer observed at a " + f"fixed short tenure (`customer_start + {cfg.early_tenure_weeks}w`); the " + "genuine cold-start case. Task ids prefixed `early_`.", + "", + "## Tasks", + "", + "| task_id | type | target | window (days) |", + "|---|---|---|---|", + ] + for t in tasks: + lines.append( + f"| `{t.task_id}` | {t.task_type} | `{t.label_column}` | {t.label_window_days} |" + ) + + lines += [ + "", + "## Relational tables", + "", + "| table | rows |", + "|---|---|", + ] + for name, count in table_counts.items(): + lines.append(f"| `{name}` | {count} |") + + lines += [ + "", + "## Leakage trap", + "", + "`mrr_change_full_period` is a deliberate trap: it is computed through " + "the end of simulation, so post-cutoff expansions inflate it. Use " + "`mrr_change_at_snapshot` (computed strictly at the cutoff) instead.", + "", + "## Reproducibility", + "", + f"- Recipe: `{cfg.recipe_id}`", + f"- Seed: `{cfg.seed}`", + f"- Scheme: `{world_spec.scheme}`", + "", + "Deterministic given (recipe, config, seed, package version).", + "", + ] + return "\n".join(lines) diff --git a/leadforge/schemes/lifecycle/render/metadata.py b/leadforge/schemes/lifecycle/render/metadata.py new file mode 100644 index 0000000..06d0179 --- /dev/null +++ b/leadforge/schemes/lifecycle/render/metadata.py @@ -0,0 +1,58 @@ +"""Lifecycle hidden-truth serialisation for ``research_instructor`` metadata. + +Helpers used by :meth:`leadforge.schemes.lifecycle.LifecycleScheme.write_metadata` +to serialise the scheme's latent truth. The lifecycle scheme has no hidden +*graph* (unlike lead scoring); its hidden truth is the per-entity latent +registry and the motif-derived mechanism parameters. +""" + +from __future__ import annotations + +import dataclasses +from types import MappingProxyType +from typing import TYPE_CHECKING, Any + +from leadforge.schemes.lifecycle.mechanisms import assign_lifecycle_mechanisms + +if TYPE_CHECKING: + from leadforge.schemes.lifecycle.population import CustomerLatentState + +__all__ = ["latent_registry_dict", "mechanism_summary_dict"] + + +def latent_registry_dict(latent_state: CustomerLatentState) -> dict[str, Any]: + """Return the per-entity latent registry as a JSON-serialisable dict.""" + return { + "account_latents": latent_state.account_latents, + "customer_latents": latent_state.customer_latents, + } + + +def _params_to_dict(params: Any) -> dict[str, Any]: + """Convert a mechanism params dataclass to plain JSON-serialisable types. + + Unwraps the ``MappingProxyType`` latent-weight fields to plain dicts so the + result is ``json.dumps``-able. + """ + out: dict[str, Any] = {} + for f in dataclasses.fields(params): + value = getattr(params, f.name) + if isinstance(value, MappingProxyType): + value = dict(value) + out[f.name] = value + return out + + +def mechanism_summary_dict(motif_family: str) -> dict[str, Any]: + """Return the motif's mechanism parameters as a JSON-serialisable dict. + + Reconstructs the assignment deterministically from *motif_family* (the same + call the engine makes), so the summary always matches the simulated world. + """ + assignment = assign_lifecycle_mechanisms(motif_family) + return { + "motif_family": assignment.motif_family, + "churn_hazard": _params_to_dict(assignment.churn_hazard), + "expansion_propensity": _params_to_dict(assignment.expansion_propensity), + "payment_failure": _params_to_dict(assignment.payment_failure), + } diff --git a/tests/schemes/lifecycle/test_build_world.py b/tests/schemes/lifecycle/test_build_world.py index bd1b754..b443b83 100644 --- a/tests/schemes/lifecycle/test_build_world.py +++ b/tests/schemes/lifecycle/test_build_world.py @@ -83,6 +83,18 @@ def test_difficulty_not_yet_differentiating() -> None: ] +def test_rejects_unsupported_forward_windows_override() -> None: + """COPILOT-1: config-driven forward windows aren't threaded into the + snapshot builder yet, so an override must be rejected early and clearly + rather than produce a manifest that disagrees with the task dirs (or + under-simulate and fail opaquely downstream).""" + from leadforge.core.exceptions import InvalidConfigError + + cfg = GenerationConfig(seed=5, n_customers=40, forward_windows_days=(30, 90)) + with pytest.raises(InvalidConfigError, match="forward_windows_days"): + get_scheme("lifecycle").build_world(cfg, narrative=None) + + def test_narrative_is_optional() -> None: # The lifecycle population builder generates its own firmographics; build_world # accepts narrative for protocol parity but must not require it. diff --git a/tests/schemes/lifecycle/test_write_bundle.py b/tests/schemes/lifecycle/test_write_bundle.py new file mode 100644 index 0000000..73de03b --- /dev/null +++ b/tests/schemes/lifecycle/test_write_bundle.py @@ -0,0 +1,257 @@ +"""Tests for LifecycleScheme.write_bundle — instructor-mode bundle (LTV-Pn.4b).""" + +from __future__ import annotations + +import dataclasses +import json +from pathlib import Path + +import pandas as pd +import pytest + +from leadforge.core.models import DifficultyParams, GenerationConfig +from leadforge.schemes import get_scheme +from leadforge.schemes.lifecycle.snapshots import CHURN_WINDOW_DAYS, FORWARD_WINDOWS_DAYS + +_TS = "2026-01-01T00:00:00+00:00" +_EXPECTED_TASK_IDS = { + "pltv_revenue_90d", + "pltv_revenue_365d", + "pltv_revenue_730d", + "churned_within_180d", + "early_pltv_revenue_90d", + "early_pltv_revenue_365d", + "early_pltv_revenue_730d", + "early_churned_within_180d", +} + + +def _config(**kw) -> GenerationConfig: + base = { + "seed": 42, + "n_customers": 150, + "recipe_id": "b2b_saas_ltv_v1", + "exposure_mode": "research_instructor", + } + base.update(kw) + return GenerationConfig(**base) + + +def _write(tmp_path: Path, config: GenerationConfig | None = None) -> Path: + config = config or _config() + scheme = get_scheme("lifecycle") + bundle = scheme.build_world(config, narrative=None) + out = tmp_path / "bundle" + scheme.write_bundle(bundle, str(out), generation_timestamp=_TS) + return out + + +# --------------------------------------------------------------------------- +# Bundle shape +# --------------------------------------------------------------------------- + + +def test_required_bundle_files_present(tmp_path) -> None: + out = _write(tmp_path) + for f in ("manifest.json", "dataset_card.md", "feature_dictionary.csv"): + assert (out / f).is_file(), f"missing {f}" + assert (out / "tables").is_dir() + assert (out / "tasks").is_dir() + assert (out / "metadata").is_dir() # research_instructor + + +def test_six_relational_tables(tmp_path) -> None: + out = _write(tmp_path) + tables = {p.stem for p in (out / "tables").glob("*.parquet")} + assert tables == { + "accounts", + "customers", + "subscriptions", + "subscription_events", + "health_signals", + "invoices", + } + + +def test_eight_task_directories(tmp_path) -> None: + out = _write(tmp_path) + task_dirs = {p.name for p in (out / "tasks").iterdir() if p.is_dir()} + assert task_dirs == _EXPECTED_TASK_IDS + for td in (out / "tasks").iterdir(): + for split in ("train", "valid", "test"): + assert (td / f"{split}.parquet").is_file(), f"{td.name} missing {split}" + assert (td / "task_manifest.json").is_file() + + +def test_task_manifest_types(tmp_path) -> None: + out = _write(tmp_path) + for td in (out / "tasks").iterdir(): + m = json.loads((td / "task_manifest.json").read_text()) + if "pltv_revenue" in td.name: + assert m["task_type"] == "regression" + assert m["label_column"].startswith("ltv_revenue_") + else: + assert m["task_type"] == "binary_classification" + assert m["label_column"] == "churned_within_180d" + assert m["label_window_days"] == CHURN_WINDOW_DAYS + + +# --------------------------------------------------------------------------- +# Manifest +# --------------------------------------------------------------------------- + + +def test_manifest_records_scheme_and_lifecycle_fields(tmp_path) -> None: + out = _write(tmp_path) + m = json.loads((out / "manifest.json").read_text()) + assert m["generation_scheme"] == "lifecycle" + assert m["bundle_schema_version"] == "6" + assert m["motif_family"] in { + "product_led_retention", + "relationship_led_retention", + "expansion_led_growth", + "payment_fragile", + "churner_dominated", + } + # Records the ACTUAL exported windows (COPILOT-1), not config's copy. + assert m["forward_windows_days"] == list(FORWARD_WINDOWS_DAYS) + assert m["observation_date"] # non-empty ISO date + assert set(m["tasks"]) == _EXPECTED_TASK_IDS + assert len(m["tables"]) == 6 + + +# --------------------------------------------------------------------------- +# Hidden-truth metadata +# --------------------------------------------------------------------------- + + +def test_metadata_files(tmp_path) -> None: + out = _write(tmp_path) + meta = out / "metadata" + # world_spec.json (shared, generic) + lifecycle hidden truth; NO graph. + assert (meta / "world_spec.json").is_file() + assert (meta / "latent_registry.json").is_file() + assert (meta / "mechanism_summary.json").is_file() + assert not (meta / "graph.json").exists() + + latents = json.loads((meta / "latent_registry.json").read_text()) + assert set(latents) == {"account_latents", "customer_latents"} + mech = json.loads((meta / "mechanism_summary.json").read_text()) + assert set(mech) == { + "motif_family", + "churn_hazard", + "expansion_propensity", + "payment_failure", + } + + +# --------------------------------------------------------------------------- +# Each task split is single-target (no cross-target leakage) +# --------------------------------------------------------------------------- + + +def test_each_task_split_has_only_its_own_target(tmp_path) -> None: + """A task's parquet must contain ONLY its own target among the target + columns — otherwise e.g. ltv_revenue_730d would leak ltv_revenue_90d. The + deliberate mrr_change_full_period trap (leakage_risk, not a target) is kept. + """ + from leadforge.schemes.lifecycle.features import CUSTOMER_SNAPSHOT_FEATURES + + out = _write(tmp_path) + all_targets = {f.name for f in CUSTOMER_SNAPSHOT_FEATURES if f.is_target} + for td in (out / "tasks").iterdir(): + manifest = json.loads((td / "task_manifest.json").read_text()) + own = manifest["label_column"] + df = pd.read_parquet(td / "train.parquet") + present_targets = all_targets & set(df.columns) + assert present_targets == {own}, ( + f"{td.name}: expected only target {own!r}, found {sorted(present_targets)}" + ) + # The deliberate trap survives in every task. + assert "mrr_change_full_period" in df.columns + + +# --------------------------------------------------------------------------- +# Determinism +# --------------------------------------------------------------------------- + + +def test_bundle_deterministic(tmp_path) -> None: + import hashlib + + def hashes(root: Path) -> dict[str, str]: + return { + str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(root.rglob("*")) + if p.is_file() + } + + a = _write(tmp_path / "a") + b = _write(tmp_path / "b") + assert hashes(a) == hashes(b) + + +# --------------------------------------------------------------------------- +# Difficulty threading (the LTV-Pn.4a pinned obligation) +# --------------------------------------------------------------------------- + + +def test_difficulty_params_thread_into_snapshots(tmp_path) -> None: + """config.difficulty_params must reach the snapshot builders — with strong + distortion knobs the task features differ from an undistorted bundle. + (Recipe-driven resolution of difficulty_params lands in LTV-Po; this proves + the wiring so that resolution will take effect.)""" + params = DifficultyParams( + signal_strength=1.0, + noise_scale=1.0, + missing_rate=0.3, + outlier_rate=0.05, + conversion_rate_lo=0.02, + conversion_rate_hi=0.4, + committee_friction=0.5, + ) + plain = _write(tmp_path / "plain") + distorted = _write(tmp_path / "distorted", config=_config(difficulty_params=params)) + + plain_df = pd.read_parquet(plain / "tasks" / "pltv_revenue_365d" / "train.parquet") + dist_df = pd.read_parquet(distorted / "tasks" / "pltv_revenue_365d" / "train.parquet") + # A numeric feature column should differ once distortions are applied. + assert not plain_df["avg_active_users_l12w"].equals(dist_df["avg_active_users_l12w"]) + # Targets are never distorted (the distortion helper excludes them). + assert plain_df["ltv_revenue_365d"].equals(dist_df["ltv_revenue_365d"]) + + +# --------------------------------------------------------------------------- +# Exposure guard +# --------------------------------------------------------------------------- + + +def test_student_public_refused_until_pn4c(tmp_path) -> None: + scheme = get_scheme("lifecycle") + bundle = scheme.build_world(_config(exposure_mode="student_public"), narrative=None) + with pytest.raises(NotImplementedError, match="LTV-Pn.4c"): + scheme.write_bundle(bundle, str(tmp_path / "public"), generation_timestamp=_TS) + + +def test_unpopulated_bundle_refused(tmp_path) -> None: + from leadforge.core.models import WorldBundle + + scheme = get_scheme("lifecycle") + with pytest.raises(RuntimeError, match="lifecycle artifacts"): + scheme.write_bundle(WorldBundle(), str(tmp_path / "x"), generation_timestamp=_TS) + + +def test_lead_scoring_artifacts_rejected_by_lifecycle_writer(tmp_path) -> None: + # Defensive: a bundle from the wrong scheme must not silently half-write. + from leadforge.api.generator import Generator + + gen = Generator.from_recipe("b2b_saas_procurement_v1", seed=1) + ls_bundle = gen.generate(n_accounts=10, n_contacts=30, n_leads=30, difficulty="intro") + # Re-label it as lifecycle to force the dispatch onto the lifecycle writer. + mislabeled = dataclasses.replace( + ls_bundle, spec=dataclasses.replace(ls_bundle.spec, scheme="lifecycle") + ) + with pytest.raises(RuntimeError, match="lifecycle artifacts"): + get_scheme("lifecycle").write_bundle( + mislabeled, str(tmp_path / "y"), generation_timestamp=_TS + ) diff --git a/tests/schemes/test_registry.py b/tests/schemes/test_registry.py index eb2d869..e77b67a 100644 --- a/tests/schemes/test_registry.py +++ b/tests/schemes/test_registry.py @@ -35,14 +35,17 @@ def test_lifecycle_scheme_registered() -> None: assert LIFECYCLE_SCHEME.name == "lifecycle" -def test_lifecycle_write_path_is_stubbed() -> None: - # build_world is implemented (LTV-Pn.4a); the on-disk write path lands in - # Pn.4b–c and must fail loudly until then rather than silently no-op. +def test_lifecycle_write_path_rejects_bad_bundle() -> None: + # build_world + instructor write_bundle/write_metadata are implemented + # (LTV-Pn.4a/4b); they must reject a bundle lacking lifecycle artifacts + # rather than half-write. Full bundle coverage is in test_write_bundle.py. + from leadforge.core.models import WorldBundle + sch = get_scheme("lifecycle") - with pytest.raises(NotImplementedError): - sch.write_bundle(None, "out") # type: ignore[arg-type] - with pytest.raises(NotImplementedError): - sch.write_metadata(None, None) # type: ignore[arg-type] + with pytest.raises(RuntimeError, match="lifecycle artifacts"): + sch.write_bundle(WorldBundle(), "out") + with pytest.raises(RuntimeError, match="lifecycle artifacts"): + sch.write_metadata(WorldBundle(), None) # type: ignore[arg-type] def test_lead_scoring_scheme_name() -> None: diff --git a/tests/schemes/test_scheme_metadata_hook.py b/tests/schemes/test_scheme_metadata_hook.py index c18a343..4d44d3d 100644 --- a/tests/schemes/test_scheme_metadata_hook.py +++ b/tests/schemes/test_scheme_metadata_hook.py @@ -73,8 +73,10 @@ def test_write_metadata_rejects_unpopulated_bundle(tmp_path) -> None: get_scheme("lead_scoring").write_metadata(WorldBundle(), meta) -def test_lifecycle_metadata_hook_is_stubbed(tmp_path) -> None: - with pytest.raises(NotImplementedError): +def test_lifecycle_metadata_hook_rejects_unpopulated_bundle(tmp_path) -> None: + # Implemented in LTV-Pn.4b; an unpopulated bundle is rejected (full + # coverage in tests/schemes/lifecycle/test_write_bundle.py). + with pytest.raises(RuntimeError, match="lifecycle artifacts"): get_scheme("lifecycle").write_metadata(WorldBundle(), tmp_path)