Changelog¶
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]¶
[0.3.2] - 2026-09-06¶
The exact backend now searches coarse-to-fine by default, which certifies far more of the measured matrix in the same budget; a budget that ran out no longer costs seconds more for its own warning; certificates of exact solves always name the engine that ran; and a packaged credit demo makes every example in the documentation and the README run as written. The documentation is restructured so that each page has one job, and its claims are sized to what the benchmarks measure.
Details
Added¶
treecf.datasets.credit_demo(): the documentation's credit model, shipped inside the package with a deterministic background sample and one declined applicant, so every example in the documentation and the README runs as written on a fresh install.
Changed¶
- The exact backend's default search is the coarse-to-fine one (
search="refine"); the earlier engine stays available assearch="classic". Both prove the same optimum and the same infeasibility certificates, and the refine search certifies far more of the measured matrix in the same budget — on a 300-tree, 50-feature model it settles a three-lever coalition in under half a second where the classic search runs out of a 60 s budget. The returned row may be a different argmin of the same cost. - Certificates of exact solves always record the engine.
solve.declared.searchis taken from the result's own solver statistics when the caller does not passsearch=, so a certificate never leaves the engine to be inferred from the issuing release's default.
Fixed¶
- The budget-exhaustion warning no longer costs seconds of its own. Sizing the search space for the warning's "≈ 10^N states" figure ran the presolve pass in Python — one bracket walk through every tree per candidate state — after the budget had already ended, which on a 300-tree, 50-feature model added 5 s to a 10 s budget. The figure is now the un-presolved size, which costs a domain build.
[0.3.1] - 2026-09-06¶
The exact backend gains an opt-in coarse-to-fine search (search="refine") that
certifies far larger models in the same time, a maximal region mode that proves where a
certified box ends, a certification trace you can plot, and a search profile that sizes a
solve before it runs. Auditors get a one-page portfolio report; analysts get certified
recourse menus over lever sets and lever-diverse plans. Two correctness fixes reach every
user: XGBoost and CatBoost models now route float64 inputs exactly as the native model
does, and a value policy no longer withdraws an exact certificate up front. Regions stop at
the observed data range instead of running to infinity, and region phrases never overstate
the box. Everything else is byte-identical to 0.3.0.
Details
Added¶
- Branch-and-refine exact search.
explain(..., backend="exact", search="refine")holds each numeric feature to a range of routing cells first and descends only where the score bound forces it, accepting whole boxes at their true minimum cost; it proves the same optimum and the same certified infeasibility as the classic engine (the returned row may be a different argmin of the same cost).solver_statsgainssearch,coarse_accepts, andrefinements; the Python and Rust engines agree byte for byte on a new fixture set. On the measured benchmark matrix it certifies 13 of the 18 model-scale cells within 60 s where the classic engine certifies 6, adding 50 trees at depth 3 with 20 features, every 12-feature cell at depth 5, 200 trees at depth 3 with 12 features, and the depth-5 8-feature cells at 100 and 200 trees; the remaining 20-feature cells stay out of reach for both engines within that budget. The defaultsearch="classic"is unchanged. - Maximal recourse regions.
explain(..., region=True, region_mode="maximal", region_budget=...)andExplainer.recourse_region(..., mode="maximal", budget=, keep_witnesses=)settle every side the fast growth stops with a budgeted search for a violating point in the next routing cell: an empty search extends the side, a witness proves it maximal, a spent budget leaves it unproven.RecourseRegion.maximal,maximal_categories, andwitnessescarry the findings; batch files round-trip them and certificates store the flags as additive keys under schema version 2.plot_regiondraws a proved side as a filled square and shows the "not necessarily maximal" caveat only while a side is unproven. The defaultregion_mode="fast"is unchanged. - Portfolio report.
treecf.audit.portfolio_report(batch, groups, ...)summarizes a campaign as a strict-JSON artifact — population and proof mix, recourse burden per segment, dominant levers, missing-value transitions, fingerprints — and renders it on request as one self-contained HTML page or as Markdown with figures beside the file. Disparity ratios are off by default and framed by one fixed sentence when enabled. - Certification trace. Every exact solve records
solver_stats["trace"]— the incumbent cost and the sound lower bound sampled at every incumbent update and every power-of-two node count, capped at 256 entries — andplot_certification_tracedraws it with the outcome named. - Search profile.
Explainer.search_profile(x, target=None)sizes the classic search before it runs (atomic cells, domain sizes, influential features,log10_states, and the presolve-filtered sizes with a target); the budget-exhaustion warning quotes the figure. - Conformance hardening. A parser fuzz leg with a committed corpus of once-crashing dumps, and a CatBoost category-hashing property leg over drawn strings, both in CI's slow leg.
- Commit hygiene. A pull-request CI job checks that commit subjects and bodies describe behavior, mirrored by a CONTRIBUTING checklist item; the narration test covers Rust and Markdown sources too.
- Certified recourse menus over lever sets (
recourse_menu), lever-diverse plans (explain_diverse), and the menu matrix plot. Every lever set up to a size is solved as its own coalition; the menu lists the minimal frontier, the sets proved unable to reach the target, and whether every set was settled with a certificate.explain_diversereturns the cheapest plans with distinct lever sets, or climbs declared coalitions and their unions;plot_recourse_menudraws the matrix with a proof glyph per row.
Changed¶
- Regions stop at the data range where no constraint bounds them. A side without a
Rangeused to grow to infinity, so an unconstrained count read"≤ 1"over an implicit minus infinity. An explainer withbackgrounddata now grows such a side no further than the observed range (widened to include the counterfactual itself); the box is a sound sub-box of the old one,RecourseRegion.data_limitednames the sides that stopped there,describe()marks them"(data-limited)", andplot_regiondraws them with a diamond cap. In the maximal mode a data-limited side counts as settled, like aRangebound. An explainer built fromnormalizersalone is unchanged.
Fixed¶
- XGBoost and CatBoost route float64 inputs as the native model does. Both libraries cast inputs to float32 before comparing against a split, so a float64 value within half a float32 ulp of a threshold used to route one way natively and the other way in the IR — enough to flip leaves on a fraction of real training rows and to hand back counterfactuals whose verified score the native model did not reproduce. The parsers now store the float64 boundary of that cast (the mechanism the sklearn parser already used), and the conformance suites probe unquantized float64 neighbours of every threshold for all three libraries.
RecourseRegion.describe()never overstates the box. An endpoint that rounds to a value outside the interval is phrased strictly ("< 1","in [0, 1)") instead of rounding a boundary one float32 ulp below 1 up to"≤ 1"; features under an"integer"value policy are phrased on the integers the box contains ("= 0","≤ 0","in [2, 4]"), recorded in the newRecourseRegion.integer_featuresfield, which batch files round-trip.- A value policy no longer withdraws the exact certificate up front. With an order pair
over a policy-bound feature the search used to give up its optimality claim before
expanding a single node. It now withdraws only when a completion actually breaks such a
pair on values its cells could still have ordered; a policy run that never meets one reports
proof="optimal"as the documentation promised. Declaring the same order pair twice no longer withdraws either. - Parser errors are normalized. A malformed model dump now raises
ParserErrornaming the format and the cause where it used to leak a bareKeyError,IndexError,TypeError,ValueError, orZeroDivisionError— a scalar at the top level, a missing key, a truncated parallel array, a probability base score of exactly one, a LightGBM category token that is not an integer, a CatBoostscale_and_biasof the wrong shape or a split on a float feature the model does not declare, a negative feature count, a child pointer outside the tree, and a threshold that overflows float32 (some of which only failed once the model was scored). Every parsed ensemble is now structurally validated before it is returned. - The certification concept page described the certificate schema as version 1 and the
calibrator block as a bare string; both now match what
certificatewrites.
Invariants¶
- Default behavior is byte-identical to the previous release, with one deliberate exception — regions grown by an explainer that has background data stop at the data range (see Changed): the classic exact fixtures, the fast region fixtures, the genetic parity fixtures, and the committed certificate goldens are untouched, and every other new behavior sits behind a flag or a new function.
- The Python and Rust engines remain byte-identical on every solve, now including the refine search, the maximal region mode, and the certification trace.
[0.3.0] - 2026-08-31¶
Native categorical splits parse exactly for LightGBM, XGBoost, scikit-learn
HistGradientBoosting, and CatBoost, and every backend searches them over category blocks,
so cardinality is not the cost driver. Regions certify category sets and certificates store
them as schema version 2; the exact backend gains a presolve pass; plot_region and the
recourse-burden views arrive; the docs are rebuilt around workflows with every snippet
executed in CI. Numeric-model results are byte-identical to 0.2.4.
Details
Added¶
- Native categorical splits. Models trained with native categorical support now parse
exactly into set-membership IR nodes: LightGBM (
categorical_feature), XGBoost (enable_categorical), scikit-learnHistGradientBoosting(categorical_features, including string categories viacategories=), and CatBoost (cat_features; one-hot and single-feature-statistic splits, hashing reproduced bit-exactly). CatBoost models built with categorical feature combinations raiseParserErrornaming themax_ctr_complexity=1retraining recipe; the newParserErrortype covers "recognized but unparseable as given". Explainer(categories=...). Display names (and, where useful, declared cardinalities beyond training) for categorical features; required for CatBoost with native categorical features and for HistGradientBoosting trained on string categories, optional elsewhere.- Category blocks. Every backend searches categorical features over routing-equivalence classes of codes, so search cost scales with how finely the ensemble partitions the feature, not its cardinality. Categorical distance is flat: any change of code costs one weighted unit.
AllowedCategoriesconstraint. Whitelist a categorical feature's codes by display name or raw code; order- and arithmetic-shaped constraints (Range,Monotone,Linear,Equals,Implies,OneHot) are rejected on categorical features at construction.- Categorical exact search and regions.
backend="exact"proves optimality and certified infeasibility over the block grid;region=Truecertifies category sets per categorical feature (RecourseRegion.feature_categories/.category_names/.cat_sets), stored in certificates as schema version 2. Schema version 1 certificates still verify, pinned by a committed golden file. - Presolve. The exact backend filters each feature's candidate states by reachable score
and plausibility brackets before branching;
solver_statsgainspresolve_removedandpresolve_certified, and an emptied domain certifies infeasibility with zero nodes expanded. Results are bit-identical with presolve on; only node counts drop. - Visualization.
plot_region(the certified box, with per-bound cap markers and categorical tiles) andplot_recourse_burden/recourse_burden_table(feasible share and cost distribution by segment, kept side by side). - Docs. Reader-oriented navigation (workflow guides, grouped concepts, split API pages, benchmarks and changelog pages); every fenced snippet executed in CI against a committed docs model; a structure test pins that no published URL disappears and every plot function ships a committed figure.
SECURITY.md,CONTRIBUTING.md,scripts/bump_version.py(with a version-consistency test), and#![forbid(unsafe_code)]in the Rust core.
Changed¶
- Exact-search performance. Presolve, a feature-to-trees index, and per-tree bracket caching in region growth. Measured before/after (same machine, same seeds, medians):
| Scenario (exact backend, warm start, 5 s / 2M-node budgets, 10 seeds, 4-core x86_64) | 0.2.4 median | 0.3.0 median |
|---|---|---|
| 30 trees / depth 4 / 8 features — every solve proved optimal | 0.292 s | 0.295 s |
| 60 trees / depth 5 / 12 features — budget-capped, best-found | 5.014 s | 5.012 s |
| 300 trees / depth 6 / 50 features — budget-capped, best-found | 5.208 s | 5.133 s |
No legacy case regresses (the largest change is +1.0% on the small case, within run noise); on the large budget-capped case the search now expands 308,822 nodes in the same budget where 0.2.4 expanded 205,755. New certification measurements (60 s budget, 3 seeds): the 200-tree / depth-5 / 12-feature reference case does not certify within 60 s on the 4-core benchmark machine; the native-categorical suite (4 numeric levers plus cardinality-3/8/15 categoricals, LightGBM) certifies in 0.022 s median even at 200 trees. Full tables: the docs benchmarks page, generated from the same measured JSON.
Invariants¶
- Numeric-model results are byte-identical to the previous release: fingerprints, solves, regions, and stored fixtures are unchanged, pinned by a dedicated invariance suite.
- No genetic or parity fixture was regenerated; exact fixtures were regenerated only under an equality guard asserting identical plans, distances, and proofs.
- The Python and Rust engines remain byte-identical on every solve, domain, and region, including the new categorical paths.
[0.2.4] - 2026-08-23¶
Calibrator provenance: certificates and batch records carry the calibrator's
fingerprint and a calibrated-probability read-out, check_certificate(calibrator=) can
re-check it, and a probcal test matrix pins the duck-typed protocol. Strictly additive.
Details
Added¶
- Calibrator provenance in certificates. For calibrated-space targets the certificate's
target.calibratorblock is now structured —{embedded: false, fingerprint, type, buffer_logit}— with the fingerprint duck-read from the calibrator's ownfingerprint()(nullwhen absent; probcal calibrators provide one).check_certificateaccepts an optionalcalibrator=keyword: when given, the report gainscalibrator_match, true only if the fingerprints agree and re-inverting the stored calibrated bounds through the passed calibrator reproduces the stored raw interval. - Calibrator provenance in batch records.
BatchRecord.calibrator_fingerprintrepeats the target calibrator's fingerprint on every row, so each JSON line stays self-contained. score_calibratedread-out.Counterfactual,BatchRecord, and the certificate'sfactualblock now carry the calibrator's probability at the result (and at the factual) for calibrated targets whose calibrator exposespredict_proba;Noneotherwise. Presentational only: the engine still optimizes and verifies against the raw interval.- Plateau-aware exactness tests. Calibrated targets on and one float above step-calibrator plateau levels, cross-checked against brute-force enumeration in calibrated space and against real probcal isotonic/centered-isotonic fits.
- probcal test matrix. New optional
testextra (and probcal in thedevextra): 7 fitted probcal calibrators x target ops x buffer levels on sklearn and LightGBM models, every plan re-verified through the model and calibrator; dedicated CI job with pinned probcal + lightgbm.src/never imports probcal — the duck-typed protocol is unchanged. - Docs.
concepts/calibration.mdgains provenance, read-out, and worked-example sections, and pins the guarantee thatexplain_batchcallsinterval_inverseexactly once per call (once per band for ladders), backed by counting tests.
Compatibility¶
- Strictly additive. All new dataclass fields default to
None; 0.2.x batch JSON and certificates load with the new fields defaulted.check_certificatewithoutcalibrator=produces byte-identical reports to 0.2.3. Calibrators missing optional duck members (fingerprint,predict_proba) degrade tonull/None, never an error.
[0.2.3] - 2026-08-23¶
A correctness fix for scikit-learn tree ensembles: their float32 input cast could route a counterfactual sitting on a split boundary differently from the model itself, so an "optimal" plan could miss its target. Thresholds are now stored as the exact float64 boundary of that cast.
Details
Fixed¶
- sklearn
tree_-based ensembles (RandomForest, GradientBoosting, IsolationForest) routed differently from sklearn itself at split boundaries, because sklearn casts inputs to float32 before comparing against the float64 threshold while the IR evaluates in float64. A counterfactual whose coordinate landed exactly on a split threshold — the natural optimum of a smallest-change search, since<=cells are closed on the left — could flip through many trees at once: in the reproducing case (GradientBoostingClassifier,subsample=0.8), the exact backend stampedproof="optimal"on anx_cfwhose truedecision_functionmargin was 3.09 raw-score units away from the reportedscore_raw, silently violating the target. Thresholds are now re-expressed at parse time as the exact float64 boundary of the float32 cast (largest float64Twithfloat32(T) <= t, round-half-to-even handled), so float64 IR routing reproduces sklearn bit-for-bit for every input — search, certificates, andscore_rawincluded. Verified by a 138k-probe property sweep, new unquantized conformance tests (exact-threshold and float64-neighbour probes; the old harness quantized all probes to the float32 grid, which is exactly why this never surfaced), and probcal's joint recourse scenarios. HistGradientBoosting predicts on the float64 grid and is unchanged; XGBoost also casts features to float32 natively and should get the same treatment once a reproducing case is confirmed (follow-up).
Internal¶
- Restructured a late-initialized binding in the exact search's proof/lower-bound
epilogue (behavior-identical) — clippy 1.98's
needless_late_initbegan rejecting the old form under-D warningson the freshly installed stable toolchain in CI.
[0.2.2] - 2026-08-19¶
Audit certificates: Explainer.certificate turns any result into a self-contained
JSON record with model and constraint fingerprints and a fresh verification, and
check_certificate re-checks one later. Batch records gain proof and solver_stats. No
solver behavior changes.
Details
Added¶
- Audit certificates:
Explainer.certificate(x, result, target)turns any storedCounterfactualorInfeasible(the certified "no" included) into a strict-JSON-serializable audit record — a reproducibility record plus a fresh verification. It binds the claim to a model fingerprint, a constraint fingerprint, and the solve parameters, and re-verifies the returned plan (score, target membership, constraint check, plausibility, sampled region points) at issue time; it does not cryptographically prove that a search ran or that aproof="optimal"claim is true — re-running with the recorded seed/budgets on a fingerprint-matching model is how a validator checks that. A certificate whose fresh verification fails is still issued with the failing checks recorded, plus aTreecfWarningnaming them.Explainer.check_certificate(cert)is the validator's tool: it recomputes both fingerprints against the current explainer, re-runs the verification block, and reports (model_match/constraints_match/verification_ok/mismatches) without ever raising on a mismatch. The newtreecf.auditmodule exposes the underlyingir_fingerprintandconstraints_fingerprint(SHA-256 over canonical byte encodings — stable across Python versions, platforms, and dict ordering; a callablevalue_policyhas no canonical encoding and marks the certificate"reproducible": falsewith a reason). BatchRecord.proofandBatchRecord.solver_stats: every batch record now carries the claim and (for exact solves) the diagnostics of the single-instance result that produced it —Counterfactual.proofvalues for feasible records,Infeasible.proof("search_exhausted"/"certified") for infeasibility markers. Genetic/python records carry empty stats (those engines report no per-row diagnostics).BatchResult.to_framegains aproofcolumn (solver_statsstays record-only);save/loadround-trip both fields, and files from earlier versions load with feasibility-based defaults.
Fixed¶
- The batch aggregate degraded-result warning pointed at "each result's own
proof/solver_stats" while
BatchRecordexposed neither field; the fields now exist, so the message is true as written (the wording itself is unchanged).
Notes¶
- No solver behavior changes; no fixtures touched; no Rust source changes (only the mirrored
version in
rust/Cargo.toml/Cargo.lock).
[0.2.1] - 2026-08-15¶
Ctrl-C now interrupts an exact search, a region growth, or a batch solve promptly.
Every degraded exact result warns and says whether the budget ran out or a conservative
repair withdrew the certificate, and exact batches become opt-in behind
allow_exact_batch=True. No result of any completed call changes.
Details
Added¶
- Interruptibility: a
Ctrl-Cduring an exact search, a certified-region growth, or a batch genetic solve now raisesKeyboardInterruptpromptly instead of waiting for the whole search to finish. The Rust core polls for it from inside its released GIL (about every 2^18 nodes for the exact search); the pure-Python exact fallback already raised promptly, since Python delivers signals between bytecode instructions. Reliable only when the call happens on the main thread. Nothing is returned on interrupt -- whatever incumbent or partially grown region existed is discarded. - The exact backend now always warns when it returns a degraded result
(
solver_stats["completed"] is False): aTreecfWarningnames whether the search genuinely ran out of budget or instead withdrew its optimality certificate through a conservative constraint repair without touching the budget -- the two causes are never conflated, and the message includes a lower-bound/gap parenthetical when one is available, plus an unseeded-warm-start clause when the incumbent came from an unseeded warm pass.Target.bands,explain_coalitions, andexplain_batchcollapse every degraded solve in one call into a single aggregate warning instead of one per solve. explain_batch(..., backend="exact")is now opt-in behindallow_exact_batch=True: without it, raisesValueErrornaming a worst-case wall-time estimate (rows × plans ×time_budget_s) instead of running unbounded. Opting in also replaceswarm_start's per-row (or, indiversity="seeds"mode, per-attempt) genetic warm passes with a single vectorized warm pass shared across the whole batch -- in seeds mode this means every attempt of a row now shares one incumbent instead of each attempt warm-starting its own (withn_per_example=1the result still matches a sequentialexplain(..., backend="exact")call exactly).
Changed¶
- README and the package's one-line description refreshed to cover the exact backend, certified infeasibility, and recourse regions (updates the PyPI project page on the next release).
- No result of any non-interrupted call changes in this release, and no fixtures were regenerated: the full pre-existing test suite passes unchanged.
Fixed¶
Ctrl-Clatency during an exact search, region growth, or batch genetic solve used to equal however much of the search remained; it is now near-immediate (see Interruptibility above).
Docs¶
- Every public API object rendered on the API reference now documents its parameters
(with default semantics, not just default values), return shape, and deliberate raises
to the same depth, with cross-references to the relevant concepts pages; several
objects that previously had no docstring at all (
Target.raw/bands,BatchResult.for_id/save/load,suggest_constraintsand its result types,Plausibility.isolation_forest/anomaly_score) were entirely absent from the rendered docs and are now covered. - README quick-look comment recalibrated to name the warned-degrade case alongside
proof="optimal"and a certified "no"; new Certification sections cover interruption and the always-on degraded-result warning.
[0.2.0] - 2026-08-14¶
The exact backend: a branch-and-bound search over the routing cells that reports
proof="optimal", certifies infeasibility, and widens plans into recourse regions —
certified boxes around a plan — plus the recourse map plot. The Rust core's random-number
library was upgraded, so seeded genetic results may differ from 0.1.x.
Details
Added¶
backend="exact": a branch-and-bound search over the same routing-atomic cell grid the genetic backend shares, reportingproof="optimal"(no cheaper feasible row exists) orproof="optimal_within_gap"(within agap > 0relative fraction of the optimum) instead of"heuristic". Rust-first with a byte-identical pure-Python fallback when the extension is not importable.warm_start(defaultTrue) seeds the search with a short genetic pass;node_budget(default 2,000,000 assignments) andgap(default0.0) trade proof strength against wall time. Supports single-featureLinearconstraints and the canonical two-feature order pair exactly; any other multi-featureLinearor a callablevalue_policyraisesConstraintValidationErrornamingbackend="genetic"as the fallback.- Certified infeasibility:
Infeasible.proof="certified"from the exact backend means the whole reachable grid was tried and every row rejected — not merely that a budget ran out. NewInfeasible.proof("search_exhausted"|"certified") andInfeasible.solver_statsfields; both default so existing code is unaffected. - Recourse regions:
explain(..., region=True)(also onexplain_coalitions/explain_batch) widens a verified counterfactual into aRecourseRegion— a per-feature box where every point is certified feasible by interval arithmetic over the whole box, not sampled. Works with every backend viaExplainer.recourse_region;Counterfactual.regioncarries it, andBatchRecord.regionpersists it through batch save/load. plot_recourse_map: one-axes map of a single applicant's recourse options — model output on x, recourse cost J on y — with the accept band, an arrow per plan, infeasible coalitions marked, and aschematic=Trueslide-style mode.- Docs: new Certification concepts page covering the proof taxonomy, what a certificate does and does not cover, and the region layer's guarantees.
Changed¶
- PyPI development-status classifier raised to
4 - Beta. - Rust core: rand upgraded 0.9 → 0.10 (with rand_distr 0.6 and rand_pcg 0.10). Seeded runs stay deterministic for a given treecf version, but the random stream may differ from builds against rand 0.9, so genetic-search results for the same seed can change across this upgrade.
- The genetic backend itself is unchanged by this release: the full pre-existing test suite passes without fixture regeneration.
Fixed¶
- Derived per-feature bounds from single-feature linear constraints now include the linear check's tolerance, so they no longer exclude counterfactuals the constraint itself admits (previously possible with very small or very large coefficients).
[0.1.1] - 2026-08-08¶
A factual that violates its own constraints now warns; single-feature linear constraints lower into bounds and other linears get a projection repair, which ends spurious infeasibility; wheels are smoke-tested before upload. Seeded results that involve non-canonical linear constraints changed.
Details
Added¶
TreecfWarning, emitted when a factual violates its constraints — once perexplaincall, and as a single per-constraint aggregate inexplain_batch. The warning spells out that the returned plan includes changes made solely to satisfy the violated constraints.- Derived per-feature bounds for single-feature
Linearconstraints (constraint("income >= 100")now clips candidates like the equivalentRange); vacuous zero-coefficient linears are dropped, unsatisfiable ones rejected at compile time. - Declared Rust MSRV (rustc 1.86) in
rust/Cargo.tomlwith an enforcing CI job; building from the sdist needs 1.86+, wheels need no toolchain. - Wheel smoke tests in the release workflow: every runnable wheel target is
installed into a fresh venv (musllinux inside an Alpine container) and runs
one
explainper backend before upload. CITATION.cffversion is now checked againsttreecf.__version__in the test suite.
Fixed¶
- Satisfiable
Linearconstraints whose feasible set lies far from the factual no longer come backInfeasible: single-feature linears lower into bounds, and multi-feature linears get halfspace-projection repair. apply_linkno longer raisesOverflowErrorfor raw scores below ≈ −710; mid-range sigmoid outputs are bit-for-bit unchanged.CITATION.cffandrust/Cargo.tomlversion drift (both said 0.0.1 while the released package was 0.1.0).
Changed¶
- Repair for non-canonical linear constraints now runs a 3-round cyclic
halfspace projection; seeded results from 0.1.0 that involve such
constraints are not reproducible in 0.1.1. The canonical order-pair
repair (
a - b <= 0) is unchanged, and the existing parity fixtures regenerated byte-identical; a new11-linear-projectionfixture pins the projection behavior.
[0.1.0] - 2026-07-23¶
Calibrated targets through a duck-typed calibrator protocol, post-solve pruning of changes that verification proves unnecessary, and a published benchmark against DiCE and NICE; a band-target field-propagation bug is fixed.
Details
Added¶
- Calibrated targets:
Target.calibrated(calibrator, ...)expresses the target on the post-hoc calibrated probability scale and lazily inverts it through the calibrator's duck-typed generalized inverse (interval_inverse(lo, hi, *, space="logit", buffer_logit=...)+is_monotone_) — no calibration-library dependency.Target.bandsacceptsspace="calibrated"withcalibrator=/buffer_logit=for masterscales defined on calibrated PD.Target.probabilitynow documents that it targets the uncalibrated model probability.
Fixed¶
-
Target.band_intervalsfield propagation: per-band targets were rebuilt from(space, lo, hi)only, silently dropping any other field — now all fields propagate (surfaced by the calibrated-bands work). -
Competitor benchmark:
scripts/bench_vs_competitors.py(PEP 723, self-contained viauv run) compares treecf with DiCE and NICE on two model scales; results published in Backends and proofs — 8–3400× faster than DiCE with far cheaper plans, cheapest plans overall, 157 rows/s batch production on the medium model; NICE's per-instance speed and treecf's own misses reported as-is. - Post-solve pruning: every returned plan now drops changes that verification proves unnecessary (cheapest first, each revert re-verified in float space). The search's revert-to-factual mutation is stochastic, so a stalled run could ship a residual micro-change that crossed no decision threshold — pure distance cost with zero score effect.
CITATION.cff.
Changed¶
- Compiled extensions are no longer tracked in git (history rewritten to drop
the committed
.so; wheels come from CI, local builds via maturin). - Publish steps skip files already on the index, making tag-triggered
re-releases idempotent; retroactive
v0.0.1tag and GitHub release created. - PyPI keywords no longer mention the removed CP-SAT backend; README/docs state the published version (0.0.1) consistently.
[0.0.1] - 2026-07-13¶
The genetic backend runs on a Rust core, batch production runs in parallel inside it, coalitions mode and the batch plots arrive, and the CP-SAT exact backend is removed.
First published release (PyPI). Version deliberately resets BELOW 0.1.0 (which was never published): the Rust-backed rebuild supersedes the prior pure-Python implementation outright and restarts the version line.
Details
Changed¶
- The genetic backend runs on a Rust core by default (44-58x faster
than the numpy implementation on realistic workloads; 24.6x single-threaded).
backend="genetic"uses Rust; the pure-Python GA remains available asbackend="python". - Packaging switched from hatchling (pure Python) to maturin (single mixed Rust/Python package). Installing from source now requires a Rust toolchain; platform wheels are built in CI. The numpy-only-core dependency policy ends; runtime Python dependencies are unchanged (numpy only).
- Release: platform wheels now include linux-aarch64 (Graviton, Docker on Apple Silicon) alongside linux/musllinux x86_64, macOS arm64/x86_64, and Windows x64; the PyPI description no longer mentions the removed CP-SAT backend.
- Docs: the standalone Benchmarks page is gone; the headline numbers, the
single-core explanation, and the batch-parallelism caveat now live in a
"Performance" section of Backends and proofs. Full protocol and
reproduction stay in
scripts/bench_genetic.py/scripts/bench_batch.py. explain_batchruns its solves in parallel inside the Rust core: the seeds path solves one wave of independently seeded attempts per Rust call (rayon across tasks, GIL released) and lever-blocking batches all primary solves; per-wave verification scores come from one vectorized IR pass. Records are identical to the former sequential per-row loop (same seeds, dedup, and stopping rule), with one caveat: a solve that hits its per-tasktime_budget_sunder core contention may stop at a different generation than it would sequentially. Also: routing-atomic cells are now cached on the Rust ensemble instead of rebuilt per solve, and lever-blocking clones reuse the parent's marshaled Rust ensembles. ~1.7x batch throughput on a 4-core machine (scripts/bench_batch.py); the gain grows with core count.
Added¶
- Coalitions mode (opt-in):
Explainer.explain_coalitions(x, target, coalitions={...}, include_full=False)produces one counterfactual per named feature group, each solve allowed to change only that group (everything else frozen);Infeasibleper group means that group alone cannot reach the target.explain_batch(..., diversity="coalitions")scales it to datasets (one record per group per row; newcoalitionfield onBatchRecord, persisted and exposed into_frame()).plot_alternatives/plot_tradeoffaccept the outcome mapping directly, labeling plans by coalition name. Never the default mode. Documented in a new Concepts page, a "Grouped recourse" section of How it works, and a tutorial section. - Single-instance comparison plots (
treecf.viz):plot_alternatives(every alternative plan's changes on shared axes, one color per plan, σ-standardized with an explainer) andplot_tradeoff(cost vs achieved score per plan, with target lines). Both acceptCounterfactualobjects or feasibleBatchRecordentries. - Docs: pipeline and genetic-loop diagrams (Mermaid) in "How it works";
reorganized Home and Getting started (single install section, alternatives
walkthrough, "where next" links), pipeline-ordered Concepts nav, and the
stale
proofvalues from the removed CP-SAT era corrected. - Batch visualizations (
treecf.viz_batch,[viz]extra):plot_batch_levers(which levers plans use, by direction, with essential-lever annotations),plot_batch_matrix(plans × features heatmap, effort-shaded with an explainer),plot_batch_summary(cost / sparsity / feasibility panel), andplot_batch_deltas(per-lever delta distributions, σ-standardized with an explainer). Demonstrated in the credit-risk tutorial. - Docs: long-form "How treecf finds counterfactuals" article walking one applicant from objective to verified counterfactual; MathJax wired into the docs build for the objective and plausibility formulas.
- Batch production:
Explainer.explain_batch(X, target, n_per_example=k, diversity="seeds"|"lever-blocking", ids=...)mass-produces counterfactuals for a dataset (~ms/row via the Rust engine);BatchResultpersists to portable JSON (save/load), supportsfor_idlookup and a lazy-pandasto_frame(). Lever-blocking mode also records per-row essential levers. - New visualizations:
plot_waterfall(SHAP-style waterfall of exact score deltas per change, cutoff line, probability space for sigmoid models) andplot_effort(decomposition of the distance J across changes). treecf._treecf_coreextension: tree-IR evaluation (bitwise-identical to the Python evaluator), constraint check/repair (bitwise-identical), and the genetic algorithm (statistically indistinguishable across 200 seeds x 10 scenarios; every result float-verified in Python).- Parity harness: flat-array cross-language contract
(
treecf.ir.flatten,treecf.constraints.flatten), golden per-seed fixtures and 200-seed distributional baselines under tests/fixtures/parity/.
Removed¶
- The exact CP-SAT backend, entirely:
backend="cpsat", the[cpsat]/ortools extra, the AIM integer encoding, the HiGHS stub, optimality proofs (proof="optimal"),n_counterfactuals/diversity cuts, infeasibilityrelaxation_hint, and the bands single-compilation amortization. The genetic engines are the sole backends ("genetic"= Rust default,"python"= numpy reference);Target.bandsstill works (one search per band). Users needing provable optimality should pair the IR with a dedicated exact-optimization package. The brute-force oracle remains the test-suite's optimality bracket.
Fixed¶
- Counterfactual values adjacent to open cell bounds now step one float32 ulp inside (previously float64): a float64-ulp neighbour of a threshold collapses onto it in native float32 comparisons, so the deployed model could route such values opposite to the IR. Both engines changed identically; parity fixtures regenerated.
Before 0.0.1 (unpublished)¶
The pure-Python line the Rust rebuild superseded; kept for the record.
Details
Added¶
- Parser breadth: LightGBM / sklearn (RF, GB, HistGB) / CatBoost parsers, all
conformance-gated; isolation-forest plausibility as a hard constraint;
Target.bandsrating ladder (one compilation, N solves); diverse counterfactuals via no-good cuts; infeasibility relaxation hints;suggest_constraintsdata mining with transitive reduction and near-invariant data-quality findings;vizmodule (plot_changes/plot_counterfactuals/plot_ladder). - Genetic backend: numpy-only constrained GA (Deb ranking, seeded,
proof="heuristic"), vectorized constraint check/repair, cross-backend soundness suite. - Constraint layer: string sugar parser,
Linear/Equals/Implies/OneHot/AllowMissing, NaN as a first-class counterfactual value, per-feature value policies with cell-safe snapping. - Vertical slice: XGBoost (object/JSON dump) → tree IR → routing-atomic
cells → CP-SAT → provably optimal counterfactual, with
Freeze/Monotone/Rangeconstraints, raw/probability targets, MAD-chain normalizers, float-space verification with K×10 retry, and a brute-force exactness oracle gating the backend (50-case randomized suite). - Release engineering: CI conformance matrix over library versions, mkdocs-material docs with three executed tutorial notebooks (quickstart, credit-risk walkthrough, no-solver environments), performance smoke benchmark, clean-venv packaging verification.
- Project skeleton: packaging, CI, docs infrastructure.
Known limitations¶
- CP-SAT solve time misses the <1s target at 300+ trees (~40s median on the benchmark suite); planned v0.2 optimization via table-constraint encoding.
- Plausibility cannot combine with AllowMissing/NaN factuals.
n_counterfactuals > 1requires the CP-SAT backend.