closed-loop-default-detection 0.2.0__tar.gz → 0.4.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 (59) hide show
  1. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/PKG-INFO +21 -114
  2. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/README.md +18 -112
  3. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/pyproject.toml +4 -4
  4. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/cldd/__init__.py +4 -1
  5. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/cldd/counterfactual.py +17 -1
  6. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/cldd/emp.py +49 -2
  7. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/cldd/feedback.py +69 -12
  8. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/cldd/fidelity.py +26 -17
  9. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/cldd/loop.py +13 -1
  10. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/cldd/scm.py +28 -1
  11. closed_loop_default_detection-0.4.0/src/cldd/selection.py +169 -0
  12. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/cldd/synthetic.py +26 -1
  13. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/closed_loop_default_detection.egg-info/PKG-INFO +21 -114
  14. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/closed_loop_default_detection.egg-info/SOURCES.txt +18 -0
  15. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/closed_loop_default_detection.egg-info/requires.txt +1 -0
  16. closed_loop_default_detection-0.4.0/tests/test_artifact_integrity.py +67 -0
  17. closed_loop_default_detection-0.4.0/tests/test_book_profit.py +329 -0
  18. closed_loop_default_detection-0.4.0/tests/test_doc_numbers.py +376 -0
  19. closed_loop_default_detection-0.4.0/tests/test_feedback_flags.py +307 -0
  20. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/tests/test_fidelity.py +2 -0
  21. closed_loop_default_detection-0.4.0/tests/test_fidelity_default_dir.py +94 -0
  22. closed_loop_default_detection-0.4.0/tests/test_preregistration.py +78 -0
  23. closed_loop_default_detection-0.4.0/tests/test_readme_lean.py +43 -0
  24. closed_loop_default_detection-0.4.0/tests/test_segment_knobs.py +121 -0
  25. closed_loop_default_detection-0.4.0/tests/test_segment_stats.py +252 -0
  26. closed_loop_default_detection-0.4.0/tests/test_segment_sweep.py +114 -0
  27. closed_loop_default_detection-0.4.0/tests/test_selection.py +100 -0
  28. closed_loop_default_detection-0.4.0/tests/test_spaced_manifest.py +58 -0
  29. closed_loop_default_detection-0.4.0/tests/test_strength_replication.py +400 -0
  30. closed_loop_default_detection-0.4.0/tests/test_surface_knobs.py +118 -0
  31. closed_loop_default_detection-0.4.0/tests/test_surface_stats.py +121 -0
  32. closed_loop_default_detection-0.4.0/tests/test_surface_sweep.py +68 -0
  33. closed_loop_default_detection-0.4.0/tests/test_sweep_stats.py +501 -0
  34. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/LICENSE +0 -0
  35. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/setup.cfg +0 -0
  36. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/cldd/config.py +0 -0
  37. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/cldd/correctors.py +0 -0
  38. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/cldd/diagnostics.py +0 -0
  39. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/cldd/eval_default.py +0 -0
  40. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/cldd/model_pd.py +0 -0
  41. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/cldd/py.typed +0 -0
  42. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/cldd/reject_inference.py +0 -0
  43. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/closed_loop_default_detection.egg-info/dependency_links.txt +0 -0
  44. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/src/closed_loop_default_detection.egg-info/top_level.txt +0 -0
  45. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/tests/test_correctors.py +0 -0
  46. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/tests/test_counterfactual.py +0 -0
  47. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/tests/test_diagnostics.py +0 -0
  48. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/tests/test_emp.py +0 -0
  49. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/tests/test_eval_default.py +0 -0
  50. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/tests/test_exploration.py +0 -0
  51. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/tests/test_feedback.py +0 -0
  52. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/tests/test_fidelity_report.py +0 -0
  53. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/tests/test_loop.py +0 -0
  54. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/tests/test_loop_scm.py +0 -0
  55. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/tests/test_model_pd.py +0 -0
  56. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/tests/test_reject_inference.py +0 -0
  57. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/tests/test_scm_gating.py +0 -0
  58. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.0}/tests/test_sklearn_compat.py +0 -0
  59. {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.4.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.4.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
@@ -26,6 +26,7 @@ Requires-Dist: matplotlib>=3.8
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: pytest>=8.0; extra == "dev"
28
28
  Requires-Dist: pytest-cov>=5; extra == "dev"
29
+ Requires-Dist: build>=1; extra == "dev"
29
30
  Provides-Extra: docs
30
31
  Requires-Dist: sphinx>=7; extra == "docs"
31
32
  Requires-Dist: furo; extra == "docs"
@@ -48,105 +49,14 @@ hides labels the way real approval policies do, and grades every correction agai
48
49
  - **Deterministic** — byte-identical per seed, scikit-learn-only, no services or GPUs.
49
50
  - **Pluggable** — correction levers (IPW, retrain, exploration, reject inference) are classes;
50
51
  add yours by subclassing `Corrector`.
51
- - **Honest by construction** — every number below recomputes from committed CSVs; limits are
52
- reported, not smoothed over.
53
-
54
- ## The result it produces
55
-
56
- The loop escalates selection severity until correction fails and reports the **operating
57
- frontier** — the last severity at which declined-cohort calibration still holds (target
58
- ECE ≤ 0.10). From the committed runs (`artifacts/loop_frontier*.csv`, seed 42):
59
-
60
- | Selection severity | 0.0 | 0.2 | 0.4 | 0.6 |
61
- |---|---|---|---|---|
62
- | Naive declined ECE (flat world) | 0.021 | 0.045 | 0.108 | 0.161 |
63
- | **IPW-corrected** (flat world) | 0.020 | 0.038 | **0.086 ✓** | **0.154 ✗** |
64
- | **IPW-corrected** (SCM world) | 0.036 | 0.038 | **0.097 ✓** | **0.244 ✗** |
65
-
66
- On **this seed** both worlds land the frontier at severity 0.4, and the counterfactual
67
- deliverable breaks at the same boundary: across 25 seeds, g-computation cuts
68
- strong-propagation counterfactual MAE from 0.099 to 0.086 (−13.5%, positive on 24/25 seeds,
69
- Wilcoxon p = 1.5e-7) *inside* the frontier — and collapses to a negligible +0.0017 at full
70
- severity, where **no deployable advantage is claimed**. One cause explains both: selection
71
- through an **unobserved confounder**, which backdoor adjustment and IPW cannot fix. That
72
- single measured limit — not an unverifiable score — is the deliverable.
73
-
74
- ### The frontier is a distribution, not a point (v2)
75
-
76
- A frontier quoted from one seed is a figure published without an error bar. `v0.2.0` runs the
77
- loop across the full 25-seed set (`artifacts/frontier_sweep.csv`) — and **seed 42 turns out to
78
- sit at the optimistic end**:
79
-
80
- | World | min | median | max | seeds at 0.4 | seeds at 0.2 |
81
- |---|---|---|---|---|---|
82
- | flat | 0.2 | **0.4** | 0.4 | 15/25 | 10/25 |
83
- | SCM | 0.2 | **0.2** | 0.4 | 11/25 | 14/25 |
84
-
85
- In the SCM world the *majority of seeds fail one step earlier than the headline*: the median
86
- frontier is **0.2**, and seed 42's 0.4 is a minority outcome (11/25). The honest statement is
87
- that the operating frontier is **0.2–0.4 depending on the draw**, not a clean 0.4 — the
88
- single-seed table above is a valid instance of it, not its center. Nothing about the
89
- mechanism changes (the unobserved confounder still explains the failure); what changes is how
90
- precisely the boundary can be quoted.
91
-
92
- *Caveat on the sweep:* loop seed `s` consumes generator seeds `s..s+7`, and the 25-seed set has
93
- gaps smaller than 8, so some runs share feature draws. No two runs duplicate a cohort, but the
94
- 25 rows are not fully independent — the set is kept for comparability with the counterfactual
95
- sweep that uses it.
96
-
97
- Reproduce the headline from committed evidence: `python scripts/paired_significance.py`.
98
- The full independent assessment (methodology, all numbers, what didn't hold) is the
99
- accompanying article, [`docs/assessment.md`](docs/assessment.md) — a **dated snapshot**, written
100
- against the single-seed frontier and not retro-fitted with the distribution above.
101
-
102
- ## Pricing the frontier (v2)
103
-
104
- Calibration says *whether* the model is wrong on the declines; it does not say **what being
105
- wrong costs**. v2 adds an expected-maximum-profit (EMP) reporting axis — computed from the
106
- same in-process scores the loop already measures, and **never** a loop-control input (ECE
107
- still decides pass/fail and the frontier). Two variants run side by side:
108
-
109
- | Variant | Parameters | What it answers |
110
- |---|---|---|
111
- | `empc` — literature EMPC | Verbraken et al. (2014) prior: `p0=0.55, p1=0.10, ROI=0.2644` | benchmark-comparable: what the standard measure says |
112
- | `emp_h` — harness-derived | this harness's own economics + **planted** default timing | what the loan structure actually pays |
113
-
114
- **The two disagree — and the disagreement is the finding.** In the SCM world they move in
115
- opposite directions as selection severity rises (from `artifacts/loop_frontier_scm.csv`,
116
- declined subpopulation, seed 42):
117
-
118
- | Selection severity | 0.0 | 0.2 | 0.4 | 0.6 |
119
- |---|---|---|---|---|
120
- | Literature `empc` (naive) | 0.0217 | 0.0331 | 0.0416 | **0.0420 ↑** |
121
- | Harness `emp_h` (naive) | 0.0387 | 0.0297 | 0.0139 | **0.0024 ↓** |
122
-
123
- The convenience prior **misprices this loan structure**: it assumes `ROI = 0.2644` where a
124
- 60-day daily-ACH loan actually returns `0.0875` (3% origination fee + 5.75% term interest) —
125
- a **3.0× overstatement** — and puts **55% of defaults at full recovery where the harness plants
126
- 1.2%** (prior mean λ = 0.275 vs harness 0.419). Priced honestly, the profit on the declined
127
- pool collapses to near-zero at the frontier; priced by the literature prior, it appears to
128
- *grow*. Same model, same scores, opposite conclusion.
129
-
130
- **Reading caveats — the boundary of what this measures:**
131
-
132
- - **Raw EMP moves with world hardness.** A riskier declined pool changes EMP even for a
133
- perfect model, so a cross-severity EMP delta is *not* pure model signal. Read the two
134
- variants against each other at fixed severity, not the trend in isolation.
135
- - **`emp_h` rests on unfitted timing.** `days_to_default` is planted but independent of
136
- features and risk given default — spec-shaped, never validated against real recovery data.
137
- So `emp_h` is a **verified experiment, not a verified result**: the arithmetic is exact, the
138
- timing distribution is an assumption.
139
- - **Post-term defaults are imputed.** ~22.5% of planted defaults land at day 90, past the
140
- 60-day term, and the generator does not model their payment history; they are priced at the
141
- cohort's mean in-term loss fraction (a stated convention, not measured truth).
142
- - **`emp_h` is SCM-only.** The flat generator plants no timing, so its `emp_h` columns are
143
- empty by design; only `empc` is reported there.
144
-
145
- Exploration now carries a price tag too. From `artifacts/exploration_frontier.csv` (SCM,
146
- seed 42, 10% budget, severity 0.6): the lever buys **157 labels for $439,578** — about
147
- **$2,800 per label**, of which 56 are realized defaults. That is the cost of *identification*,
148
- in dollars a lender can see, on the only lever that buys it rather than reweighting what is
149
- already identified.
52
+ - **Honest by construction** — every results figure registered in the doc-number gate
53
+ recomputes from a committed CSV in CI, and every artifact is pinned by content hash; the
54
+ results and their limits are in [docs/results.md](docs/results.md).
55
+
56
+ > **What this is — and is not.** CLDD is a *validation harness* graded against planted
57
+ > synthetic ground truth; it is **not a credit-modeling toolkit**. The correction levers it
58
+ > ships (IPW, retrain, exploration, reject inference) exist to be *measured against a known
59
+ > answer* — nothing in this repo is validated for making decisions on real borrower data.
150
60
 
151
61
  ## Install
152
62
 
@@ -248,17 +158,18 @@ python scripts/paired_significance.py # recompute the headline stat from
248
158
 
249
159
  ## Validation
250
160
 
251
- `pytest` — 149 tests, all synthetic, no real data needed. CI runs a pinned-repro job (exact
252
- pins), a cross-version/OS compat matrix, and a strict docs build. Six float-sensitive tests
253
- reproduce only under the pins in `requirements-dev.txt`; the optional marginal-fidelity gate
254
- compares the SCM against a **private** real dataset via `CLDD_DATA_DIR` and is the only thing
255
- that needs it. Details, reproducibility, and troubleshooting:
256
- [docs/validation.md](docs/validation.md).
161
+ `pytest`, all synthetic, no real data needed. CI runs a pinned-repro job (exact pins), a
162
+ cross-version/OS compat matrix, and a strict docs build; a doc-number gate recomputes every
163
+ registered figure in the docs from a committed artifact. A few float-sensitive tests reproduce
164
+ only under the pins in `requirements-dev.txt`; the optional marginal-fidelity gate compares
165
+ the SCM against a **private** real dataset via `CLDD_DATA_DIR` and is the only thing that
166
+ needs it. Details, reproducibility, and troubleshooting: [docs/validation.md](docs/validation.md).
257
167
 
258
168
  ## Documentation
259
169
 
260
170
  | Where | What |
261
171
  |---|---|
172
+ | [docs/results.md](docs/results.md) | **every measured result** — the frontier and its distribution, the v4 intervention, the two pre-registered experiments, the two pricing layers |
262
173
  | [docs/quickstart.md](docs/quickstart.md) | run the loop, the counterfactual eval, the fidelity report |
263
174
  | [docs/how-it-works.md](docs/how-it-works.md) | loop mechanics, diagnostics, feedback simulation, repo map |
264
175
  | [docs/configuration.md](docs/configuration.md) | every knob (`config.py`) and the one env var |
@@ -270,12 +181,8 @@ Build locally: `pip install -e ".[docs]" && sphinx-build -b html -W docs docs/_b
270
181
 
271
182
  ## Status
272
183
 
273
- `0.2.0` **alpha** on [PyPI](https://pypi.org/project/closed-loop-default-detection/),
274
- changelog in [CHANGELOG.md](CHANGELOG.md).
275
-
276
- Shipped in 0.1.0: the loop, both synthetic worlds, all correction levers, the fidelity gate,
277
- the sklearn estimator, CI on three gates. Added in 0.2.0: `cldd.emp` (both EMP variants),
278
- priced exploration, the EMP-optimal cutoff, and the 25-seed frontier sweep.
184
+ **Alpha**, on [PyPI](https://pypi.org/project/closed-loop-default-detection/); version
185
+ history in [CHANGELOG.md](CHANGELOG.md).
279
186
 
280
187
  CLDD began as a validation harness for the Intuit TechWeek SMB Underwriting Challenge; it is
281
188
  not a submission and does not alter challenge files.
@@ -290,7 +197,7 @@ Metadata in [`CITATION.cff`](CITATION.cff) (GitHub's "Cite this repository" read
290
197
  title = {{closed-loop-default-detection}: measuring selective-labels default
291
198
  detection and the PD model's operating frontier},
292
199
  year = {2026},
293
- version = {0.2.0},
200
+ version = {0.4.0},
294
201
  license = {MIT},
295
202
  url = {https://github.com/hossainpazooki/closed-loop-default-detection}
296
203
  }
@@ -14,105 +14,14 @@ hides labels the way real approval policies do, and grades every correction agai
14
14
  - **Deterministic** — byte-identical per seed, scikit-learn-only, no services or GPUs.
15
15
  - **Pluggable** — correction levers (IPW, retrain, exploration, reject inference) are classes;
16
16
  add yours by subclassing `Corrector`.
17
- - **Honest by construction** — every number below recomputes from committed CSVs; limits are
18
- reported, not smoothed over.
19
-
20
- ## The result it produces
21
-
22
- The loop escalates selection severity until correction fails and reports the **operating
23
- frontier** — the last severity at which declined-cohort calibration still holds (target
24
- ECE ≤ 0.10). From the committed runs (`artifacts/loop_frontier*.csv`, seed 42):
25
-
26
- | Selection severity | 0.0 | 0.2 | 0.4 | 0.6 |
27
- |---|---|---|---|---|
28
- | Naive declined ECE (flat world) | 0.021 | 0.045 | 0.108 | 0.161 |
29
- | **IPW-corrected** (flat world) | 0.020 | 0.038 | **0.086 ✓** | **0.154 ✗** |
30
- | **IPW-corrected** (SCM world) | 0.036 | 0.038 | **0.097 ✓** | **0.244 ✗** |
31
-
32
- On **this seed** both worlds land the frontier at severity 0.4, and the counterfactual
33
- deliverable breaks at the same boundary: across 25 seeds, g-computation cuts
34
- strong-propagation counterfactual MAE from 0.099 to 0.086 (−13.5%, positive on 24/25 seeds,
35
- Wilcoxon p = 1.5e-7) *inside* the frontier — and collapses to a negligible +0.0017 at full
36
- severity, where **no deployable advantage is claimed**. One cause explains both: selection
37
- through an **unobserved confounder**, which backdoor adjustment and IPW cannot fix. That
38
- single measured limit — not an unverifiable score — is the deliverable.
39
-
40
- ### The frontier is a distribution, not a point (v2)
41
-
42
- A frontier quoted from one seed is a figure published without an error bar. `v0.2.0` runs the
43
- loop across the full 25-seed set (`artifacts/frontier_sweep.csv`) — and **seed 42 turns out to
44
- sit at the optimistic end**:
45
-
46
- | World | min | median | max | seeds at 0.4 | seeds at 0.2 |
47
- |---|---|---|---|---|---|
48
- | flat | 0.2 | **0.4** | 0.4 | 15/25 | 10/25 |
49
- | SCM | 0.2 | **0.2** | 0.4 | 11/25 | 14/25 |
50
-
51
- In the SCM world the *majority of seeds fail one step earlier than the headline*: the median
52
- frontier is **0.2**, and seed 42's 0.4 is a minority outcome (11/25). The honest statement is
53
- that the operating frontier is **0.2–0.4 depending on the draw**, not a clean 0.4 — the
54
- single-seed table above is a valid instance of it, not its center. Nothing about the
55
- mechanism changes (the unobserved confounder still explains the failure); what changes is how
56
- precisely the boundary can be quoted.
57
-
58
- *Caveat on the sweep:* loop seed `s` consumes generator seeds `s..s+7`, and the 25-seed set has
59
- gaps smaller than 8, so some runs share feature draws. No two runs duplicate a cohort, but the
60
- 25 rows are not fully independent — the set is kept for comparability with the counterfactual
61
- sweep that uses it.
62
-
63
- Reproduce the headline from committed evidence: `python scripts/paired_significance.py`.
64
- The full independent assessment (methodology, all numbers, what didn't hold) is the
65
- accompanying article, [`docs/assessment.md`](docs/assessment.md) — a **dated snapshot**, written
66
- against the single-seed frontier and not retro-fitted with the distribution above.
67
-
68
- ## Pricing the frontier (v2)
69
-
70
- Calibration says *whether* the model is wrong on the declines; it does not say **what being
71
- wrong costs**. v2 adds an expected-maximum-profit (EMP) reporting axis — computed from the
72
- same in-process scores the loop already measures, and **never** a loop-control input (ECE
73
- still decides pass/fail and the frontier). Two variants run side by side:
74
-
75
- | Variant | Parameters | What it answers |
76
- |---|---|---|
77
- | `empc` — literature EMPC | Verbraken et al. (2014) prior: `p0=0.55, p1=0.10, ROI=0.2644` | benchmark-comparable: what the standard measure says |
78
- | `emp_h` — harness-derived | this harness's own economics + **planted** default timing | what the loan structure actually pays |
79
-
80
- **The two disagree — and the disagreement is the finding.** In the SCM world they move in
81
- opposite directions as selection severity rises (from `artifacts/loop_frontier_scm.csv`,
82
- declined subpopulation, seed 42):
83
-
84
- | Selection severity | 0.0 | 0.2 | 0.4 | 0.6 |
85
- |---|---|---|---|---|
86
- | Literature `empc` (naive) | 0.0217 | 0.0331 | 0.0416 | **0.0420 ↑** |
87
- | Harness `emp_h` (naive) | 0.0387 | 0.0297 | 0.0139 | **0.0024 ↓** |
88
-
89
- The convenience prior **misprices this loan structure**: it assumes `ROI = 0.2644` where a
90
- 60-day daily-ACH loan actually returns `0.0875` (3% origination fee + 5.75% term interest) —
91
- a **3.0× overstatement** — and puts **55% of defaults at full recovery where the harness plants
92
- 1.2%** (prior mean λ = 0.275 vs harness 0.419). Priced honestly, the profit on the declined
93
- pool collapses to near-zero at the frontier; priced by the literature prior, it appears to
94
- *grow*. Same model, same scores, opposite conclusion.
95
-
96
- **Reading caveats — the boundary of what this measures:**
97
-
98
- - **Raw EMP moves with world hardness.** A riskier declined pool changes EMP even for a
99
- perfect model, so a cross-severity EMP delta is *not* pure model signal. Read the two
100
- variants against each other at fixed severity, not the trend in isolation.
101
- - **`emp_h` rests on unfitted timing.** `days_to_default` is planted but independent of
102
- features and risk given default — spec-shaped, never validated against real recovery data.
103
- So `emp_h` is a **verified experiment, not a verified result**: the arithmetic is exact, the
104
- timing distribution is an assumption.
105
- - **Post-term defaults are imputed.** ~22.5% of planted defaults land at day 90, past the
106
- 60-day term, and the generator does not model their payment history; they are priced at the
107
- cohort's mean in-term loss fraction (a stated convention, not measured truth).
108
- - **`emp_h` is SCM-only.** The flat generator plants no timing, so its `emp_h` columns are
109
- empty by design; only `empc` is reported there.
110
-
111
- Exploration now carries a price tag too. From `artifacts/exploration_frontier.csv` (SCM,
112
- seed 42, 10% budget, severity 0.6): the lever buys **157 labels for $439,578** — about
113
- **$2,800 per label**, of which 56 are realized defaults. That is the cost of *identification*,
114
- in dollars a lender can see, on the only lever that buys it rather than reweighting what is
115
- already identified.
17
+ - **Honest by construction** — every results figure registered in the doc-number gate
18
+ recomputes from a committed CSV in CI, and every artifact is pinned by content hash; the
19
+ results and their limits are in [docs/results.md](docs/results.md).
20
+
21
+ > **What this is — and is not.** CLDD is a *validation harness* graded against planted
22
+ > synthetic ground truth; it is **not a credit-modeling toolkit**. The correction levers it
23
+ > ships (IPW, retrain, exploration, reject inference) exist to be *measured against a known
24
+ > answer* — nothing in this repo is validated for making decisions on real borrower data.
116
25
 
117
26
  ## Install
118
27
 
@@ -214,17 +123,18 @@ python scripts/paired_significance.py # recompute the headline stat from
214
123
 
215
124
  ## Validation
216
125
 
217
- `pytest` — 149 tests, all synthetic, no real data needed. CI runs a pinned-repro job (exact
218
- pins), a cross-version/OS compat matrix, and a strict docs build. Six float-sensitive tests
219
- reproduce only under the pins in `requirements-dev.txt`; the optional marginal-fidelity gate
220
- compares the SCM against a **private** real dataset via `CLDD_DATA_DIR` and is the only thing
221
- that needs it. Details, reproducibility, and troubleshooting:
222
- [docs/validation.md](docs/validation.md).
126
+ `pytest`, all synthetic, no real data needed. CI runs a pinned-repro job (exact pins), a
127
+ cross-version/OS compat matrix, and a strict docs build; a doc-number gate recomputes every
128
+ registered figure in the docs from a committed artifact. A few float-sensitive tests reproduce
129
+ only under the pins in `requirements-dev.txt`; the optional marginal-fidelity gate compares
130
+ the SCM against a **private** real dataset via `CLDD_DATA_DIR` and is the only thing that
131
+ needs it. Details, reproducibility, and troubleshooting: [docs/validation.md](docs/validation.md).
223
132
 
224
133
  ## Documentation
225
134
 
226
135
  | Where | What |
227
136
  |---|---|
137
+ | [docs/results.md](docs/results.md) | **every measured result** — the frontier and its distribution, the v4 intervention, the two pre-registered experiments, the two pricing layers |
228
138
  | [docs/quickstart.md](docs/quickstart.md) | run the loop, the counterfactual eval, the fidelity report |
229
139
  | [docs/how-it-works.md](docs/how-it-works.md) | loop mechanics, diagnostics, feedback simulation, repo map |
230
140
  | [docs/configuration.md](docs/configuration.md) | every knob (`config.py`) and the one env var |
@@ -236,12 +146,8 @@ Build locally: `pip install -e ".[docs]" && sphinx-build -b html -W docs docs/_b
236
146
 
237
147
  ## Status
238
148
 
239
- `0.2.0` **alpha** on [PyPI](https://pypi.org/project/closed-loop-default-detection/),
240
- changelog in [CHANGELOG.md](CHANGELOG.md).
241
-
242
- Shipped in 0.1.0: the loop, both synthetic worlds, all correction levers, the fidelity gate,
243
- the sklearn estimator, CI on three gates. Added in 0.2.0: `cldd.emp` (both EMP variants),
244
- priced exploration, the EMP-optimal cutoff, and the 25-seed frontier sweep.
149
+ **Alpha**, on [PyPI](https://pypi.org/project/closed-loop-default-detection/); version
150
+ history in [CHANGELOG.md](CHANGELOG.md).
245
151
 
246
152
  CLDD began as a validation harness for the Intuit TechWeek SMB Underwriting Challenge; it is
247
153
  not a submission and does not alter challenge files.
@@ -256,7 +162,7 @@ Metadata in [`CITATION.cff`](CITATION.cff) (GitHub's "Cite this repository" read
256
162
  title = {{closed-loop-default-detection}: measuring selective-labels default
257
163
  detection and the PD model's operating frontier},
258
164
  year = {2026},
259
- version = {0.2.0},
165
+ version = {0.4.0},
260
166
  license = {MIT},
261
167
  url = {https://github.com/hossainpazooki/closed-loop-default-detection}
262
168
  }
@@ -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.4.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",
@@ -45,7 +45,7 @@ dependencies = [
45
45
  ]
46
46
 
47
47
  [project.optional-dependencies]
48
- dev = ["pytest>=8.0", "pytest-cov>=5"]
48
+ dev = ["pytest>=8.0", "pytest-cov>=5", "build>=1"]
49
49
  docs = ["sphinx>=7", "furo", "myst-parser>=2"]
50
50
 
51
51
  [project.urls]
@@ -70,7 +70,7 @@ markers = [
70
70
  # Coverage is opt-in (run `pytest --cov=cldd`); it is not wired into addopts so a
71
71
  # plain `pytest` stays lightweight. The private-data fidelity path (cldd/fidelity.py)
72
72
  # is only exercised when the real dataset is present, so its data-loading branch is
73
- # expected to be uncovered on public CI by design — see README.
73
+ # expected to be uncovered on public CI by design — see docs/validation.md.
74
74
  [tool.coverage.run]
75
75
  source = ["cldd"]
76
76
  branch = true
@@ -11,7 +11,7 @@ from importlib.metadata import PackageNotFoundError, version as _pkg_version
11
11
  try: # pyproject `version` is the single source of truth (read from installed metadata)
12
12
  __version__ = _pkg_version("closed-loop-default-detection")
13
13
  except PackageNotFoundError: # running from a source tree with no install
14
- __version__ = "0.2.0"
14
+ __version__ = "0.4.0"
15
15
 
16
16
  from .correctors import (
17
17
  CorrectionOutcome,
@@ -57,6 +57,7 @@ from .scm import (
57
57
  dag_children,
58
58
  dag_parents,
59
59
  )
60
+ from .selection import SegmentedSelection, knockout_rates
60
61
  from .synthetic import SyntheticBorrowerGenerator
61
62
 
62
63
  __all__ = [
@@ -105,4 +106,6 @@ __all__ = [
105
106
  "GComputationEstimator",
106
107
  "run_counterfactual_eval",
107
108
  "generate_queries",
109
+ "SegmentedSelection",
110
+ "knockout_rates",
108
111
  ]
@@ -632,6 +632,7 @@ def run_counterfactual_eval(
632
632
  n_query_applicants: int = 300,
633
633
  seed: int = config.RANDOM_SEED,
634
634
  generator: StructuralBorrowerGenerator | None = None,
635
+ unobserved_strength: float | None = None,
635
636
  ) -> CounterfactualResult:
636
637
  """Build a cohort, generate Deliverable-C-style queries, grade the estimators.
637
638
 
@@ -644,13 +645,27 @@ def run_counterfactual_eval(
644
645
  Both deployable estimators are fit on the APPROVED rows only (selective-labels
645
646
  regime), so their conditionals are distorted by selection — exactly the
646
647
  confound g-computation's backdoor adjustment is meant to repair.
648
+
649
+ ``unobserved_strength`` (v4 surface knob) is forwarded to the internal
650
+ ``StructuralBorrowerGenerator`` construction only when ``generator`` is None;
651
+ the effective value is echoed in ``meta``. ``None`` (the default) is
652
+ byte-identical to the pre-knob path.
647
653
  """
654
+ if generator is not None and unobserved_strength is not None:
655
+ raise ValueError(
656
+ "unobserved_strength is forwarded to the internal generator "
657
+ "construction only; pass it OR a pre-built generator, not both "
658
+ "(silently ignoring it would let a driver drift from the eval)"
659
+ )
648
660
  if generator is None:
649
- generator = StructuralBorrowerGenerator(
661
+ gen_kwargs: dict = dict(
650
662
  n_applicants=n_applicants,
651
663
  selection_severity=selection_severity,
652
664
  seed=seed,
653
665
  )
666
+ if unobserved_strength is not None:
667
+ gen_kwargs["unobserved_strength"] = unobserved_strength
668
+ generator = StructuralBorrowerGenerator(**gen_kwargs)
654
669
  cohort = generator.generate_cohort()
655
670
  state = cohort["scm_state"]
656
671
  features = cohort["features"]
@@ -763,5 +778,6 @@ def run_counterfactual_eval(
763
778
  "n_applicants": n_applicants,
764
779
  "seed": seed,
765
780
  "approval_rate": float(approved.mean()),
781
+ "unobserved_strength": float(generator.unobserved_strength),
766
782
  },
767
783
  )
@@ -1,7 +1,7 @@
1
1
  """Expected-maximum-profit (EMP) reporting layer — the v2 measurement axis.
2
2
 
3
3
  Prices the frontier the loop already measures; **never** a loop-control input
4
- (spec: ``docs/superpowers/specs/2026-07-13-cldd-v2-emp-design.md``). Two
4
+ (spec: ``2026-07-13-cldd-v2-emp-design.md``). Two
5
5
  variants, side by side:
6
6
 
7
7
  * :func:`empc_literature` — the Verbraken/Bravo/Weber/Baesens EMPC closed form
@@ -18,7 +18,7 @@ of mean per-loan exposure** (exposure = ``requested_amount``), so the two
18
18
  columns are directly comparable. Both consume ``scores`` as a ranking only and
19
19
  are invariant to strictly monotone transforms.
20
20
 
21
- Reading caveats (documented in README + CSV column notes):
21
+ Reading caveats (documented in docs/results.md + CSV column notes):
22
22
 
23
23
  * Planted default timing is **marginally spec-shaped but conditionally
24
24
  cosmetic** — independent of features/risk given default status — so harness
@@ -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,