Spaces:
Running on Zero
Running on Zero
| """Typed, validated configuration loading for CellTriage. | |
| WHAT: Pydantic schemas for all five YAML configs, plus loaders that parse and | |
| validate them, and cross-config consistency checks. | |
| WHY a schema rather than ``yaml.safe_load``: this project encodes its | |
| methodological commitments as configuration. Its hard constraints are | |
| expressible as invariants over these files -- escape cost must | |
| dominate overkill cost, feature selection must be fitted inside CV folds, | |
| neural networks are forbidden, and every excluded cell must carry a reason. A | |
| plain dict load would let any of those be violated by a typo and the run would | |
| proceed, producing numbers that look fine and are not. Validation turns each | |
| commitment into something that must be *deliberately* defeated rather than | |
| accidentally lost. | |
| The validators here are therefore not defensive boilerplate; each one | |
| corresponds to a stated project rule and is annotated with which. | |
| """ | |
| from __future__ import annotations | |
| from pathlib import Path | |
| from typing import Any, Literal | |
| import yaml | |
| from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator | |
| from src.utils.paths import CONFIG_DIR | |
| # --------------------------------------------------------------------------- | |
| # Base | |
| # --------------------------------------------------------------------------- | |
| class _StrictModel(BaseModel): | |
| """Base model that forbids unknown keys. | |
| WHY ``extra='forbid'``: a mistyped key in a YAML file is otherwise silently | |
| ignored, so the pipeline runs with the default rather than the intended | |
| value. For a file that holds cost assumptions and leakage controls, a typo | |
| that changes behaviour without complaining is the worst possible failure | |
| mode. | |
| """ | |
| model_config = ConfigDict(extra="forbid", frozen=False) | |
| # --------------------------------------------------------------------------- | |
| # grading.yaml | |
| # --------------------------------------------------------------------------- | |
| class GradeSpec(_StrictModel): | |
| """One product tier.""" | |
| min_cycles: int = Field(ge=0, description="Inclusive lower bound on observed cycle life.") | |
| tier: str = Field(min_length=1, description="Human-readable application tier.") | |
| class LabellingSpec(_StrictModel): | |
| boundary_inclusive: bool | |
| class GradingConfig(_StrictModel): | |
| """Grade boundaries and product tiers (``configs/grading.yaml``).""" | |
| grades: dict[str, GradeSpec] | |
| warranty_target_cycles: int = Field(ge=0) | |
| grade_order: list[str] = Field(min_length=2) | |
| shipped_grades: list[str] = Field(min_length=1) | |
| labelling: LabellingSpec | |
| def _check_grade_structure(self) -> "GradingConfig": | |
| if set(self.grade_order) != set(self.grades): | |
| raise ValueError( | |
| f"grade_order {self.grade_order} does not match the grades defined " | |
| f"({sorted(self.grades)}). Every grade must appear exactly once." | |
| ) | |
| if len(set(self.grade_order)) != len(self.grade_order): | |
| raise ValueError(f"grade_order contains duplicates: {self.grade_order}") | |
| # Ordering is load-bearing: it determines which off-diagonal cost-matrix | |
| # entries count as escapes. If the order were not strictly decreasing in | |
| # min_cycles, "more demanding tier" would be undefined. | |
| thresholds = [self.grades[g].min_cycles for g in self.grade_order] | |
| if thresholds != sorted(thresholds, reverse=True) or len(set(thresholds)) != len(thresholds): | |
| raise ValueError( | |
| f"grade_order must be strictly decreasing in min_cycles; got " | |
| f"{dict(zip(self.grade_order, thresholds))}." | |
| ) | |
| if thresholds[-1] != 0: | |
| raise ValueError( | |
| f"The least demanding grade ('{self.grade_order[-1]}') must have " | |
| f"min_cycles == 0 so that every cell receives a grade; got {thresholds[-1]}." | |
| ) | |
| unknown_shipped = set(self.shipped_grades) - set(self.grade_order) | |
| if unknown_shipped: | |
| raise ValueError(f"shipped_grades references undefined grades: {sorted(unknown_shipped)}") | |
| if self.grade_order[-1] in self.shipped_grades: | |
| raise ValueError( | |
| f"The scrap grade '{self.grade_order[-1]}' cannot be a shipped grade; " | |
| "escape rate is defined over shipped cells only." | |
| ) | |
| highest, lowest_shipped = thresholds[0], self.grades[self.grade_order[-2]].min_cycles | |
| if not lowest_shipped <= self.warranty_target_cycles <= highest: | |
| raise ValueError( | |
| f"warranty_target_cycles ({self.warranty_target_cycles}) must lie between the " | |
| f"lowest shipped-grade boundary ({lowest_shipped}) and the highest " | |
| f"({highest})." | |
| ) | |
| return self | |
| def grade_for(self, cycle_life: float) -> str: | |
| """Assign a grade to an observed cycle life. | |
| Boundary handling is explicit (see ``labelling.boundary_inclusive``): | |
| a value exactly on a boundary goes to the higher tier. An off-by-one | |
| here silently moves cells between classes and makes the reported class | |
| balance irreproducible. | |
| """ | |
| for grade in self.grade_order: | |
| threshold = self.grades[grade].min_cycles | |
| if (cycle_life >= threshold) if self.labelling.boundary_inclusive else (cycle_life > threshold): | |
| return grade | |
| return self.grade_order[-1] | |
| # --------------------------------------------------------------------------- | |
| # costs.yaml | |
| # --------------------------------------------------------------------------- | |
| class SensitivitySpec(_StrictModel): | |
| escape_overkill_ratios: list[float] = Field(min_length=2) | |
| def _ascending_and_positive(cls, v: list[float]) -> list[float]: | |
| if any(r <= 0 for r in v): | |
| raise ValueError(f"escape_overkill_ratios must all be positive; got {v}") | |
| if v != sorted(v): | |
| raise ValueError(f"escape_overkill_ratios must be ascending; got {v}") | |
| return v | |
| class UnitsSpec(_StrictModel): | |
| basis: str | |
| currency: str | None = None | |
| class CostsConfig(_StrictModel): | |
| """Cost matrix and chamber economics (``configs/costs.yaml``).""" | |
| cost_matrix: dict[str, list[float]] | |
| cycle_test_cost: float = Field(gt=0) | |
| chamber_slots: int = Field(gt=0) | |
| batch_size: int = Field(gt=0) | |
| sensitivity: SensitivitySpec | |
| units: UnitsSpec | |
| def _check_matrix(self) -> "CostsConfig": | |
| n = len(self.cost_matrix) | |
| for row_key, row in self.cost_matrix.items(): | |
| if not row_key.startswith("true_"): | |
| raise ValueError(f"Cost-matrix row keys must be 'true_<GRADE>'; got '{row_key}'.") | |
| if len(row) != n: | |
| raise ValueError( | |
| f"Cost matrix must be square: row '{row_key}' has {len(row)} entries, expected {n}." | |
| ) | |
| if any(c < 0 for c in row): | |
| raise ValueError(f"Cost-matrix entries must be non-negative; row '{row_key}' = {row}.") | |
| rows = self.row_grades() | |
| for i, grade in enumerate(rows): | |
| diagonal = self.cost_matrix[f"true_{grade}"][i] | |
| if diagonal != 0.0: | |
| raise ValueError( | |
| f"Diagonal entry for grade '{grade}' must be 0 (a correct assignment costs " | |
| f"nothing); got {diagonal}." | |
| ) | |
| escapes = self.escape_costs() | |
| overkills = self.overkill_costs() | |
| if not escapes or not overkills: | |
| raise ValueError("Cost matrix must contain at least one escape and one overkill entry.") | |
| # PROJECT RULE: escape cost must dominate overkill cost. A pack's life | |
| # is governed by its weakest cell, so an escaped cell carries warranty | |
| # and recall exposure while an overkilled cell costs only its | |
| # manufacturing value. If this inversion were ever introduced by an | |
| # edit, every decision-theoretic conclusion in the project would flip | |
| # without any other symptom. | |
| if min(escapes.values()) <= max(overkills.values()): | |
| raise ValueError( | |
| f"Every escape cost must exceed every overkill cost. " | |
| f"Cheapest escape {min(escapes.values())} ({min(escapes, key=escapes.get)}) does not " | |
| f"exceed costliest overkill {max(overkills.values())} " | |
| f"({max(overkills, key=overkills.get)})." | |
| ) | |
| # Capacity must actually bind, otherwise Phase 8D's allocation problem | |
| # is vacuous: if every cell can be held, there is nothing to allocate. | |
| if self.chamber_slots >= self.batch_size: | |
| raise ValueError( | |
| f"chamber_slots ({self.chamber_slots}) must be smaller than batch_size " | |
| f"({self.batch_size}); otherwise the capacity constraint does not bind and the " | |
| "allocation problem in Phase 8D is trivial." | |
| ) | |
| return self | |
| def row_grades(self) -> list[str]: | |
| """Grade labels in matrix row order, most demanding first.""" | |
| return [key.removeprefix("true_") for key in self.cost_matrix] | |
| def _off_diagonal(self, kind: Literal["escape", "overkill"]) -> dict[tuple[str, str], float]: | |
| """Return off-diagonal entries of the requested kind, keyed (true, assigned). | |
| Rows are true grades and columns assigned grades, both ordered most | |
| demanding first. A cell assigned to a MORE demanding tier than it can | |
| sustain is an escape, which means ``col_index < row_index`` -- the | |
| lower-left triangle. See the orientation note in ``configs/costs.yaml``: | |
| the build plan's prose says "above the diagonal" but its own numbers | |
| place escapes below it, and the numbers govern. | |
| """ | |
| grades = self.row_grades() | |
| out: dict[tuple[str, str], float] = {} | |
| for i, true_grade in enumerate(grades): | |
| row = self.cost_matrix[f"true_{true_grade}"] | |
| for j, assigned_grade in enumerate(grades): | |
| if i == j: | |
| continue | |
| is_escape = j < i | |
| if (kind == "escape") == is_escape: | |
| out[(true_grade, assigned_grade)] = row[j] | |
| return out | |
| def escape_costs(self) -> dict[tuple[str, str], float]: | |
| """Costs of assigning a cell to a tier it cannot sustain.""" | |
| return self._off_diagonal("escape") | |
| def overkill_costs(self) -> dict[tuple[str, str], float]: | |
| """Costs of downgrading or scrapping a capable cell.""" | |
| return self._off_diagonal("overkill") | |
| def escape_overkill_ratio(self) -> float: | |
| """Mean escape cost divided by mean overkill cost, for the sensitivity sweep.""" | |
| escapes = list(self.escape_costs().values()) | |
| overkills = list(self.overkill_costs().values()) | |
| return (sum(escapes) / len(escapes)) / (sum(overkills) / len(overkills)) | |
| # --------------------------------------------------------------------------- | |
| # data.yaml | |
| # --------------------------------------------------------------------------- | |
| class DatasetSpec(_StrictModel): | |
| name: str | |
| chemistry: str | |
| cell_model: str | |
| nominal_capacity_ah: float = Field(gt=0) | |
| nominal_voltage_v: float = Field(gt=0) | |
| cycling_temperature_c: float | |
| discharge_protocol: str | |
| eol_capacity_fraction: float = Field(gt=0, lt=1) | |
| class SourceFileSpec(_StrictModel): | |
| """One downloadable source file, with the provenance needed to re-fetch it.""" | |
| name: str = Field(min_length=1) | |
| url: str = Field(pattern=r"^https://") | |
| size_bytes: int = Field(gt=0) | |
| batch: str = Field(min_length=1) | |
| #: Populated by src/data/downloader.py from the downloaded bytes. Null until | |
| #: the file has actually been fetched and hashed. | |
| sha256: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") | |
| class SourceSpec(_StrictModel): | |
| verified: bool | |
| primary_citation: str | |
| extension_citation: str | |
| files: list[SourceFileSpec] = Field(default_factory=list) | |
| format: str | |
| verified_on: str | None = None | |
| landing_page: str | None = None | |
| total_size_bytes: int | None = None | |
| extension_located: bool = False | |
| def _verified_requires_evidence(self) -> "SourceSpec": | |
| # Task 2.0 rule: do not hardcode a URL you have not verified. Flipping | |
| # `verified` to true without a digest for every file would defeat the | |
| # check this exists to enforce -- a URL that merely resolves is not a | |
| # URL whose contents have been verified. | |
| if self.verified: | |
| if not self.files: | |
| raise ValueError("source.verified is true but source.files is empty.") | |
| undigested = [f.name for f in self.files if not f.sha256] | |
| if undigested: | |
| raise ValueError( | |
| f"source.verified is true but these files have no sha256 digest: {undigested}. " | |
| "A digest can only come from bytes that were actually downloaded." | |
| ) | |
| if self.total_size_bytes is not None and self.files: | |
| declared = sum(f.size_bytes for f in self.files) | |
| if declared != self.total_size_bytes: | |
| raise ValueError( | |
| f"source.total_size_bytes ({self.total_size_bytes}) does not equal the sum of the " | |
| f"per-file sizes ({declared})." | |
| ) | |
| return self | |
| class PathsSpec(_StrictModel): | |
| raw: str | |
| interim: str | |
| processed: str | |
| cells: str | |
| class BatchSpec(_StrictModel): | |
| date: str | |
| role: Literal["in_distribution", "out_of_distribution"] | |
| expected_cells_raw: int = Field(gt=0) | |
| expected_cells_used: int = Field(ge=0) | |
| verified: bool | |
| note: str | None = None | |
| def _used_not_more_than_raw(self) -> "BatchSpec": | |
| if self.expected_cells_used > self.expected_cells_raw: | |
| raise ValueError( | |
| f"expected_cells_used ({self.expected_cells_used}) exceeds expected_cells_raw " | |
| f"({self.expected_cells_raw})." | |
| ) | |
| return self | |
| class ContinuationSpec(_StrictModel): | |
| primary: str | |
| continued_as: str | |
| known_extra_cycles: int = Field(gt=0) | |
| verified: bool | |
| def _distinct_records(self) -> "ContinuationSpec": | |
| if self.primary == self.continued_as: | |
| raise ValueError(f"A cell cannot continue as itself: '{self.primary}'.") | |
| return self | |
| class ExclusionSpec(_StrictModel): | |
| """One excluded cell. | |
| ``reason`` is required and must be substantive. PROJECT RULE: never | |
| silently drop a cell -- always log the cell ID and the reason. Making the | |
| reason a required, minimum-length field means an undocumented exclusion | |
| cannot be added to this file at all. | |
| """ | |
| cell_id: str = Field(min_length=1) | |
| batch: str = Field(min_length=1) | |
| reason: str = Field(min_length=15) | |
| verified: bool | |
| def _reason_is_substantive(cls, v: str) -> str: | |
| placeholders = {"tbd", "todo", "n/a", "na", "unknown", "excluded", "see above", "-"} | |
| if v.strip().lower().rstrip(".") in placeholders: | |
| raise ValueError(f"Exclusion reason '{v}' is a placeholder, not a reason.") | |
| return v | |
| class ReconciliationSpec(_StrictModel): | |
| expected_cells_used: int = Field(gt=0) | |
| expected_cells_used_with_extension: int = Field(gt=0) | |
| verified: bool | |
| def _extension_is_larger(self) -> "ReconciliationSpec": | |
| if self.expected_cells_used_with_extension < self.expected_cells_used: | |
| raise ValueError("The extended corpus cannot contain fewer cells than the primary one.") | |
| return self | |
| class ParsingSpec(_StrictModel): | |
| """Parsing and storage policy for Phase 2.""" | |
| within_cycle_max_cycle: int = Field(gt=0) | |
| parquet_compression: Literal["zstd", "snappy", "gzip", "brotli", "none"] | |
| class CycleLifeRecomputeSpec(_StrictModel): | |
| method: Literal["first_below_threshold"] | |
| smoothing_window: int = Field(ge=1) | |
| min_cycle: int = Field(ge=1) | |
| #: Fraction above the end-of-life threshold still counted as having reached | |
| #: it. Derived empirically in Phase 2, not assumed -- see the extensive | |
| #: rationale in configs/data.yaml. Capped well below 10% because a large | |
| #: tolerance would start absorbing genuinely censored cells. | |
| eol_tolerance_fraction: float = Field(ge=0.0, le=0.05) | |
| class PlaceholderRowSpec(_StrictModel): | |
| expect_at_most_one: bool | |
| expect_at_first_cycle: bool | |
| #: Batches empirically confirmed to carry the all-zero placeholder row. | |
| batches_with_placeholder: list[str] = Field(default_factory=list) | |
| class ValidationSpec(_StrictModel): | |
| capacity_max_fraction_of_nominal: float = Field(gt=1.0) | |
| voltage_range_v: list[float] = Field(min_length=2, max_length=2) | |
| protocol_voltage_range_v: list[float] = Field(min_length=2, max_length=2) | |
| temperature_range_c: list[float] = Field(min_length=2, max_length=2) | |
| require_monotonic_cycle_index: bool | |
| forbid_duplicate_cycle_index: bool | |
| cycle_life_disagreement_tolerance: int = Field(ge=0) | |
| cycle_life_recompute: CycleLifeRecomputeSpec | |
| placeholder_row: PlaceholderRowSpec | |
| systematic_violation_fraction: float = Field(gt=0.0, le=0.5) | |
| systematic_cell_fraction: float = Field(gt=0.0, le=1.0) | |
| max_cycle_duration_minutes: float = Field(gt=0.0) | |
| def _validation_bound_contains_protocol(self) -> "ValidationSpec": | |
| # The validation bound exists to catch mis-parsing, so it must be at | |
| # least as wide as the protocol setpoints. A bound TIGHTER than the | |
| # protocol would flag correctly parsed data as impossible. | |
| if (self.voltage_range_v[0] > self.protocol_voltage_range_v[0] | |
| or self.voltage_range_v[1] < self.protocol_voltage_range_v[1]): | |
| raise ValueError( | |
| f"voltage_range_v {self.voltage_range_v} must contain the protocol range " | |
| f"{self.protocol_voltage_range_v}; a bound tighter than the protocol would reject " | |
| "correctly parsed data." | |
| ) | |
| return self | |
| def _ordered_range(cls, v: list[float]) -> list[float]: | |
| if v[0] >= v[1]: | |
| raise ValueError(f"Range must be [low, high] with low < high; got {v}.") | |
| return v | |
| class DataConfig(_StrictModel): | |
| """Dataset structure, joins and exclusions (``configs/data.yaml``).""" | |
| dataset: DatasetSpec | |
| source: SourceSpec | |
| paths: PathsSpec | |
| batches: dict[str, BatchSpec] | |
| continuations: list[ContinuationSpec] | |
| exclusions: list[ExclusionSpec] | |
| reconciliation: ReconciliationSpec | |
| parsing: ParsingSpec | |
| validation: ValidationSpec | |
| def _check_cross_references(self) -> "DataConfig": | |
| known_batches = set(self.batches) | |
| unknown = {e.cell_id: e.batch for e in self.exclusions if e.batch not in known_batches} | |
| if unknown: | |
| raise ValueError(f"Exclusions reference undefined batches: {unknown}") | |
| ids = [e.cell_id for e in self.exclusions] | |
| duplicates = {c for c in ids if ids.count(c) > 1} | |
| if duplicates: | |
| raise ValueError(f"Duplicate cell IDs in the exclusion list: {sorted(duplicates)}") | |
| # A cell cannot be both joined into another record and excluded: the | |
| # two operations would disagree about whether it exists. | |
| continuation_ids = {c.primary for c in self.continuations} | { | |
| c.continued_as for c in self.continuations | |
| } | |
| conflict = continuation_ids & set(ids) | |
| if conflict: | |
| raise ValueError( | |
| f"Cells appear in both the continuation joins and the exclusion list: {sorted(conflict)}. " | |
| "A cell cannot be simultaneously joined and dropped." | |
| ) | |
| if not any(b.role == "out_of_distribution" for b in self.batches.values()): | |
| raise ValueError( | |
| "At least one batch must be marked out_of_distribution; RQ4 has no test set otherwise." | |
| ) | |
| return self | |
| def excluded_cell_ids(self) -> set[str]: | |
| return {e.cell_id for e in self.exclusions} | |
| def unverified_assumptions(self) -> list[str]: | |
| """List every config entry still carrying ``verified: false``. | |
| WHY: Phase 2 must close each of these out. Surfacing them as a list | |
| means the outstanding assumptions are printable rather than buried in | |
| the YAML. | |
| """ | |
| pending: list[str] = [] | |
| if not self.source.verified: | |
| pending.append("source (URLs, checksums, file sizes)") | |
| pending.extend(f"batches.{name} (cell counts)" for name, b in self.batches.items() if not b.verified) | |
| pending.extend( | |
| f"continuations.{c.primary}->{c.continued_as} (extra-cycle count)" | |
| for c in self.continuations | |
| if not c.verified | |
| ) | |
| pending.extend(f"exclusions.{e.cell_id} (stated reason)" for e in self.exclusions if not e.verified) | |
| if not self.reconciliation.verified: | |
| pending.append("reconciliation (final cell count)") | |
| return pending | |
| # --------------------------------------------------------------------------- | |
| # features.yaml | |
| # --------------------------------------------------------------------------- | |
| class CurveSpec(_StrictModel): | |
| voltage_min_v: float | |
| voltage_max_v: float | |
| n_grid_points: int = Field(gt=1) | |
| baseline_cycle: int = Field(gt=0) | |
| min_baseline_cycle: int = Field(gt=0) | |
| interpolation: str | |
| def _check_grid(self) -> "CurveSpec": | |
| if self.voltage_min_v >= self.voltage_max_v: | |
| raise ValueError( | |
| f"voltage_min_v ({self.voltage_min_v}) must be below voltage_max_v ({self.voltage_max_v})." | |
| ) | |
| if self.min_baseline_cycle > self.baseline_cycle: | |
| raise ValueError( | |
| f"min_baseline_cycle ({self.min_baseline_cycle}) cannot exceed baseline_cycle " | |
| f"({self.baseline_cycle})." | |
| ) | |
| return self | |
| class FeatureGroupSpec(_StrictModel): | |
| enabled: bool | |
| module: str | |
| in_line_measurable: bool | |
| is_process_recipe: bool | |
| rationale: str = Field(min_length=20) | |
| class MutualInformationSpec(_StrictModel): | |
| enabled: bool | |
| n_neighbors: int = Field(gt=0) | |
| class StabilitySelectionSpec(_StrictModel): | |
| enabled: bool | |
| n_bootstrap: int = Field(gt=0) | |
| sample_fraction: float = Field(gt=0, le=1) | |
| selection_frequency_threshold: float = Field(gt=0, le=1) | |
| class SelectionSpec(_StrictModel): | |
| fit_inside_cv_only: bool | |
| variance_threshold: float = Field(ge=0) | |
| correlation_threshold: float = Field(gt=0, le=1) | |
| mutual_information: MutualInformationSpec | |
| stability_selection: StabilitySelectionSpec | |
| def _must_fit_inside_cv(cls, v: bool) -> bool: | |
| # HARD CONSTRAINT: with n ~ 124-169 cells, fitting selection on the full | |
| # dataset is the fastest way to produce a fake result. This validator | |
| # exists so that disabling the rule requires deliberately editing a | |
| # schema, not just flipping a YAML flag. | |
| if not v: | |
| raise ValueError( | |
| "selection.fit_inside_cv_only must be true. Feature selection fitted outside " | |
| "cross-validation folds leaks the evaluation set into model construction." | |
| ) | |
| return v | |
| class PreprocessingSpec(_StrictModel): | |
| scaler: str | |
| impute_strategy: str | |
| class FeatureOutputSpec(_StrictModel): | |
| directory: str | |
| filename_template: str | |
| float_precision: str | |
| class FeaturesConfig(_StrictModel): | |
| """Budgets, feature groups and selection rules (``configs/features.yaml``).""" | |
| budgets: list[int] = Field(min_length=1) | |
| reference_budget: int = Field(gt=0) | |
| curve: CurveSpec | |
| groups: dict[str, FeatureGroupSpec] | |
| feature_sets: dict[str, list[str]] | |
| selection: SelectionSpec | |
| preprocessing: PreprocessingSpec | |
| output: FeatureOutputSpec | |
| def _ascending_unique_positive(cls, v: list[int]) -> list[int]: | |
| if any(b <= 0 for b in v): | |
| raise ValueError(f"Budgets must be positive cycle counts; got {v}.") | |
| if v != sorted(v) or len(set(v)) != len(v): | |
| raise ValueError(f"Budgets must be strictly ascending and unique; got {v}.") | |
| return v | |
| def _check_consistency(self) -> "FeaturesConfig": | |
| if self.reference_budget not in self.budgets: | |
| raise ValueError( | |
| f"reference_budget ({self.reference_budget}) must be one of the configured budgets " | |
| f"{self.budgets}." | |
| ) | |
| for set_name, members in self.feature_sets.items(): | |
| unknown = set(members) - set(self.groups) | |
| if unknown: | |
| raise ValueError(f"feature_sets.{set_name} references undefined groups: {sorted(unknown)}") | |
| if len(set(members)) != len(members): | |
| raise ValueError(f"feature_sets.{set_name} contains duplicate groups.") | |
| # RQ4 control. Reporting results with and without process-recipe | |
| # descriptors is required, so a feature set free of them must exist. | |
| recipe_groups = {name for name, g in self.groups.items() if g.is_process_recipe} | |
| if recipe_groups: | |
| if not any(recipe_groups.isdisjoint(m) for m in self.feature_sets.values()): | |
| raise ValueError( | |
| f"No feature set excludes the process-recipe groups {sorted(recipe_groups)}. " | |
| "RQ4 requires results reported both with and without them." | |
| ) | |
| # The baseline cycle for DeltaQ(V) must be reachable at the smallest | |
| # budget, otherwise the curve features are undefined there and the | |
| # smallest budget silently yields an all-NaN column. | |
| if self.curve.min_baseline_cycle > min(self.budgets): | |
| raise ValueError( | |
| f"curve.min_baseline_cycle ({self.curve.min_baseline_cycle}) exceeds the smallest " | |
| f"budget ({min(self.budgets)}); DeltaQ(V) features would be undefined there." | |
| ) | |
| return self | |
| def enabled_groups(self) -> list[str]: | |
| return [name for name, group in self.groups.items() if group.enabled] | |
| # --------------------------------------------------------------------------- | |
| # models.yaml | |
| # --------------------------------------------------------------------------- | |
| class ModelConstraintsSpec(_StrictModel): | |
| allow_neural_networks: bool | |
| def _no_neural_networks(cls, v: bool) -> bool: | |
| # A methodological commitment of the project, made structural so it | |
| # cannot be relaxed by editing a config file. | |
| if v: | |
| raise ValueError( | |
| "allow_neural_networks must be false. This project is committed to classical " | |
| "supervised ML: feature engineering rather than estimator capacity is the " | |
| "bottleneck, and exact TreeSHAP attributions are what make a scrap decision " | |
| "auditable by a process engineer." | |
| ) | |
| return v | |
| class TargetSpec(_StrictModel): | |
| name: str | |
| transform: Literal["log10", "log", "none"] | |
| class CVFoldSpec(_StrictModel): | |
| n_splits: int = Field(ge=2) | |
| n_repeats: int = Field(ge=1) | |
| purpose: str | None = None | |
| class CVSpec(_StrictModel): | |
| group_key: str | |
| outer: CVFoldSpec | |
| inner: CVFoldSpec | |
| class SplitSchemeSpec(_StrictModel): | |
| enabled: bool | |
| role: str | |
| train_batches: list[str] | None = None | |
| test_batches: list[str] | None = None | |
| n_folds: int | None = Field(default=None, ge=2) | |
| def _no_batch_overlap(self) -> "SplitSchemeSpec": | |
| # HARD CONSTRAINT: no cell in two splits. At batch granularity this is | |
| # the strongest check available before the data is parsed. | |
| if self.train_batches and self.test_batches: | |
| overlap = set(self.train_batches) & set(self.test_batches) | |
| if overlap: | |
| raise ValueError(f"Batches appear in both train and test: {sorted(overlap)}") | |
| return self | |
| class SeedsSpec(_StrictModel): | |
| global_: int = Field(alias="global") | |
| numpy: int | |
| outer_repeats: list[int] | |
| bootstrap: int | |
| model_config = ConfigDict(extra="forbid", populate_by_name=True) | |
| def _unique_seeds(cls, v: list[int]) -> list[int]: | |
| if len(set(v)) != len(v): | |
| raise ValueError(f"Outer-repeat seeds must be unique, else repeats are not independent; got {v}.") | |
| return v | |
| class BootstrapSpec(_StrictModel): | |
| n_resamples: int = Field(ge=1000) | |
| confidence_level: float = Field(gt=0, lt=1) | |
| method: str | |
| class MetricsSpec(_StrictModel): | |
| statistical: list[str] = Field(min_length=1) | |
| manufacturing: list[str] = Field(min_length=1) | |
| primary: str | |
| def _primary_is_declared(self) -> "MetricsSpec": | |
| if self.primary not in (*self.statistical, *self.manufacturing): | |
| raise ValueError(f"metrics.primary '{self.primary}' is not among the declared metrics.") | |
| return self | |
| class SearchDimSpec(_StrictModel): | |
| type: Literal["float", "int", "categorical"] | |
| low: float | None = None | |
| high: float | None = None | |
| log: bool = False | |
| choices: list[Any] | None = None | |
| def _check_bounds(self) -> "SearchDimSpec": | |
| if self.type == "categorical": | |
| if not self.choices: | |
| raise ValueError("A categorical search dimension requires 'choices'.") | |
| return self | |
| if self.low is None or self.high is None: | |
| raise ValueError(f"A {self.type} search dimension requires 'low' and 'high'.") | |
| if self.low >= self.high: | |
| raise ValueError(f"Search bounds must satisfy low < high; got low={self.low}, high={self.high}.") | |
| if self.log and self.low <= 0: | |
| raise ValueError(f"Log-scaled search requires a positive lower bound; got {self.low}.") | |
| return self | |
| class ModelSpec(_StrictModel): | |
| enabled: bool | |
| estimator: str | |
| role: str | None = None | |
| params: dict[str, Any] = Field(default_factory=dict) | |
| search_space: dict[str, SearchDimSpec] = Field(default_factory=dict) | |
| def _reject_neural_estimators(cls, v: str) -> str: | |
| # Defence in depth behind the allow_neural_networks flag: catches an | |
| # estimator that would violate the constraint even if the flag were | |
| # somehow bypassed. | |
| forbidden = ("MLP", "torch", "tensorflow", "keras", "neural_network") | |
| if any(token.lower() in v.lower() for token in forbidden): | |
| raise ValueError(f"Estimator '{v}' appears to be a neural network, which this project forbids.") | |
| return v | |
| class HyperparameterSearchSpec(_StrictModel): | |
| engine: str | |
| n_trials: int = Field(gt=0) | |
| timeout_seconds: int = Field(gt=0) | |
| scope: str | |
| sampler: str | |
| seed: int | |
| def _inner_fold_only(cls, v: str) -> str: | |
| # PROJECT RULE: never tune hyperparameters on the test set. | |
| if v != "inner_fold_only": | |
| raise ValueError( | |
| f"hyperparameter_search.scope must be 'inner_fold_only'; got '{v}'. Tuning against " | |
| "outer-fold performance is tuning on the test set." | |
| ) | |
| return v | |
| class QuantileSpec(_StrictModel): | |
| enabled: bool | |
| estimator: str | |
| objective: str | |
| quantiles: list[float] = Field(min_length=2) | |
| def _valid_quantiles(cls, v: list[float]) -> list[float]: | |
| if any(not 0 < q < 1 for q in v): | |
| raise ValueError(f"Quantiles must lie strictly in (0, 1); got {v}.") | |
| if v != sorted(v) or len(set(v)) != len(v): | |
| raise ValueError(f"Quantiles must be strictly ascending and unique; got {v}.") | |
| return v | |
| def _symmetric_for_cqr(self) -> "QuantileSpec": | |
| # Conformalized quantile regression needs symmetric lower/upper pairs | |
| # to form intervals at a nominal level. | |
| for q in self.quantiles: | |
| if not any(abs((1 - q) - other) < 1e-9 for other in self.quantiles): | |
| raise ValueError( | |
| f"Quantile {q} has no complementary quantile {1 - q:.2f}; CQR intervals require " | |
| "symmetric pairs." | |
| ) | |
| return self | |
| class SecondaryClassifierSpec(_StrictModel): | |
| enabled: bool | |
| role: str | |
| estimators: list[str] = Field(min_length=1) | |
| class_weight: str | None = None | |
| min_class_count_warning: int = Field(ge=1) | |
| class GradingRouteSpec(_StrictModel): | |
| """How A/B/C grades are produced (reviewed decision after Phase 3).""" | |
| primary: Literal["ordinal_from_regression", "direct_classification"] | |
| secondary: SecondaryClassifierSpec | |
| def _secondary_is_not_the_headline(self) -> "GradingRouteSpec": | |
| # The secondary route exists for interpretability only. Requiring it to | |
| # declare that in its own role string keeps the distinction visible at | |
| # the point of use rather than only in a design document. | |
| if self.secondary.enabled and "secondary" not in self.secondary.role.lower(): | |
| raise ValueError( | |
| "grading_route.secondary.role must state that it is a secondary view; it must " | |
| "never be reported as the headline grading result." | |
| ) | |
| return self | |
| class EnsembleSpec(_StrictModel): | |
| enabled: bool | |
| meta_learner: str | |
| use_out_of_fold_only: bool | |
| def _oof_only(cls, v: bool) -> bool: | |
| if not v: | |
| raise ValueError( | |
| "ensemble.use_out_of_fold_only must be true; stacking on in-fold predictions leaks " | |
| "base-model training performance into the meta-learner." | |
| ) | |
| return v | |
| class RegistrySpec(_StrictModel): | |
| directory: str | |
| record_provenance: bool | |
| class ModelsConfig(_StrictModel): | |
| """Model spaces, CV protocol and seeds (``configs/models.yaml``).""" | |
| constraints: ModelConstraintsSpec | |
| target: TargetSpec | |
| cv: CVSpec | |
| splits: dict[str, SplitSchemeSpec] | |
| seeds: SeedsSpec | |
| bootstrap: BootstrapSpec | |
| metrics: MetricsSpec | |
| hyperparameter_search: HyperparameterSearchSpec | |
| models: dict[str, ModelSpec] | |
| quantile: QuantileSpec | |
| grading_route: GradingRouteSpec | |
| ensemble: EnsembleSpec | |
| registry: RegistrySpec | |
| def _check_protocol(self) -> "ModelsConfig": | |
| if len(self.seeds.outer_repeats) != self.cv.outer.n_repeats: | |
| raise ValueError( | |
| f"seeds.outer_repeats has {len(self.seeds.outer_repeats)} entries but " | |
| f"cv.outer.n_repeats is {self.cv.outer.n_repeats}; every repeat must have a fixed seed " | |
| "for the run to be reproducible." | |
| ) | |
| if not any(m.role == "floor" for m in self.models.values()): | |
| raise ValueError( | |
| "At least one dummy model must be declared with role 'floor'. Without a floor, a " | |
| "reported error is not interpretable." | |
| ) | |
| if not any(m.role == "severson_reproduction_and_baseline" for m in self.models.values()): | |
| raise ValueError( | |
| "No model is designated for the Severson reproduction (Gate 2), which is the " | |
| "credibility anchor of the project." | |
| ) | |
| return self | |
| def enabled_models(self) -> list[str]: | |
| return [name for name, spec in self.models.items() if spec.enabled] | |
| # --------------------------------------------------------------------------- | |
| # Bundle and loaders | |
| # --------------------------------------------------------------------------- | |
| CONFIG_SCHEMAS: dict[str, type[BaseModel]] = { | |
| "data": DataConfig, | |
| "features": FeaturesConfig, | |
| "models": ModelsConfig, | |
| "grading": GradingConfig, | |
| "costs": CostsConfig, | |
| } | |
| class ConfigBundle(_StrictModel): | |
| """All five validated configs, plus cross-file consistency checks. | |
| WHY cross-file validation: the individually valid files can still disagree | |
| with each other -- a cost matrix whose grades do not match the grading | |
| scheme is the obvious case, and it would produce a decision layer that | |
| indexes the wrong tier without ever raising. | |
| """ | |
| data: DataConfig | |
| features: FeaturesConfig | |
| models: ModelsConfig | |
| grading: GradingConfig | |
| costs: CostsConfig | |
| def _check_cross_config(self) -> "ConfigBundle": | |
| if self.costs.row_grades() != self.grading.grade_order: | |
| raise ValueError( | |
| f"Cost-matrix grades {self.costs.row_grades()} must match grading.grade_order " | |
| f"{self.grading.grade_order} in both membership and order; otherwise the decision " | |
| "layer indexes the wrong tier." | |
| ) | |
| configured_batches = set(self.data.batches) | |
| for name, scheme in self.models.splits.items(): | |
| for field in ("train_batches", "test_batches"): | |
| declared = getattr(scheme, field) or [] | |
| unknown = set(declared) - configured_batches | |
| if unknown: | |
| raise ValueError( | |
| f"splits.{name}.{field} references batches not defined in data.yaml: {sorted(unknown)}" | |
| ) | |
| ood_batches = {n for n, b in self.data.batches.items() if b.role == "out_of_distribution"} | |
| holdout = self.models.splits.get("batch_holdout") | |
| if holdout and holdout.enabled and set(holdout.test_batches or []) != ood_batches: | |
| raise ValueError( | |
| f"batch_holdout tests on {holdout.test_batches} but data.yaml marks {sorted(ood_batches)} " | |
| "as out_of_distribution. The OOD split must match the declared OOD batches." | |
| ) | |
| return self | |
| def unverified_assumptions(self) -> list[str]: | |
| """Outstanding ``verified: false`` entries that Phase 2 must close out.""" | |
| return self.data.unverified_assumptions() | |
| def load_config(name: str, config_dir: Path | None = None) -> BaseModel: | |
| """Load and validate a single config by stem (e.g. ``"costs"``). | |
| Raises: | |
| KeyError: If ``name`` is not one of the five known configs. | |
| FileNotFoundError: If the YAML file is absent. | |
| pydantic.ValidationError: If the contents violate the schema. | |
| """ | |
| if name not in CONFIG_SCHEMAS: | |
| raise KeyError(f"Unknown config '{name}'. Known configs: {sorted(CONFIG_SCHEMAS)}.") | |
| path = (config_dir or CONFIG_DIR) / f"{name}.yaml" | |
| if not path.exists(): | |
| raise FileNotFoundError(f"Config file not found: {path}") | |
| with path.open("r", encoding="utf-8") as handle: | |
| raw = yaml.safe_load(handle) | |
| if raw is None: | |
| raise ValueError(f"Config file is empty: {path}") | |
| return CONFIG_SCHEMAS[name].model_validate(raw) | |
| def load_configs(config_dir: Path | None = None) -> ConfigBundle: | |
| """Load and validate all five configs together, including cross-file checks.""" | |
| return ConfigBundle.model_validate({name: load_config(name, config_dir) for name in CONFIG_SCHEMAS}) | |
| def run_config_validation(config_dir: Path | None = None) -> ConfigBundle: | |
| """Validate every config and log a summary. Entry point for Phase 1 acceptance.""" | |
| from src.utils.logger import get_logger, log_section | |
| logger = get_logger("config") | |
| log_section(logger, "Configuration validation") | |
| bundle = load_configs(config_dir) | |
| logger.info("All %d configs loaded and validated against their schemas.", len(CONFIG_SCHEMAS)) | |
| logger.info( | |
| "Grades: %s | warranty target: %d cycles", | |
| ", ".join(f"{g}>={bundle.grading.grades[g].min_cycles}" for g in bundle.grading.grade_order), | |
| bundle.grading.warranty_target_cycles, | |
| ) | |
| logger.info( | |
| "Cost matrix: mean escape/overkill ratio = %.1f (sweep: %s)", | |
| bundle.costs.escape_overkill_ratio(), | |
| bundle.costs.sensitivity.escape_overkill_ratios, | |
| ) | |
| logger.info("Budgets: %s (reference %d)", bundle.features.budgets, bundle.features.reference_budget) | |
| logger.info("Enabled feature groups: %s", ", ".join(bundle.features.enabled_groups())) | |
| logger.info("Enabled models: %s", ", ".join(bundle.models.enabled_models())) | |
| logger.info("Exclusions declared: %d cells, each with a reason.", len(bundle.data.exclusions)) | |
| pending = bundle.unverified_assumptions() | |
| logger.warning("Unverified assumptions outstanding for Phase 2: %d", len(pending)) | |
| for item in pending: | |
| logger.warning(" [ ] %s", item) | |
| return bundle | |
| if __name__ == "__main__": | |
| run_config_validation() | |