treecf 0.2.2__tar.gz → 0.2.4__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 (62) hide show
  1. {treecf-0.2.2 → treecf-0.2.4}/PKG-INFO +8 -1
  2. {treecf-0.2.2 → treecf-0.2.4}/README.md +2 -0
  3. {treecf-0.2.2 → treecf-0.2.4}/pyproject.toml +7 -1
  4. {treecf-0.2.2 → treecf-0.2.4}/rust/Cargo.lock +1 -1
  5. {treecf-0.2.2 → treecf-0.2.4}/rust/Cargo.toml +1 -1
  6. {treecf-0.2.2 → treecf-0.2.4}/rust/src/exact/search.rs +9 -8
  7. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/__init__.py +1 -1
  8. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/api.py +53 -3
  9. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/audit.py +101 -4
  10. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/batch.py +37 -0
  11. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/parsers/sklearn.py +35 -3
  12. {treecf-0.2.2 → treecf-0.2.4}/LICENSE +0 -0
  13. {treecf-0.2.2 → treecf-0.2.4}/rust/src/cells.rs +0 -0
  14. {treecf-0.2.2 → treecf-0.2.4}/rust/src/constraints.rs +0 -0
  15. {treecf-0.2.2 → treecf-0.2.4}/rust/src/exact/domains.rs +0 -0
  16. {treecf-0.2.2 → treecf-0.2.4}/rust/src/exact/mod.rs +0 -0
  17. {treecf-0.2.2 → treecf-0.2.4}/rust/src/exact/orderpairs.rs +0 -0
  18. {treecf-0.2.2 → treecf-0.2.4}/rust/src/exact/propagation.rs +0 -0
  19. {treecf-0.2.2 → treecf-0.2.4}/rust/src/exact/test_support.rs +0 -0
  20. {treecf-0.2.2 → treecf-0.2.4}/rust/src/ga.rs +0 -0
  21. {treecf-0.2.2 → treecf-0.2.4}/rust/src/interrupt.rs +0 -0
  22. {treecf-0.2.2 → treecf-0.2.4}/rust/src/ir.rs +0 -0
  23. {treecf-0.2.2 → treecf-0.2.4}/rust/src/lib.rs +0 -0
  24. {treecf-0.2.2 → treecf-0.2.4}/rust/src/py.rs +0 -0
  25. {treecf-0.2.2 → treecf-0.2.4}/rust/src/regions.rs +0 -0
  26. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/_errors.py +0 -0
  27. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/_json.py +0 -0
  28. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/aim/__init__.py +0 -0
  29. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/aim/cells.py +0 -0
  30. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/__init__.py +0 -0
  31. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/_exact_bounds.py +0 -0
  32. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/_exact_domains.py +0 -0
  33. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/_exact_orderpairs.py +0 -0
  34. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/_exact_propagation.py +0 -0
  35. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/exact.py +0 -0
  36. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/exact_rust.py +0 -0
  37. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/genetic.py +0 -0
  38. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/genetic_rust.py +0 -0
  39. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/backends/regions_rust.py +0 -0
  40. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/constraints/__init__.py +0 -0
  41. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/constraints/compile.py +0 -0
  42. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/constraints/flatten.py +0 -0
  43. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/constraints/objects.py +0 -0
  44. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/constraints/parser.py +0 -0
  45. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/__init__.py +0 -0
  46. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/conformance.py +0 -0
  47. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/evaluate.py +0 -0
  48. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/flatten.py +0 -0
  49. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/model.py +0 -0
  50. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/parsers/__init__.py +0 -0
  51. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/parsers/catboost.py +0 -0
  52. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/parsers/json_dump.py +0 -0
  53. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/parsers/lightgbm.py +0 -0
  54. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/ir/parsers/xgboost.py +0 -0
  55. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/mining.py +0 -0
  56. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/objective.py +0 -0
  57. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/plausibility.py +0 -0
  58. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/py.typed +0 -0
  59. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/regions.py +0 -0
  60. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/targets.py +0 -0
  61. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/viz.py +0 -0
  62. {treecf-0.2.2 → treecf-0.2.4}/src/treecf/viz_batch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: treecf
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -29,6 +29,7 @@ Requires-Dist: lightgbm>=4.3 ; extra == 'dev'
29
29
  Requires-Dist: catboost>=1.2 ; extra == 'dev'
