closed-loop-default-detection 0.2.0__tar.gz → 0.3.0__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 (44) hide show
  1. {closed_loop_default_detection-0.2.0/src/closed_loop_default_detection.egg-info → closed_loop_default_detection-0.3.0}/PKG-INFO +31 -2
  2. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/README.md +29 -0
  3. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/pyproject.toml +2 -2
  4. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/emp.py +47 -0
  5. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/feedback.py +69 -12
  6. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0/src/closed_loop_default_detection.egg-info}/PKG-INFO +31 -2
  7. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/closed_loop_default_detection.egg-info/SOURCES.txt +3 -0
  8. closed_loop_default_detection-0.3.0/tests/test_book_profit.py +329 -0
  9. closed_loop_default_detection-0.3.0/tests/test_feedback_flags.py +307 -0
  10. closed_loop_default_detection-0.3.0/tests/test_sweep_stats.py +501 -0
  11. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/LICENSE +0 -0
  12. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/setup.cfg +0 -0
  13. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/__init__.py +0 -0
  14. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/config.py +0 -0
  15. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/correctors.py +0 -0
  16. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/counterfactual.py +0 -0
  17. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/diagnostics.py +0 -0
  18. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/eval_default.py +0 -0
  19. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/fidelity.py +0 -0
  20. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/loop.py +0 -0
  21. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/model_pd.py +0 -0
  22. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/py.typed +0 -0
  23. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/reject_inference.py +0 -0
  24. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/scm.py +0 -0
  25. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/synthetic.py +0 -0
  26. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/closed_loop_default_detection.egg-info/dependency_links.txt +0 -0
  27. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/closed_loop_default_detection.egg-info/requires.txt +0 -0
  28. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/closed_loop_default_detection.egg-info/top_level.txt +0 -0
  29. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_correctors.py +0 -0
  30. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_counterfactual.py +0 -0
  31. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_diagnostics.py +0 -0
  32. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_emp.py +0 -0
  33. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_eval_default.py +0 -0
  34. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_exploration.py +0 -0
  35. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_feedback.py +0 -0
  36. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_fidelity.py +0 -0
  37. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_fidelity_report.py +0 -0
  38. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_loop.py +0 -0
  39. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_loop_scm.py +0 -0
  40. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_model_pd.py +0 -0
  41. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_reject_inference.py +0 -0
  42. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_scm_gating.py +0 -0
  43. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_sklearn_compat.py +0 -0
  44. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_synthetic.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: closed-loop-default-detection
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Closed loop that measures selective-labels default detection on synthetic SMB lending cohorts and finds the PD model's operating frontier.
5
- Author-email: Hossain Pazooki <hossain@pazooki.com>
5
+ Author: Hossain Pazooki
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/hossainpazooki/closed-loop-default-detection
8
8
  Project-URL: Repository, https://github.com/hossainpazooki/closed-loop-default-detection
@@ -148,6 +148,35 @@ seed 42, 10% budget, severity 0.6): the lever buys **157 labels for $439,578**
148
148
  in dollars a lender can see, on the only lever that buys it rather than reweighting what is
149
149
  already identified.
150
150
 
