calm-benchmark 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
calm/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """Public API for CALM's reproducible menopausal-status benchmark engine."""
2
+
3
+ from calm.cli import app
4
+ from calm.config.loading import load_config
5
+ from calm.config.models import RunConfig
6
+ from calm.pipeline.attribution import FeatureAttribution, PredictionAttribution, attribute
7
+ from calm.pipeline.calibrate import CalibratedModel, PredictionResult, calibrate, predict
8
+ from calm.pipeline.evaluate import EvaluationResult, Subgroup, evaluate
9
+ from calm.pipeline.features import select_features
10
+ from calm.pipeline.labels import LabelResult, build_labels, get_label_rule
11
+ from calm.pipeline.report import ReportArtifacts, ReportWriter
12
+ from calm.pipeline.split import FrozenSplit, stratified_split
13
+ from calm.pipeline.train import TrainedModel, train
14
+
15
+ __version__ = "0.1.3"
16
+
17
+ __all__ = [
18
+ "CalibratedModel",
19
+ "FeatureAttribution",
20
+ "EvaluationResult",
21
+ "FrozenSplit",
22
+ "LabelResult",
23
+ "PredictionResult",
24
+ "PredictionAttribution",
25
+ "ReportArtifacts",
26
+ "ReportWriter",
27
+ "RunConfig",
28
+ "Subgroup",
29
+ "TrainedModel",
30
+ "app",
31
+ "attribute",
32
+ "build_labels",
33
+ "calibrate",
34
+ "evaluate",
35
+ "get_label_rule",
36
+ "load_config",
37
+ "predict",
38
+ "select_features",
39
+ "stratified_split",
40
+ "train",
41
+ ]
@@ -0,0 +1,6 @@
1
+ """Concrete adapters for external systems."""
2
+
3
+ from calm.adapters.nhanes import NhanesSource
4
+ from calm.adapters.synthetic import SyntheticSource
5
+
6
+ __all__ = ["NhanesSource", "SyntheticSource"]
@@ -0,0 +1,30 @@
1
+ """Official NCHS HTML codebook metadata ingestion."""
2
+
3
+ from bs4 import BeautifulSoup
4
+
5
+ from calm.domain.entities import ValueLabel, VariableCode, VariableMetadata
6
+
7
+
8
+ def parse_codebook(html: str) -> tuple[VariableMetadata, ...]:
9
+ """Extract variable labels and coded response labels from an NCHS codebook page."""
10
+ soup = BeautifulSoup(html, "html.parser")
11
+ variables: list[VariableMetadata] = []
12
+ for heading in soup.find_all("h3"):
13
+ text = heading.get_text(" ", strip=True)
14
+ code, separator, label = text.partition(" - ")
15
+ if not separator or not code.isupper():
16
+ continue
17
+ table = heading.find_next("table")
18
+ value_labels: list[ValueLabel] = []
19
+ if table is not None:
20
+ for row in table.find_all("tr")[1:]:
21
+ cells = row.find_all("td")
22
+ if len(cells) >= 2:
23
+ value_labels.append(
24
+ ValueLabel(
25
+ cells[0].get_text(" ", strip=True),
26
+ cells[1].get_text(" ", strip=True),
27
+ )
28
+ )
29
+ variables.append(VariableMetadata(VariableCode(code), label, tuple(value_labels)))
30
+ return tuple(variables)
@@ -0,0 +1,70 @@
1
+ """NHANES raw-file acquisition adapter."""
2
+
3
+ from collections.abc import Callable
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Protocol, cast
7
+ from urllib.request import urlopen
8
+
9
+ from calm.domain.entities import CycleSpec, RawBundle, RawFile
10
+ from calm.observability.hashing import hash_bytes
11
+
12
+
13
+ class ByteFetcher(Protocol):
14
+ """Fetch the bytes at a configured source URL."""
15
+
16
+ def __call__(self, url: str) -> bytes:
17
+ """Return source bytes for a URL."""
18
+
19
+
20
+ def fetch_url_bytes(url: str) -> bytes:
21
+ """Fetch raw bytes from a configured URL.
22
+
23
+ Args:
24
+ url: Direct source URL explicitly supplied by orchestration.
25
+
26
+ Returns:
27
+ Response bytes.
28
+ """
29
+ with urlopen(url) as response: # noqa: S310
30
+ return cast(bytes, response.read())
31
+
32
+
33
+ class NhanesSource:
34
+ """Acquire configured NHANES XPT components and record their lineage."""
35
+
36
+ def __init__(
37
+ self,
38
+ destination: Path,
39
+ components: tuple[str, ...],
40
+ url_for: Callable[[CycleSpec, str], str],
41
+ clock: Callable[[], datetime],
42
+ fetcher: ByteFetcher = fetch_url_bytes,
43
+ ) -> None:
44
+ """Initialize acquisition dependencies supplied by orchestration."""
45
+ self._destination = destination
46
+ self._components = components
47
+ self._url_for = url_for
48
+ self._clock = clock
49
+ self._fetcher = fetcher
50
+
51
+ def fetch(self, cycle: CycleSpec) -> RawBundle:
52
+ """Fetch configured components for a cycle and persist their exact bytes."""
53
+ files: list[RawFile] = []
54
+ cycle_directory = self._destination / cycle.identifier
55
+ cycle_directory.mkdir(parents=True, exist_ok=True)
56
+ for component in self._components:
57
+ source_url = self._url_for(cycle, component)
58
+ payload = self._fetcher(source_url)
59
+ local_path = cycle_directory / f"{component}.xpt"
60
+ local_path.write_bytes(payload)
61
+ files.append(
62
+ RawFile(
63
+ component=component,
64
+ source_url=source_url,
65
+ checksum=str(hash_bytes(payload)),
66
+ local_path=local_path,
67
+ fetched_at=self._clock(),
68
+ )
69
+ )
70
+ return RawBundle(cycle=cycle, files=tuple(files))
@@ -0,0 +1,118 @@
1
+ """Pinned local adapter and metadata contract for public NHANES 2017--2018."""
2
+
3
+ from collections.abc import Mapping
4
+ from datetime import UTC, datetime
5
+ from pathlib import Path
6
+
7
+ from calm.domain.entities import (
8
+ CycleSpec,
9
+ RawBundle,
10
+ RawFile,
11
+ ValueLabel,
12
+ VariableCode,
13
+ VariableMetadata,
14
+ )
15
+ from calm.domain.errors import DomainVerificationError
16
+ from calm.observability.hashing import hash_bytes
17
+
18
+ _BASE_URL = "https://wwwn.cdc.gov/Nchs/Data/Nhanes/Public/2017/DataFiles"
19
+
20
+
21
+ class Nhanes2017_2018Source:
22
+ """Read the pinned public 2017--2018 files already present on local storage."""
23
+
24
+ _files = {
25
+ "demographics": (
26
+ "DEMO_J.XPT",
27
+ "c0b46e0345ea19404928656277c8b0d10b0cca348a9b2fe4fc3c67e8b7ee73ec",
28
+ ), # noqa: E501
29
+ "questionnaire": (
30
+ "RHQ_J.XPT",
31
+ "c919896216154ea207470dd84e428f5fdd65ced39735148c78fb6032e0b77a7b",
32
+ ), # noqa: E501
33
+ "body_measures": (
34
+ "BMX_J.XPT",
35
+ "8d675e42d8826ac98714b2c3dd4c5138a5e353fb4424f7eff5e6db4a01ce838a",
36
+ ), # noqa: E501
37
+ "blood_pressure": (
38
+ "BPX_J.XPT",
39
+ "f98f1d9a4c18173e715c749b4294f6f1525140d5ad5751316bb17cc038fb3423",
40
+ ), # noqa: E501
41
+ "total_cholesterol": (
42
+ "TCHOL_J.XPT",
43
+ "0291a6a4f6d82dac8392c8c992b519946824dfeda01647935520cf506ed9f4ab",
44
+ ), # noqa: E501
45
+ "hdl": ("HDL_J.XPT", "9f4eb41b89f0c9f3d6262f873e671eeb2f2eb40581a8426b2a3c8fd4fc19439e"), # noqa: E501
46
+ "glycated_hemoglobin": (
47
+ "GHB_J.XPT",
48
+ "35f07094573a0061a03ed609a5a363b34eb1b1c7065d1623b43d72e132a8a654",
49
+ ), # noqa: E501
50
+ "complete_blood_count": (
51
+ "CBC_J.XPT",
52
+ "00f964098dc91272e415344554cbf1b627ecaeb2eda201518527446a7d81e742",
53
+ ), # noqa: E501
54
+ }
55
+
56
+ def __init__(self, directory: Path) -> None:
57
+ """Initialize the directory containing the downloaded official XPT files."""
58
+ self._directory = directory
59
+
60
+ def fetch(self, cycle: CycleSpec) -> RawBundle:
61
+ """Validate and expose the immutable local files for the supported cycle."""
62
+ if cycle.identifier != "2017-2018":
63
+ raise DomainVerificationError(f"unsupported NHANES cycle: {cycle.identifier}")
64
+ files: list[RawFile] = []
65
+ for component, (filename, expected_checksum) in self._files.items():
66
+ path = self._directory / filename
67
+ if not path.is_file():
68
+ raise DomainVerificationError(f"missing pinned NHANES input: {path}")
69
+ checksum = str(hash_bytes(path.read_bytes()))
70
+ if checksum != expected_checksum:
71
+ raise DomainVerificationError(
72
+ f"checksum mismatch for pinned NHANES input: {filename}"
73
+ )
74
+ files.append(
75
+ RawFile(
76
+ component=component,
77
+ source_url=f"{_BASE_URL}/{filename}",
78
+ checksum=checksum,
79
+ local_path=path,
80
+ fetched_at=datetime.fromtimestamp(path.stat().st_mtime, UTC),
81
+ )
82
+ )
83
+ return RawBundle(cycle, tuple(files))
84
+
85
+
86
+ def questionnaire_metadata_2017_2018() -> tuple[VariableMetadata, ...]:
87
+ """Return the versioned RHQ codebook contract needed by the public label rule."""
88
+ return (
89
+ VariableMetadata(VariableCode("SEQN"), "Respondent sequence number"),
90
+ VariableMetadata(
91
+ VariableCode("RHQ031"),
92
+ "Had regular periods in past 12 months",
93
+ (ValueLabel("1", "Yes"), ValueLabel("2", "No")),
94
+ ),
95
+ VariableMetadata(
96
+ VariableCode("RHD043"),
97
+ "Reason not having regular periods",
98
+ (
99
+ ValueLabel("1", "Pregnancy"),
100
+ ValueLabel("2", "Breast feeding"),
101
+ ValueLabel("3", "Hysterectomy"),
102
+ ValueLabel("7", "Menopause/Change of life"),
103
+ ),
104
+ ),
105
+ )
106
+
107
+
108
+ def validate_2017_2018_schema(tables: Mapping[str, tuple[str, ...]]) -> None:
109
+ """Fail before cohort construction unless required source fields are exactly available."""
110
+ required = {
111
+ "demographics": {"SEQN", "RIAGENDR", "RIDAGEYR"},
112
+ "questionnaire": {"SEQN", "RHQ031", "RHD043"},
113
+ }
114
+ for table, columns in required.items():
115
+ available = set(tables.get(table, ()))
116
+ missing = sorted(columns - available)
117
+ if missing:
118
+ raise DomainVerificationError(f"2017-2018 {table} schema mismatch: missing {missing}")
@@ -0,0 +1,109 @@
1
+ """Deterministic offline NHANES-like source for tests and demonstrations."""
2
+
3
+ from datetime import UTC, datetime
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+ import pyreadstat
8
+
9
+ from calm.domain.entities import CycleSpec, RawBundle, RawFile
10
+ from calm.observability.hashing import hash_bytes
11
+
12
+
13
+ class SyntheticSource:
14
+ """Produce a metadata-labelled benchmark bundle without network access."""
15
+
16
+ def __init__(self, destination: Path) -> None:
17
+ """Set the destination for generated fixture data."""
18
+ self._destination = destination
19
+
20
+ def fetch(self, cycle: CycleSpec) -> RawBundle:
21
+ """Write the component bundle needed by the complete synthetic pipeline."""
22
+ directory = self._destination / cycle.identifier
23
+ directory.mkdir(parents=True, exist_ok=True)
24
+ identifiers = list(range(1, 81))
25
+ postmenopausal = [identifier % 2 == 0 for identifier in identifiers]
26
+ paths = {
27
+ "questionnaire": directory / "questionnaire.sav",
28
+ "demographics": directory / "demographics.sav",
29
+ "laboratory": directory / "laboratory.sav",
30
+ }
31
+ pyreadstat.write_sav(
32
+ pd.DataFrame(
33
+ {
34
+ "SEQN": identifiers,
35
+ "SEX": [2] * 80,
36
+ "AGE": [40 + index % 20 for index in range(80)],
37
+ }
38
+ ),
39
+ paths["demographics"],
40
+ column_labels={"SEQN": "Respondent sequence number", "SEX": "Sex", "AGE": "Age"},
41
+ variable_value_labels={"SEX": {1: "Male", 2: "Female"}},
42
+ )
43
+ pyreadstat.write_sav(
44
+ pd.DataFrame(
45
+ {
46
+ "SEQN": identifiers,
47
+ "STATUS": [2 if value else 1 for value in postmenopausal],
48
+ "RHQ031": [2 if value else 1 for value in postmenopausal],
49
+ "RHD043": [7 if value else None for value in postmenopausal],
50
+ "RHD280": [2] * 80,
51
+ "RHQ305": [2] * 80,
52
+ "RHD143": [2] * 80,
53
+ "RHQ200": [2] * 80,
54
+ "RHQ540": [2] * 80,
55
+ }
56
+ ),
57
+ paths["questionnaire"],
58
+ column_labels={
59
+ column: column
60
+ for column in (
61
+ "SEQN",
62
+ "STATUS",
63
+ "RHQ031",
64
+ "RHD043",
65
+ "RHD280",
66
+ "RHQ305",
67
+ "RHD143",
68
+ "RHQ200",
69
+ "RHQ540",
70
+ )
71
+ },
72
+ variable_value_labels={
73
+ "STATUS": {1: "Yes", 2: "No"},
74
+ "RHQ031": {1: "Yes", 2: "No"},
75
+ "RHD043": {3: "Hysterectomy", 7: "Menopause/Change of life"},
76
+ **{
77
+ column: {1: "Yes", 2: "No"}
78
+ for column in ("RHD280", "RHQ305", "RHD143", "RHQ200", "RHQ540")
79
+ },
80
+ },
81
+ )
82
+ pyreadstat.write_sav(
83
+ pd.DataFrame(
84
+ {
85
+ "SEQN": identifiers,
86
+ "marker_one": [float(index % 2) for index in range(80)],
87
+ "marker_two": [float((index * 7) % 11) for index in range(80)],
88
+ }
89
+ ),
90
+ paths["laboratory"],
91
+ column_labels={
92
+ "SEQN": "Respondent sequence number",
93
+ "marker_one": "Synthetic marker one",
94
+ "marker_two": "Synthetic marker two",
95
+ },
96
+ )
97
+ return RawBundle(
98
+ cycle=cycle,
99
+ files=tuple(
100
+ RawFile(
101
+ component=component,
102
+ source_url=f"synthetic://{component}",
103
+ checksum=str(hash_bytes(path.read_bytes())),
104
+ local_path=path,
105
+ fetched_at=datetime(2026, 1, 1, tzinfo=UTC),
106
+ )
107
+ for component, path in paths.items()
108
+ ),
109
+ )
calm/cli.py ADDED
@@ -0,0 +1,86 @@
1
+ """Command-line interface for CALM."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from calm.adapters.nhanes_2017_2018 import Nhanes2017_2018Source
9
+ from calm.adapters.synthetic import SyntheticSource
10
+ from calm.config.loading import load_config
11
+ from calm.config.models import (
12
+ CalibrationConfig,
13
+ CohortConfig,
14
+ CycleConfig,
15
+ FeatureConfig,
16
+ LabelConfig,
17
+ ModelConfig,
18
+ PathsConfig,
19
+ RunConfig,
20
+ SplitConfig,
21
+ )
22
+ from calm.orchestration.benchmark import run_benchmark
23
+
24
+ app = typer.Typer(
25
+ name="calm",
26
+ help="Build reproducible menopausal-status prediction benchmarks.",
27
+ no_args_is_help=True,
28
+ )
29
+
30
+
31
+ @app.callback()
32
+ def main() -> None:
33
+ """Run the CALM command-line application."""
34
+
35
+
36
+ @app.command()
37
+ def demo(
38
+ output: Annotated[
39
+ Path, typer.Option(help="Directory in which to write demo artifacts.")
40
+ ] = Path("runs"),
41
+ run_id: Annotated[str, typer.Option(help="Stable name for the demo run.")] = "synthetic-demo",
42
+ ) -> None:
43
+ """Run the complete offline synthetic pipeline and write its audit artifacts."""
44
+ config = RunConfig(
45
+ seed=31,
46
+ cycles=(CycleConfig(identifier="2017-2018"),),
47
+ cohort=CohortConfig(age_policy="restricted", minimum_age=40, maximum_age=60),
48
+ label=LabelConfig(
49
+ rule_name="public_cessation",
50
+ perimenopause_policy="exclude",
51
+ include_early_natural_menopause=False,
52
+ ),
53
+ features=FeatureConfig(allowlist=("marker_one", "marker_two")),
54
+ split=SplitConfig(validation_fraction=0.2, test_fraction=0.2),
55
+ model=ModelConfig(estimator="logistic_regression", options={"max_iter": 200}),
56
+ calibration=CalibrationConfig(method="sigmoid", no_call_lower=0.45, no_call_upper=0.55),
57
+ paths=PathsConfig(raw_data=output / ".raw", cache=output / ".cache", runs=output),
58
+ )
59
+ result = run_benchmark(config, SyntheticSource(config.paths.raw_data), run_id)
60
+ typer.echo(f"Wrote benchmark artifacts to {result.artifacts.metrics_path.parent}")
61
+
62
+
63
+ @app.command("run-synthetic")
64
+ def run_synthetic(
65
+ config_path: Annotated[Path, typer.Option("--config", exists=True, readable=True)],
66
+ run_id: Annotated[
67
+ str, typer.Option(help="Stable name for the benchmark run.")
68
+ ] = "synthetic-run",
69
+ ) -> None:
70
+ """Execute the complete synthetic benchmark from a validated TOML configuration."""
71
+ config = load_config(config_path)
72
+ result = run_benchmark(config, SyntheticSource(config.paths.raw_data), run_id)
73
+ typer.echo(f"Wrote benchmark artifacts to {result.artifacts.metrics_path.parent}")
74
+
75
+
76
+ @app.command("run-nhanes")
77
+ def run_nhanes(
78
+ config_path: Annotated[Path, typer.Option("--config", exists=True, readable=True)],
79
+ run_id: Annotated[str, typer.Option(help="Stable name for the benchmark run.")] = (
80
+ "nhanes-2017-2018"
81
+ ),
82
+ ) -> None:
83
+ """Run the pinned public NHANES 2017--2018 benchmark from downloaded inputs."""
84
+ config = load_config(config_path)
85
+ result = run_benchmark(config, Nhanes2017_2018Source(config.paths.raw_data), run_id)
86
+ typer.echo(f"Wrote benchmark artifacts to {result.artifacts.metrics_path.parent}")
@@ -0,0 +1,6 @@
1
+ """Configuration contracts and loading."""
2
+
3
+ from calm.config.loading import load_config
4
+ from calm.config.models import RunConfig
5
+
6
+ __all__ = ["RunConfig", "load_config"]
calm/config/loading.py ADDED
@@ -0,0 +1,19 @@
1
+ """TOML configuration loading at the application boundary."""
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+
6
+ from calm.config.models import RunConfig
7
+
8
+
9
+ def load_config(path: Path) -> RunConfig:
10
+ """Load and validate a run configuration from a TOML file.
11
+
12
+ Args:
13
+ path: Location of the user-supplied TOML configuration.
14
+
15
+ Returns:
16
+ A validated immutable run configuration.
17
+ """
18
+ with path.open("rb") as config_file:
19
+ return RunConfig.model_validate(tomllib.load(config_file))
calm/config/models.py ADDED
@@ -0,0 +1,132 @@
1
+ """Validated, immutable configuration contracts for CALM runs."""
2
+
3
+ from pathlib import Path
4
+ from typing import Literal, Self
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
7
+
8
+
9
+ class ConfigModel(BaseModel):
10
+ """Base class for immutable, strict CALM configuration models."""
11
+
12
+ model_config = ConfigDict(extra="forbid", frozen=True)
13
+
14
+
15
+ class CycleConfig(ConfigModel):
16
+ """A continuous NHANES two-year cycle selected for a run."""
17
+
18
+ identifier: str = Field(pattern=r"^\d{4}-\d{4}$")
19
+ components: tuple[
20
+ Literal["demographics", "examination", "laboratory", "questionnaire"], ...
21
+ ] = (
22
+ "demographics",
23
+ "examination",
24
+ "laboratory",
25
+ "questionnaire",
26
+ )
27
+
28
+
29
+ class CohortConfig(ConfigModel):
30
+ """Rules for selecting the analytical cohort."""
31
+
32
+ age_policy: Literal["restricted", "full_range"]
33
+ minimum_age: int = Field(ge=0, le=120)
34
+ maximum_age: int = Field(ge=0, le=120)
35
+
36
+ @model_validator(mode="after")
37
+ def validate_age_window(self) -> Self:
38
+ """Require a nonempty configured age interval."""
39
+ if self.minimum_age >= self.maximum_age:
40
+ msg = "minimum_age must be less than maximum_age"
41
+ raise ValueError(msg)
42
+ return self
43
+
44
+
45
+ class LabelConfig(ConfigModel):
46
+ """Policies governing metadata-verified outcome construction."""
47
+
48
+ rule_name: str = "natural_menopause"
49
+ rule_version: str = "1"
50
+ perimenopause_policy: Literal["exclude", "premenopausal"]
51
+ include_early_natural_menopause: bool = True
52
+
53
+
54
+ class FeatureConfig(ConfigModel):
55
+ """An explicit allowlist of candidate non-label feature names."""
56
+
57
+ allowlist: tuple[str, ...] = Field(min_length=1)
58
+
59
+
60
+ class SplitConfig(ConfigModel):
61
+ """Deterministic partition fractions for model development."""
62
+
63
+ test_fraction: float = Field(gt=0, lt=1)
64
+ validation_fraction: float = Field(gt=0, lt=1)
65
+
66
+ @model_validator(mode="after")
67
+ def validate_training_fraction(self) -> Self:
68
+ """Retain a nonempty training partition."""
69
+ if self.test_fraction + self.validation_fraction >= 1:
70
+ msg = "test_fraction and validation_fraction must sum to less than 1"
71
+ raise ValueError(msg)
72
+ return self
73
+
74
+
75
+ class ModelConfig(ConfigModel):
76
+ """Configured estimator selection and stable scalar options."""
77
+
78
+ estimator: Literal["logistic_regression", "gradient_boosting"]
79
+ options: dict[str, str | int | float | bool] = Field(default_factory=dict)
80
+
81
+
82
+ class CalibrationConfig(ConfigModel):
83
+ """Probability calibration and abstention-band settings."""
84
+
85
+ method: Literal["sigmoid", "isotonic"]
86
+ no_call_lower: float = Field(ge=0, le=1)
87
+ no_call_upper: float = Field(ge=0, le=1)
88
+ minimum_validation_samples: int = Field(default=10, ge=2)
89
+ minimum_validation_per_class: int = Field(default=5, ge=2)
90
+ selection_protocol: Literal["preselected"] = "preselected"
91
+
92
+ @model_validator(mode="after")
93
+ def validate_no_call_band(self) -> Self:
94
+ """Require a properly ordered abstention interval."""
95
+ if self.no_call_lower >= self.no_call_upper:
96
+ msg = "no_call_lower must be less than no_call_upper"
97
+ raise ValueError(msg)
98
+ return self
99
+
100
+
101
+ class EvaluationConfig(ConfigModel):
102
+ """Uncertainty and reporting policies fixed before frozen-test evaluation."""
103
+
104
+ bootstrap_resamples: int = Field(default=200, ge=100)
105
+ confidence_level: float = Field(default=0.95, gt=0, lt=1)
106
+ survey_weight_decision: Literal["not_applied_individual_level"] = "not_applied_individual_level"
107
+ generalization_protocol: Literal["within_cycle_frozen_test", "held_out_cycle_required"] = (
108
+ "within_cycle_frozen_test"
109
+ )
110
+
111
+
112
+ class PathsConfig(ConfigModel):
113
+ """Filesystem locations supplied by the application boundary."""
114
+
115
+ raw_data: Path
116
+ cache: Path
117
+ runs: Path
118
+
119
+
120
+ class RunConfig(ConfigModel):
121
+ """The complete validated and immutable intent of one CALM run."""
122
+
123
+ seed: int = Field(ge=0)
124
+ cycles: tuple[CycleConfig, ...] = Field(min_length=1)
125
+ cohort: CohortConfig
126
+ label: LabelConfig
127
+ features: FeatureConfig
128
+ split: SplitConfig
129
+ model: ModelConfig
130
+ calibration: CalibrationConfig
131
+ evaluation: EvaluationConfig = EvaluationConfig()
132
+ paths: PathsConfig
@@ -0,0 +1,5 @@
1
+ """Pure domain entities and invariants."""
2
+
3
+ from calm.domain.entities import Cohort, LabelSet, VariableCode
4
+
5
+ __all__ = ["Cohort", "LabelSet", "VariableCode"]