30
30
  Requires-Dist: scikit-learn>=1.4 ; extra == 'dev'
31
31
  Requires-Dist: matplotlib>=3.8 ; extra == 'dev'
32
+ Requires-Dist: probcal>=0.2 ; extra == 'dev'
32
33
  Requires-Dist: mkdocs>=1.6 ; extra == 'docs'
33
34
  Requires-Dist: mkdocs-material>=9.5 ; extra == 'docs'
34
35
  Requires-Dist: mkdocstrings[python]>=0.27 ; extra == 'docs'
@@ -37,6 +38,9 @@ Requires-Dist: mkdocs-jupyter>=0.24 ; extra == 'docs'
37
38
  Requires-Dist: ipykernel>=6.29 ; extra == 'docs'
38
39
  Requires-Dist: lightgbm>=4.3 ; extra == 'lightgbm'
39
40
  Requires-Dist: scikit-learn>=1.4 ; extra == 'sklearn'
41
+ Requires-Dist: pytest>=8.0 ; extra == 'test'
42
+ Requires-Dist: hypothesis>=6.100 ; extra == 'test'
43
+ Requires-Dist: probcal>=0.2 ; extra == 'test'
40
44
  Requires-Dist: matplotlib>=3.8 ; extra == 'viz'
41
45
  Requires-Dist: xgboost>=2.0 ; extra == 'xgboost'
42
46
  Provides-Extra: all
@@ -45,6 +49,7 @@ Provides-Extra: dev
45
49
  Provides-Extra: docs
46
50
  Provides-Extra: lightgbm
47
51
  Provides-Extra: sklearn
52
+ Provides-Extra: test
48
53
  Provides-Extra: viz
49
54
  Provides-Extra: xgboost
50
55
  License-File: LICENSE
@@ -61,6 +66,8 @@ Project-URL: Issues, https://github.com/wlazlod/treecf/issues
61
66
 
62
67
  # treecf
63
68
 
