diff --git a/.agent-plan.md b/.agent-plan.md index 2db5a90..14c3d14 100644 --- a/.agent-plan.md +++ b/.agent-plan.md @@ -40,12 +40,13 @@ early against the known-good lead-scoring path + physical reorg into `schemes/`**. (Framing follows Google `lifetime_value`/ZILN and Voyantis pLTV.) Status: `LTV-M0` landed (#102, #103, #106). `LTV-M1`: `LTV-Pb` merged (#104); -`LTV-Pc` (pLTV feature/task specs) still outstanding. `LTV-M2`: `LTV-Pd` -(scheme protocol + registry) merged (#107); `LTV-Pe` (scheme owns bundle -rendering — second half of the seam) opened as **#108** (awaiting review, -verified byte-identical). M2 reordered so the render seam precedes the physical -move. Next in M2: `LTV-Pf` (physically move lead-scoring pipeline into -`schemes/lead_scoring/`), then `LTV-Pg` (scaffold `schemes/lifecycle/`). +`LTV-Pc` (pLTV feature/task specs) still outstanding. `LTV-M2`: `LTV-Pd` (#107) +and `LTV-Pe` (#108) merged (scheme protocol + render seam). `LTV-Pf` (physical +move, **hard break / no shims** per D12) split into Pf.1 (compute core — +simulation/mechanisms/structure moved) opened as **#109**, and Pf.2 (render +move, pending). Verified byte-identical. Sibling `leadforge-datasets-private` +build scripts must update to the new import paths (breakage issue filed). Next: +`LTV-Pf.2` (render), then `LTV-Pg` (scaffold `schemes/lifecycle/`). --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af52b3..c085dd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ Format inspired by [Keep a Changelog](https://keepachangelog.com/). ## Unreleased +### Moved — lead-scoring internals under `schemes/lead_scoring/` (breaking: internal import paths) + +Part of the peer-generation-schemes architecture (`docs/ltv/design.md` §2.5). +The lead-scoring compute core was physically relocated under the new +`leadforge.schemes.lead_scoring` package. **The documented public API +(`leadforge.api`, the CLI) is unchanged**, and generated bundles are +byte-identical; only direct imports of these *internal* modules break (no +back-compat shims, by design): + +| old import path | new import path | +|---|---| +| `leadforge.simulation.*` | `leadforge.schemes.lead_scoring.simulation.*` | +| `leadforge.mechanisms.*` | `leadforge.schemes.lead_scoring.mechanisms.*` | +| `leadforge.structure.*` | `leadforge.schemes.lead_scoring.structure.*` | + +`render/{snapshots,relational,tasks}` and the lead-scoring `schema` specs +relocate in follow-up PRs. Consumers importing internals (e.g. the +`leadforge-datasets-private` build scripts) must update to the new paths; +the package stays on the `1.x` line (the public contract did not change). + ### CLI surfaces v4 fields - `leadforge inspect` now prints `Primary task`, `Label window`, diff --git a/CLAUDE.md b/CLAUDE.md index a8e6c06..9317851 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,10 +157,13 @@ leadforge/ core/ rng.py, ids.py, time.py, enums.py, models.py, exceptions.py, ... narrative/ spec.py, company.py, product.py, personas.py, market.py, funnel.py, dataset_card.py schema/ entities.py, relationships.py, events.py, features.py, tasks.py, dictionaries.py - structure/ node_types.py, graph.py, motifs.py, templates.py, rewiring.py, sampler.py, constraints.py - mechanisms/ base.py, static.py, transitions.py, counts.py, categorical.py, scores.py, hazards.py, measurement.py, policies.py - simulation/ world.py, state.py, population.py, scheduler.py, engine.py, interventions.py - render/ relational.py, snapshots.py, metadata.py, manifests.py, graph_export.py, notebooks.py + schemes/ base.py (GenerationScheme protocol + SCHEME_REGISTRY); + lead_scoring/ — the lead-scoring scheme: __init__.py (build_world/ + write_bundle) + simulation/, mechanisms/, structure/ (moved in + LTV-Pf.1). render/ + lead-scoring schema specs migrate here in + LTV-Pf.2 / LTV-Pg. See docs/ltv/design.md §2.5. + render/ relational.py (+ write_relational_tables), snapshots.py, manifests.py, tasks.py + # lead-scoring render still here pending LTV-Pf.2 exposure/ modes.py, filters.py, redaction.py validation/ invariants.py, artifact_checks.py, realism.py, difficulty.py, drift.py recipes/ registry.py, b2b_saas_procurement_v1/{recipe,narrative,schema,motifs,difficulty_profiles}.yaml @@ -239,23 +242,17 @@ leadforge/ # Python package root │ ├── relationships.py # FK constraints (ALL_CONSTRAINTS) │ ├── tasks.py # SplitSpec, TaskManifest, CONVERTED_WITHIN_90_DAYS │ └── dictionaries.py # Feature dictionary CSV writer -├── structure/ # Hidden world graph -│ ├── graph.py # WorldGraph (DAG wrapper) -│ ├── motifs.py # 5 motif families -│ ├── rewiring.py # Stochastic graph perturbation -│ └── sampler.py # sample_hidden_graph() -├── mechanisms/ # Node/edge behavior -│ ├── policies.py # assign_mechanisms() — motif → MechanismAssignment -│ ├── hazards.py # ConversionHazard -│ ├── transitions.py # StageSequence, HazardTransition -│ ├── counts.py # PoissonIntensity, RecencyDecayIntensity -│ ├── categorical.py # CategoricalInfluence, CHANNEL_QUALITY_SCORES -│ └── scores.py # LatentScore -├── simulation/ # World evolution -│ ├── engine.py # simulate_world() — 90-day daily loop -│ ├── state.py # LeadSimState (per-lead mutable state) -│ └── population.py # build_population() — accounts, contacts, leads -├── render/ # Bundle output +├── schemes/ # Generation schemes (peer pipelines) + registry +│ ├── base.py # GenerationScheme protocol + SCHEME_REGISTRY +│ └── lead_scoring/ # The lead-scoring scheme (LeadScoringScheme) +│ ├── __init__.py # build_world() + write_bundle() +│ ├── structure/ # Hidden world graph (WorldGraph, motifs, sampler) +│ ├── mechanisms/ # Node/edge behavior (policies, hazards, scores, …) +│ └── simulation/ # World evolution (engine, population, state) +│ # NOTE (LTV-M2 reorg in progress): render/{snapshots,relational,tasks} +│ # relocate under schemes/lead_scoring/ in a follow-up; schema specs split +│ # in LTV-Pg. See docs/ltv/design.md §2.5 for the target layout. +├── render/ # Bundle output (envelope + not-yet-moved lead-scoring render) │ ├── snapshots.py # build_snapshot() — ML-ready lead table │ ├── relational.py # to_dataframes() — 9-table dict │ ├── tasks.py # write_task_splits() — train/valid/test Parquet diff --git a/assets/leadforge_advanced.png b/assets/leadforge_advanced.png new file mode 100644 index 0000000..7e5719d Binary files /dev/null and b/assets/leadforge_advanced.png differ diff --git a/assets/leadforge_intermediate.png b/assets/leadforge_intermediate.png new file mode 100644 index 0000000..b26bd15 Binary files /dev/null and b/assets/leadforge_intermediate.png differ diff --git a/assets/leadforge_intro.png b/assets/leadforge_intro.png new file mode 100644 index 0000000..a6817f0 Binary files /dev/null and b/assets/leadforge_intro.png differ diff --git a/docs/ltv/roadmap.md b/docs/ltv/roadmap.md index f97ffa3..2133602 100644 --- a/docs/ltv/roadmap.md +++ b/docs/ltv/roadmap.md @@ -42,7 +42,7 @@ protocol + registry, with the package physically reorganized into |-----------|------------|-----|------------| | `LTV-M0` | Planning + design lock | `LTV-Pa` | #102, #103 (+ scheme reframe) | | `LTV-M1` | Lifecycle schema foundation | `LTV-Pb`, `LTV-Pc` | #104 (Pb) | -| `LTV-M2` | Generation-scheme architecture + physical reorg | `LTV-Pd`, `LTV-Pe`, `LTV-Pf`, `LTV-Pg` | #107 (Pd), #108 (Pe) | +| `LTV-M2` | Generation-scheme architecture + physical reorg | `LTV-Pd`, `LTV-Pe`, `LTV-Pf`, `LTV-Pg` | #107 (Pd), #108 (Pe), #109 (Pf.1) | | `LTV-M3` | Customer population + lifecycle world | `LTV-Ph`, `LTV-Pi` | | | `LTV-M4` | Lifecycle simulation engine | `LTV-Pj`, `LTV-Pk` | | | `LTV-M5` | Customer snapshots + pLTV targets (both regimes) | `LTV-Pl`, `LTV-Pm` | | @@ -114,13 +114,23 @@ Total: ~19 PRs across 9 milestones. `save`, base-direct resolution (footgun guard), full suite green. - Labels: `type: refactor`, `layer: render`, `layer: api` - [ ] **`LTV-Pf`** — `refactor: move lead-scoring pipeline to schemes/lead_scoring/`. - Physically relocate the (now fully scheme-owned) lead-scoring population/ - engine/state/mechanisms/structure/snapshot/relational/task modules + its - entity/feature/task specs under `schemes/lead_scoring/`; leave shared - primitives in `schema/`, `render/` envelope, etc. Add back-compat import - shims where `scripts/` or the sibling datasets repo reference internal paths. - - Tests: full suite + hash-determinism green; public API imports unchanged; - shim coverage. + Physically relocate the (now fully scheme-owned) lead-scoring modules under + `schemes/lead_scoring/`; leave shared primitives in `schema/` and the + `render/` envelope. **Hard break, no shims** (decision D12): old internal + import paths are removed and all in-repo callers updated; the + `leadforge-datasets-private` build scripts must update in lockstep (tracked + via a breakage issue there). Public API (`leadforge.api`, CLI) unchanged; + package stays `1.x` with a CHANGELOG "Moved" note. Split into two PRs to keep + each reviewable and byte-identical: + - [x] **`LTV-Pf.1`** — compute core: `simulation/` + `mechanisms/` + + `structure/` moved as whole directories (21 file renames, all callers + rewritten). Verified byte-identical; full suite green. (**PR #109**) + - [ ] **`LTV-Pf.2`** — render: relocate `render/{snapshots,relational,tasks}` + under the scheme, splitting `render/relational.py` so the shared + `write_relational_tables` stays in the envelope while the 9-table + `to_dataframes` moves. (The lead-scoring `schema` specs split lands with + `LTV-Pg`.) + - Tests: full suite + hash-determinism green; public API imports unchanged. - Labels: `type: refactor`, `layer: schema`, `layer: simulation`, `layer: render` - [ ] **`LTV-Pg`** — `refactor: scaffold schemes/lifecycle/ + relocate LTV-Pb/Pc specs`. Create `schemes/lifecycle/`; move the lifecycle entity rows (from #104) and @@ -198,6 +208,14 @@ Total: ~19 PRs across 9 milestones. + windows in the manifest; bump `BUNDLE_SCHEMA_VERSION` 5 → 6 (D5); teach the task-split writer the continuous-target path. Extend `CLAUDE.md` hard constraints with the lifecycle snapshot-safety clause + the schemes/ layout. + - **Layering cleanup (carried debt, see `Known deferred cleanups` below):** + generalise `build_manifest` (drop the lead-scoring `world_graph` param) and + `apply_exposure` (stop hard-coding the lead-scoring hidden graph + latent + registry) so they are scheme-agnostic; with that done, remove the + `core.models` / `render.relational` **TYPE_CHECKING** back-references to + `leadforge.schemes.lead_scoring.*` introduced in `LTV-Pf.1` (a core→scheme + layering inversion), and lift the shared render orchestration out of each + scheme's `write_bundle` (the decomposition deferred in `LTV-Pe`). - Tests: dispatch, lead-scoring path unaffected, manifest fields, regression split writer, exposure filtering for new tables. - Labels: `type: feature`, `layer: api`, `layer: render` @@ -240,6 +258,28 @@ Total: ~19 PRs across 9 milestones. --- +## Known deferred cleanups (tech debt carried by M2, paid down in M6) + +The peer-schemes reorg deliberately defers a few cleanups to keep each M2 PR +byte-identical and reviewable. They are tracked here and discharged in +**`LTV-Pn`** (M6), where the manifest/exposure generalization makes them clean: + +1. **Shared render orchestration** — `LTV-Pe` left each scheme owning its full + `write_bundle`; only `write_relational_tables` is shared. A shared bundle + orchestrator with scheme render hooks lands once there are two schemes. +2. **`build_manifest` / `apply_exposure` are lead-scoring-coupled** — + `build_manifest` takes a `world_graph`; `apply_exposure` writes the + lead-scoring hidden graph + latent registry. Generalize both to be + scheme-agnostic. +3. **core→scheme layering inversion** — `LTV-Pf.1` introduced + `TYPE_CHECKING`-only imports of `leadforge.schemes.lead_scoring.*` in + `core.models` (`WorldBundle.world_graph: WorldGraph | None`) and + `render.relational`. Harmless at runtime (no eager import), but `core`/shared + `render` should not reference a scheme. Remove once (2) makes + `WorldBundle` hold scheme-agnostic artifacts. + +--- + ## Dependencies ``` diff --git a/leadforge/core/models.py b/leadforge/core/models.py index e7acbe3..3fb228c 100644 --- a/leadforge/core/models.py +++ b/leadforge/core/models.py @@ -11,9 +11,9 @@ if TYPE_CHECKING: from leadforge.narrative.spec import NarrativeSpec - from leadforge.simulation.engine import SimulationResult - from leadforge.simulation.population import PopulationResult - from leadforge.structure.graph import WorldGraph + from leadforge.schemes.lead_scoring.simulation.engine import SimulationResult + from leadforge.schemes.lead_scoring.simulation.population import PopulationResult + from leadforge.schemes.lead_scoring.structure.graph import WorldGraph # Default generation scheme when a recipe/world does not declare one. Kept here diff --git a/leadforge/exposure/metadata.py b/leadforge/exposure/metadata.py index 69b43be..e66788d 100644 --- a/leadforge/exposure/metadata.py +++ b/leadforge/exposure/metadata.py @@ -29,7 +29,7 @@ def write_metadata_dir(bundle: WorldBundle, bundle_root: Path) -> None: bundle_root: Root directory of the written bundle. """ from leadforge.core.rng import RNGRoot - from leadforge.mechanisms.policies import assign_mechanisms + from leadforge.schemes.lead_scoring.mechanisms.policies import assign_mechanisms # Callers must only invoke this after full bundle assembly; world_graph # and population are guaranteed non-None at that point. diff --git a/leadforge/render/manifests.py b/leadforge/render/manifests.py index 92e57a3..418780c 100644 --- a/leadforge/render/manifests.py +++ b/leadforge/render/manifests.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: from leadforge.core.models import GenerationConfig - from leadforge.structure.graph import WorldGraph + from leadforge.schemes.lead_scoring.structure.graph import WorldGraph # Bump this whenever the bundle layout or manifest schema changes. # History: diff --git a/leadforge/render/relational.py b/leadforge/render/relational.py index 08319a4..efca9fd 100644 --- a/leadforge/render/relational.py +++ b/leadforge/render/relational.py @@ -30,8 +30,8 @@ from collections.abc import Collection from pathlib import Path - from leadforge.simulation.engine import SimulationResult - from leadforge.simulation.population import PopulationResult + from leadforge.schemes.lead_scoring.simulation.engine import SimulationResult + from leadforge.schemes.lead_scoring.simulation.population import PopulationResult _Source = Literal["population", "simulation"] @@ -63,9 +63,9 @@ def to_dataframes( """Convert simulation output to one typed DataFrame per relational table. Args: - result: Output of :func:`~leadforge.simulation.engine.simulate_world`. + result: Output of :func:`~leadforge.schemes.lead_scoring.simulation.engine.simulate_world`. population: Output of - :func:`~leadforge.simulation.population.build_population`. + :func:`~leadforge.schemes.lead_scoring.simulation.population.build_population`. Returns: Dict mapping table name → ``pd.DataFrame`` with dtypes matching the diff --git a/leadforge/render/snapshots.py b/leadforge/render/snapshots.py index 318cda9..b770eee 100644 --- a/leadforge/render/snapshots.py +++ b/leadforge/render/snapshots.py @@ -24,12 +24,12 @@ TouchRow, ) from leadforge.schema.features import LEAD_SNAPSHOT_FEATURES -from leadforge.simulation.population import REVENUE_BAND_MIDPOINTS +from leadforge.schemes.lead_scoring.simulation.population import REVENUE_BAND_MIDPOINTS if TYPE_CHECKING: from leadforge.core.models import DifficultyParams - from leadforge.simulation.engine import SimulationResult - from leadforge.simulation.population import PopulationResult + from leadforge.schemes.lead_scoring.simulation.engine import SimulationResult + from leadforge.schemes.lead_scoring.simulation.population import PopulationResult # Ordered column list and dtypes derived from the canonical feature spec. _SNAPSHOT_COLUMNS = [f.name for f in LEAD_SNAPSHOT_FEATURES] @@ -76,9 +76,9 @@ def build_snapshot( horizon). Args: - result: Output of :func:`~leadforge.simulation.engine.simulate_world`. + result: Output of :func:`~leadforge.schemes.lead_scoring.simulation.engine.simulate_world`. population: Output of - :func:`~leadforge.simulation.population.build_population`. + :func:`~leadforge.schemes.lead_scoring.simulation.population.build_population`. horizon_days: Simulation horizon length. Defaults to 90. snapshot_day: Optional windowed snapshot day. When set, only events with timestamps ``<= lead_created_at + timedelta(days=snapshot_day)`` diff --git a/leadforge/schemes/lead_scoring/__init__.py b/leadforge/schemes/lead_scoring/__init__.py index 7437236..55e1dea 100644 --- a/leadforge/schemes/lead_scoring/__init__.py +++ b/leadforge/schemes/lead_scoring/__init__.py @@ -3,13 +3,13 @@ Owns the lead-scoring pipeline — hidden-DAG sampling, difficulty interpretation, population, simulation, and bundle assembly — behind the single :meth:`~leadforge.schemes.base.GenerationScheme.build_world` entry point. This -is the first scheme extracted (LTV-Pd) and the trunk the lifecycle scheme -parallels. +is the first scheme extracted, and the trunk the lifecycle scheme parallels. -The implementation modules (``population``, ``engine``, mechanisms, structure, -render) still live under their original package paths; they are physically -relocated into this package in LTV-Pe. Until then ``build_world`` calls the -current homes, keeping the lead-scoring bundle's output byte-for-byte identical. +The compute-core modules (``simulation``, ``mechanisms``, ``structure``) live +under this package as of LTV-Pf. The render modules (``snapshots``, +``relational``, ``tasks``) still live under ``leadforge.render`` and are +relocated in a follow-up; ``build_world`` / ``write_bundle`` import from their +current homes. """ from __future__ import annotations @@ -43,9 +43,9 @@ def build_world( """ from leadforge.core.models import WorldBundle, WorldSpec from leadforge.core.rng import RNGRoot - from leadforge.simulation.engine import simulate_world - from leadforge.simulation.population import build_population - from leadforge.structure.sampler import sample_hidden_graph + from leadforge.schemes.lead_scoring.simulation.engine import simulate_world + from leadforge.schemes.lead_scoring.simulation.population import build_population + from leadforge.schemes.lead_scoring.structure.sampler import sample_hidden_graph latent_touch_intensity = bool(options.get("latent_touch_intensity", False)) diff --git a/leadforge/mechanisms/__init__.py b/leadforge/schemes/lead_scoring/mechanisms/__init__.py similarity index 100% rename from leadforge/mechanisms/__init__.py rename to leadforge/schemes/lead_scoring/mechanisms/__init__.py diff --git a/leadforge/mechanisms/base.py b/leadforge/schemes/lead_scoring/mechanisms/base.py similarity index 97% rename from leadforge/mechanisms/base.py rename to leadforge/schemes/lead_scoring/mechanisms/base.py index 217c36a..8ffaa51 100644 --- a/leadforge/mechanisms/base.py +++ b/leadforge/schemes/lead_scoring/mechanisms/base.py @@ -135,7 +135,8 @@ def from_dict(cls, data: dict[str, Any]) -> MechanismSummary: class MechanismAssignment: """Named mechanism instances consumed by the simulation engine. - All fields are populated by :func:`~leadforge.mechanisms.policies.assign_mechanisms`. + All fields are populated by + :func:`~leadforge.schemes.lead_scoring.mechanisms.policies.assign_mechanisms`. """ motif_family: str diff --git a/leadforge/mechanisms/categorical.py b/leadforge/schemes/lead_scoring/mechanisms/categorical.py similarity index 96% rename from leadforge/mechanisms/categorical.py rename to leadforge/schemes/lead_scoring/mechanisms/categorical.py index 38a56a2..d14847b 100644 --- a/leadforge/mechanisms/categorical.py +++ b/leadforge/schemes/lead_scoring/mechanisms/categorical.py @@ -10,7 +10,7 @@ import random from typing import Any -from leadforge.mechanisms.base import Mechanism, MechanismContext +from leadforge.schemes.lead_scoring.mechanisms.base import Mechanism, MechanismContext class CategoricalInfluence(Mechanism): diff --git a/leadforge/mechanisms/counts.py b/leadforge/schemes/lead_scoring/mechanisms/counts.py similarity index 99% rename from leadforge/mechanisms/counts.py rename to leadforge/schemes/lead_scoring/mechanisms/counts.py index d9f7bde..ed5be8b 100644 --- a/leadforge/mechanisms/counts.py +++ b/leadforge/schemes/lead_scoring/mechanisms/counts.py @@ -12,7 +12,7 @@ from dataclasses import dataclass, field from typing import Any -from leadforge.mechanisms.base import Mechanism, MechanismContext +from leadforge.schemes.lead_scoring.mechanisms.base import Mechanism, MechanismContext @dataclass(frozen=True) diff --git a/leadforge/mechanisms/hazards.py b/leadforge/schemes/lead_scoring/mechanisms/hazards.py similarity index 86% rename from leadforge/mechanisms/hazards.py rename to leadforge/schemes/lead_scoring/mechanisms/hazards.py index a288e88..52b8252 100644 --- a/leadforge/mechanisms/hazards.py +++ b/leadforge/schemes/lead_scoring/mechanisms/hazards.py @@ -2,7 +2,8 @@ :class:`ConversionHazard` is the primary mechanism called by the simulation engine on each day step for each active lead. It maps the merged latent state -to a daily conversion probability via a :class:`~leadforge.mechanisms.scores.LatentScore`. +to a daily conversion probability via a +:class:`~leadforge.schemes.lead_scoring.mechanisms.scores.LatentScore`. """ from __future__ import annotations @@ -10,8 +11,8 @@ import random from typing import Any -from leadforge.mechanisms.base import Mechanism, MechanismContext -from leadforge.mechanisms.scores import LatentScore +from leadforge.schemes.lead_scoring.mechanisms.base import Mechanism, MechanismContext +from leadforge.schemes.lead_scoring.mechanisms.scores import LatentScore class ConversionHazard(Mechanism): @@ -22,7 +23,7 @@ class ConversionHazard(Mechanism): p_convert = clip(base_rate + scale * score, 0, max_daily_rate) Args: - score_mech: A :class:`~leadforge.mechanisms.scores.LatentScore` + score_mech: A :class:`~leadforge.schemes.lead_scoring.mechanisms.scores.LatentScore` instance that maps latents → [0, 1] score. base_rate: Minimum daily conversion probability (intercept). scale: Multiplier on the latent score. diff --git a/leadforge/mechanisms/influence.py b/leadforge/schemes/lead_scoring/mechanisms/influence.py similarity index 98% rename from leadforge/mechanisms/influence.py rename to leadforge/schemes/lead_scoring/mechanisms/influence.py index e9dd54f..676627f 100644 --- a/leadforge/mechanisms/influence.py +++ b/leadforge/schemes/lead_scoring/mechanisms/influence.py @@ -10,7 +10,7 @@ import random from typing import Any -from leadforge.mechanisms.base import Mechanism, MechanismContext +from leadforge.schemes.lead_scoring.mechanisms.base import Mechanism, MechanismContext def _weighted_sum(latents: dict[str, float], weights: dict[str, float], bias: float) -> float: diff --git a/leadforge/mechanisms/measurement.py b/leadforge/schemes/lead_scoring/mechanisms/measurement.py similarity index 98% rename from leadforge/mechanisms/measurement.py rename to leadforge/schemes/lead_scoring/mechanisms/measurement.py index 55b16e0..2c2c3bf 100644 --- a/leadforge/mechanisms/measurement.py +++ b/leadforge/schemes/lead_scoring/mechanisms/measurement.py @@ -10,7 +10,7 @@ import random from typing import Any -from leadforge.mechanisms.base import Mechanism, MechanismContext +from leadforge.schemes.lead_scoring.mechanisms.base import Mechanism, MechanismContext class NoisyProxy(Mechanism): diff --git a/leadforge/mechanisms/policies.py b/leadforge/schemes/lead_scoring/mechanisms/policies.py similarity index 93% rename from leadforge/mechanisms/policies.py rename to leadforge/schemes/lead_scoring/mechanisms/policies.py index fdb1be4..3d7d69a 100644 --- a/leadforge/mechanisms/policies.py +++ b/leadforge/schemes/lead_scoring/mechanisms/policies.py @@ -1,7 +1,8 @@ """Mechanism assignment policy — wires mechanism instances to a world. :func:`assign_mechanisms` is the single entry point. It inspects the active -motif family and constructs a :class:`~leadforge.mechanisms.base.MechanismAssignment` +motif family and constructs a +:class:`~leadforge.schemes.lead_scoring.mechanisms.base.MechanismAssignment` whose parameters reflect the structural bias of that world. Motif-family parameter tuning @@ -26,19 +27,19 @@ import random from typing import TYPE_CHECKING, Any -from leadforge.mechanisms.base import MechanismAssignment +from leadforge.schemes.lead_scoring.mechanisms.base import MechanismAssignment if TYPE_CHECKING: from leadforge.core.models import DifficultyParams -from leadforge.mechanisms.counts import ( +from leadforge.schemes.lead_scoring.mechanisms.counts import ( FollowupRampConfig, LatentDecayIntensity, RecencyDecayIntensity, ) -from leadforge.mechanisms.hazards import ConversionHazard -from leadforge.mechanisms.measurement import NoisyProxy -from leadforge.mechanisms.scores import LatentScore -from leadforge.mechanisms.transitions import HazardTransition +from leadforge.schemes.lead_scoring.mechanisms.hazards import ConversionHazard +from leadforge.schemes.lead_scoring.mechanisms.measurement import NoisyProxy +from leadforge.schemes.lead_scoring.mechanisms.scores import LatentScore +from leadforge.schemes.lead_scoring.mechanisms.transitions import HazardTransition # --------------------------------------------------------------------------- # Motif-family parameter tables @@ -239,11 +240,12 @@ def assign_mechanisms( latent_touch_intensity: bool = False, difficulty_params: DifficultyParams | None = None, ) -> MechanismAssignment: - """Build a :class:`~leadforge.mechanisms.base.MechanismAssignment` for *motif_family*. + """Build a :class:`~leadforge.schemes.lead_scoring.mechanisms.base.MechanismAssignment` + for *motif_family*. Parameters are tuned to the structural bias of the motif family so the resulting simulation is consistent with the hidden world sampled by - :func:`~leadforge.structure.sampler.sample_hidden_graph`. + :func:`~leadforge.schemes.lead_scoring.structure.sampler.sample_hidden_graph`. Args: motif_family: Name of the active motif family (e.g. ``"fit_dominant"``). @@ -251,13 +253,14 @@ def assign_mechanisms( parameter perturbation (currently unused but reserved for future use so the signature is stable). latent_touch_intensity: When ``True``, use - :class:`~leadforge.mechanisms.counts.LatentDecayIntensity` instead - of :class:`~leadforge.mechanisms.counts.RecencyDecayIntensity` for + :class:`~leadforge.schemes.lead_scoring.mechanisms.counts.LatentDecayIntensity` instead + of :class:`~leadforge.schemes.lead_scoring.mechanisms.counts.RecencyDecayIntensity` for touch emission, making touch intensity depend on the same latent traits that drive conversion. Returns: - A fully populated :class:`~leadforge.mechanisms.base.MechanismAssignment`. + A fully populated + :class:`~leadforge.schemes.lead_scoring.mechanisms.base.MechanismAssignment`. """ conv_weights = dict(_CONVERSION_SCORE_WEIGHTS.get(motif_family, _DEFAULT_CONVERSION_WEIGHTS)) hazard_p = dict(_HAZARD_PARAMS.get(motif_family, _DEFAULT_HAZARD_PARAMS)) diff --git a/leadforge/mechanisms/scores.py b/leadforge/schemes/lead_scoring/mechanisms/scores.py similarity index 86% rename from leadforge/mechanisms/scores.py rename to leadforge/schemes/lead_scoring/mechanisms/scores.py index 8d5ad48..741153e 100644 --- a/leadforge/mechanisms/scores.py +++ b/leadforge/schemes/lead_scoring/mechanisms/scores.py @@ -1,8 +1,8 @@ """Latent scoring — maps merged latent state to a scalar score in [0, 1]. :class:`LatentScore` is the core building block used by -:class:`~leadforge.mechanisms.hazards.ConversionHazard` and -:class:`~leadforge.mechanisms.transitions.HazardTransition` to collapse +:class:`~leadforge.schemes.lead_scoring.mechanisms.hazards.ConversionHazard` and +:class:`~leadforge.schemes.lead_scoring.mechanisms.transitions.HazardTransition` to collapse multiple latent traits into a single predictive signal. """ @@ -12,7 +12,7 @@ import random from typing import Any -from leadforge.mechanisms.base import Mechanism, MechanismContext +from leadforge.schemes.lead_scoring.mechanisms.base import Mechanism, MechanismContext class LatentScore(Mechanism): diff --git a/leadforge/mechanisms/static.py b/leadforge/schemes/lead_scoring/mechanisms/static.py similarity index 98% rename from leadforge/mechanisms/static.py rename to leadforge/schemes/lead_scoring/mechanisms/static.py index 6c1817f..4393596 100644 --- a/leadforge/mechanisms/static.py +++ b/leadforge/schemes/lead_scoring/mechanisms/static.py @@ -11,7 +11,7 @@ import random from typing import Any -from leadforge.mechanisms.base import Mechanism, MechanismContext +from leadforge.schemes.lead_scoring.mechanisms.base import Mechanism, MechanismContext class CategoricalDraw(Mechanism): diff --git a/leadforge/mechanisms/transitions.py b/leadforge/schemes/lead_scoring/mechanisms/transitions.py similarity index 95% rename from leadforge/mechanisms/transitions.py rename to leadforge/schemes/lead_scoring/mechanisms/transitions.py index 8c90f35..785d1db 100644 --- a/leadforge/mechanisms/transitions.py +++ b/leadforge/schemes/lead_scoring/mechanisms/transitions.py @@ -10,8 +10,8 @@ import random from typing import Any -from leadforge.mechanisms.base import Mechanism, MechanismContext -from leadforge.mechanisms.scores import LatentScore +from leadforge.schemes.lead_scoring.mechanisms.base import Mechanism, MechanismContext +from leadforge.schemes.lead_scoring.mechanisms.scores import LatentScore # Default v1 funnel stage ordering (matches narrative.yaml funnel_stages). _DEFAULT_STAGE_ORDER = ( @@ -94,7 +94,7 @@ class HazardTransition(Mechanism): unrealistically quickly. Args: - score_mech: :class:`~leadforge.mechanisms.scores.LatentScore` + score_mech: :class:`~leadforge.schemes.lead_scoring.mechanisms.scores.LatentScore` mapping merged latents → [0, 1] score. base_rate: Minimum daily advancement probability. scale: Score multiplier. diff --git a/leadforge/simulation/__init__.py b/leadforge/schemes/lead_scoring/simulation/__init__.py similarity index 100% rename from leadforge/simulation/__init__.py rename to leadforge/schemes/lead_scoring/simulation/__init__.py diff --git a/leadforge/simulation/engine.py b/leadforge/schemes/lead_scoring/simulation/engine.py similarity index 95% rename from leadforge/simulation/engine.py rename to leadforge/schemes/lead_scoring/simulation/engine.py index 4ed9375..f57f346 100644 --- a/leadforge/simulation/engine.py +++ b/leadforge/schemes/lead_scoring/simulation/engine.py @@ -2,8 +2,8 @@ :func:`simulate_world` is the single public entry point. It iterates daily steps for every lead in the population, driven by the -:class:`~leadforge.mechanisms.base.MechanismAssignment` produced by -:func:`~leadforge.mechanisms.policies.assign_mechanisms`, and emits the full +:class:`~leadforge.schemes.lead_scoring.mechanisms.base.MechanismAssignment` produced by +:func:`~leadforge.schemes.lead_scoring.mechanisms.policies.assign_mechanisms`, and emits the full set of relational event rows. Simulation contract @@ -15,9 +15,11 @@ **within** ``config.label_window_days`` of the lead's creation. The simulation still runs for ``config.horizon_days`` to produce rich event histories; only label derivation is gated by the label window. -- Stage advancement is driven by :class:`~leadforge.mechanisms.transitions.HazardTransition` +- Stage advancement is driven by + :class:`~leadforge.schemes.lead_scoring.mechanisms.transitions.HazardTransition` (mql → … → negotiation); final conversion is driven by - :class:`~leadforge.mechanisms.hazards.ConversionHazard` (negotiation → closed_won). + :class:`~leadforge.schemes.lead_scoring.mechanisms.hazards.ConversionHazard` + (negotiation → closed_won). - A rare **direct conversion** path allows pre-SQL leads (``mql``, ``sal``) to convert with a heavily discounted daily probability (``_DIRECT_CONVERSION_DISCOUNT`` × normal hazard rate), breaking the @@ -52,10 +54,6 @@ from leadforge.core.ids import ID_PREFIXES, make_id from leadforge.core.models import GenerationConfig from leadforge.core.rng import RNGRoot -from leadforge.mechanisms.base import MechanismContext -from leadforge.mechanisms.hazards import ConversionHazard -from leadforge.mechanisms.policies import assign_mechanisms -from leadforge.mechanisms.transitions import StageSequence from leadforge.schema.entities import ( CustomerRow, LeadRow, @@ -65,9 +63,13 @@ SubscriptionRow, TouchRow, ) -from leadforge.simulation.population import PopulationResult -from leadforge.simulation.state import LeadSimState -from leadforge.structure.graph import WorldGraph +from leadforge.schemes.lead_scoring.mechanisms.base import MechanismContext +from leadforge.schemes.lead_scoring.mechanisms.hazards import ConversionHazard +from leadforge.schemes.lead_scoring.mechanisms.policies import assign_mechanisms +from leadforge.schemes.lead_scoring.mechanisms.transitions import StageSequence +from leadforge.schemes.lead_scoring.simulation.population import PopulationResult +from leadforge.schemes.lead_scoring.simulation.state import LeadSimState +from leadforge.schemes.lead_scoring.structure.graph import WorldGraph # --------------------------------------------------------------------------- # Internal constants @@ -182,7 +184,7 @@ def simulate_world( config: Fully resolved generation configuration (counts, seed, horizon). population: Output of - :func:`~leadforge.simulation.population.build_population`. + :func:`~leadforge.schemes.lead_scoring.simulation.population.build_population`. world_graph: The sampled hidden world graph; its ``motif_family`` attribute selects the appropriate mechanism parameters. diff --git a/leadforge/simulation/population.py b/leadforge/schemes/lead_scoring/simulation/population.py similarity index 99% rename from leadforge/simulation/population.py rename to leadforge/schemes/lead_scoring/simulation/population.py index 1f6b6d3..ffc5fcf 100644 --- a/leadforge/simulation/population.py +++ b/leadforge/schemes/lead_scoring/simulation/population.py @@ -36,7 +36,7 @@ if TYPE_CHECKING: from leadforge.narrative.spec import NarrativeSpec - from leadforge.structure.graph import WorldGraph + from leadforge.schemes.lead_scoring.structure.graph import WorldGraph # --------------------------------------------------------------------------- diff --git a/leadforge/simulation/state.py b/leadforge/schemes/lead_scoring/simulation/state.py similarity index 97% rename from leadforge/simulation/state.py rename to leadforge/schemes/lead_scoring/simulation/state.py index 74e73e3..2ad421b 100644 --- a/leadforge/simulation/state.py +++ b/leadforge/schemes/lead_scoring/simulation/state.py @@ -1,7 +1,7 @@ """Per-lead mutable state for the discrete-time simulation engine. :class:`LeadSimState` is the only mutable object touched by -:func:`~leadforge.simulation.engine.simulate_world`. After the simulation +:func:`~leadforge.schemes.lead_scoring.simulation.engine.simulate_world`. After the simulation loop completes, the final state of each instance is used to populate the :class:`~leadforge.schema.entities.LeadRow` and any post-conversion entity rows (opportunity, customer, subscription). diff --git a/leadforge/structure/__init__.py b/leadforge/schemes/lead_scoring/structure/__init__.py similarity index 100% rename from leadforge/structure/__init__.py rename to leadforge/schemes/lead_scoring/structure/__init__.py diff --git a/leadforge/structure/graph.py b/leadforge/schemes/lead_scoring/structure/graph.py similarity index 99% rename from leadforge/structure/graph.py rename to leadforge/schemes/lead_scoring/structure/graph.py index cdcd5bf..c2a830d 100644 --- a/leadforge/structure/graph.py +++ b/leadforge/schemes/lead_scoring/structure/graph.py @@ -16,7 +16,7 @@ import networkx as nx from leadforge.core.exceptions import LeadforgeError -from leadforge.structure.node_types import LEAF_ONLY, REQUIRES_PARENT, NodeType +from leadforge.schemes.lead_scoring.structure.node_types import LEAF_ONLY, REQUIRES_PARENT, NodeType class GraphValidationError(LeadforgeError): diff --git a/leadforge/structure/motifs.py b/leadforge/schemes/lead_scoring/structure/motifs.py similarity index 96% rename from leadforge/structure/motifs.py rename to leadforge/schemes/lead_scoring/structure/motifs.py index 3f67988..eef978d 100644 --- a/leadforge/structure/motifs.py +++ b/leadforge/schemes/lead_scoring/structure/motifs.py @@ -2,8 +2,8 @@ Each :class:`MotifFamily` describes the canonical node/edge skeleton for one named hidden-world template. The five v1 families are defined at the -bottom of this module; they are consumed by :mod:`leadforge.structure.sampler` -to seed a concrete :class:`~leadforge.structure.graph.WorldGraph`. +bottom of this module; they are consumed by :mod:`leadforge.schemes.lead_scoring.structure.sampler` +to seed a concrete :class:`~leadforge.schemes.lead_scoring.structure.graph.WorldGraph`. See §11.2 of the architecture spec for the semantics of each family. """ @@ -12,8 +12,8 @@ from dataclasses import dataclass, field -from leadforge.structure.graph import EdgeSpec, NodeSpec -from leadforge.structure.node_types import NodeType +from leadforge.schemes.lead_scoring.structure.graph import EdgeSpec, NodeSpec +from leadforge.schemes.lead_scoring.structure.node_types import NodeType @dataclass(frozen=True) @@ -29,7 +29,7 @@ class MotifFamily: canonical_edges: Directed edges between the canonical nodes. optional_node_ids: IDs from *canonical_nodes* that may be dropped during stochastic rewiring (see - :mod:`leadforge.structure.rewiring`). + :mod:`leadforge.schemes.lead_scoring.structure.rewiring`). """ name: str diff --git a/leadforge/structure/node_types.py b/leadforge/schemes/lead_scoring/structure/node_types.py similarity index 100% rename from leadforge/structure/node_types.py rename to leadforge/schemes/lead_scoring/structure/node_types.py diff --git a/leadforge/structure/rewiring.py b/leadforge/schemes/lead_scoring/structure/rewiring.py similarity index 88% rename from leadforge/structure/rewiring.py rename to leadforge/schemes/lead_scoring/structure/rewiring.py index be29bc3..8c27429 100644 --- a/leadforge/structure/rewiring.py +++ b/leadforge/schemes/lead_scoring/structure/rewiring.py @@ -1,9 +1,9 @@ """Stochastic rewiring of motif-family graph skeletons. -:func:`rewire` takes a :class:`~leadforge.structure.motifs.MotifFamily` +:func:`rewire` takes a :class:`~leadforge.schemes.lead_scoring.structure.motifs.MotifFamily` and a seeded :class:`~numpy.random.Generator` and returns perturbed lists -of :class:`~leadforge.structure.graph.NodeSpec` and -:class:`~leadforge.structure.graph.EdgeSpec` that still satisfy the graph +of :class:`~leadforge.schemes.lead_scoring.structure.graph.NodeSpec` and +:class:`~leadforge.schemes.lead_scoring.structure.graph.EdgeSpec` that still satisfy the graph invariants (acyclicity, legality, nondegeneracy). Permitted variability (§11.3 of architecture spec): @@ -21,13 +21,13 @@ from typing import TYPE_CHECKING -from leadforge.structure.graph import EdgeSpec, NodeSpec -from leadforge.structure.node_types import NodeType +from leadforge.schemes.lead_scoring.structure.graph import EdgeSpec, NodeSpec +from leadforge.schemes.lead_scoring.structure.node_types import NodeType if TYPE_CHECKING: import numpy as np - from leadforge.structure.motifs import MotifFamily + from leadforge.schemes.lead_scoring.structure.motifs import MotifFamily # Maximum ± perturbation applied to each edge weight. _WEIGHT_JITTER = 0.15 @@ -62,7 +62,7 @@ def rewire( Returns: A ``(nodes, edges)`` tuple suitable for passing to - :class:`~leadforge.structure.graph.WorldGraph`. + :class:`~leadforge.schemes.lead_scoring.structure.graph.WorldGraph`. """ # metadata is already immutable (MappingProxyType); a plain dict() copy is # sufficient — NodeSpec/EdgeSpec will re-wrap it in a new proxy. diff --git a/leadforge/structure/sampler.py b/leadforge/schemes/lead_scoring/structure/sampler.py similarity index 85% rename from leadforge/structure/sampler.py rename to leadforge/schemes/lead_scoring/structure/sampler.py index d964b9a..1bd64d4 100644 --- a/leadforge/structure/sampler.py +++ b/leadforge/schemes/lead_scoring/structure/sampler.py @@ -3,7 +3,7 @@ :func:`sample_hidden_graph` is the single entry point consumed by the simulation layer. It selects a motif family (pinned by name or chosen at random from the seed), applies stochastic rewiring, and returns a -validated :class:`~leadforge.structure.graph.WorldGraph`. +validated :class:`~leadforge.schemes.lead_scoring.structure.graph.WorldGraph`. """ from __future__ import annotations @@ -11,13 +11,13 @@ import numpy as np from leadforge.core.rng import RNGRoot -from leadforge.structure.graph import GraphValidationError, WorldGraph -from leadforge.structure.motifs import ( +from leadforge.schemes.lead_scoring.structure.graph import GraphValidationError, WorldGraph +from leadforge.schemes.lead_scoring.structure.motifs import ( ALL_MOTIF_FAMILIES, MotifFamily, get_motif_family, ) -from leadforge.structure.rewiring import rewire +from leadforge.schemes.lead_scoring.structure.rewiring import rewire # Maximum number of rewiring attempts before giving up. _MAX_ATTEMPTS = 20 @@ -38,12 +38,13 @@ def sample_hidden_graph( child stream of this root so the sampler integrates with the repo's RNG convention. motif_family_name: If provided, pin the motif family by name - (must be one of :data:`~leadforge.structure.motifs.MOTIF_FAMILY_NAMES`). + (must be one of + :data:`~leadforge.schemes.lead_scoring.structure.motifs.MOTIF_FAMILY_NAMES`). If ``None``, a family is chosen uniformly at random from the five v1 families. Returns: - A validated :class:`~leadforge.structure.graph.WorldGraph`. + A validated :class:`~leadforge.schemes.lead_scoring.structure.graph.WorldGraph`. Raises: TypeError: If *rng_root* is not an :class:`RNGRoot` instance. diff --git a/scripts/spike_category_signal.py b/scripts/spike_category_signal.py index 58b677d..62f079c 100644 --- a/scripts/spike_category_signal.py +++ b/scripts/spike_category_signal.py @@ -27,9 +27,9 @@ from leadforge.api.generator import Generator from leadforge.core.rng import RNGRoot from leadforge.render.snapshots import build_snapshot -from leadforge.simulation.engine import simulate_world -from leadforge.simulation.population import PopulationResult, build_population -from leadforge.structure.sampler import sample_hidden_graph +from leadforge.schemes.lead_scoring.simulation.engine import simulate_world +from leadforge.schemes.lead_scoring.simulation.population import PopulationResult, build_population +from leadforge.schemes.lead_scoring.structure.sampler import sample_hidden_graph SEED = 42 N_LEADS = 5000 diff --git a/tests/mechanisms/test_mechanisms.py b/tests/mechanisms/test_mechanisms.py index 99b0c68..d88efbe 100644 --- a/tests/mechanisms/test_mechanisms.py +++ b/tests/mechanisms/test_mechanisms.py @@ -7,28 +7,46 @@ import pytest -from leadforge.mechanisms.base import MechanismAssignment, MechanismContext, MechanismSummary -from leadforge.mechanisms.categorical import CHANNEL_QUALITY_SCORES, CategoricalInfluence -from leadforge.mechanisms.counts import ( +from leadforge.schemes.lead_scoring.mechanisms.base import ( + MechanismAssignment, + MechanismContext, + MechanismSummary, +) +from leadforge.schemes.lead_scoring.mechanisms.categorical import ( + CHANNEL_QUALITY_SCORES, + CategoricalInfluence, +) +from leadforge.schemes.lead_scoring.mechanisms.counts import ( FollowupRampConfig, LatentDecayIntensity, PoissonIntensity, RecencyDecayIntensity, ) -from leadforge.mechanisms.hazards import ConversionHazard -from leadforge.mechanisms.influence import ( +from leadforge.schemes.lead_scoring.mechanisms.hazards import ConversionHazard +from leadforge.schemes.lead_scoring.mechanisms.influence import ( AdditiveInfluence, InteractionTerm, LogisticInfluence, SaturatingInfluence, ThresholdInfluence, ) -from leadforge.mechanisms.measurement import NoisyCategorization, NoisyProxy, ProxyCompression -from leadforge.mechanisms.policies import assign_mechanisms, mechanism_params_for_motif -from leadforge.mechanisms.scores import LatentScore -from leadforge.mechanisms.static import BoundedNumericDraw, CategoricalDraw, MixtureDraw -from leadforge.mechanisms.transitions import HazardTransition, StageSequence -from leadforge.structure.motifs import MOTIF_FAMILY_NAMES +from leadforge.schemes.lead_scoring.mechanisms.measurement import ( + NoisyCategorization, + NoisyProxy, + ProxyCompression, +) +from leadforge.schemes.lead_scoring.mechanisms.policies import ( + assign_mechanisms, + mechanism_params_for_motif, +) +from leadforge.schemes.lead_scoring.mechanisms.scores import LatentScore +from leadforge.schemes.lead_scoring.mechanisms.static import ( + BoundedNumericDraw, + CategoricalDraw, + MixtureDraw, +) +from leadforge.schemes.lead_scoring.mechanisms.transitions import HazardTransition, StageSequence +from leadforge.schemes.lead_scoring.structure.motifs import MOTIF_FAMILY_NAMES _LATENTS = { "latent_account_fit": 0.7, diff --git a/tests/render/test_render.py b/tests/render/test_render.py index ddeadcf..b82ae48 100644 --- a/tests/render/test_render.py +++ b/tests/render/test_render.py @@ -10,9 +10,9 @@ from leadforge.core.models import GenerationConfig from leadforge.core.rng import RNGRoot from leadforge.schema.features import LEAD_SNAPSHOT_FEATURES -from leadforge.simulation.engine import simulate_world -from leadforge.simulation.population import build_population -from leadforge.structure.sampler import sample_hidden_graph +from leadforge.schemes.lead_scoring.simulation.engine import simulate_world +from leadforge.schemes.lead_scoring.simulation.population import build_population +from leadforge.schemes.lead_scoring.structure.sampler import sample_hidden_graph # --------------------------------------------------------------------------- # Shared fixtures diff --git a/tests/render/test_snapshot_windowed.py b/tests/render/test_snapshot_windowed.py index 5b5dff4..8d36d33 100644 --- a/tests/render/test_snapshot_windowed.py +++ b/tests/render/test_snapshot_windowed.py @@ -12,9 +12,9 @@ from leadforge.core.models import GenerationConfig from leadforge.core.rng import RNGRoot from leadforge.render.snapshots import build_snapshot -from leadforge.simulation.engine import simulate_world -from leadforge.simulation.population import build_population -from leadforge.structure.sampler import sample_hidden_graph +from leadforge.schemes.lead_scoring.simulation.engine import simulate_world +from leadforge.schemes.lead_scoring.simulation.population import build_population +from leadforge.schemes.lead_scoring.structure.sampler import sample_hidden_graph def _make_narrative(seed: int = 42): diff --git a/tests/schemes/test_module_layout.py b/tests/schemes/test_module_layout.py new file mode 100644 index 0000000..c6e75ab --- /dev/null +++ b/tests/schemes/test_module_layout.py @@ -0,0 +1,44 @@ +"""Lock the LTV-Pf module move (hard break, no shims — decision D12). + +These tests pin the *layout* decision in code: the lead-scoring compute core +lives under ``leadforge.schemes.lead_scoring.*`` and the old flat paths are +gone (no back-compat re-export shims). If a future change accidentally +reintroduces a shim at an old path, or fails to move a module, these fail. +""" + +import importlib + +import pytest + +# (old flat path, new scheme-owned path) for the modules moved in LTV-Pf.1. +_MOVED = [ + ("leadforge.simulation.engine", "leadforge.schemes.lead_scoring.simulation.engine"), + ("leadforge.simulation.population", "leadforge.schemes.lead_scoring.simulation.population"), + ("leadforge.simulation.state", "leadforge.schemes.lead_scoring.simulation.state"), + ("leadforge.mechanisms.policies", "leadforge.schemes.lead_scoring.mechanisms.policies"), + ("leadforge.structure.sampler", "leadforge.schemes.lead_scoring.structure.sampler"), + ("leadforge.structure.graph", "leadforge.schemes.lead_scoring.structure.graph"), +] + + +@pytest.mark.parametrize(("_old", "new"), _MOVED) +def test_new_path_importable(_old: str, new: str) -> None: + assert importlib.import_module(new) is not None + + +@pytest.mark.parametrize(("old", "_new"), _MOVED) +def test_old_path_is_gone(old: str, _new: str) -> None: + # Hard break: the old flat module path must no longer resolve. + with pytest.raises(ModuleNotFoundError): + importlib.import_module(old) + + +@pytest.mark.parametrize("pkg", ["simulation", "mechanisms", "structure"]) +def test_old_top_level_package_is_gone(pkg: str) -> None: + with pytest.raises(ModuleNotFoundError): + importlib.import_module(f"leadforge.{pkg}") + + +def test_public_api_unchanged_by_the_move() -> None: + # The documented public surface must keep importing from its stable home. + from leadforge.api import Generator, list_recipes # noqa: F401 diff --git a/tests/simulation/test_engine.py b/tests/simulation/test_engine.py index 6d957fa..44ee64b 100644 --- a/tests/simulation/test_engine.py +++ b/tests/simulation/test_engine.py @@ -15,10 +15,14 @@ SubscriptionRow, TouchRow, ) -from leadforge.simulation.engine import SimulationResult, _plan_from_acv, simulate_world -from leadforge.simulation.population import build_population -from leadforge.simulation.state import LeadSimState -from leadforge.structure.sampler import sample_hidden_graph +from leadforge.schemes.lead_scoring.simulation.engine import ( + SimulationResult, + _plan_from_acv, + simulate_world, +) +from leadforge.schemes.lead_scoring.simulation.population import build_population +from leadforge.schemes.lead_scoring.simulation.state import LeadSimState +from leadforge.schemes.lead_scoring.structure.sampler import sample_hidden_graph # --------------------------------------------------------------------------- # Fixtures diff --git a/tests/simulation/test_population.py b/tests/simulation/test_population.py index 45c82c7..c35076a 100644 --- a/tests/simulation/test_population.py +++ b/tests/simulation/test_population.py @@ -1,4 +1,4 @@ -"""Tests for leadforge.simulation.population — build_population.""" +"""Tests for leadforge.schemes.lead_scoring.simulation.population — build_population.""" from __future__ import annotations @@ -10,13 +10,13 @@ from leadforge.core.models import GenerationConfig from leadforge.core.rng import RNGRoot from leadforge.narrative.spec import NarrativeSpec -from leadforge.simulation.population import ( +from leadforge.schemes.lead_scoring.simulation.population import ( _N_REPS, PopulationResult, _channel_weights, build_population, ) -from leadforge.structure.sampler import sample_hidden_graph +from leadforge.schemes.lead_scoring.structure.sampler import sample_hidden_graph # --------------------------------------------------------------------------- # Fixtures @@ -509,7 +509,9 @@ def test_lead_source_boost_not_stacked_per_contact() -> None: """ import copy - from leadforge.simulation.population import _apply_category_latent_correlations + from leadforge.schemes.lead_scoring.simulation.population import ( + _apply_category_latent_correlations, + ) # Use enough leads relative to contacts to guarantee shared contacts. config = GenerationConfig(seed=42, n_accounts=30, n_contacts=90, n_leads=200) diff --git a/tests/structure/test_graph.py b/tests/structure/test_graph.py index 6752b05..b75b710 100644 --- a/tests/structure/test_graph.py +++ b/tests/structure/test_graph.py @@ -1,16 +1,16 @@ -"""Tests for leadforge.structure.graph — WorldGraph validation and exports.""" +"""Tests for leadforge.schemes.lead_scoring.structure.graph — WorldGraph validation and exports.""" import json import pytest -from leadforge.structure.graph import ( +from leadforge.schemes.lead_scoring.structure.graph import ( EdgeSpec, GraphValidationError, NodeSpec, WorldGraph, ) -from leadforge.structure.node_types import NodeType +from leadforge.schemes.lead_scoring.structure.node_types import NodeType # --------------------------------------------------------------------------- # Helpers diff --git a/tests/structure/test_motifs.py b/tests/structure/test_motifs.py index 68b855a..e191696 100644 --- a/tests/structure/test_motifs.py +++ b/tests/structure/test_motifs.py @@ -1,14 +1,14 @@ -"""Tests for leadforge.structure.motifs — motif family definitions.""" +"""Tests for leadforge.schemes.lead_scoring.structure.motifs — motif family definitions.""" import pytest -from leadforge.structure.motifs import ( +from leadforge.schemes.lead_scoring.structure.motifs import ( ALL_MOTIF_FAMILIES, MOTIF_FAMILY_NAMES, MotifFamily, get_motif_family, ) -from leadforge.structure.node_types import NodeType +from leadforge.schemes.lead_scoring.structure.node_types import NodeType # --------------------------------------------------------------------------- # Registry @@ -89,7 +89,7 @@ def test_motif_edge_weights_in_range(motif: MotifFamily) -> None: @pytest.mark.parametrize("motif", ALL_MOTIF_FAMILIES) def test_motif_canonical_skeleton_builds_valid_graph(motif: MotifFamily) -> None: """The canonical (non-rewired) skeleton must pass WorldGraph validation.""" - from leadforge.structure.graph import WorldGraph + from leadforge.schemes.lead_scoring.structure.graph import WorldGraph g = WorldGraph( nodes=list(motif.canonical_nodes), diff --git a/tests/structure/test_node_types.py b/tests/structure/test_node_types.py index b6894ad..aeef1f4 100644 --- a/tests/structure/test_node_types.py +++ b/tests/structure/test_node_types.py @@ -1,6 +1,6 @@ -"""Tests for leadforge.structure.node_types.""" +"""Tests for leadforge.schemes.lead_scoring.structure.node_types.""" -from leadforge.structure.node_types import ( +from leadforge.schemes.lead_scoring.structure.node_types import ( LEAF_ONLY, REQUIRES_PARENT, ROOT_ELIGIBLE, diff --git a/tests/structure/test_rewiring.py b/tests/structure/test_rewiring.py index ab133b5..4dc131b 100644 --- a/tests/structure/test_rewiring.py +++ b/tests/structure/test_rewiring.py @@ -1,11 +1,11 @@ -"""Tests for leadforge.structure.rewiring — stochastic rewiring rules.""" +"""Tests for leadforge.schemes.lead_scoring.structure.rewiring — stochastic rewiring rules.""" import numpy as np import pytest -from leadforge.structure.graph import WorldGraph -from leadforge.structure.motifs import ALL_MOTIF_FAMILIES, MotifFamily -from leadforge.structure.rewiring import rewire +from leadforge.schemes.lead_scoring.structure.graph import WorldGraph +from leadforge.schemes.lead_scoring.structure.motifs import ALL_MOTIF_FAMILIES, MotifFamily +from leadforge.schemes.lead_scoring.structure.rewiring import rewire # --------------------------------------------------------------------------- # Helpers @@ -81,7 +81,7 @@ def test_rewire_is_deterministic(motif: MotifFamily) -> None: def test_different_seeds_produce_different_graphs() -> None: """At least some seeds should yield structurally different graphs.""" - from leadforge.structure.motifs import FIT_DOMINANT + from leadforge.schemes.lead_scoring.structure.motifs import FIT_DOMINANT structures: set[tuple[str, ...]] = set() for seed in range(40): @@ -98,7 +98,7 @@ def test_different_seeds_produce_different_graphs() -> None: def test_required_nodes_never_dropped() -> None: """Non-optional nodes must always be present after rewiring.""" - from leadforge.structure.motifs import FIT_DOMINANT + from leadforge.schemes.lead_scoring.structure.motifs import FIT_DOMINANT required = { n.node_id diff --git a/tests/structure/test_sampler.py b/tests/structure/test_sampler.py index 098c106..bd8033c 100644 --- a/tests/structure/test_sampler.py +++ b/tests/structure/test_sampler.py @@ -1,12 +1,12 @@ -"""Tests for leadforge.structure.sampler — sample_hidden_graph.""" +"""Tests for leadforge.schemes.lead_scoring.structure.sampler — sample_hidden_graph.""" import pytest from leadforge.core.rng import RNGRoot -from leadforge.structure.graph import WorldGraph -from leadforge.structure.motifs import MOTIF_FAMILY_NAMES -from leadforge.structure.node_types import NodeType -from leadforge.structure.sampler import sample_hidden_graph +from leadforge.schemes.lead_scoring.structure.graph import WorldGraph +from leadforge.schemes.lead_scoring.structure.motifs import MOTIF_FAMILY_NAMES +from leadforge.schemes.lead_scoring.structure.node_types import NodeType +from leadforge.schemes.lead_scoring.structure.sampler import sample_hidden_graph # --------------------------------------------------------------------------- # Basic contract diff --git a/tests/test_difficulty_modulation.py b/tests/test_difficulty_modulation.py index ba0097a..006bc1c 100644 --- a/tests/test_difficulty_modulation.py +++ b/tests/test_difficulty_modulation.py @@ -6,7 +6,7 @@ from leadforge.api.generator import Generator from leadforge.core.models import DifficultyParams, GenerationConfig -from leadforge.mechanisms.policies import assign_mechanisms +from leadforge.schemes.lead_scoring.mechanisms.policies import assign_mechanisms _MEDIUM = {"n_leads": 500, "n_accounts": 200, "n_contacts": 600}