151
+ ## Pricing the feedback loop (v3)
152
+
153
+ `v0.3.0` prices the **dynamic** regime, where the model's own approvals create its next
154
+ training set: three arms (treatment = retrain each generation; **frozen** = generation-0
155
+ model deployed forever; **prior** = prior-policy funding), 25 spaced seeds × 3 severities ×
156
+ 2 exploration rates × 12 generations = **450 runs**
157
+ (`artifacts/feedback_profit_sweep.csv`), with three pre-registered hypotheses tested as
158
+ within-seed paired contrasts on realized funded-book P&L at severity 0.4, Holm-corrected.
159
+ Every number below recomputes via `python scripts/feedback_sweep_stats.py`. All of it
160
+ consumes **planted, risk-unlinked default timing** — a verified *experiment*, not a
161
+ verified *result*.
162
+
163
+ | Hypothesis (severity 0.4, gens 1–11) | Median paired deficit | Sign | Verdict |
164
+ |---|---|---|---|
165
+ | H1 feedback accumulation costs money (treatment − frozen, ε=0) | **−0.0028** | 24/25 | **confirmed** (p_holm 2.3e-06, clears the 0.0018 noise floor) |
166
+ | H2 the policy switch costs money (frozen − prior, ε=0) | +0.0119 | 0/25 | **not confirmed — measured opposite in direction** |
167
+ | H3 exploration buys profit back (treatment ε=.05 − ε=0) | −0.0024 | 0/25 | **not confirmed** |
168
+
169
+ The decomposition v3 exists for came back one-for-three: letting the loop close on its own
170
+ labels measurably costs the book (H1), but at these severities the *switch* to model-based
171
+ funding is profitable relative to the prior policy (H2's predicted sign was wrong — reported
172
+ as measured, not softened), and 5% exploration does not pay for itself in-window (H3; the
173
+ policy-book-only secondary H3a is negative too). The **H4 integrity control** — frozen-arm
174
+ paired difference must equal the explored slice's own P&L *exactly* — holds at ≤1e-10 across
175
+ all 900 checked rows, and the answer to the v2-banked question is re-verified at 25 seeds:
176
+ the declined-ECE alarm fires at generation 1–2 in every ε=0 run (severity 0.4: 25/25 at
177
+ generation 1). Full mechanics in [`docs/how-it-works.md`](docs/how-it-works.md); gates in
178
+ [`docs/validation.md`](docs/validation.md).
179
+
151
180
  ## Install
152
181
 
153
182
  ```bash
@@ -114,6 +114,35 @@ seed 42, 10% budget, severity 0.6): the lever buys **157 labels for $439,578**
114
114
  in dollars a lender can see, on the only lever that buys it rather than reweighting what is
115
115
  already identified.
116
116
 
117
+ ## Pricing the feedback loop (v3)
118
+
119
+ `v0.3.0` prices the **dynamic** regime, where the model's own approvals create its next
120
+ training set: three arms (treatment = retrain each generation; **frozen** = generation-0
121
+ model deployed forever; **prior** = prior-policy funding), 25 spaced seeds × 3 severities ×
122
+ 2 exploration rates × 12 generations = **450 runs**
123
+ (`artifacts/feedback_profit_sweep.csv`), with three pre-registered hypotheses tested as
124
+ within-seed paired contrasts on realized funded-book P&L at severity 0.4, Holm-corrected.
125
+ Every number below recomputes via `python scripts/feedback_sweep_stats.py`. All of it
126
+ consumes **planted, risk-unlinked default timing** — a verified *experiment*, not a
127
+ verified *result*.
128
+
129
+ | Hypothesis (severity 0.4, gens 1–11) | Median paired deficit | Sign | Verdict |
130
+ |---|---|---|---|
131
+ | H1 feedback accumulation costs money (treatment − frozen, ε=0) | **−0.0028** | 24/25 | **confirmed** (p_holm 2.3e-06, clears the 0.0018 noise floor) |
132
+ | H2 the policy switch costs money (frozen − prior, ε=0) | +0.0119 | 0/25 | **not confirmed — measured opposite in direction** |
133
+ | H3 exploration buys profit back (treatment ε=.05 − ε=0) | −0.0024 | 0/25 | **not confirmed** |
134
+
135
+ The decomposition v3 exists for came back one-for-three: letting the loop close on its own
136
+ labels measurably costs the book (H1), but at these severities the *switch* to model-based
137
+ funding is profitable relative to the prior policy (H2's predicted sign was wrong — reported
138
+ as measured, not softened), and 5% exploration does not pay for itself in-window (H3; the
139
+ policy-book-only secondary H3a is negative too). The **H4 integrity control** — frozen-arm
140
+ paired difference must equal the explored slice's own P&L *exactly* — holds at ≤1e-10 across
141
+ all 900 checked rows, and the answer to the v2-banked question is re-verified at 25 seeds:
142
+ the declined-ECE alarm fires at generation 1–2 in every ε=0 run (severity 0.4: 25/25 at
143
+ generation 1). Full mechanics in [`docs/how-it-works.md`](docs/how-it-works.md); gates in
144
+ [`docs/validation.md`](docs/validation.md).
145
+
117
146
  ## Install
118
147
 
119
148
  ```bash
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "closed-loop-default-detection"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Closed loop that measures selective-labels default detection on synthetic SMB lending cohorts and finds the PD model's operating frontier."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "MIT"
12
12
  license-files = ["LICENSE"]