69
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22069503.svg)](https://doi.org/10.5281/zenodo.22069503)
70
+
64
71
  **Constrained, threshold-aware counterfactual explanations for tree ensembles.**
65
72
 
66
73
  `treecf` answers the question: *"what is the minimal, feasible change to this instance such
@@ -1,5 +1,7 @@
1
1
  # treecf
2
2
 
3
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22069503.svg)](https://doi.org/10.5281/zenodo.22069503)
4
+
3
5
  **Constrained, threshold-aware counterfactual explanations for tree ensembles.**
4
6
 
5
7
  `treecf` answers the question: *"what is the minimal, feasible change to this instance such
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "treecf"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "Constrained, threshold-aware counterfactual explanations for tree ensembles (XGBoost, LightGBM, CatBoost, sklearn) — fast Rust genetic search, exact optimality proofs, certified infeasibility, and recourse regions."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -44,6 +44,11 @@ all = [
44
44
  "scikit-learn>=1.4",
45
45
  "matplotlib>=3.8",
46
46
  ]
47
+ test = [
48
+ "pytest>=8.0",
49
+ "hypothesis>=6.100",
50
+ "probcal>=0.2",
51
+ ]
47
52
  dev = [
48
53
  "maturin>=1.7",
49
54
  "pytest>=8.0",
@@ -56,6 +61,7 @@ dev = [
56
61
  "catboost>=1.2",
57
62
  "scikit-learn>=1.4",
58
63
  "matplotlib>=3.8",
64
+ "probcal>=0.2",
59
65
  ]
60
66
  docs = [
61
67
  "mkdocs>=1.6",
@@ -419,7 +419,7 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
419
419
 
420
420
  [[package]]
421
421
  name = "treecf-core"
422
- version = "0.2.2"
422
+ version = "0.2.4"
423
423
  dependencies = [
424
424
  "numpy",
425
425
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "treecf-core"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  edition = "2021"
5
5
  # f64::next_down (cells.rs) stabilized in 1.86; pyo3 0.29 needs 1.83
6
6
  rust-version = "1.86"
@@ -1053,19 +1053,18 @@ pub fn solve_exact(
1053
1053
  }
1054
1054
 
1055
1055
  completed = completed && dropped_floor >= incumbent_cost;
1056
- let lower_bound;
1057
- let proof;
1058
- if completed {
1059
- lower_bound = match incumbent_row {
1056
+ let (lower_bound, proof) = if completed {
1057
+ let bound = match incumbent_row {
1060
1058
  None => f64::INFINITY,
1061
1059
  Some(_) if gap == 0.0 => incumbent_cost,
1062
1060
  Some(_) => incumbent_cost / (1.0 + gap),
1063
1061
  };
1064
- proof = if gap > 0.0 && gap_prune_fired {
1062
+ let label = if gap > 0.0 && gap_prune_fired {
1065
1063
  "optimal_within_gap"
1066
1064
  } else {
1067
1065
  "optimal"
1068
1066
  };
1067
+ (bound, label)
1069
1068
  } else {
1070
1069
  let mut open_view = f64::INFINITY;
1071
1070
  if !ctx.order.is_empty() {
@@ -1081,9 +1080,11 @@ pub fn solve_exact(
1081
1080
  } else {
1082
1081
  dropped_floor
1083
1082
  };
1084
- lower_bound = py_min(py_min(open_view, incumbent_cost), set_aside_view);
1085
- proof = "heuristic";
1086
- }
1083
+ (
1084
+ py_min(py_min(open_view, incumbent_cost), set_aside_view),
1085
+ "heuristic",
1086
+ )
1087
+ };
1087
1088
 
1088
1089
  let mut snapped: Vec<usize> = Vec::new();
1089
1090
  for (level, chosen_state) in incumbent_states.iter().flatten().enumerate() {
@@ -28,7 +28,7 @@ from treecf.plausibility import Plausibility
28
28
  from treecf.regions import RecourseRegion
29
29
  from treecf.targets import Target
30
30
 
31
- __version__ = "0.2.2"
31
+ __version__ = "0.2.4"
32
32
 
33
33
  __all__ = [
34
34
  "AllowMissing",
@@ -129,6 +129,11 @@ class Counterfactual:
129
129
  region: The certified box around ``x_cf``, set only when the search ran
130
130
  with ``region=True`` (``Explainer.explain``/``explain_batch``/
131
131
  ``explain_coalitions``); ``None`` otherwise.
132
+ score_calibrated: The calibrator's probability at ``x_cf`` — set only
133
+ for a calibrated-space target whose calibrator exposes
134
+ ``predict_proba``; ``None`` otherwise. Presentational: the engine
135
+ optimized and verified against the raw interval the calibrator's
136
+ ``interval_inverse`` produced, never against this value.
132
137
  """
133
138
 
134
139
  x_cf: FloatArray
@@ -141,6 +146,7 @@ class Counterfactual:
141
146
  solver_stats: dict[str, object] = field(default_factory=dict)
142
147
  snapped: dict[str, bool] = field(default_factory=dict) # value_policy outcome
143
148
  region: RecourseRegion | None = None # set when `explain(..., region=True)`
149
+ score_calibrated: float | None = None # presentational read-out; see docstring
144
150
 
145
151
 
146
152
  @dataclass(frozen=True)
@@ -171,6 +177,25 @@ class Infeasible:
171
177
  solver_stats: dict[str, object] = field(default_factory=dict)
172
178
 
173
179
 
180
+ def _calibrated_readout(target: Target, score_raw: float) -> float | None:
181
+ """Calibrated probability at a raw score, or ``None`` when unavailable.
182
+
183
+ Duck-typed on ``predict_proba`` (never a probcal import); any failure in
184
+ the external calibrator degrades to ``None`` rather than erroring —
185
+ the read-out is presentational, the engine consumed the raw interval.
186
+ """
187
+ if target.space != "calibrated":
188
+ return None
189
+ predict = getattr(target.calibrator, "predict_proba", None)
190
+ if not callable(predict):
191
+ return None
192
+ try:
193
+ prob = 1.0 / (1.0 + np.exp(-np.float64(score_raw)))
194
+ return float(np.asarray(predict(np.array([prob])), dtype=np.float64).reshape(-1)[0])
195
+ except Exception:
196
+ return None
197
+
198
+
174
199
  # documented defaults for the exact-only kwargs; ``None`` at the public call
175
200
  # sites is the sentinel for "not explicitly passed"
176
201
  _DEFAULT_WARM_START = True
@@ -563,6 +588,10 @@ class Explainer:
563
588
  )
564
589
  if region and isinstance(outcome, Counterfactual):
565
590
  outcome = replace(outcome, region=self._region_for(x, outcome.x_cf, interval))
591
+ if isinstance(outcome, Counterfactual) and target.space == "calibrated":
592
+ outcome = replace(
593
+ outcome, score_calibrated=_calibrated_readout(target, outcome.score_raw)
594
+ )
566
595
  results[name] = outcome
567
596
  message = _degraded_summary(band_degraded, len(band_degraded), len(intervals), "bands")
568
597
  if message is not None:
@@ -584,6 +613,10 @@ class Explainer:
584
613
  )
585
614
  if region and isinstance(result, Counterfactual):
586
615
  result = replace(result, region=self._region_for(x, result.x_cf, interval))
616
+ if isinstance(result, Counterfactual) and target.space == "calibrated":
617
+ result = replace(
618
+ result, score_calibrated=_calibrated_readout(target, result.score_raw)
619
+ )
587
620
  return result
588
621
 
589
622
  def explain_batch(
@@ -1318,7 +1351,9 @@ class Explainer:
1318
1351
  gap=gap, time_budget_s=time_budget_s, warm_start=warm_start,
1319
1352
  )
1320
1353
 
1321
- def check_certificate(self, cert: dict[str, object]) -> dict[str, object]:
1354
+ def check_certificate(
1355
+ self, cert: dict[str, object], *, calibrator: object | None = None
1356
+ ) -> dict[str, object]:
1322
1357
  """Validate a stored certificate against *this* explainer.
1323
1358
 
1324
1359
  Recomputes both fingerprints (model and constraints) against this
@@ -1327,18 +1362,33 @@ class Explainer:
1327
1362
  changed constraint set each flips the corresponding boolean. This
1328
1363
  method reports — it never raises on a mismatch.
1329
1364
 
1365
+ Without ``calibrator=``, a calibrated-target certificate is still
1366
+ fully verifiable in *plan geometry*: the resolved ``raw_interval``
1367
+ is stored, so this proves the plan reaches the stored interval — it
1368
+ does not prove which calibrator produced that interval. Passing
1369
+ ``calibrator=`` adds exactly that: the duck-typed ``fingerprint()``
1370
+ is compared with the stored one, and the certificate's calibrated
1371
+ ``lo``/``hi`` are re-inverted through the supplied calibrator and
1372
+ compared with the stored interval (rtol 1e-9, infinities by
1373
+ identity). Neither mode requires treecf to import a calibration
1374
+ library.
1375
+
1330
1376
  Args:
1331
1377
  cert: A certificate produced by ``Explainer.certificate`` (a
1332
1378
  ``json.loads`` round trip of one works identically).
1379
+ calibrator: Optional duck-typed calibrator (the object handed to
1380
+ ``Target.calibrated``) to additionally verify calibrator
1381
+ provenance against a calibrated-target certificate.
1333
1382
 
1334
1383
  Returns:
1335
1384
  ``{"model_match": bool, "constraints_match": bool,
1336
1385
  "verification_ok": bool, "mismatches": [...]}`` with one
1337
- human-readable string per mismatch.
1386
+ human-readable string per mismatch, plus ``"calibrator_match":
1387
+ bool`` when ``calibrator=`` was given.
1338
1388
  """
1339
1389
  from treecf.audit import check_certificate
1340
1390
 
1341
- return check_certificate(self, cert)
1391
+ return check_certificate(self, cert, calibrator=calibrator)
1342
1392
 
1343
1393
  def _region_for(
1344
1394
  self, x: FloatArray, x_cf: FloatArray, interval: tuple[float, float]
@@ -309,6 +309,22 @@ def _verify_infeasible(
309
309
  }
310
310
 
311
311
 
312
+ def _duck_fingerprint(calibrator: object) -> str | None:
313
+ """The calibrator's duck-typed ``fingerprint()``, or ``None``.
314
+
315
+ Absent or raising members degrade to ``None`` — provenance is optional
316
+ by design; treecf never requires a calibration library at runtime.
317
+ """
318
+ fn = getattr(calibrator, "fingerprint", None)
319
+ if not callable(fn):
320
+ return None
321
+ try:
322
+ value = fn()
323
+ except Exception:
324
+ return None
325
+ return str(value) if value is not None else None
326
+
327
+
312
328
  def _target_block(
313
329
  target: Target, band: str | None, explainer: Explainer
314
330
  ) -> tuple[dict[str, object], tuple[float, float]]:
@@ -338,10 +354,25 @@ def _target_block(
338
354
  if band is not None:
339
355
  block["band"] = band
340
356
  if target.space == "calibrated":
341
- block["calibrator"] = "external — not embedded"
357
+ block["calibrator"] = {
358
+ "embedded": False,
359
+ "fingerprint": _duck_fingerprint(target.calibrator),
360
+ "type": type(target.calibrator).__name__,
361
+ "buffer_logit": _json_float(target.buffer_logit),
362
+ }
342
363
  return block, interval
343
364
 
344
365
 
366
+ def _factual_block(explainer: Explainer, x: FloatArray, target: Target) -> dict[str, object]:
367
+ block: dict[str, object] = {"x": _encode_array(x)}
368
+ if target.space == "calibrated":
369
+ from treecf.api import _calibrated_readout
370
+
371
+ readout = _calibrated_readout(target, raw_score(explainer.ir, x))
372
+ block["score_calibrated"] = _json_float(readout) if readout is not None else None
373
+ return block
374
+
375
+
345
376
  def build_certificate(
346
377
  explainer: Explainer,
347
378
  x: FloatArray,
@@ -405,7 +436,7 @@ def build_certificate(
405
436
  },
406
437
  "target": target_block,
407
438
  "solve": solve,
408
- "factual": {"x": _encode_array(x)},
439
+ "factual": _factual_block(explainer, x, target),
409
440
  }
410
441
  if reproducible_reason is not None:
411
442
  cert["reproducible_reason"] = reproducible_reason
@@ -443,7 +474,9 @@ def build_certificate(
443
474
  return cert
444
475
 
445
476
 
446
- def check_certificate(explainer: Explainer, cert: dict[str, object]) -> dict[str, object]:
477
+ def check_certificate(
478
+ explainer: Explainer, cert: dict[str, object], *, calibrator: object | None = None
479
+ ) -> dict[str, object]:
447
480
  """Body of ``Explainer.check_certificate``; see its docstring."""
448
481
  mismatches: list[str] = []
449
482
 
@@ -517,9 +550,73 @@ def check_certificate(explainer: Explainer, cert: dict[str, object]) -> dict[str
517
550
  verification_ok = False
518
551
  mismatches.append(f"verification could not be re-run: {exc}")
519
552
 
520
- return {
553
+ report: dict[str, object] = {
521
554
  "model_match": model_match,
522
555
  "constraints_match": constraints_match,
523
556
  "verification_ok": verification_ok,
524
557
  "mismatches": mismatches,
525
558
  }
559
+ if calibrator is not None:
560
+ report["calibrator_match"] = _check_calibrator(cert, calibrator, mismatches)
561
+ return report
562
+
563
+
564
+ def _check_calibrator(
565
+ cert: dict[str, object], calibrator: object, mismatches: list[str]
566
+ ) -> bool:
567
+ """The two extra checks ``check_certificate(calibrator=...)`` adds.
568
+
569
+ (a) the duck-typed fingerprint against the stored one — unavailable on
570
+ either side is noted in ``mismatches`` without failing the match, since
571
+ absence is not evidence of a different calibrator; (b) the certificate's
572
+ calibrated ``lo``/``hi`` re-inverted through the calibrator against the
573
+ stored ``raw_interval`` (rtol 1e-9, infinities by identity) — the check
574
+ that actually proves this calibrator produces this plan geometry.
575
+ """
576
+ ok = True
577
+ target = cert.get("target")
578
+ if not isinstance(target, dict) or target.get("space") != "calibrated":
579
+ mismatches.append("calibrator= given but the certificate's target is not calibrated")
580
+ return False
581
+ stored_block = target.get("calibrator")
582
+ stored_fp = stored_block.get("fingerprint") if isinstance(stored_block, dict) else None
583
+ fresh_fp = _duck_fingerprint(calibrator)
584
+ if stored_fp is None or fresh_fp is None:
585
+ mismatches.append(
586
+ "calibrator fingerprint unavailable on "
587
+ + ("both sides" if stored_fp is None and fresh_fp is None else "one side")
588
+ + " — identity not confirmed by fingerprint"
589
+ )
590
+ elif stored_fp != fresh_fp:
591
+ ok = False
592
+ mismatches.append("calibrator fingerprint does not match the certificate's")
593
+ stored_buffer = (
594
+ _from_json_float(stored_block.get("buffer_logit", 0.0))
595
+ if isinstance(stored_block, dict)
596
+ else 0.0
597
+ )
598
+ raw = target.get("raw_interval")
599
+ try:
600
+ lo = _from_json_float(target.get("lo"))
601
+ hi = _from_json_float(target.get("hi"))
602
+ fresh_lo, fresh_hi = calibrator.interval_inverse( # type: ignore[attr-defined]
603
+ lo, hi, space="logit", buffer_logit=stored_buffer
604
+ )
605
+ stored_lo = _from_json_float(raw[0]) # type: ignore[index]
606
+ stored_hi = _from_json_float(raw[1]) # type: ignore[index]
607
+ for fresh, stored in ((fresh_lo, stored_lo), (fresh_hi, stored_hi)):
608
+ if math.isinf(fresh) or math.isinf(stored):
609
+ same = fresh == stored
610
+ else:
611
+ same = math.isclose(fresh, stored, rel_tol=1e-9, abs_tol=1e-12)
612
+ if not same:
613
+ ok = False
614
+ mismatches.append(
615
+ "re-inverted interval does not match the stored raw_interval: "
616
+ f"fresh ({fresh_lo}, {fresh_hi}) vs stored ({stored_lo}, {stored_hi})"
617
+ )
618
+ break
619
+ except Exception as exc:
620
+ ok = False
621
+ mismatches.append(f"re-inversion through the supplied calibrator failed: {exc}")
622
+ return ok
@@ -93,6 +93,14 @@ class BatchRecord:
93
93
  solver_stats: Exact-backend diagnostics for the solve behind this
94
94
  record, same keys as ``Counterfactual.solver_stats``; empty for
95
95
  genetic/python solves (those engines report no per-row stats).
96
+ calibrator_fingerprint: The duck-typed ``fingerprint()`` of the
97
+ calibrated target's calibrator, when it exposes one; ``None``
98
+ for raw/probability targets or fingerprint-less calibrators.
99
+ Repeated on every record so each file line is self-contained.
100
+ score_calibrated: The calibrator's probability at ``x_cf`` for a
101
+ calibrated target whose calibrator exposes ``predict_proba``;
102
+ presentational only — the engine optimized and verified on the
103
+ resolved raw interval. ``None`` otherwise.
96
104
  """
97
105
 
98
106
  id: object
@@ -110,6 +118,11 @@ class BatchRecord:
110
118
  region: RecourseRegion | None = None # set by explain_batch(..., region=True)
111
119
  proof: str = "heuristic" # mirrors Counterfactual.proof / Infeasible.proof
112
120
  solver_stats: dict[str, object] = field(default_factory=dict) # exact-backend only
121
+ # Calibrated-target provenance and read-out (0.2.4). The fingerprint is one
122
+ # value repeated per record on purpose: every JSON line stays self-contained
123
+ # for a validator who receives only a slice of the file.
124
+ calibrator_fingerprint: str | None = None
125
+ score_calibrated: float | None = None # presentational; the engine used raw_interval
113
126
 
114
127
 
115
128
  @dataclass(frozen=True)
@@ -192,6 +205,8 @@ class BatchResult:
192
205
  "blocked_lever": record.blocked_lever,
193
206
  "coalition": record.coalition,
194
207
  "proof": record.proof,
208
+ "calibrator_fingerprint": record.calibrator_fingerprint,
209
+ "score_calibrated": record.score_calibrated,
195
210
  "solver_stats": {
196
211
  key: encode_floats(value)
197
212
  for key, value in record.solver_stats.items()
@@ -279,6 +294,9 @@ class BatchResult:
279
294
  proof=raw.get(
280
295
  "proof", "heuristic" if raw["feasible"] else "search_exhausted"
281
296
  ),
297
+ # absent in files written before 0.2.4
298
+ calibrator_fingerprint=raw.get("calibrator_fingerprint"),
299
+ score_calibrated=raw.get("score_calibrated"),
282
300
  solver_stats={
283
301
  key: decode_floats(value)
284
302
  for key, value in raw.get("solver_stats", {}).items()
@@ -561,6 +579,24 @@ def explain_batch(
561
579
  if message is not None:
562
580
  warnings.warn(message, TreecfWarning, stacklevel=2)
563
581
 
582
+ if target.space == "calibrated":
583
+ from dataclasses import replace as _replace
584
+
585
+ from treecf.api import _calibrated_readout
586
+ from treecf.audit import _duck_fingerprint
587
+
588
+ calibrator_fp = _duck_fingerprint(target.calibrator)
589
+ if calibrator_fp is not None:
590
+ records = [_replace(r, calibrator_fingerprint=calibrator_fp) for r in records]
591
+ # The Rust wave paths assemble Counterfactuals without going through
592
+ # Explainer._explain, so the read-out is filled here for every path.
593
+ records = [
594
+ _replace(r, score_calibrated=_calibrated_readout(target, r.score_raw))
595
+ if r.feasible and r.score_calibrated is None and r.score_raw is not None
596
+ else r
597
+ for r in records
598
+ ]
599
+
564
600
  return BatchResult(
565
601
  feature_names=explainer.ir.feature_names,
566
602
  diversity=diversity,
@@ -595,6 +631,7 @@ def _record_from(
595
631
  n_changed=cf.n_changed,
596
632
  score_raw=cf.score_raw,
597
633
  score_prob=cf.score_prob,
634
+ score_calibrated=cf.score_calibrated,
598
635
  seed=seed,
599
636
  blocked_lever=blocked_lever,
600
637
  coalition=coalition,
@@ -96,8 +96,40 @@ def _parse_hist_gradient_boosting(model: Any) -> EnsembleIR:
96
96
  )
97
97
 
98
98
 
99
+ def _effective_le_threshold(t: float) -> float:
100
+ """The float64 boundary of sklearn's float32 input cast, exactly.
101
+
102
+ sklearn ``tree_``-based ensembles route ``float32(x) <= float64(t)`` —
103
+ the input is cast to float32 (round-to-nearest-even) before the
104
+ comparison. The IR evaluates in float64, so the stored threshold must be
105
+ the largest float64 ``T`` with ``float32(T) <= t``; then ``x <= T``
106
+ reproduces the native routing for *every* float64 ``x``, including points
107
+ exactly on split boundaries — where a counterfactual search naturally
108
+ lands. (Verified by a 138k-probe property sweep and the unquantized
109
+ conformance tests.)
110
+
111
+ Construction: let ``f`` be the largest float32 with ``f <= t`` and ``s``
112
+ its float32 successor; every ``x`` below their float64 midpoint rounds to
113
+ ``<= f``. The midpoint itself rounds half-to-even: it belongs to the left
114
+ side exactly when it rounds back to ``f``.
115
+ """
116
+ f32 = np.float32(t)
117
+ if float(f32) > t:
118
+ f32 = np.nextafter(f32, np.float32(-np.inf))
119
+ succ = np.nextafter(f32, np.float32(np.inf))
120
+ mid = (float(f32) + float(succ)) / 2.0
121
+ if float(np.float32(mid)) == float(f32):
122
+ return mid
123
+ return float(np.nextafter(mid, -np.inf))
124
+
125
+
99
126
  def _tree_from_arrays(tree: Any, scale: float, classifier: bool) -> Tree:
100
- """Convert a fitted ``sklearn.tree._tree.Tree`` to IR nodes (LE convention)."""
127
+ """Convert a fitted ``sklearn.tree._tree.Tree`` to IR nodes (LE convention).
128
+
129
+ Thresholds are re-expressed on the float64 grid via
130
+ :func:`_effective_le_threshold` so the IR's float64 routing matches
131
+ sklearn's float32-cast routing bit-for-bit.
132
+ """
101
133
  left = tree.children_left
102
134
  right = tree.children_right
103
135
  feature = tree.feature
@@ -120,7 +152,7 @@ def _tree_from_arrays(tree: Any, scale: float, classifier: bool) -> Tree:
120
152
  Node(
121
153
  node_id=i,
122
154
  feature=int(feature[i]),
123
- threshold=float(threshold[i]),
155
+ threshold=_effective_le_threshold(float(threshold[i])),
124
156
  op=SplitOp.LE,
125
157
  missing_left=missing_left,
126
158
  left=int(left[i]),
@@ -183,7 +215,7 @@ def parse_isolation_forest(model: Any) -> EnsembleIR:
183
215
  Node(
184
216
  node_id=i,
185
217
  feature=int(tree.feature[i]),
186
- threshold=float(tree.threshold[i]),
218
+ threshold=_effective_le_threshold(float(tree.threshold[i])),
187
219
  op=SplitOp.LE,
188
220
  missing_left=None,
189
221
  left=int(tree.children_left[i]),
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes