From bf843dd5fab74f572076a046210bc1cb8420bba0 Mon Sep 17 00:00:00 2001 From: Shay Palachy Date: Fri, 12 Jun 2026 23:32:52 +0300 Subject: [PATCH 1/3] refactor: scheme-agnostic WorldBundle + exposure metadata hook [LTV-Pn.2] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second sub-PR of the split LTV-Pn. Removes the last core/shared-layer references to the lead-scoring scheme (carried cleanups #2 and #3), so the lifecycle scheme can plug into the same envelope. WorldBundle (cleanup #3): - Replaced the three lead-scoring-typed fields (population / simulation_result / world_graph) with a single opaque `artifacts: Any`. Each scheme defines and unwraps its own container; lead-scoring adds LeadScoringArtifacts (schemes/lead_scoring/artifacts.py). core.models no longer imports any lead_scoring type — the layering inversion introduced in LTV-Pf.1 is gone. Exposure (cleanup #2): - apply_exposure is now scheme-agnostic: it writes the generic, spec-only world_spec.json (kept in exposure/metadata.py as write_world_spec_json) and dispatches the scheme-specific hidden-truth files to a new GenerationScheme.write_metadata(bundle, meta_dir) hook, resolved from bundle.spec.scheme via the registry. The lead-scoring graph / latent registry / mechanism-summary writers moved out of exposure/ into LeadScoringScheme.write_metadata. exposure/ no longer references lead_scoring. - Protocol gains write_metadata; the lifecycle stub implements it (raises NotImplementedError until Pn.4). Byte-identity: the full lead-scoring bundle is byte-identical across BOTH exposure modes (research_instructor 21 files, student_public 14) — verified against a pre-refactor SHA-256 reference. The metadata writers moved, not changed; world_spec.json content/order is preserved. Re-scope: the shared bundle orchestrator (cleanup #1) moves from this PR to LTV-Pn.4. Per the roadmap's own note it is best designed with the second scheme's write_bundle in hand; extracting it now against one scheme would guess the hook shape. Roadmap + deferred-cleanups updated (#2, #3 done; #1 → Pn.4). Tests: new tests/schemes/test_scheme_metadata_hook.py (artifacts populated; WorldBundle has only spec+artifacts; generic world_spec writer; lead-scoring hook emits the 4 hidden-truth files; unpopulated-bundle + lifecycle-stub raise). Updated field-access sites in 5 test modules. Full suite 1808 passed / 51 skipped; ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 --- .agent-plan.md | 9 ++- docs/ltv/roadmap.md | 51 ++++++++------ leadforge/core/models.py | 19 +++-- leadforge/exposure/metadata.py | 74 +++++-------------- leadforge/exposure/modes.py | 34 +++++---- leadforge/schemes/base.py | 13 ++++ leadforge/schemes/lead_scoring/__init__.py | 70 ++++++++++++++---- leadforge/schemes/lead_scoring/artifacts.py | 29 ++++++++ leadforge/schemes/lifecycle/__init__.py | 5 ++ tests/api/test_generator.py | 5 +- tests/render/test_render.py | 20 ++++-- tests/schemes/test_registry.py | 16 ++--- tests/schemes/test_render_dispatch.py | 2 +- tests/schemes/test_scheme_metadata_hook.py | 78 +++++++++++++++++++++ tests/test_difficulty_modulation.py | 6 +- 15 files changed, 295 insertions(+), 136 deletions(-) create mode 100644 leadforge/schemes/lead_scoring/artifacts.py create mode 100644 tests/schemes/test_scheme_metadata_hook.py diff --git a/.agent-plan.md b/.agent-plan.md index a628e22..6c63ee0 100644 --- a/.agent-plan.md +++ b/.agent-plan.md @@ -73,9 +73,12 @@ builders unified on one per-customer-cutoff core; 19 tests) opened as drops the lead-scoring `world_graph` param for `generation_scheme` / `motif_family` / `extra_fields`; every manifest records `generation_scheme`; `BUNDLE_SCHEMA_VERSION` 5 → 6; lead-scoring data files byte-identical) opened -as **#121**. Next: `LTV-Pn.2` (scheme-agnostic `WorldBundle` + exposure hook + -shared bundle orchestrator), then `Pn.3` (lifecycle config + regression task -model), `Pn.4` (complete `LifecycleScheme` + e2e bundle), `LTV-Po` (recipe). +as **#121** (merged). `LTV-Pn.2` (scheme-agnostic `WorldBundle` — `artifacts: +Any`; `apply_exposure` dispatches hidden truth to a +`GenerationScheme.write_metadata` hook; cleanups #2 + #3 discharged; +lead-scoring byte-identical both modes) opened as **#122**. Next: `Pn.3` +(lifecycle config + regression task model), `Pn.4` (complete `LifecycleScheme` ++ shared bundle orchestrator + e2e bundle), `LTV-Po` (recipe). --- diff --git a/docs/ltv/roadmap.md b/docs/ltv/roadmap.md index e3253a6..88d5b87 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) | +| `LTV-M6` | Register LifecycleScheme + recipe + manifest/version | `LTV-Pn.1…4`, `LTV-Po` | #121 (Pn.1), #122 (Pn.2) | | `LTV-M7` | Validation + regression-metric calibration | `LTV-Pp` | | | `LTV-M8` | CLI, notebooks, publish | `LTV-Pq`, `LTV-Pr`, `LTV-Ps` | | @@ -282,13 +282,20 @@ pipeline + schema bump). Split into four sub-PRs in dependency order: tasks/); only `manifest.json` changes (new field + version). Schema contract test renamed v5 → v6. - Labels: `type: refactor`, `layer: render` -- [ ] **`LTV-Pn.2`** — `refactor: scheme-agnostic WorldBundle + exposure hook + - shared bundle orchestrator`. Generalise `WorldBundle` to hold scheme-owned - artifacts (finishing cleanup #3: drop the `core.models` / `render` → - `lead_scoring.*` back-refs); make `apply_exposure` / `write_metadata_dir` - scheme-agnostic via a hidden-truth hook (cleanup #2); lift a shared bundle - orchestrator with scheme render hooks out of `write_bundle` (cleanup #1). - Lead-scoring bundle byte-identical (full SHA-256 harness). +- [x] **`LTV-Pn.2`** — `refactor: scheme-agnostic WorldBundle + exposure hook` + (**PR #122**). `WorldBundle` now holds only `spec` + an opaque + `artifacts: Any` (scheme-owned; lead-scoring stores `LeadScoringArtifacts`), + finishing cleanup #3 — the `core.models` lead-scoring type imports are gone. + `apply_exposure` is scheme-agnostic: it writes the generic `world_spec.json` + and dispatches hidden-truth files to the producing scheme's new + `GenerationScheme.write_metadata` hook (cleanup #2); the lead-scoring graph / + latent registry / mechanism summary writers moved out of `exposure/` into the + lead-scoring scheme. Lead-scoring bundle **byte-identical** across both + exposure modes (full SHA-256 harness). + - **Re-scoped:** the shared bundle orchestrator (cleanup #1) moves to + `LTV-Pn.4` — per this file's own note it is best designed *with the second + scheme's `write_bundle` in hand*; building it now against one scheme would + guess the hook shape. - Labels: `type: refactor`, `layer: api`, `layer: core`, `layer: render` - [ ] **`LTV-Pn.3`** — `feat: lifecycle config + regression task model`. Add `n_customers` + lifecycle config (forward windows, early-tenure, observation @@ -300,7 +307,10 @@ pipeline + schema bump). Split into four sub-PRs in dependency order: Implement `LifecycleScheme.build_world` (population → sim) and `write_bundle` (lifecycle relational tables; both regime snapshots → two task families × 3 windows + secondary churn; dataset card; manifest `observation_date` + - windows via `extra_fields`). First end-to-end lifecycle bundle (programmatic; + windows via `extra_fields`; lifecycle `write_metadata` hidden-truth hook). + With both schemes' `write_bundle` in hand, **lift the shared bundle + orchestrator with scheme render hooks** out of the two implementations + (carried cleanup #1). First end-to-end lifecycle bundle (programmatic; recipe wiring is `LTV-Po`). Extend `CLAUDE.md` hard constraints with the lifecycle snapshot-safety clause + the `schemes/` layout. Carries the LTV-Pp validation flags: early-regime degenerate-column exemptions; the @@ -353,19 +363,16 @@ byte-identical and reviewable. They are tracked here and discharged in 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.*`. Harmless at runtime (no eager import), but `core`/shared - `render` should not reference a scheme. **Partly discharged in `LTV-Pn.1`** - (removed the `render.manifests` → `lead_scoring.structure.graph` back-ref); - the `core.models.WorldBundle` back-refs follow in `LTV-Pn.2` once - `WorldBundle` holds scheme-agnostic artifacts. + orchestrator with scheme render hooks lands in **`LTV-Pn.4`**, once the + lifecycle `write_bundle` exists to reveal the real shared shape. +2. ~~**`build_manifest` / `apply_exposure` are lead-scoring-coupled**~~ — + **Done** (`build_manifest` in `LTV-Pn.1`; `apply_exposure` in `LTV-Pn.2` via + the `write_metadata` scheme hook). +3. ~~**core→scheme layering inversion**~~ — **Done.** `LTV-Pn.1` removed the + `render.manifests` back-ref; `LTV-Pn.2` removed the `core.models.WorldBundle` + lead-scoring type imports (it now holds an opaque `artifacts: Any`). Only a + `DEFAULT_SCHEME = "lead_scoring"` string default and doc-comment + cross-references remain — neither is an import/type inversion. --- diff --git a/leadforge/core/models.py b/leadforge/core/models.py index 3fb228c..6957c18 100644 --- a/leadforge/core/models.py +++ b/leadforge/core/models.py @@ -11,9 +11,6 @@ if TYPE_CHECKING: from leadforge.narrative.spec import NarrativeSpec - 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 @@ -165,15 +162,16 @@ class WorldBundle: Attributes: spec: Fully resolved world specification (config + narrative). - population: Generated accounts, contacts, leads, and latent state. - simulation_result: Simulated event tables and final lead outcomes. - world_graph: Sampled hidden world graph used during simulation. + artifacts: The producing scheme's in-memory result (e.g. + :class:`~leadforge.schemes.lead_scoring.artifacts.LeadScoringArtifacts`). + Opaque to the shared core layer — typed ``Any`` so ``core`` never + references a scheme. Each scheme stores and unwraps its own + container; ``None`` until :meth:`~leadforge.api.generator.Generator.generate` + populates it. """ spec: WorldSpec = field(default_factory=WorldSpec) - population: PopulationResult | None = None - simulation_result: SimulationResult | None = None - world_graph: WorldGraph | None = None + artifacts: Any = None def save(self, path: str, generation_timestamp: str | None = None) -> None: """Write the full output bundle to *path*. @@ -195,8 +193,7 @@ def save(self, path: str, generation_timestamp: str | None = None) -> None: Pass a fixed value to produce byte-identical manifests. Raises: - RuntimeError: if :attr:`simulation_result`, :attr:`population`, - or :attr:`world_graph` have not been populated (i.e. if + RuntimeError: if :attr:`artifacts` has not been populated (i.e. if :meth:`~leadforge.api.generator.Generator.generate` was not called). """ diff --git a/leadforge/exposure/metadata.py b/leadforge/exposure/metadata.py index e66788d..f6951e0 100644 --- a/leadforge/exposure/metadata.py +++ b/leadforge/exposure/metadata.py @@ -1,71 +1,35 @@ -"""Write hidden-truth metadata files for ``research_instructor`` mode. - -:func:`write_metadata_dir` creates ``bundle_root/metadata/`` and populates -it with five files that expose the full hidden world: - -- ``graph.json`` — world graph as JSON (nodes, edges, motif family) -- ``graph.graphml`` — world graph as GraphML for graph tools -- ``world_spec.json`` — generation config + narrative spec -- ``latent_registry.json`` — per-entity latent trait values -- ``mechanism_summary.json`` — mechanism assignment summary +"""Scheme-agnostic hidden-truth metadata for ``research_instructor`` mode. + +The bundle's ``metadata/`` directory mixes scheme-agnostic provenance +(``world_spec.json`` — config + narrative) with scheme-specific hidden truth +(the lead-scoring world graph, latent registry, and mechanism summary; the +lifecycle scheme will emit its own). Only the generic part lives here; +:func:`write_world_spec_json` writes it. Each scheme owns the rest via its +:meth:`~leadforge.schemes.base.GenerationScheme.write_metadata` hook, called by +:func:`leadforge.exposure.modes.apply_exposure`. """ from __future__ import annotations import dataclasses import json -from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: - from leadforge.core.models import WorldBundle - - -def write_metadata_dir(bundle: WorldBundle, bundle_root: Path) -> None: - """Populate ``bundle_root/metadata/`` with all hidden-truth files. + from pathlib import Path - Args: - bundle: Fully populated :class:`~leadforge.core.models.WorldBundle`. - bundle_root: Root directory of the written bundle. - """ - from leadforge.core.rng import RNGRoot - 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. - assert bundle.world_graph is not None # noqa: S101 - assert bundle.population is not None # noqa: S101 + from leadforge.core.models import WorldSpec - meta_dir = bundle_root / "metadata" - meta_dir.mkdir(exist_ok=True) +__all__ = ["write_world_spec_json"] - # graph.json + graph.graphml - (meta_dir / "graph.json").write_text(bundle.world_graph.to_json()) - (meta_dir / "graph.graphml").write_text(bundle.world_graph.to_graphml()) - # latent_registry.json - ls = bundle.population.latent_state - latent_registry: dict[str, object] = { - "account_latents": ls.account_latents, - "contact_latents": ls.contact_latents, - "lead_latents": ls.lead_latents, - } - (meta_dir / "latent_registry.json").write_text(json.dumps(latent_registry, indent=2)) +def write_world_spec_json(spec: WorldSpec, meta_dir: Path) -> None: + """Write ``meta_dir/world_spec.json`` — the resolved config + narrative. - # world_spec.json — config + narrative (if present) - config_dict = dataclasses.asdict(bundle.spec.config) - narrative_dict = ( - dataclasses.asdict(bundle.spec.narrative) if bundle.spec.narrative is not None else None - ) + Scheme-agnostic: depends only on the shared :class:`WorldSpec`, so it is + identical across generation schemes. + """ + config_dict = dataclasses.asdict(spec.config) + narrative_dict = dataclasses.asdict(spec.narrative) if spec.narrative is not None else None world_spec_dict = {"config": config_dict, "narrative": narrative_dict} (meta_dir / "world_spec.json").write_text(json.dumps(world_spec_dict, indent=2)) - - # mechanism_summary.json - # Reconstruct the mechanism assignment with the same RNG substream that - # was used during simulation — produces the identical parameter values. - motif_family = bundle.world_graph.motif_family - mech_rng = RNGRoot(bundle.spec.config.seed).child("mechanisms") - assignment = assign_mechanisms(motif_family, mech_rng) - (meta_dir / "mechanism_summary.json").write_text( - json.dumps(assignment.summary().to_dict(), indent=2) - ) diff --git a/leadforge/exposure/modes.py b/leadforge/exposure/modes.py index 19901dd..c6882b7 100644 --- a/leadforge/exposure/modes.py +++ b/leadforge/exposure/modes.py @@ -1,41 +1,51 @@ """Exposure-mode dispatch for bundle publication. -:func:`apply_exposure` is the single entry point called by -:func:`~leadforge.api.bundle.write_bundle`. It reads the resolved -:class:`~leadforge.exposure.filters.BundleFilter` for the requested mode -and performs the corresponding writes (or skips them). +:func:`apply_exposure` is the single entry point called by each scheme's +``write_bundle``. It reads the resolved +:class:`~leadforge.exposure.filters.BundleFilter` for the requested mode and, +when hidden truth should be published, writes the scheme-agnostic +``world_spec.json`` and delegates the scheme-specific hidden-truth files to the +producing scheme's :meth:`~leadforge.schemes.base.GenerationScheme.write_metadata` +hook. This keeps the exposure layer free of any single scheme's types. """ from __future__ import annotations import shutil -from pathlib import Path from typing import TYPE_CHECKING -from leadforge.core.enums import ExposureMode from leadforge.exposure.filters import get_filter -from leadforge.exposure.metadata import write_metadata_dir +from leadforge.exposure.metadata import write_world_spec_json if TYPE_CHECKING: + from pathlib import Path + + from leadforge.core.enums import ExposureMode from leadforge.core.models import WorldBundle def apply_exposure(bundle: WorldBundle, bundle_root: Path, mode: ExposureMode) -> None: """Apply exposure filtering for *mode* to the bundle at *bundle_root*. - For ``research_instructor`` mode this writes the ``metadata/`` - directory with all hidden-truth files. For ``student_public`` mode any - pre-existing ``metadata/`` directory is removed so that hidden truth - is never accidentally published when reusing an output path. + For modes whose filter sets ``write_metadata`` (e.g. ``research_instructor``) + this creates ``metadata/``, writes the scheme-agnostic ``world_spec.json``, + and calls the producing scheme's ``write_metadata`` hook for its + hidden-truth files. For modes that must not publish hidden truth (e.g. + ``student_public``) any pre-existing ``metadata/`` directory is removed so + truth is never accidentally republished when reusing an output path. Args: bundle: Fully populated :class:`~leadforge.core.models.WorldBundle`. bundle_root: Root directory of the written bundle (must already exist). mode: Exposure mode that controls which artefacts are published. """ + from leadforge.schemes import get_scheme + filt = get_filter(mode) meta_dir = bundle_root / "metadata" if filt.write_metadata: - write_metadata_dir(bundle, bundle_root) + meta_dir.mkdir(exist_ok=True) + write_world_spec_json(bundle.spec, meta_dir) + get_scheme(bundle.spec.scheme).write_metadata(bundle, meta_dir) elif meta_dir.exists(): shutil.rmtree(meta_dir) diff --git a/leadforge/schemes/base.py b/leadforge/schemes/base.py index fd62a19..0a68499 100644 --- a/leadforge/schemes/base.py +++ b/leadforge/schemes/base.py @@ -40,6 +40,8 @@ from leadforge.core.exceptions import LeadforgeError if TYPE_CHECKING: + from pathlib import Path + from leadforge.core.models import GenerationConfig, WorldBundle from leadforge.narrative.spec import NarrativeSpec @@ -92,6 +94,17 @@ def write_bundle( """ ... + def write_metadata(self, bundle: WorldBundle, meta_dir: Path) -> None: + """Write the scheme's hidden-truth files into an existing *meta_dir*. + + Called by :func:`leadforge.exposure.modes.apply_exposure` for modes + that publish hidden truth (e.g. ``research_instructor``), after the + shared, scheme-agnostic ``world_spec.json`` is written. A scheme emits + whatever latent truth it has — for lead scoring the world graph, latent + registry, and mechanism summary; other schemes emit their own. + """ + ... + # Name → scheme instance. Populated by importing the built-in scheme modules # (each self-registers on import). ``_ensure_builtins`` triggers this lazily so diff --git a/leadforge/schemes/lead_scoring/__init__.py b/leadforge/schemes/lead_scoring/__init__.py index edead9c..da1017f 100644 --- a/leadforge/schemes/lead_scoring/__init__.py +++ b/leadforge/schemes/lead_scoring/__init__.py @@ -20,6 +20,8 @@ from leadforge.schemes.base import register_scheme if TYPE_CHECKING: + from pathlib import Path + from leadforge.core.models import GenerationConfig, WorldBundle from leadforge.narrative.spec import NarrativeSpec @@ -67,12 +69,16 @@ def build_world( latent_touch_intensity=latent_touch_intensity, ) + from leadforge.schemes.lead_scoring.artifacts import LeadScoringArtifacts + spec = WorldSpec(config=config, narrative=narrative, scheme=self.name) return WorldBundle( spec=spec, - population=population, - simulation_result=result, - world_graph=world_graph, + artifacts=LeadScoringArtifacts( + population=population, + simulation_result=result, + world_graph=world_graph, + ), ) @staticmethod @@ -164,6 +170,7 @@ def write_bundle( from leadforge.render.manifests import build_manifest, write_manifest from leadforge.render.relational_io import write_relational_tables from leadforge.schema.dictionaries import write_feature_dictionary + from leadforge.schemes.lead_scoring.artifacts import LeadScoringArtifacts from leadforge.schemes.lead_scoring.features import ( LEAD_SNAPSHOT_FEATURES, redacted_columns_for, @@ -176,22 +183,20 @@ def write_bundle( from leadforge.schemes.lead_scoring.render.tasks import write_task_splits from leadforge.schemes.lead_scoring.tasks import task_manifest_for_config - if ( - bundle.simulation_result is None - or bundle.population is None - or bundle.world_graph is None - ): + artifacts = bundle.artifacts + if not isinstance(artifacts, LeadScoringArtifacts): raise RuntimeError( - "WorldBundle is not fully populated. Call Generator.generate() first." + "WorldBundle is not populated with lead-scoring artifacts. " + "Call Generator.generate() first." ) root = Path(path) root.mkdir(parents=True, exist_ok=True) config = bundle.spec.config - result = bundle.simulation_result - population = bundle.population - world_graph = bundle.world_graph + result = artifacts.simulation_result + population = artifacts.population + world_graph = artifacts.world_graph # The redaction set comes from the canonical feature spec — the same # source of truth the validator uses. It is applied uniformly to @@ -276,6 +281,47 @@ def write_bundle( ) write_manifest(manifest, root) + def write_metadata(self, bundle: WorldBundle, meta_dir: Path) -> None: + """Write the lead-scoring hidden-truth files into *meta_dir*. + + Called by :func:`leadforge.exposure.modes.apply_exposure` for + ``research_instructor`` mode (after the shared, scheme-agnostic + ``world_spec.json`` is written). Emits the hidden world graph + (``graph.json`` / ``graph.graphml``), the per-entity latent registry + (``latent_registry.json``), and the mechanism-assignment summary + (``mechanism_summary.json``). + """ + import json + + from leadforge.core.rng import RNGRoot + from leadforge.schemes.lead_scoring.artifacts import LeadScoringArtifacts + from leadforge.schemes.lead_scoring.mechanisms.policies import assign_mechanisms + + artifacts = bundle.artifacts + if not isinstance(artifacts, LeadScoringArtifacts): + raise RuntimeError("WorldBundle is not populated with lead-scoring artifacts.") + world_graph = artifacts.world_graph + + (meta_dir / "graph.json").write_text(world_graph.to_json()) + (meta_dir / "graph.graphml").write_text(world_graph.to_graphml()) + + ls = artifacts.population.latent_state + latent_registry: dict[str, object] = { + "account_latents": ls.account_latents, + "contact_latents": ls.contact_latents, + "lead_latents": ls.lead_latents, + } + (meta_dir / "latent_registry.json").write_text(json.dumps(latent_registry, indent=2)) + + # Reconstruct the mechanism assignment with the same RNG substream used + # during simulation — produces the identical parameter values. + motif_family = world_graph.motif_family + mech_rng = RNGRoot(bundle.spec.config.seed).child("mechanisms") + assignment = assign_mechanisms(motif_family, mech_rng) + (meta_dir / "mechanism_summary.json").write_text( + json.dumps(assignment.summary().to_dict(), indent=2) + ) + LEAD_SCORING_SCHEME = LeadScoringScheme() register_scheme(LEAD_SCORING_SCHEME) diff --git a/leadforge/schemes/lead_scoring/artifacts.py b/leadforge/schemes/lead_scoring/artifacts.py new file mode 100644 index 0000000..d62b944 --- /dev/null +++ b/leadforge/schemes/lead_scoring/artifacts.py @@ -0,0 +1,29 @@ +"""In-memory artifacts produced by the lead-scoring pipeline. + +:class:`LeadScoringArtifacts` is the scheme-owned payload carried by a +:class:`~leadforge.core.models.WorldBundle` for this scheme. The bundle's +``artifacts`` field is typed ``Any`` in the shared core layer (it must not +reference a scheme); each scheme defines and unwraps its own container here, +so the core never depends on lead-scoring types. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + 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 + +__all__ = ["LeadScoringArtifacts"] + + +@dataclass +class LeadScoringArtifacts: + """The in-memory result of one lead-scoring generation run.""" + + population: PopulationResult + simulation_result: SimulationResult + world_graph: WorldGraph diff --git a/leadforge/schemes/lifecycle/__init__.py b/leadforge/schemes/lifecycle/__init__.py index f9a8380..5caea51 100644 --- a/leadforge/schemes/lifecycle/__init__.py +++ b/leadforge/schemes/lifecycle/__init__.py @@ -15,6 +15,8 @@ from leadforge.schemes.base import register_scheme if TYPE_CHECKING: + from pathlib import Path + from leadforge.core.models import GenerationConfig, WorldBundle from leadforge.narrative.spec import NarrativeSpec @@ -45,6 +47,9 @@ def write_bundle( ) -> None: raise NotImplementedError(_NOT_IMPLEMENTED) + def write_metadata(self, bundle: WorldBundle, meta_dir: Path) -> None: + raise NotImplementedError(_NOT_IMPLEMENTED) + LIFECYCLE_SCHEME = LifecycleScheme() register_scheme(LIFECYCLE_SCHEME) diff --git a/tests/api/test_generator.py b/tests/api/test_generator.py index 05e896e..68b7ba8 100644 --- a/tests/api/test_generator.py +++ b/tests/api/test_generator.py @@ -66,8 +66,9 @@ def test_generate_returns_world_bundle() -> None: gen = Generator.from_recipe("b2b_saas_procurement_v1", seed=42) bundle = gen.generate(n_leads=30, n_accounts=15, n_contacts=45) assert isinstance(bundle, WorldBundle) - assert bundle.simulation_result is not None - assert bundle.population is not None + assert bundle.artifacts is not None + assert bundle.artifacts.simulation_result is not None + assert bundle.artifacts.population is not None def test_generate_respects_recipe_difficulty_when_not_overridden() -> None: diff --git a/tests/render/test_render.py b/tests/render/test_render.py index 5986271..0e89021 100644 --- a/tests/render/test_render.py +++ b/tests/render/test_render.py @@ -435,12 +435,15 @@ def test_full_bundle_written(self, sim_outputs, tmp_path): config, population, result, world_graph = sim_outputs from leadforge.api.bundle import write_bundle from leadforge.core.models import WorldBundle, WorldSpec + from leadforge.schemes.lead_scoring.artifacts import LeadScoringArtifacts bundle = WorldBundle( spec=WorldSpec(config=config), - population=population, - simulation_result=result, - world_graph=world_graph, + artifacts=LeadScoringArtifacts( + population=population, + simulation_result=result, + world_graph=world_graph, + ), ) write_bundle(bundle, str(tmp_path)) @@ -454,12 +457,15 @@ def test_manifest_is_valid_json(self, sim_outputs, tmp_path): config, population, result, world_graph = sim_outputs from leadforge.api.bundle import write_bundle from leadforge.core.models import WorldBundle, WorldSpec + from leadforge.schemes.lead_scoring.artifacts import LeadScoringArtifacts bundle = WorldBundle( spec=WorldSpec(config=config), - population=population, - simulation_result=result, - world_graph=world_graph, + artifacts=LeadScoringArtifacts( + population=population, + simulation_result=result, + world_graph=world_graph, + ), ) write_bundle(bundle, str(tmp_path)) @@ -470,7 +476,7 @@ def test_unpopulated_bundle_raises(self, tmp_path): from leadforge.api.bundle import write_bundle from leadforge.core.models import WorldBundle - with pytest.raises(RuntimeError, match="not fully populated"): + with pytest.raises(RuntimeError, match="not populated with lead-scoring artifacts"): write_bundle(WorldBundle(), str(tmp_path)) def test_generator_generate_and_save(self, tmp_path): diff --git a/tests/schemes/test_registry.py b/tests/schemes/test_registry.py index 633f239..5664fae 100644 --- a/tests/schemes/test_registry.py +++ b/tests/schemes/test_registry.py @@ -143,9 +143,9 @@ def test_from_recipe_sets_scheme_on_world_spec() -> None: def test_generate_runs_through_registered_scheme() -> None: gen = Generator.from_recipe("b2b_saas_procurement_v1", seed=42) bundle = gen.generate(**_SMALL) - assert bundle.population is not None - assert bundle.simulation_result is not None - assert len(bundle.population.leads) == 60 + assert bundle.artifacts.population is not None + assert bundle.artifacts.simulation_result is not None + assert len(bundle.artifacts.population.leads) == 60 def test_generate_records_scheme_on_bundle_spec() -> None: @@ -162,16 +162,16 @@ def test_generate_is_deterministic_through_scheme() -> None: # given (recipe, config, seed). a = Generator.from_recipe("b2b_saas_procurement_v1", seed=42).generate(**_SMALL) b = Generator.from_recipe("b2b_saas_procurement_v1", seed=42).generate(**_SMALL) - assert a.simulation_result is not None - assert b.simulation_result is not None + assert a.artifacts.simulation_result is not None + assert b.artifacts.simulation_result is not None lead_outcomes_a = { - lead.lead_id: lead.converted_within_90_days for lead in a.simulation_result.leads + lead.lead_id: lead.converted_within_90_days for lead in a.artifacts.simulation_result.leads } lead_outcomes_b = { - lead.lead_id: lead.converted_within_90_days for lead in b.simulation_result.leads + lead.lead_id: lead.converted_within_90_days for lead in b.artifacts.simulation_result.leads } assert lead_outcomes_a == lead_outcomes_b - assert len(a.simulation_result.touches) == len(b.simulation_result.touches) + assert len(a.artifacts.simulation_result.touches) == len(b.artifacts.simulation_result.touches) def test_generate_unknown_scheme_raises() -> None: diff --git a/tests/schemes/test_render_dispatch.py b/tests/schemes/test_render_dispatch.py index 7cd6357..b68f8c9 100644 --- a/tests/schemes/test_render_dispatch.py +++ b/tests/schemes/test_render_dispatch.py @@ -57,7 +57,7 @@ def test_write_bundle_unknown_scheme_raises(tmp_path: Path) -> None: def test_write_bundle_unpopulated_raises(tmp_path: Path) -> None: # Default bundle has spec.scheme == "lead_scoring" → dispatches, then the # lead-scoring write_bundle rejects the unpopulated bundle. - with pytest.raises(RuntimeError, match="not fully populated"): + with pytest.raises(RuntimeError, match="not populated with lead-scoring artifacts"): write_bundle(WorldBundle(), str(tmp_path)) diff --git a/tests/schemes/test_scheme_metadata_hook.py b/tests/schemes/test_scheme_metadata_hook.py new file mode 100644 index 0000000..5ec6e2e --- /dev/null +++ b/tests/schemes/test_scheme_metadata_hook.py @@ -0,0 +1,78 @@ +"""Tests for the scheme-agnostic WorldBundle + metadata hook seam (LTV-Pn.2).""" + +from __future__ import annotations + +import json + +import pytest + +from leadforge.api.generator import Generator +from leadforge.core.models import WorldBundle, WorldSpec +from leadforge.exposure.metadata import write_world_spec_json +from leadforge.schemes import get_scheme +from leadforge.schemes.lead_scoring.artifacts import LeadScoringArtifacts + + +def _populated_bundle() -> WorldBundle: + gen = Generator.from_recipe("b2b_saas_procurement_v1", seed=7) + return gen.generate(n_accounts=20, n_contacts=60, n_leads=60, difficulty="intro") + + +# --------------------------------------------------------------------------- +# WorldBundle holds scheme-owned artifacts (cleanup #3) +# --------------------------------------------------------------------------- + + +def test_build_world_populates_scheme_artifacts() -> None: + bundle = _populated_bundle() + assert isinstance(bundle.artifacts, LeadScoringArtifacts) + assert bundle.artifacts.population is not None + assert bundle.artifacts.simulation_result is not None + assert bundle.artifacts.world_graph is not None + + +def test_worldbundle_has_no_scheme_typed_fields() -> None: + """The generalized bundle exposes only spec + opaque artifacts; the old + lead-scoring-typed fields are gone (the core->scheme layering inversion).""" + field_names = {f.name for f in WorldBundle.__dataclass_fields__.values()} + assert field_names == {"spec", "artifacts"} + + +# --------------------------------------------------------------------------- +# Generic world_spec writer is scheme-agnostic +# --------------------------------------------------------------------------- + + +def test_write_world_spec_json_needs_only_spec(tmp_path) -> None: + # Depends on WorldSpec alone — no scheme artifacts required. + spec = WorldSpec() + write_world_spec_json(spec, tmp_path) + data = json.loads((tmp_path / "world_spec.json").read_text()) + assert set(data) == {"config", "narrative"} + assert data["narrative"] is None # default spec has no narrative + + +# --------------------------------------------------------------------------- +# apply_exposure dispatches to the producing scheme's write_metadata hook +# --------------------------------------------------------------------------- + + +def test_lead_scoring_write_metadata_emits_hidden_truth(tmp_path) -> None: + bundle = _populated_bundle() + meta = tmp_path / "metadata" + meta.mkdir() + get_scheme(bundle.spec.scheme).write_metadata(bundle, meta) + for fname in ("graph.json", "graph.graphml", "latent_registry.json", "mechanism_summary.json"): + assert (meta / fname).exists(), f"hook did not emit {fname}" + + +def test_write_metadata_rejects_unpopulated_bundle(tmp_path) -> None: + meta = tmp_path / "metadata" + meta.mkdir() + with pytest.raises(RuntimeError, match="lead-scoring artifacts"): + get_scheme("lead_scoring").write_metadata(WorldBundle(), meta) + + +def test_lifecycle_metadata_hook_is_stubbed(tmp_path) -> None: + with pytest.raises(NotImplementedError): + get_scheme("lifecycle").write_metadata(WorldBundle(), tmp_path) diff --git a/tests/test_difficulty_modulation.py b/tests/test_difficulty_modulation.py index 95478a1..b539c4d 100644 --- a/tests/test_difficulty_modulation.py +++ b/tests/test_difficulty_modulation.py @@ -121,7 +121,7 @@ def test_rate_within_range(self, difficulty: str, lo: float, hi: float) -> None: difficulty=difficulty, ) bundle = gen.generate(**_MEDIUM) - leads = bundle.simulation_result.leads + leads = bundle.artifacts.simulation_result.leads rate = sum(1 for lead in leads if lead.current_stage == "closed_won") / len(leads) # Allow 8% tolerance for small-sample variance. tolerance = 0.08 @@ -138,7 +138,7 @@ def test_ordering(self) -> None: difficulty=difficulty, ) bundle = gen.generate(**_MEDIUM) - leads = bundle.simulation_result.leads + leads = bundle.artifacts.simulation_result.leads rates[difficulty] = sum( 1 for lead in leads if lead.current_stage == "closed_won" ) / len(leads) @@ -158,7 +158,7 @@ def test_same_seed_same_difficulty_identical(self) -> None: difficulty="intermediate", ) bundle = gen.generate(n_leads=100, n_accounts=50, n_contacts=150) - leads = bundle.simulation_result.leads + leads = bundle.artifacts.simulation_result.leads stages = [lead.current_stage for lead in leads] results.append(stages) assert results[0] == results[1] From fdf87f5371812fa60c19d72995cb8e84fa86a272 Mon Sep 17 00:00:00 2001 From: Shay Palachy Date: Fri, 12 Jun 2026 23:38:33 +0300 Subject: [PATCH 2/3] fix(scripts): migrate build_v*_snapshot to bundle.artifacts [LTV-Pn.2] Self-review finding on the WorldBundle generalization: five dataset-build scripts still read the removed bundle.simulation_result / bundle.population fields and would AttributeError at runtime. This slipped both nets: - mypy leadforge does not type-check scripts/, and - the existing tests/scripts/test_build_v*_snapshot.py cover only the pipelines.build_v* transform helpers (pure DataFrame functions), never the generate_bundle() entry point that touches the bundle. CI's "Validate v6/v7" jobs validate a pre-built CSV; they do not regenerate, so they would not have caught it either. Fix: build_v4/v5/v6/v7_snapshot.py and build_midproject_lead_scoring.py now read bundle.artifacts.simulation_result / bundle.artifacts.population. Smoke- ran the v6 builder end-to-end through Generator.generate() to confirm. Guard: tests/scripts/test_build_v6_snapshot.py gains TestGenerateBundleArtifactsPath, which loads the script via importlib and runs generate_bundle(small) so a future WorldBundle field rename can't silently break the generate path again. The other four scripts share the identical access pattern. Full suite 1809 passed / 51 skipped; ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 --- scripts/build_midproject_lead_scoring.py | 4 ++-- scripts/build_v4_snapshot.py | 4 ++-- scripts/build_v5_snapshot.py | 4 ++-- scripts/build_v6_snapshot.py | 8 +++---- scripts/build_v7_snapshot.py | 8 +++---- tests/scripts/test_build_v6_snapshot.py | 28 ++++++++++++++++++++++++ 6 files changed, 42 insertions(+), 14 deletions(-) diff --git a/scripts/build_midproject_lead_scoring.py b/scripts/build_midproject_lead_scoring.py index b449ae7..2855239 100644 --- a/scripts/build_midproject_lead_scoring.py +++ b/scripts/build_midproject_lead_scoring.py @@ -49,8 +49,8 @@ def generate_bundle(seed: int = SEED, n_leads: int = N_LEADS): ) bundle = gen.generate(latent_touch_intensity=True) snapshot = build_snapshot( - bundle.simulation_result, - bundle.population, + bundle.artifacts.simulation_result, + bundle.artifacts.population, snapshot_day=SNAPSHOT_DAY, ) return snapshot, bundle diff --git a/scripts/build_v4_snapshot.py b/scripts/build_v4_snapshot.py index eb278d8..bb76212 100644 --- a/scripts/build_v4_snapshot.py +++ b/scripts/build_v4_snapshot.py @@ -82,8 +82,8 @@ def generate_bundle(seed: int = SEED, n_leads: int = N_LEADS) -> pd.DataFrame: ) bundle = gen.generate() return build_snapshot( - bundle.simulation_result, - bundle.population, + bundle.artifacts.simulation_result, + bundle.artifacts.population, snapshot_day=SNAPSHOT_DAY, ) diff --git a/scripts/build_v5_snapshot.py b/scripts/build_v5_snapshot.py index 6fb7008..61da18b 100644 --- a/scripts/build_v5_snapshot.py +++ b/scripts/build_v5_snapshot.py @@ -50,8 +50,8 @@ def generate_bundle(seed: int = SEED, n_leads: int = N_LEADS) -> pd.DataFrame: ) bundle = gen.generate() return build_snapshot( - bundle.simulation_result, - bundle.population, + bundle.artifacts.simulation_result, + bundle.artifacts.population, snapshot_day=SNAPSHOT_DAY, ) diff --git a/scripts/build_v6_snapshot.py b/scripts/build_v6_snapshot.py index 09c4ec8..9ad4a49 100644 --- a/scripts/build_v6_snapshot.py +++ b/scripts/build_v6_snapshot.py @@ -57,8 +57,8 @@ def generate_bundle(seed: int = SEED, n_leads: int = N_LEADS): ) bundle = gen.generate(latent_touch_intensity=True) snapshot = build_snapshot( - bundle.simulation_result, - bundle.population, + bundle.artifacts.simulation_result, + bundle.artifacts.population, snapshot_day=SNAPSHOT_DAY, ) return snapshot, bundle @@ -75,10 +75,10 @@ def build_v6_datasets(seed: int = SEED) -> tuple[pd.DataFrame, pd.DataFrame]: ) # Compute post-snapshot touches from event timeline (boosted in next step) - lead_dates = {lead.lead_id: lead.lead_created_at for lead in bundle.population.leads} + lead_dates = {lead.lead_id: lead.lead_created_at for lead in bundle.artifacts.population.leads} trap_series = compute_post_snapshot_touches( snapshot, - bundle.simulation_result.touches, + bundle.artifacts.simulation_result.touches, lead_dates, snapshot_day=SNAPSHOT_DAY, ) diff --git a/scripts/build_v7_snapshot.py b/scripts/build_v7_snapshot.py index da84af9..b55c5e6 100644 --- a/scripts/build_v7_snapshot.py +++ b/scripts/build_v7_snapshot.py @@ -56,8 +56,8 @@ def generate_bundle(seed: int = SEED, n_leads: int = N_LEADS): ) bundle = gen.generate(latent_touch_intensity=True) snapshot = build_snapshot( - bundle.simulation_result, - bundle.population, + bundle.artifacts.simulation_result, + bundle.artifacts.population, snapshot_day=SNAPSHOT_DAY, ) return snapshot, bundle @@ -74,10 +74,10 @@ def build_v7_datasets(seed: int = SEED) -> tuple[pd.DataFrame, pd.DataFrame]: ) # Compute post-snapshot touches from event timeline (purely causal, no boost) - lead_dates = {lead.lead_id: lead.lead_created_at for lead in bundle.population.leads} + lead_dates = {lead.lead_id: lead.lead_created_at for lead in bundle.artifacts.population.leads} trap_series = compute_post_snapshot_touches( snapshot, - bundle.simulation_result.touches, + bundle.artifacts.simulation_result.touches, lead_dates, snapshot_day=SNAPSHOT_DAY, ) diff --git a/tests/scripts/test_build_v6_snapshot.py b/tests/scripts/test_build_v6_snapshot.py index 3208ed7..d74d3e2 100644 --- a/tests/scripts/test_build_v6_snapshot.py +++ b/tests/scripts/test_build_v6_snapshot.py @@ -328,3 +328,31 @@ def test_boost_increases_mean_for_converted(self): result = boost_leakage_trap(df, seed=42) after_mean = result.loc[result["converted"] == 1, INSTRUCTOR_TRAP_COL].mean() assert after_mean > before_mean + + +class TestGenerateBundleArtifactsPath: + """Regression guard (LTV-Pn.2): the v6 builder reads the simulation result + and population through ``bundle.artifacts``. The pipeline-helper tests above + never touch ``generate_bundle``, so a WorldBundle field rename slips past + them and past ``mypy leadforge`` (scripts are not type-checked). Run the + real generate path once, small, to catch that class of break. The other + build_v*_snapshot scripts share this exact access pattern. + """ + + def test_generate_bundle_reads_via_artifacts(self) -> None: + import importlib.util + from pathlib import Path + + script = Path(__file__).resolve().parents[2] / "scripts" / "build_v6_snapshot.py" + spec = importlib.util.spec_from_file_location("build_v6_snapshot", script) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + snapshot, bundle = module.generate_bundle(seed=42, n_leads=40) + # The access pattern under guard: artifacts carries sim result + population. + assert bundle.artifacts is not None + assert bundle.artifacts.simulation_result is not None + assert bundle.artifacts.population is not None + assert len(snapshot) > 0 From 7216d6d7fcd16c62cbb30b08836ffc25fba8eb42 Mon Sep 17 00:00:00 2001 From: Shay Palachy Date: Sat, 13 Jun 2026 09:55:49 +0300 Subject: [PATCH 3/3] fix(exposure): clear metadata/ before rewrite; fix stale docstring [LTV-Pn.2] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Copilot review findings on #122. 1. apply_exposure reused an existing metadata/ via mkdir(exist_ok=True) when writing hidden truth, so a reused output path could retain stale files. Pre-existing behavior, but Pn.2 makes it newly dangerous: once the lifecycle scheme writes a different hidden-truth file set, regenerating a lifecycle bundle over a path that previously held a lead-scoring bundle would orphan graph.graphml / mechanism_summary.json into the new bundle. Now apply_exposure always removes any existing metadata/ first, then recreates it when writing — so contents exactly match the current bundle (mirroring the non-writing branch, which already rmtree'd it). Byte-identity preserved for both modes (fresh paths have no metadata/, so the rmtree is a guarded no-op). Regression tests: a pre-seeded stale file is gone after an instructor rewrite; student_public still removes the dir entirely. 2. The lead_scoring.write_bundle docstring still described apply_exposure as writing the lead-scoring hidden graph + latent registry directly. Updated to reflect the Pn.1/Pn.2 reality: build_manifest and apply_exposure are scheme-agnostic, and hidden truth is delegated to write_metadata; the remaining shared-orchestrator extraction is deferred to Pn.4. Full suite 1811 passed / 51 skipped; ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 --- leadforge/exposure/modes.py | 10 ++++-- leadforge/schemes/lead_scoring/__init__.py | 17 +++++---- tests/schemes/test_scheme_metadata_hook.py | 41 ++++++++++++++++++++++ 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/leadforge/exposure/modes.py b/leadforge/exposure/modes.py index c6882b7..fa35f82 100644 --- a/leadforge/exposure/modes.py +++ b/leadforge/exposure/modes.py @@ -43,9 +43,13 @@ def apply_exposure(bundle: WorldBundle, bundle_root: Path, mode: ExposureMode) - filt = get_filter(mode) meta_dir = bundle_root / "metadata" + # Always start from a clean metadata/ so its contents exactly match the + # current bundle. Reusing an output path across runs — or across schemes, + # which emit different hidden-truth file sets — must not leave stale files + # behind (the non-writing branch below clears it for the same reason). + if meta_dir.exists(): + shutil.rmtree(meta_dir) if filt.write_metadata: - meta_dir.mkdir(exist_ok=True) + meta_dir.mkdir(parents=True) write_world_spec_json(bundle.spec, meta_dir) get_scheme(bundle.spec.scheme).write_metadata(bundle, meta_dir) - elif meta_dir.exists(): - shutil.rmtree(meta_dir) diff --git a/leadforge/schemes/lead_scoring/__init__.py b/leadforge/schemes/lead_scoring/__init__.py index da1017f..03fdfca 100644 --- a/leadforge/schemes/lead_scoring/__init__.py +++ b/leadforge/schemes/lead_scoring/__init__.py @@ -152,15 +152,14 @@ def write_bundle( factored out (``write_relational_tables``); the rest is intentionally *not* yet shared. - The deeper envelope/scheme decomposition (a shared bundle orchestrator - with scheme render hooks) is deferred to ``LTV-M6``: it requires - generalising ``build_manifest`` (today it takes the lead-scoring - ``world_graph``) and ``apply_exposure`` (today it writes the - lead-scoring hidden graph + latent registry), and is best designed with - a second scheme in hand. Until then ``LifecycleScheme.write_bundle`` - will reuse ``write_relational_tables`` + the leaf helpers - (``build_manifest`` / ``apply_exposure`` / ``get_filter``) but - orchestrate them itself. + ``build_manifest`` (``LTV-Pn.1``) and ``apply_exposure`` (``LTV-Pn.2``) + are now scheme-agnostic: ``apply_exposure`` writes the generic + ``world_spec.json`` and delegates this scheme's hidden-truth files + (graph, latent registry, mechanism summary) to + :meth:`write_metadata`. The remaining shared-orchestrator decomposition + (a bundle orchestrator with scheme render hooks lifted out of each + ``write_bundle``) is deferred to ``LTV-Pn.4``, once the lifecycle + ``write_bundle`` exists to reveal the real shared shape. """ from pathlib import Path diff --git a/tests/schemes/test_scheme_metadata_hook.py b/tests/schemes/test_scheme_metadata_hook.py index 5ec6e2e..c18a343 100644 --- a/tests/schemes/test_scheme_metadata_hook.py +++ b/tests/schemes/test_scheme_metadata_hook.py @@ -76,3 +76,44 @@ def test_write_metadata_rejects_unpopulated_bundle(tmp_path) -> None: def test_lifecycle_metadata_hook_is_stubbed(tmp_path) -> None: with pytest.raises(NotImplementedError): get_scheme("lifecycle").write_metadata(WorldBundle(), tmp_path) + + +# --------------------------------------------------------------------------- +# apply_exposure starts from a clean metadata/ (Copilot review on #122) +# --------------------------------------------------------------------------- + + +def test_apply_exposure_clears_stale_metadata(tmp_path) -> None: + """A reused output path must not retain hidden-truth files that the current + bundle did not write — critical once a different scheme (with a different + file set) regenerates over the same path.""" + from leadforge.core.enums import ExposureMode + from leadforge.exposure.modes import apply_exposure + + bundle = _populated_bundle() + + # Pre-seed a stale metadata/ with a file no scheme writes. + meta = tmp_path / "metadata" + meta.mkdir() + stale = meta / "stale_graph.graphml" + stale.write_text("") + + apply_exposure(bundle, tmp_path, ExposureMode.research_instructor) + + assert not stale.exists(), "stale metadata file survived the rewrite" + # The current bundle's hidden-truth files are present. + assert (meta / "graph.json").exists() + assert (meta / "world_spec.json").exists() + + +def test_apply_exposure_student_public_removes_metadata(tmp_path) -> None: + from leadforge.core.enums import ExposureMode + from leadforge.exposure.modes import apply_exposure + + bundle = _populated_bundle() + meta = tmp_path / "metadata" + meta.mkdir() + (meta / "graph.json").write_text("{}") + + apply_exposure(bundle, tmp_path, ExposureMode.student_public) + assert not meta.exists(), "student_public must not retain a metadata/ dir"