eb-optimization 0.2.0__tar.gz → 0.2.3__tar.gz

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.
Files changed (33) hide show
  1. {eb_optimization-0.2.0/src/eb_optimization.egg-info → eb_optimization-0.2.3}/PKG-INFO +14 -4
  2. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/README.md +2 -2
  3. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/pyproject.toml +24 -7
  4. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/__init__.py +18 -3
  5. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/_utils.py +4 -8
  6. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/policies/__init__.py +33 -36
  7. eb_optimization-0.2.3/src/eb_optimization/policies/cost_ratio_policy.py +409 -0
  8. eb_optimization-0.2.3/src/eb_optimization/policies/dqc_policy.py +331 -0
  9. eb_optimization-0.2.3/src/eb_optimization/policies/ral_policy.py +600 -0
  10. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/policies/tau_policy.py +16 -19
  11. eb_optimization-0.2.3/src/eb_optimization/search/__init__.py +12 -0
  12. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/search/grid.py +6 -4
  13. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/search/kernels.py +14 -14
  14. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/tuning/__init__.py +5 -8
  15. eb_optimization-0.2.3/src/eb_optimization/tuning/cost_ratio.py +911 -0
  16. eb_optimization-0.2.3/src/eb_optimization/tuning/ral.py +175 -0
  17. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/tuning/sensitivity.py +31 -79
  18. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/tuning/tau.py +39 -110
  19. {eb_optimization-0.2.0 → eb_optimization-0.2.3/src/eb_optimization.egg-info}/PKG-INFO +14 -4
  20. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization.egg-info/SOURCES.txt +1 -1
  21. eb_optimization-0.2.3/src/eb_optimization.egg-info/requires.txt +29 -0
  22. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/tests/test_public_api.py +21 -9
  23. eb_optimization-0.2.0/src/eb_optimization/policies/cost_ratio_policy.py +0 -294
  24. eb_optimization-0.2.0/src/eb_optimization/policies/ral_policy.py +0 -162
  25. eb_optimization-0.2.0/src/eb_optimization/search/__init__.py +0 -32
  26. eb_optimization-0.2.0/src/eb_optimization/search/results.py +0 -0
  27. eb_optimization-0.2.0/src/eb_optimization/tuning/cost_ratio.py +0 -270
  28. eb_optimization-0.2.0/src/eb_optimization/tuning/ral.py +0 -144
  29. eb_optimization-0.2.0/src/eb_optimization.egg-info/requires.txt +0 -18
  30. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/LICENSE +0 -0
  31. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/setup.cfg +0 -0
  32. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization.egg-info/dependency_links.txt +0 -0
  33. {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eb-optimization
3
- Version: 0.2.0
3
+ Version: 0.2.3
4
4
  Summary: Electric Barometer: Optimization and tuning utilities for EB objectives and policy parameters.
5
5
  Author-email: "Kyle Corrie (Economistician)" <kcorrie@economistician.com>
6
6
  License-Expression: BSD-3-Clause
@@ -28,11 +28,21 @@ Provides-Extra: opt
28
28
  Provides-Extra: test
29
29
  Requires-Dist: pytest>=8.0; extra == "test"
30
30
  Requires-Dist: scikit-learn>=1.3; extra == "test"
31
- Requires-Dist: eb-optimization[eb]; extra == "test"
32
31
  Provides-Extra: dev
33
32
  Requires-Dist: pytest>=8.0; extra == "dev"
34
33
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
35
- Requires-Dist: eb-optimization[eb]; extra == "dev"
34
+ Requires-Dist: ruff>=0.0.1; extra == "dev"
35
+ Requires-Dist: pyright>=1.1; extra == "dev"
36
+ Requires-Dist: twine>=4.0; extra == "dev"
37
+ Provides-Extra: all
38
+ Requires-Dist: eb-metrics<0.3,>=0.2; extra == "all"
39
+ Requires-Dist: eb-evaluation<0.3,>=0.2; extra == "all"
40
+ Requires-Dist: pytest>=8.0; extra == "all"
41
+ Requires-Dist: scikit-learn>=1.3; extra == "all"
42
+ Requires-Dist: pytest-cov>=5.0; extra == "all"
43
+ Requires-Dist: ruff>=0.0.1; extra == "all"
44
+ Requires-Dist: pyright>=1.1; extra == "all"
45
+ Requires-Dist: twine>=4.0; extra == "all"
36
46
  Dynamic: license-file
37
47
 
38
48
  # Electric Barometer · Optimization (`eb-optimization`)
@@ -120,5 +130,5 @@ print(R)
120
130
 
121
131
  ## License
122
132
 
123
- BSD 3-Clause License.
133
+ BSD 3-Clause License.
124
134
  © 2025 Kyle Corrie.
@@ -83,5 +83,5 @@ print(R)
83
83
 
84
84
  ## License
85
85
 
86
- BSD 3-Clause License.
87
- © 2025 Kyle Corrie.
86
+ BSD 3-Clause License.
87
+ © 2025 Kyle Corrie.
@@ -3,7 +3,7 @@
3
3
  ######################################
4
4
  [project]
5
5
  name = "eb-optimization"
6
- version = "0.2.0"
6
+ version = "0.2.3"
7
7
  description = "Electric Barometer: Optimization and tuning utilities for EB objectives and policy parameters."
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.10"
@@ -67,22 +67,39 @@ eb = [
67
67
  ]
68
68
 
69
69
  # Optional optimization backends (future-proofing)
70
- # Keep empty for now; add later when you actually introduce them.
71
- opt = [
72
- ]
70
+ opt = []
73
71
 
74
72
  # CI / test-only dependencies
75
73
  test = [
76
74
  "pytest>=8.0",
77
75
  "scikit-learn>=1.3",
78
- "eb-optimization[eb]",
79
76
  ]
80
77
 
81
78
  # Local developer tooling
82
79
  dev = [
83
80
  "pytest>=8.0",
84
81
  "pytest-cov>=5.0",
85
- "eb-optimization[eb]",
82
+ "ruff>=0.0.1",
83
+ "pyright>=1.1",
84
+ "twine>=4.0",
85
+ ]
86
+
87
+ # Combined extra to satisfy the "extras: all" CI contract.
88
+ # Aggregates EB ecosystem dependencies and test tooling.
89
+ all = [
90
+ # EB ecosystem deps
91
+ "eb-metrics>=0.2,<0.3",
92
+ "eb-evaluation>=0.2,<0.3",
93
+
94
+ # test deps
95
+ "pytest>=8.0",
96
+ "scikit-learn>=1.3",
97
+
98
+ # dev tooling
99
+ "pytest-cov>=5.0",
100
+ "ruff>=0.0.1",
101
+ "pyright>=1.1",
102
+ "twine>=4.0",
86
103
  ]
87
104
 
88
105
  ######################################
@@ -103,4 +120,4 @@ where = ["src"]
103
120
  ######################################
104
121
  [tool.pytest.ini_options]
105
122
  pythonpath = ["src"]
106
- addopts = "-ra"
123
+ addopts = "-ra"
@@ -1,5 +1,3 @@
1
- from __future__ import annotations
2
-
3
1
  """
4
2
  `eb_optimization` — optimization and tuning layer for the Electric Barometer ecosystem.
5
3
 
@@ -13,8 +11,18 @@ It intentionally does **not** define metric primitives or evaluation math.
13
11
  Those live in `eb-metrics` (and orchestration lives in `eb-evaluation`).
14
12
  """
15
13
 
14
+ from __future__ import annotations
15
+
16
16
  from importlib.metadata import PackageNotFoundError, version
17
17
 
18
+ from .policies.ral_policy import (
19
+ DEFAULT_RAL_POLICY,
20
+ RALDeltas,
21
+ RALTwoBandPolicy,
22
+ apply_ral_policy,
23
+ )
24
+ from .tuning.cost_ratio import EntityCostRatioEstimate
25
+
18
26
 
19
27
  def _resolve_version() -> str:
20
28
  """
@@ -35,4 +43,11 @@ def _resolve_version() -> str:
35
43
 
36
44
  __version__ = _resolve_version()
37
45
 
38
- __all__ = ["__version__"]
46
+ __all__ = [
47
+ "DEFAULT_RAL_POLICY",
48
+ "EntityCostRatioEstimate",
49
+ "RALDeltas",
50
+ "RALTwoBandPolicy",
51
+ "__version__",
52
+ "apply_ral_policy",
53
+ ]
@@ -4,9 +4,9 @@ import numpy as np
4
4
  from numpy.typing import ArrayLike
5
5
 
6
6
  __all__ = [
7
- "to_1d_array",
8
7
  "broadcast_param",
9
8
  "handle_sample_weight",
9
+ "to_1d_array",
10
10
  ]
11
11
 
12
12
 
@@ -73,9 +73,7 @@ def broadcast_param(x: ArrayLike, shape: tuple[int, ...], name: str) -> np.ndarr
73
73
  return np.full(shape, float(arr), dtype=float)
74
74
 
75
75
  if arr.shape != shape:
76
- raise ValueError(
77
- f"{name} must be scalar or have shape {shape}; got shape {arr.shape}"
78
- )
76
+ raise ValueError(f"{name} must be scalar or have shape {shape}; got shape {arr.shape}")
79
77
 
80
78
  return arr
81
79
 
@@ -112,11 +110,9 @@ def handle_sample_weight(sample_weight: ArrayLike | None, n: int) -> np.ndarray:
112
110
  w = np.asarray(sample_weight, dtype=float)
113
111
 
114
112
  if w.ndim != 1 or w.shape[0] != n:
115
- raise ValueError(
116
- f"sample_weight must be a 1D array of length {n}; got shape {w.shape}"
117
- )
113
+ raise ValueError(f"sample_weight must be a 1D array of length {n}; got shape {w.shape}")
118
114
 
119
115
  if np.any(w < 0):
120
116
  raise ValueError("sample_weight must be non-negative.")
121
117
 
122
- return w
118
+ return w
@@ -1,5 +1,3 @@
1
- from __future__ import annotations
2
-
3
1
  """
4
2
  Frozen policy artifacts for the Electric Barometer optimization layer.
5
3
 
@@ -25,52 +23,51 @@ Exported policies
25
23
  - Tau (τ) tolerance governance for HR@τ
26
24
  - Cost-ratio (R = c_u / c_o) governance for asymmetric loss
27
25
  - RAL policy governance (readiness adjustment layer)
26
+ - DQC (Δ*) governance for packed / quantized demand snapping and evaluation
28
27
  """
29
28
 
30
- # ---------------------------------------------------------------------
31
- # Tau (tolerance) policies
32
- # ---------------------------------------------------------------------
33
- from .tau_policy import (
34
- TauPolicy,
35
- apply_tau_policy,
36
- apply_tau_policy_hr,
37
- apply_entity_tau_policy,
38
- )
29
+ from __future__ import annotations
39
30
 
40
- # ---------------------------------------------------------------------
41
- # Cost-ratio (R) policies
42
- # ---------------------------------------------------------------------
43
31
  from .cost_ratio_policy import (
44
- CostRatioPolicy,
45
32
  DEFAULT_COST_RATIO_POLICY,
33
+ CostRatioPolicy,
46
34
  apply_cost_ratio_policy,
47
35
  apply_entity_cost_ratio_policy,
48
36
  )
49
-
50
- # ---------------------------------------------------------------------
51
- # RAL policies
52
- # ---------------------------------------------------------------------
53
- from .ral_policy import (
54
- RALPolicy,
55
- DEFAULT_RAL_POLICY,
56
- apply_ral_policy,
37
+ from .dqc_policy import (
38
+ DEFAULT_DQC_POLICY,
39
+ DQCPolicy,
40
+ DQCResult,
41
+ compute_dqc,
42
+ enforce_snapping,
43
+ hr_at_tau_grid_units,
44
+ snap_to_grid,
45
+ )
46
+ from .ral_policy import DEFAULT_RAL_POLICY, RALPolicy, apply_ral_policy
47
+ from .tau_policy import (
48
+ TauPolicy,
49
+ apply_entity_tau_policy,
50
+ apply_tau_policy,
51
+ apply_tau_policy_hr,
57
52
  )
58
53
 
59
54
  __all__ = [
60
- # Tau policies
61
- "TauPolicy",
62
- "apply_tau_policy",
63
- "apply_tau_policy_hr",
64
- "apply_entity_tau_policy",
65
-
66
- # Cost ratio policies
67
- "CostRatioPolicy",
68
55
  "DEFAULT_COST_RATIO_POLICY",
56
+ "DEFAULT_DQC_POLICY",
57
+ "DEFAULT_RAL_POLICY",
58
+ "CostRatioPolicy",
59
+ "DQCPolicy",
60
+ "DQCResult",
61
+ "RALPolicy",
62
+ "TauPolicy",
69
63
  "apply_cost_ratio_policy",
70
64
  "apply_entity_cost_ratio_policy",
71
-
72
- # RAL policies
73
- "RALPolicy",
74
- "DEFAULT_RAL_POLICY",
65
+ "apply_entity_tau_policy",
75
66
  "apply_ral_policy",
76
- ]
67
+ "apply_tau_policy",
68
+ "apply_tau_policy_hr",
69
+ "compute_dqc",
70
+ "enforce_snapping",
71
+ "hr_at_tau_grid_units",
72
+ "snap_to_grid",
73
+ ]
@@ -0,0 +1,409 @@
1
+ """
2
+ Cost-ratio (R = c_u / c_o) policy artifacts for eb-optimization.
3
+
4
+ This module defines *frozen governance* for selecting and applying a cost ratio `R`
5
+ (and derived underbuild cost `c_u`) used by asymmetric cost metrics like CWSL.
6
+
7
+ Layering & responsibilities
8
+ ---------------------------
9
+ - `tuning/cost_ratio.py`:
10
+ Calibration logic (estimating R from residuals / cost balance).
11
+ - `policies/cost_ratio_policy.py`:
12
+ Frozen configuration + deterministic application wrappers.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Sequence
18
+ from dataclasses import dataclass
19
+ from typing import Any, Literal, cast
20
+ import warnings
21
+
22
+ import numpy as np
23
+ from numpy.typing import ArrayLike
24
+ import pandas as pd
25
+
26
+ from eb_optimization.tuning.cost_ratio import (
27
+ CostRatioEstimate,
28
+ EntityCostRatioEstimate,
29
+ estimate_entity_R_from_balance,
30
+ estimate_R_cost_balance,
31
+ )
32
+
33
+ GateMode = Literal["off", "warn", "raise"]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class CostRatioPolicy:
38
+ """
39
+ Frozen cost-ratio (R) policy configuration.
40
+
41
+ Attributes
42
+ ----------
43
+ R_grid : Sequence[float]
44
+ Candidate ratios to search. Only strictly positive values are considered.
45
+ co : float
46
+ Default overbuild cost coefficient used for entity-level estimation.
47
+ min_n : int
48
+ Minimum number of observations required to estimate an entity-level R.
49
+ """
50
+
51
+ R_grid: Sequence[float] = (0.5, 1.0, 2.0, 3.0)
52
+ co: float = 1.0
53
+ min_n: int = 30
54
+
55
+ def __post_init__(self) -> None:
56
+ grid = np.asarray(list(self.R_grid), dtype=float)
57
+ if grid.ndim != 1 or grid.size == 0:
58
+ raise ValueError("R_grid must be a non-empty 1D sequence of floats.")
59
+ if not np.any(grid > 0):
60
+ raise ValueError("R_grid must contain at least one strictly positive value.")
61
+
62
+ if not np.isfinite(self.co) or float(self.co) <= 0:
63
+ raise ValueError(f"co must be finite and strictly positive. Got {self.co}.")
64
+
65
+ if self.min_n < 1:
66
+ raise ValueError(f"min_n must be >= 1. Got {self.min_n}.")
67
+
68
+
69
+ DEFAULT_COST_RATIO_POLICY = CostRatioPolicy()
70
+
71
+
72
+ def _handle_identifiability_gate(
73
+ *,
74
+ gate: GateMode,
75
+ ok: bool,
76
+ message: str,
77
+ override_reason: str | None,
78
+ ) -> dict[str, Any]:
79
+ """
80
+ Warn-only / gateable hook shared by global + entity policy applications.
81
+
82
+ Returns a JSON-serializable dict with the gating decision metadata that can be
83
+ merged into diagnostics payloads.
84
+ """
85
+ gate = cast(GateMode, gate)
86
+ if gate not in ("off", "warn", "raise"):
87
+ raise ValueError("gate must be one of: 'off', 'warn', 'raise'")
88
+
89
+ meta: dict[str, Any] = {
90
+ "gate_mode": gate,
91
+ "gate_triggered": bool(not ok),
92
+ "gate_overridden": bool((not ok) and (override_reason is not None)),
93
+ "gate_override_reason": override_reason,
94
+ }
95
+
96
+ if ok or gate == "off":
97
+ return meta
98
+
99
+ # If not ok:
100
+ if override_reason is not None:
101
+ # Override: do not warn/raise; record reason.
102
+ return meta
103
+
104
+ if gate == "warn":
105
+ warnings.warn(message, RuntimeWarning, stacklevel=2)
106
+ return meta
107
+
108
+ # gate == "raise"
109
+ raise ValueError(message)
110
+
111
+
112
+ def apply_cost_ratio_policy(
113
+ y_true: ArrayLike,
114
+ y_pred: ArrayLike,
115
+ *,
116
+ policy: CostRatioPolicy = DEFAULT_COST_RATIO_POLICY,
117
+ co: float | ArrayLike | None = None,
118
+ sample_weight: ArrayLike | None = None,
119
+ gate: GateMode = "warn",
120
+ identifiability_override_reason: str | None = None,
121
+ ) -> tuple[float, dict[str, Any]]:
122
+ """
123
+ Apply a frozen cost-ratio policy to estimate a global R.
124
+
125
+ Notes
126
+ -----
127
+ This policy boundary surfaces identifiability / stability diagnostics when available.
128
+ It does NOT change the selection behavior; it only enriches the returned diagnostics.
129
+
130
+ Gating
131
+ ------
132
+ `gate` controls what happens when tuning reports `is_identifiable=False`:
133
+
134
+ - gate="off" : no action (still reports diagnostics)
135
+ - gate="warn" : emit a RuntimeWarning (default)
136
+ - gate="raise": raise ValueError
137
+
138
+ Overrides
139
+ ---------
140
+ If `identifiability_override_reason` is provided, the gate will not warn/raise,
141
+ and the reason is recorded in diagnostics for auditability.
142
+ """
143
+ co_val = policy.co if co is None else co
144
+
145
+ # Request the richer artifact so we can surface identifiability at the policy boundary.
146
+ est_any: Any = estimate_R_cost_balance(
147
+ y_true=y_true,
148
+ y_pred=y_pred,
149
+ R_grid=policy.R_grid,
150
+ co=co_val,
151
+ sample_weight=sample_weight,
152
+ return_curve=True,
153
+ selection="curve",
154
+ )
155
+
156
+ stability_diag: dict[str, Any]
157
+ gate_meta: dict[str, Any] = {}
158
+
159
+ if isinstance(est_any, CostRatioEstimate):
160
+ est = est_any
161
+ R = float(est.R_star)
162
+
163
+ ok = bool(est.is_identifiable)
164
+ msg = (
165
+ "Cost ratio calibration is not identifiable/stable under configured diagnostics. "
166
+ f"(rel_min_gap={float(est.rel_min_gap):.6g}, "
167
+ f"grid_instability_log={float(est.grid_instability_log):.6g}, "
168
+ f"R_star={float(est.R_star):.6g}, R_range=[{float(est.R_min):.6g}, {float(est.R_max):.6g}])."
169
+ )
170
+
171
+ gate_meta = _handle_identifiability_gate(
172
+ gate=gate,
173
+ ok=ok,
174
+ message=msg,
175
+ override_reason=identifiability_override_reason,
176
+ )
177
+
178
+ stability_diag = {
179
+ # Policy-surfaced stability fields (governance/reporting only)
180
+ "rel_min_gap": float(est.rel_min_gap),
181
+ "R_min": float(est.R_min),
182
+ "R_max": float(est.R_max),
183
+ "grid_instability_log": float(est.grid_instability_log),
184
+ "is_identifiable": bool(est.is_identifiable),
185
+ # Full tuning diagnostics
186
+ "calibration_diagnostics": dict(est.diagnostics),
187
+ }
188
+ else:
189
+ R = float(est_any)
190
+ stability_diag = {
191
+ "calibration_diagnostics": {
192
+ "note": "Upstream returned a scalar R; stability diagnostics unavailable.",
193
+ }
194
+ }
195
+
196
+ diag: dict[str, Any] = {
197
+ "method": "cost_balance",
198
+ "R_grid": [float(x) for x in policy.R_grid],
199
+ "co_is_array": isinstance(co_val, list | tuple | np.ndarray | pd.Series),
200
+ "co_default_used": co is None,
201
+ "R": float(R),
202
+ **stability_diag,
203
+ **({"identifiability_gate": gate_meta} if gate_meta else {}),
204
+ }
205
+ return (float(R), diag)
206
+
207
+
208
+ def apply_entity_cost_ratio_policy(
209
+ df: pd.DataFrame,
210
+ *,
211
+ entity_col: str,
212
+ y_true_col: str,
213
+ y_pred_col: str,
214
+ policy: CostRatioPolicy = DEFAULT_COST_RATIO_POLICY,
215
+ co: float | None = None,
216
+ sample_weight_col: str | None = None,
217
+ include_diagnostics: bool = True,
218
+ gate: GateMode = "warn",
219
+ identifiability_override_reason: str | None = None,
220
+ ) -> pd.DataFrame:
221
+ """
222
+ Apply a frozen cost-ratio policy per entity.
223
+
224
+ Notes
225
+ -----
226
+ This policy boundary surfaces per-entity calibration diagnostics (in the
227
+ `diagnostics` column) for eligible entities when `include_diagnostics=True`.
228
+
229
+ Entity-level identifiability
230
+ ----------------------------
231
+ The tuning artifact returns per-entity `diagnostics` dicts. If those dicts contain
232
+ an `is_identifiable` field, this function will:
233
+ - surface a convenience `is_identifiable` column, and
234
+ - optionally warn/raise based on `gate`.
235
+
236
+ If no such field exists (older tuning versions), gating is a no-op.
237
+ """
238
+ # ---- validation: columns ----
239
+ required_cols = {entity_col, y_true_col, y_pred_col}
240
+ if sample_weight_col is not None:
241
+ required_cols.add(sample_weight_col)
242
+
243
+ missing = [c for c in required_cols if c not in df.columns]
244
+ if missing:
245
+ raise KeyError(f"Missing required columns: {missing}")
246
+
247
+ co_val = float(policy.co if co is None else co)
248
+ if not np.isfinite(co_val) or co_val <= 0:
249
+ raise ValueError(f"co must be finite and strictly positive. Got {co_val}.")
250
+
251
+ # ---- governance: identify eligible entities ----
252
+ counts_ser = cast(pd.Series, df.groupby(entity_col, dropna=False, sort=False).size())
253
+ eligible_counts = cast(pd.Series, counts_ser[counts_ser >= policy.min_n])
254
+ eligible_list = cast(list[Any], eligible_counts.index.tolist())
255
+
256
+ mask = df[entity_col].isin(eligible_list)
257
+
258
+ eligible_df = cast(pd.DataFrame, df[mask]).copy()
259
+ ineligible_df = cast(pd.DataFrame, df[~mask]).copy()
260
+
261
+ results_list: list[pd.DataFrame] = []
262
+
263
+ gate_meta: dict[str, Any] = {}
264
+ failed_entities: list[Any] = []
265
+
266
+ # Track whether we can gate at all (only if the diagnostics dicts contain is_identifiable)
267
+ identifiability_available = False
268
+
269
+ if not eligible_df.empty:
270
+ tuned_any: Any = estimate_entity_R_from_balance(
271
+ df=eligible_df,
272
+ entity_col=entity_col,
273
+ y_true_col=y_true_col,
274
+ y_pred_col=y_pred_col,
275
+ ratios=policy.R_grid,
276
+ co=co_val,
277
+ sample_weight_col=sample_weight_col,
278
+ return_result=True,
279
+ selection="curve",
280
+ )
281
+ tuned_art = cast(EntityCostRatioEstimate, tuned_any)
282
+
283
+ tuned_table = cast(pd.DataFrame, tuned_art.table).copy()
284
+
285
+ tuned = pd.DataFrame(
286
+ {
287
+ entity_col: tuned_table[entity_col],
288
+ "R": tuned_table["R_star"].astype(float),
289
+ "cu": (tuned_table["R_star"].astype(float) * float(co_val)).astype(float),
290
+ "co": float(co_val),
291
+ "under_cost": tuned_table["under_cost"].astype(float),
292
+ "over_cost": tuned_table["over_cost"].astype(float),
293
+ "diff": tuned_table["gap"].astype(float),
294
+ }
295
+ )
296
+
297
+ # Always attach diagnostics internally (for gating), drop later if include_diagnostics=False
298
+ if "diagnostics" in tuned_table.columns:
299
+ tuned["diagnostics"] = tuned_table["diagnostics"]
300
+ else:
301
+ tuned["diagnostics"] = None
302
+
303
+ tuned["reason"] = None
304
+
305
+ mapper: Any = counts_ser
306
+ tuned["n"] = tuned[entity_col].map(mapper).astype(int)
307
+
308
+ # Surface an `is_identifiable` column if diagnostics provides it.
309
+ # This is a convenience field for users/tests and lets gating be explicit/inspectable.
310
+ def _extract_is_identifiable(v: Any) -> Any:
311
+ if isinstance(v, dict) and ("is_identifiable" in v):
312
+ return bool(v.get("is_identifiable"))
313
+ return None
314
+
315
+ tuned["is_identifiable"] = tuned["diagnostics"].map(_extract_is_identifiable)
316
+
317
+ # ---- FIX: make the conditional unambiguously bool for Pyright ----
318
+ has_ident = bool(tuned["is_identifiable"].notna().to_numpy().any())
319
+ if has_ident:
320
+ identifiability_available = True
321
+ failed_mask = tuned["is_identifiable"] == False # noqa: E712
322
+ failed_entities = cast(list[Any], tuned.loc[failed_mask, entity_col].tolist())
323
+
324
+ if identifiability_available and failed_entities:
325
+ msg = (
326
+ "One or more entities have non-identifiable cost ratio calibration. "
327
+ f"Failed entities (first 10): {failed_entities[:10]!r}. "
328
+ "You may override with identifiability_override_reason."
329
+ )
330
+ gate_meta = _handle_identifiability_gate(
331
+ gate=gate,
332
+ ok=False,
333
+ message=msg,
334
+ override_reason=identifiability_override_reason,
335
+ )
336
+
337
+ results_list.append(tuned)
338
+
339
+ if not ineligible_df.empty:
340
+ ineligible_rows = cast(pd.DataFrame, ineligible_df[[entity_col]]).drop_duplicates()
341
+ ineligible_rows = ineligible_rows.assign(
342
+ R=np.nan,
343
+ cu=np.nan,
344
+ co=co_val,
345
+ under_cost=np.nan,
346
+ over_cost=np.nan,
347
+ diff=np.nan,
348
+ reason=f"min_n_not_met(<{policy.min_n})",
349
+ diagnostics=None,
350
+ is_identifiable=None,
351
+ )
352
+
353
+ mapper_ineligible: Any = counts_ser
354
+ ineligible_rows["n"] = ineligible_rows[entity_col].map(mapper_ineligible).astype(int)
355
+ results_list.append(ineligible_rows)
356
+
357
+ if not results_list:
358
+ schema_cols = [
359
+ entity_col,
360
+ "R",
361
+ "cu",
362
+ "co",
363
+ "n",
364
+ "reason",
365
+ "under_cost",
366
+ "over_cost",
367
+ "diff",
368
+ ]
369
+ if include_diagnostics:
370
+ schema_cols.append("diagnostics")
371
+ # Include is_identifiable if we can ever surface it (safe default: include it anyway)
372
+ schema_cols.append("is_identifiable")
373
+ if gate_meta:
374
+ schema_cols.append("identifiability_gate")
375
+
376
+ return pd.DataFrame(columns=pd.Index(schema_cols))
377
+
378
+ out = pd.concat(results_list, ignore_index=True, sort=False)
379
+
380
+ # Attach gate metadata as a repeated column (JSON-serializable) if it exists
381
+ if gate_meta:
382
+ out["identifiability_gate"] = [gate_meta] * int(out.shape[0])
383
+
384
+ base_cols = [entity_col, "R", "cu", "co", "n", "reason", "is_identifiable"]
385
+ diag_cols = ["under_cost", "over_cost", "diff"]
386
+
387
+ if include_diagnostics:
388
+ base_cols.append("diagnostics")
389
+
390
+ if not include_diagnostics and "diagnostics" in out.columns:
391
+ out = out.drop(columns=["diagnostics"])
392
+
393
+ if "identifiability_gate" in out.columns:
394
+ base_cols.append("identifiability_gate")
395
+
396
+ remaining = [str(c) for c in out.columns if c not in base_cols + diag_cols]
397
+ target_cols = (
398
+ (base_cols + diag_cols + remaining) if include_diagnostics else (base_cols + remaining)
399
+ )
400
+
401
+ return cast(pd.DataFrame, out[pd.Index(target_cols)])
402
+
403
+
404
+ __all__ = [
405
+ "DEFAULT_COST_RATIO_POLICY",
406
+ "CostRatioPolicy",
407
+ "apply_cost_ratio_policy",
408
+ "apply_entity_cost_ratio_policy",
409
+ ]