Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion leadforge/exposure/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,19 @@ class BundleFilter:
}


def get_filter(mode: ExposureMode) -> BundleFilter:
def get_filter(mode: str | ExposureMode) -> BundleFilter:
"""Return the :class:`BundleFilter` for *mode*.

Args:
mode: An :class:`ExposureMode` or its string value.

Raises:
ValueError: if *mode* is a string that is not a valid
:class:`ExposureMode` value.
KeyError: if *mode* has no registered filter (should never happen
with well-typed callers, but guards against future enum additions
that forget to update ``FILTERS``).
"""
if isinstance(mode, str):
mode = ExposureMode(mode)
return FILTERS[mode]
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,11 @@ def write_metadata_dir(bundle: WorldBundle, bundle_root: Path) -> None:
meta_dir = bundle_root / "metadata"
meta_dir.mkdir(exist_ok=True)

# ------------------------------------------------------------------
# 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,
Expand All @@ -56,19 +52,15 @@ def write_metadata_dir(bundle: WorldBundle, bundle_root: Path) -> None:
}
(meta_dir / "latent_registry.json").write_text(json.dumps(latent_registry, indent=2))

# ------------------------------------------------------------------
# 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
)
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
Expand Down
11 changes: 8 additions & 3 deletions leadforge/exposure/modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@

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.redaction import write_metadata_dir
from leadforge.exposure.metadata import write_metadata_dir

if TYPE_CHECKING:
from leadforge.core.models import WorldBundle
Expand All @@ -23,14 +24,18 @@ def apply_exposure(bundle: WorldBundle, bundle_root: Path, mode: ExposureMode) -
"""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 the
directory is not created and no hidden truth is published.
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.

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.
"""
filt = get_filter(mode)
meta_dir = bundle_root / "metadata"
if filt.write_metadata:
write_metadata_dir(bundle, bundle_root)
elif meta_dir.exists():
shutil.rmtree(meta_dir)
Comment on lines +40 to +41

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shutil.rmtree(meta_dir) will raise if bundle_root/metadata exists but is not a directory (e.g., a file). Since this cleanup runs in student_public mode to prevent leakage on path reuse, consider handling both cases (remove dir tree when it’s a dir; unlink when it’s a file/symlink) so save() can’t fail due to an unexpected existing path.

Suggested change
elif meta_dir.exists():
shutil.rmtree(meta_dir)
elif meta_dir.exists() or meta_dir.is_symlink():
if meta_dir.is_symlink() or not meta_dir.is_dir():
meta_dir.unlink()
else:
shutil.rmtree(meta_dir)

Copilot uses AI. Check for mistakes.
28 changes: 25 additions & 3 deletions tests/exposure/test_exposure.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,15 @@ def test_research_instructor_writes_metadata(self) -> None:
f = get_filter(ExposureMode.research_instructor)
assert f.write_metadata is True

def test_get_filter_accepts_string(self) -> None:
f = get_filter("student_public")
assert isinstance(f, BundleFilter)
assert f.write_metadata is False

def test_unknown_mode_raises(self) -> None:
"""get_filter must raise KeyError for an unregistered mode string."""
with pytest.raises(KeyError):
get_filter("totally_fake_mode") # type: ignore[arg-type]
"""get_filter must raise ValueError for an invalid mode string."""
with pytest.raises(ValueError, match="totally_fake_mode"):
get_filter("totally_fake_mode")


# ---------------------------------------------------------------------------
Expand All @@ -68,6 +73,23 @@ def test_core_files_present(self, tmp_path: Path) -> None:
assert (tmp_path / "tables").is_dir()
assert (tmp_path / "tasks").is_dir()

def test_reused_output_dir_removes_metadata_when_switching_to_student_public(
self, tmp_path: Path
) -> None:
instructor_bundle = _make_bundle("research_instructor")
instructor_bundle.save(str(tmp_path))
assert (tmp_path / "metadata").is_dir()

public_bundle = _make_bundle("student_public")
public_bundle.save(str(tmp_path))

assert not (tmp_path / "metadata").exists()
assert (tmp_path / "manifest.json").exists()
assert (tmp_path / "dataset_card.md").exists()
assert (tmp_path / "feature_dictionary.csv").exists()
assert (tmp_path / "tables").is_dir()
assert (tmp_path / "tasks").is_dir()


class TestResearchInstructorMode:
def test_metadata_dir_created(self, tmp_path: Path) -> None:
Expand Down
Loading