File size: 42,698 Bytes
749bffa
 
 
 
 
 
d92710f
 
749bffa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d92710f
 
749bffa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
"""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

    @model_validator(mode="after")
    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)

    @field_validator("escape_overkill_ratios")
    @classmethod
    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

    @model_validator(mode="after")
    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

    @model_validator(mode="after")
    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

    @model_validator(mode="after")
    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

    @model_validator(mode="after")
    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

    @field_validator("reason")
    @classmethod
    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

    @model_validator(mode="after")
    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)

    @model_validator(mode="after")
    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

    @field_validator("voltage_range_v", "temperature_range_c")
    @classmethod
    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

    @model_validator(mode="after")
    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

    @model_validator(mode="after")
    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

    @field_validator("fit_inside_cv_only")
    @classmethod
    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

    @field_validator("budgets")
    @classmethod
    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

    @model_validator(mode="after")
    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

    @field_validator("allow_neural_networks")
    @classmethod
    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)

    @model_validator(mode="after")
    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)

    @field_validator("outer_repeats")
    @classmethod
    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

    @model_validator(mode="after")
    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

    @model_validator(mode="after")
    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)

    @field_validator("estimator")
    @classmethod
    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

    @field_validator("scope")
    @classmethod
    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)

    @field_validator("quantiles")
    @classmethod
    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

    @model_validator(mode="after")
    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

    @model_validator(mode="after")
    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

    @field_validator("use_out_of_fold_only")
    @classmethod
    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

    @model_validator(mode="after")
    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

    @model_validator(mode="after")
    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()