13
- authors = [{ name = "Hossain Pazooki", email = "hossain@pazooki.com" }]
13
+ authors = [{ name = "Hossain Pazooki" }]
14
14
  keywords = [
15
15
  "selective-labels",
16
16
  "probability-of-default",
@@ -49,6 +49,7 @@ __all__ = [
49
49
  "emp_harness",
50
50
  "loss_fractions",
51
51
  "realized_profits",
52
+ "realized_book_profit",
52
53
  ]
53
54
 
54
55
 
@@ -304,6 +305,52 @@ def realized_profits(
304
305
  return profit
305
306
 
306
307
 
308
+ def realized_book_profit(
309
+ y_true: np.ndarray,
310
+ default_day: np.ndarray | None,
311
+ requested_amount: np.ndarray,
312
+ funded: np.ndarray,
313
+ economics: LoanEconomics | None = None,
314
+ ) -> float | None:
315
+ """Realized book P&L of the funded rows, normalized by full-cohort exposure.
316
+
317
+ Reuses :func:`realized_profits` priced over the FULL cohort, then sums the
318
+ funded rows -- so day-90 rows take the decision-5 mean-body imputation with
319
+ the mean computed from the *cohort's* body defaulters (lambda = 1 fallback
320
+ when the cohort body is empty, see :func:`loss_fractions`). The basis is a
321
+ planted cohort property, invariant to who funds: pricing on the funded
322
+ slice's own body mean made book P&L non-additive across slices and broke
323
+ the H4 arithmetic identity at the pilot gate (spec Rev 2.1 amendment,
324
+ 2026-07-19). Returns ``None`` when timing columns are absent (flat cohorts,
325
+ ``default_day is None``), mirroring :func:`emp_harness`. **SCM-only, like
326
+ everything in v3.**
327
+
328
+ Units: total realized profit of funded rows / (n_applicants * mean
329
+ ``requested_amount`` over the FULL cohort, not funded-only) -- profit per
330
+ applicant as a fraction of cohort mean exposure (v2 decision 6 extended);
331
+ the full-cohort denominator keeps arms with different funded counts
332
+ comparable and paired differences well-defined.
333
+
334
+ Pure numpy, zero RNG, deterministic.
335
+ """
336
+ if default_day is None:
337
+ return None
338
+
339
+ y_true = np.asarray(y_true, dtype=float)
340
+ default_day = np.asarray(default_day, dtype=float)
341
+ requested_amount = np.asarray(requested_amount, dtype=float)
342
+ funded = np.asarray(funded, dtype=bool)
343
+ n = y_true.shape[0]
344
+
345
+ mean_a = float(np.mean(requested_amount)) if n > 0 else 0.0
346
+ denom = n * mean_a
347
+ if denom == 0.0:
348
+ return float("nan")
349
+
350
+ profits = realized_profits(y_true, default_day, requested_amount, economics)
351
+ return float(np.sum(profits[funded])) / denom
352
+
353
+
307
354
  def emp_harness(
308
355
  y_true: np.ndarray,
309
356
  scores: np.ndarray,
@@ -23,6 +23,15 @@ correction (IPW, retrain) is undefined on the declined side.
23
23
  labeled-propensity weights — the identification-buying lever from the static
24
24
  loop, now acting as a *stabilizer* of the feedback dynamics.
25
25
 
26
+ v3 (docs/superpowers/specs/2026-07-14-cldd-v3-design.md) adds two additive flags,
27
+ byte-exact at their defaults, that isolate the trajectory's causes into three
28
+ arms: ``retrain=False`` freezes the generation-0 model forever (isolates
29
+ feedback accumulation — the "frozen" arm), and ``policy_mode="prior"`` funds
30
+ every generation via the cohort's own prior-policy ``approved`` column while
31
+ the model still trains for its metrics (the noise-floor "prior" arm).
32
+ ``retrain=True, policy_mode="model"`` (the defaults) is today's "treatment"
33
+ behavior, unchanged.
34
+
26
35
  Per generation we record the planted-truth calibration on the declined (never
27
36
  labeled) side, the *observable* funded-book performance, and the observable
28
37
  positivity diagnostics — so the harness can show whether the loop's blind spot
@@ -39,7 +48,7 @@ from dataclasses import dataclass, field
39
48
 
40
49
  import numpy as np
41
50
 
42
- from . import config, eval_default, model_pd
51
+ from . import config, emp, eval_default, model_pd
43
52
  from .diagnostics import PositivityDiagnostics, positivity_diagnostics
44
53
  from .loop import LeverMetrics, make_generator
45
54
 
@@ -64,6 +73,14 @@ class GenerationResult:
64
73
  diagnostics: PositivityDiagnostics
65
74
  n_explored: int = 0
66
75
  explored_defaults: int = 0
76
+ #: Realized funded-book P&L (v3), per applicant as a fraction of the FULL
77
+ #: cohort's mean ``requested_amount`` -- see ``emp.realized_book_profit``.
78
+ #: ``None`` when the cohort carries no planted default timing (flat).
79
+ book_profit: float | None = None
80
+ #: Realized P&L of the explored slice alone, same full-cohort denominator
81
+ #: as ``book_profit``. ``0.0`` when nothing was explored this generation;
82
+ #: ``None`` under the same no-timing condition as ``book_profit``.
83
+ explored_profit: float | None = None
67
84
 
68
85
 
69
86
  @dataclass
@@ -94,11 +111,15 @@ class FeedbackLoop:
94
111
  approval_rate: float = config.DEFAULT_APPROVAL_RATE,
95
112
  seed: int = config.RANDOM_SEED,
96
113
  policy_threshold: float = config.POLICY_PD_THRESHOLD,
114
+ retrain: bool = True,
115
+ policy_mode: str = "model",
97
116
  ) -> None:
98
117
  if not 0.0 <= exploration_rate < 1.0:
99
118
  raise ValueError(f"exploration_rate must be in [0, 1); got {exploration_rate!r}")
100
119
  if n_generations < 1:
101
120
  raise ValueError(f"n_generations must be >= 1; got {n_generations!r}")
121
+ if policy_mode not in ("model", "prior"):
122
+ raise ValueError(f"policy_mode must be 'model' or 'prior'; got {policy_mode!r}")
102
123
  self.selection_severity = selection_severity
103
124
  self.n_generations = n_generations
104
125
  self.exploration_rate = exploration_rate
@@ -107,6 +128,15 @@ class FeedbackLoop:
107
128
  self.approval_rate = approval_rate
108
129
  self.seed = seed
109
130
  self.policy_threshold = policy_threshold
131
+ #: ``False`` (v3 "frozen" arm): keep the generation-0 model deployed
132
+ #: unchanged forever -- no generation >= 1 ever calls
133
+ #: ``model_pd.train_pd_model`` again. Byte-exact default is ``True``.
134
+ self.retrain = retrain
135
+ #: ``"prior"`` (v3 "prior" arm): fund every generation via the
136
+ #: cohort's own prior-policy ``approved`` column; the model still
137
+ #: trains each generation (for its metrics) but never decides
138
+ #: funding. Byte-exact default is ``"model"`` (today's behavior).
139
+ self.policy_mode = policy_mode
110
140
 
111
141
  # ------------------------------------------------------------------ #
112
142
 
@@ -152,29 +182,54 @@ class FeedbackLoop:
152
182
  X = cohort["features"].to_numpy(dtype=float)
153
183
  y = cohort["true_default"]
154
184
 
155
- if model is None:
185
+ # ``policy`` label semantics are unchanged by ``policy_mode``: it
186
+ # reflects whether a model has been trained yet (generation 0 is
187
+ # always "prior"; generation >= 1 is always "model"), even when
188
+ # policy_mode="prior" means the model isn't the one FUNDING.
189
+ policy = "prior" if model is None else "model"
190
+
191
+ if self.policy_mode == "prior":
192
+ policy_funded = cohort["approved"]
193
+ elif model is None:
156
194
  policy_funded = cohort["approved"]
157
- policy = "prior"
158
195
  else:
159
196
  policy_funded = self._model_policy(model, X)
160
- policy = "model"
161
197
 
162
- explored = self._explore(policy_funded, generation)
198
+ # Frozen arm: gen-0 training must be eps-invariant (the H4
199
+ # identity requires the deployed model itself not to depend on
200
+ # eps), so exploration is suppressed at generation 0 only when
201
+ # retrain=False. This never fires on the default retrain=True
202
+ # path -- the EXPLORE_STREAM_FEEDBACK draw and its call site are
203
+ # untouched there.
204
+ if not self.retrain and generation == 0:
205
+ explored = np.zeros_like(policy_funded)
206
+ else:
207
+ explored = self._explore(policy_funded, generation)
163
208
  funded = policy_funded | explored
164
209
 
165
210
  # Diagnose the POLICY selection (pre-exploration): that is the
166
211
  # selection a deployment would be monitoring.
167
212
  diag = positivity_diagnostics(X, policy_funded, random_state=self.seed + generation)
168
213
 
169
- if self.exploration_rate > 0.0:
170
- weights = np.where(explored, 1.0 / self.exploration_rate, 1.0)[funded]
171
- else:
172
- weights = None
173
- model = model_pd.train_pd_model(
174
- X[funded], y[funded], sample_weight=weights, random_state=self.seed + generation
175
- )
214
+ # retrain=True (default): train every generation, byte-identical
215
+ # to pre-v3 behavior. retrain=False (frozen arm): train only once,
216
+ # at generation 0 (model is None), then reuse it forever.
217
+ if self.retrain or model is None:
218
+ if self.exploration_rate > 0.0:
219
+ weights = np.where(explored, 1.0 / self.exploration_rate, 1.0)[funded]
220
+ else:
221
+ weights = None
222
+ model = model_pd.train_pd_model(
223
+ X[funded], y[funded], sample_weight=weights, random_state=self.seed + generation
224
+ )
176
225
 
177
226
  scored = eval_default.score_pd_detection(model, cohort, self.policy_threshold, funded=funded)
227
+
228
+ default_day = cohort.get("days_to_default")
229
+ requested_amount = cohort["features"]["requested_amount"].to_numpy(dtype=float)
230
+ book_profit = emp.realized_book_profit(y, default_day, requested_amount, funded)
231
+ explored_profit = emp.realized_book_profit(y, default_day, requested_amount, explored)
232
+
178
233
  result.generations.append(
179
234
  GenerationResult(
180
235
  generation=generation,
@@ -185,6 +240,8 @@ class FeedbackLoop:
185
240
  diagnostics=diag,
186
241
  n_explored=int(explored.sum()),
187
242
  explored_defaults=int(y[explored].sum()),
243
+ book_profit=book_profit,
244
+ explored_profit=explored_profit,
188
245
  )
189
246
  )
190
247
 
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: closed-loop-default-detection
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Closed loop that measures selective-labels default detection on synthetic SMB lending cohorts and finds the PD model's operating frontier.
5
- Author-email: Hossain Pazooki <hossain@pazooki.com>
5
+ Author: Hossain Pazooki
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/hossainpazooki/closed-loop-default-detection
8
8
  Project-URL: Repository, https://github.com/hossainpazooki/closed-loop-default-detection
@@ -148,6 +148,35 @@ seed 42, 10% budget, severity 0.6): the lever buys **157 labels for $439,578**
148
148
  in dollars a lender can see, on the only lever that buys it rather than reweighting what is
149
149
  already identified.
150
150
 
151
+ ## Pricing the feedback loop (v3)
152
+
153
+ `v0.3.0` prices the **dynamic** regime, where the model's own approvals create its next
154
+ training set: three arms (treatment = retrain each generation; **frozen** = generation-0
155
+ model deployed forever; **prior** = prior-policy funding), 25 spaced seeds × 3 severities ×
156
+ 2 exploration rates × 12 generations = **450 runs**
157
+ (`artifacts/feedback_profit_sweep.csv`), with three pre-registered hypotheses tested as
158
+ within-seed paired contrasts on realized funded-book P&L at severity 0.4, Holm-corrected.
159
+ Every number below recomputes via `python scripts/feedback_sweep_stats.py`. All of it
160
+ consumes **planted, risk-unlinked default timing** — a verified *experiment*, not a
161
+ verified *result*.
162
+
163
+ | Hypothesis (severity 0.4, gens 1–11) | Median paired deficit | Sign | Verdict |
164
+ |---|---|---|---|
165
+ | H1 feedback accumulation costs money (treatment − frozen, ε=0) | **−0.0028** | 24/25 | **confirmed** (p_holm 2.3e-06, clears the 0.0018 noise floor) |
166
+ | H2 the policy switch costs money (frozen − prior, ε=0) | +0.0119 | 0/25 | **not confirmed — measured opposite in direction** |
167
+ | H3 exploration buys profit back (treatment ε=.05 − ε=0) | −0.0024 | 0/25 | **not confirmed** |
168
+
169
+ The decomposition v3 exists for came back one-for-three: letting the loop close on its own
170
+ labels measurably costs the book (H1), but at these severities the *switch* to model-based
171
+ funding is profitable relative to the prior policy (H2's predicted sign was wrong — reported
172
+ as measured, not softened), and 5% exploration does not pay for itself in-window (H3; the
173
+ policy-book-only secondary H3a is negative too). The **H4 integrity control** — frozen-arm
174
+ paired difference must equal the explored slice's own P&L *exactly* — holds at ≤1e-10 across
175
+ all 900 checked rows, and the answer to the v2-banked question is re-verified at 25 seeds:
176
+ the declined-ECE alarm fires at generation 1–2 in every ε=0 run (severity 0.4: 25/25 at
177
+ generation 1). Full mechanics in [`docs/how-it-works.md`](docs/how-it-works.md); gates in
178
+ [`docs/validation.md`](docs/validation.md).
179
+
151
180
  ## Install
152
181
 
153
182
  ```bash
@@ -21,6 +21,7 @@ src/closed_loop_default_detection.egg-info/SOURCES.txt
21
21
  src/closed_loop_default_detection.egg-info/dependency_links.txt
22
22
  src/closed_loop_default_detection.egg-info/requires.txt
23
23
  src/closed_loop_default_detection.egg-info/top_level.txt
24
+ tests/test_book_profit.py
24
25
  tests/test_correctors.py
25
26
  tests/test_counterfactual.py
26
27
  tests/test_diagnostics.py
@@ -28,6 +29,7 @@ tests/test_emp.py
28
29
  tests/test_eval_default.py
29
30
  tests/test_exploration.py
30
31
  tests/test_feedback.py
32
+ tests/test_feedback_flags.py
31
33
  tests/test_fidelity.py
32
34
  tests/test_fidelity_report.py
33
35
  tests/test_loop.py
@@ -36,4 +38,5 @@ tests/test_model_pd.py
36
38
  tests/test_reject_inference.py
37
39
  tests/test_scm_gating.py
38
40
  tests/test_sklearn_compat.py
41
+ tests/test_sweep_stats.py
39
42
  tests/test_synthetic.py