Skip to content

API: Explainer and results

Explainer

Explainer(
    model: object,
    background: FloatArray | None = None,
    constraints: Sequence[Constraint] = (),
    weights: dict[str, float] | None = None,
    normalizers: FloatArray
    | dict[str, float]
    | None = None,
    value_policy: dict[str, ValuePolicy] | None = None,
    plausibility: Plausibility | None = None,
    categories: Mapping[str, Sequence[str]] | None = None,
)

Counterfactual explainer for a tree-ensemble model.

Parses the model, compiles the constraints, and fits the distance normalizers once at construction, so repeated explain/explain_batch/ explain_coalitions calls reuse that work.

PARAMETER DESCRIPTION
model

A native model object (XGBoost/LightGBM/CatBoost/sklearn ensemble), a JSON dump file path or dict, or an already-parsed EnsembleIR. See Models and the IR for which native types are supported.

TYPE: object

background

Sample used to fit the per-feature distance normalizers (sigma, one per feature). Required unless normalizers is given instead; ignored when it is.

TYPE: FloatArray | None DEFAULT: None

constraints

Constraint objects (Freeze, Range, Monotone, Linear, Implies, OneHot, AllowMissing, or a string parsed by constraint()) compiled and validated immediately. Defaults to no constraints. See Constraints.

TYPE: Sequence[Constraint] DEFAULT: ()

weights

Per-feature multiplier on distance cost, {feature: weight}; a feature not listed defaults to 1.0. Use to make some levers relatively cheaper or more expensive than the normalized default.

TYPE: dict[str, float] | None DEFAULT: None

normalizers

Per-feature distance scale sigma, either an array aligned to the model's feature order or a {feature: sigma} dict. Pass this instead of background to reuse known scales (e.g. across several explainers on the same features).

TYPE: FloatArray | dict[str, float] | None DEFAULT: None

value_policy

Per-feature snapping rule, {feature: policy}, where a policy is "raw" (no snapping; the default for a feature not listed), "integer" (round to the nearest feasible integer), a Grid(step, anchor=0.0) (snap to a fixed lattice), or a callable float -> float. The exact backend treats a policy as a hard constraint on its candidates; the genetic backend snaps its winning row afterward and reverts the snap if it would break feasibility (Counterfactual.snapped records which happened) — see Certification.

TYPE: dict[str, ValuePolicy] | None DEFAULT: None

plausibility

Optional hard isolation-forest bound keeping every returned counterfactual inside the data manifold (see Plausibility.isolation_forest). Cannot be combined with AllowMissing, and explain/explain_batch/ explain_coalitions reject a factual containing NaN once it is set (isolation forests define no NaN routing) — see Plausibility.

TYPE: Plausibility | None DEFAULT: None

categories

Display names for categorical features, {feature: [name_for_code_0, name_for_code_1, ...]}. Required for CatBoost models with native categorical features (their categories are stored as hashes); optional elsewhere — it fills names and may extend a feature's declared cardinality beyond the codes seen in training.

TYPE: Mapping[str, Sequence[str]] | None DEFAULT: None

RAISES DESCRIPTION
TreecfError

If neither background nor normalizers is given, if normalizers omits a feature or resolves to a non-positive scale, if value_policy names an unknown feature or an unrecognized string policy, or if plausibility is given together with AllowMissing or a mismatched feature space.

ConstraintValidationError

If constraints contains a malformed or self-contradictory constraint.

Source code in src/treecf/api.py
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
def __init__(
    self,
    model: object,
    background: FloatArray | None = None,
    constraints: Sequence[Constraint] = (),
    weights: dict[str, float] | None = None,
    normalizers: FloatArray | dict[str, float] | None = None,
    value_policy: dict[str, ValuePolicy] | None = None,
    plausibility: Plausibility | None = None,
    categories: Mapping[str, Sequence[str]] | None = None,
) -> None:
    if isinstance(model, EnsembleIR):
        self.ir = apply_categories(model, categories) if categories else model
    else:
        self.ir = parse_model(model, categories)
    names = self.ir.feature_names
    self.compiled = compile_constraints(constraints, names, self.ir.categorical)
    self.plausibility = plausibility
    if plausibility is not None:
        if plausibility.if_ir.n_features != self.ir.n_features:
            raise TreecfError("plausibility forest must share the model's feature space")
        if self.compiled.allow_missing:
            raise TreecfError(
                "plausibility with AllowMissing is not supported "
                "(isolation forests define no NaN routing)"
            )
    self.background = (
        None if background is None else np.asarray(background, dtype=np.float64)
    )
    if self.background is not None:
        validate_feature_matrix(self.ir, self.background, where="background")
    self._data_bounds = (
        None if self.background is None else _observed_bounds(self.background)
    )
    self.sigma = _resolve_sigma(names, background, normalizers, frozenset(self.ir.categorical))
    self.weights = np.array([(weights or {}).get(name, 1.0) for name in names])
    self.value_policy = value_policy or {}
    self._rust_cache = {}
    for name, policy in self.value_policy.items():
        if name not in names:
            raise TreecfError(f"value_policy references unknown feature {name!r}")
        if names.index(name) in self.ir.categorical:
            raise ConstraintValidationError(
                f"value_policy({name!r}): {name!r} is a categorical feature — its "
                "values are codes already; use AllowedCategories to restrict them"
            )
        if isinstance(policy, str) and policy not in ("raw", "integer"):
            raise TreecfError(f"unknown value policy {policy!r} for {name!r}")

explain

explain(
    x: FloatArray,
    target: Target,
    backend: str = "genetic",
    time_budget_s: float = 10.0,
    sparsity_weight: float = 0.0,
    seed: int | None = None,
    warm_start: bool | None = None,
    node_budget: int | None = None,
    gap: float | None = None,
    search: str | None = None,
    region: bool = False,
    region_mode: str = "fast",
    region_budget: int = 100000,
) -> Counterfactual | Infeasible | dict[str, object]

Search for a counterfactual (or one per band for Target.bands).

x is the factual instance (one row, aligned to the model's feature order); target bounds the model output the counterfactual must reach. time_budget_s caps wall time per solve (per band, when target is a Target.bands ladder); math.inf is accepted and removes the time cut, letting an exact search run until it proves its answer (Ctrl-C still aborts promptly). sparsity_weight makes the search minimize distance + sparsity_weight * n_changed instead of plain distance, trading a cheaper plan that touches more features against a sparser one that costs more per feature; the returned Counterfactual.distance itself always excludes the sparsity term. 0.0 (the default) does not penalize sparsity at all. seed fixes the genetic search's (and, on the exact backend, the warm start's) randomness for reproducibility; None draws a fresh one each call.

backend="genetic" runs the bundled Rust engine (default); backend="python" runs the reference numpy implementation of the same algorithm; backend="exact" runs a branch-and-bound search over the same candidate grid that proves optimality when it finds a counterfactual and proves infeasibility when it does not, at the cost of a potentially longer solve. Every result is float-verified before being returned.

warm_start (default True), node_budget (default 2_000_000), and gap (default 0.0) configure the exact backend only; passing a non-default value together with another backend raises ValueError — deliberately not the usual TreecfError, since this rejects a Python-level argument combination rather than a modeling error. warm_start=True runs a short genetic pass first (about a quarter of time_budget_s, capped at 2 seconds) and, if it lands a verified counterfactual, feeds it to the exact search as a starting incumbent; the exact search still gets the full time_budget_s afterwards, so warm start is additive rather than deducted from the budget. gap lets the exact search settle for a counterfactual within that relative fraction of the true optimum, reported through proof="optimal_within_gap". search picks the exact engine: "refine" (the default) first holds each numeric feature to a range of routing cells and descends only where the score bound forces it; "classic" assigns one candidate value per feature at a time. Both prove the same optimum and the same infeasibility certificates — the refine search often in far fewer nodes on models with many thresholds per feature, which is why it is the default — though the row the two returns may be a different argmin of the same cost.

An exact search can return a feasible row with proof="heuristic" without exhausting node_budget or time_budget_s: conservative repair of some constraint shapes can withdraw the optimality certificate honestly rather than claim a cheapest row it did not prove — the row itself is still real and verified, only the "cheapest possible" claim is dropped. Whenever the exact search returns any result with solver_stats["completed"] is False — for that reason or because the budget genuinely ran out — a TreecfWarning names which of the two happened, never the other one. Target.bands, explain_coalitions, and explain_batch collapse this into one aggregate warning per call instead of one per solve.

If the factual itself violates a constraint, a TreecfWarning is emitted: the returned plan will include changes made solely to satisfy the constraint set.

region=True widens every successful Counterfactual into a certified RecourseRegion (cf.region) — works with any backend, genetic included. Costs one oracle call per attempted per-feature, per-direction expansion; see Explainer.recourse_region. region_mode="maximal" settles every side the fast growth stops with a budgeted search (region_budget nodes per side) and records what it proved in RecourseRegion.maximal; both arguments are only valid with region=True.

RETURNS DESCRIPTION
A single ``Counterfactual`` or ``Infeasible`` when ``target`` is a
plain interval (``Target.raw``/``probability``/``calibrated``); a
``{band_name: Counterfactual | Infeasible}`` dict, one entry per
band in solved order, when ``target`` is a ``Target.bands``
ladder. ``Infeasible`` means the search found no verified
counterfactual — see ``Infeasible.proof`` for whether that is a
certified impossibility or just an unsuccessful search.
RAISES DESCRIPTION
ValueError

If warm_start, node_budget, gap, or search is given a non-default value together with a backend other than "exact".

TreecfError

If backend is not one of "genetic", "python", or "exact", or if plausibility is configured on this explainer and x contains NaN.

ConstraintValidationError

If backend="exact" and this explainer's constraints include an unsupported multi-feature Linear shape, or a callable value_policy; the message names backend="genetic" as the fallback.

Source code in src/treecf/api.py
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
def explain(
    self,
    x: FloatArray,
    target: Target,
    backend: str = "genetic",
    time_budget_s: float = 10.0,
    sparsity_weight: float = 0.0,
    seed: int | None = None,
    warm_start: bool | None = None,
    node_budget: int | None = None,
    gap: float | None = None,
    search: str | None = None,
    region: bool = False,
    region_mode: str = "fast",
    region_budget: int = 100_000,
) -> Counterfactual | Infeasible | dict[str, object]:
    """Search for a counterfactual (or one per band for ``Target.bands``).

    ``x`` is the factual instance (one row, aligned to the model's feature
    order); ``target`` bounds the model output the counterfactual must
    reach. ``time_budget_s`` caps wall time per solve (per band, when
    ``target`` is a ``Target.bands`` ladder); ``math.inf`` is accepted
    and removes the time cut, letting an exact search run until it
    proves its answer (Ctrl-C still aborts promptly). ``sparsity_weight`` makes
    the search minimize ``distance + sparsity_weight * n_changed``
    instead of plain ``distance``, trading a cheaper plan that touches
    more features against a sparser one that costs more per feature; the
    returned ``Counterfactual.distance`` itself always excludes the
    sparsity term. ``0.0`` (the default) does not penalize sparsity at
    all. ``seed`` fixes the genetic search's (and, on the
    exact backend, the warm start's) randomness for reproducibility;
    ``None`` draws a fresh one each call.

    ``backend="genetic"`` runs the bundled Rust engine (default);
    ``backend="python"`` runs the reference numpy implementation of the
    same algorithm; ``backend="exact"`` runs a branch-and-bound search
    over the same candidate grid that proves optimality when it finds a
    counterfactual and proves infeasibility when it does not, at the cost
    of a potentially longer solve. Every result is float-verified before
    being returned.

    ``warm_start`` (default ``True``), ``node_budget`` (default
    ``2_000_000``), and ``gap`` (default ``0.0``) configure the exact
    backend only; passing a non-default value together with another
    backend raises ``ValueError`` — deliberately not the usual
    ``TreecfError``, since this rejects a Python-level argument
    combination rather than a modeling error. ``warm_start=True`` runs a
    short genetic pass first (about a quarter of ``time_budget_s``,
    capped at 2 seconds) and, if it lands a verified counterfactual, feeds
    it to the exact search as a starting incumbent; the exact search
    still gets the full ``time_budget_s`` afterwards, so warm start is
    additive rather than deducted from the budget. ``gap`` lets the exact
    search settle for a counterfactual within that relative fraction of
    the true optimum, reported through ``proof="optimal_within_gap"``.
    ``search`` picks the exact engine: ``"refine"`` (the default)
    first holds each numeric feature to a range of routing cells and
    descends only where the score bound forces it; ``"classic"``
    assigns one candidate value per feature at a time. Both prove the
    same optimum and the same infeasibility certificates — the refine
    search often in far fewer nodes on models with many thresholds per
    feature, which is why it is the default — though the row the two
    returns may be a different argmin of the same cost.

    An exact search can return a feasible row with ``proof="heuristic"``
    without exhausting ``node_budget`` or ``time_budget_s``: conservative
    repair of some constraint shapes can withdraw the optimality
    certificate honestly rather than claim a cheapest row it did not
    prove — the row itself is still real and verified, only the
    "cheapest possible" claim is dropped. Whenever the exact search
    returns any result with ``solver_stats["completed"] is False`` — for
    that reason or because the budget genuinely ran out — a
    ``TreecfWarning`` names which of the two happened, never the
    other one. ``Target.bands``, ``explain_coalitions``, and
    ``explain_batch`` collapse this into one aggregate warning per call
    instead of one per solve.

    If the factual itself violates a constraint, a ``TreecfWarning``
    is emitted: the returned plan will include changes made solely to
    satisfy the constraint set.

    ``region=True`` widens every successful ``Counterfactual`` into a
    certified ``RecourseRegion`` (``cf.region``) —
    works with any backend, genetic included. Costs one oracle call per
    attempted per-feature, per-direction expansion; see
    ``Explainer.recourse_region``. ``region_mode="maximal"`` settles
    every side the fast growth stops with a budgeted search
    (``region_budget`` nodes per side) and records what it proved in
    ``RecourseRegion.maximal``; both arguments are only valid with
    ``region=True``.

    Returns
    -------
    A single ``Counterfactual`` or ``Infeasible`` when ``target`` is a
    plain interval (``Target.raw``/``probability``/``calibrated``); a
    ``{band_name: Counterfactual | Infeasible}`` dict, one entry per
    band in solved order, when ``target`` is a ``Target.bands``
    ladder. ``Infeasible`` means the search found no verified
    counterfactual — see ``Infeasible.proof`` for whether that is a
    certified impossibility or just an unsuccessful search.

    Raises
    ------
    ValueError
        If ``warm_start``, ``node_budget``, ``gap``, or ``search`` is
        given a non-default value together with a ``backend`` other
        than ``"exact"``.
    TreecfError
        If ``backend`` is not one of ``"genetic"``,
        ``"python"``, or ``"exact"``, or if ``plausibility`` is
        configured on this explainer and ``x`` contains NaN.
    ConstraintValidationError
        If ``backend="exact"`` and this
        explainer's constraints include an unsupported multi-feature
        ``Linear`` shape, or a callable ``value_policy``; the message
        names ``backend="genetic"`` as the fallback.
    """
    return self._explain(
        x, target, backend, time_budget_s, sparsity_weight, seed, warn_factual=True,
        warm_start=warm_start, node_budget=node_budget, gap=gap, search=search,
        region=region, region_mode=region_mode, region_budget=region_budget,
    )

explain_batch

explain_batch(
    X: FloatArray,
    target: Target,
    n_per_example: int = 1,
    diversity: str = "seeds",
    ids: Sequence[object] | None = None,
    backend: str = "genetic",
    time_budget_s: float = 10.0,
    sparsity_weight: float = 0.0,
    seed: int = 0,
    coalitions: Mapping[str, Sequence[str]] | None = None,
    include_full: bool = False,
    warm_start: bool | None = None,
    node_budget: int | None = None,
    gap: float | None = None,
    search: str | None = None,
    region: bool = False,
    region_mode: str = "fast",
    region_budget: int = 100000,
    allow_exact_batch: bool = False,
) -> Any

Mass-produce counterfactuals for a dataset; see treecf.batch.

X is the factual dataset (one row per instance, aligned to the model's feature order); ids labels each row (defaults to its integer index) and must have one entry per row of X. n_per_example alternatives per row via diversity="seeds" (distinct change-sets from different seeds, best-effort) or "lever-blocking" (freeze each plan's biggest lever; also records essential levers). diversity="coalitions" instead produces one plan per named feature group in coalitions per row (n_per_example unused; see explain_coalitions for coalitions/include_full semantics, which are only valid in this mode). The returned BatchResult supports save/load/for_id/to_frame. time_budget_s, sparsity_weight, and seed carry the same meaning as in Explainer.explain, applied per solve (seed is combined with each row's index to derive a distinct per-row seed).

Solves run in parallel inside the Rust engine; time_budget_s is per solve, so a solve that hits its wall-clock budget while sharing cores may stop earlier than it would sequentially (results are otherwise identical to solving row by row).

backend="exact" has no vectorized population to parallelize, so this loops the single-instance exact solve per row (and per plan, for lever-blocking) sequentially — expect roughly linear-in-rows wall time rather than the Rust engine's parallel wave scheduling, and each row still gets the full, undiminished time_budget_s. Because that wall time is easy to underestimate, backend="exact" here is opt-in: without allow_exact_batch=True this raises ValueError naming an estimate (rows × plans × time_budget_s, hours-formatted) instead of silently running -- a floor, not a ceiling, since diversity="seeds" can retry each plan up to 3x on a seed collision; passing it through with any other backend also raises ValueError. Opting in additionally replaces warm_start's N sequential per-row (or, in seeds mode, per-attempt) genetic warm passes with a single vectorized one across every row — see treecf.batch.explain_batch for exactly which modes it covers and which keep per-solve warm starts. node_budget/gap thread through to every solve unchanged; see Explainer.explain. A KeyboardInterrupt during any batch solve discards whatever the batch has not yet finished — there is no partial BatchResult. region=True attaches a certified RecourseRegion (BatchRecord.region) to every feasible record, at the same one-oracle-call-per-expansion cost.

RETURNS DESCRIPTION
A ``BatchResult`` holding one ``BatchRecord`` per (row, plan) pair
— infeasible rows/plans get a record with ``feasible=False`` and
no ``x_cf`` rather than being omitted.
RAISES DESCRIPTION
TreecfError

If target is a Target.bands ladder, if diversity is not "seeds", "lever-blocking", or "coalitions", if coalitions/include_full is given outside diversity="coalitions" (or omitted inside it), or if ids does not have one entry per row of X.

ValueError

If warm_start, node_budget, gap, or search is given a non-default value together with a backend other than "exact"; if backend="exact" is requested without allow_exact_batch=True (message names the wall time estimate, a floor rather than a ceiling); or if allow_exact_batch=True is passed with a backend other than "exact".

Source code in src/treecf/api.py
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
def explain_batch(
    self,
    X: FloatArray,
    target: Target,
    n_per_example: int = 1,
    diversity: str = "seeds",
    ids: Sequence[object] | None = None,
    backend: str = "genetic",
    time_budget_s: float = 10.0,
    sparsity_weight: float = 0.0,
    seed: int = 0,
    coalitions: Mapping[str, Sequence[str]] | None = None,
    include_full: bool = False,
    warm_start: bool | None = None,
    node_budget: int | None = None,
    gap: float | None = None,
    search: str | None = None,
    region: bool = False,
    region_mode: str = "fast",
    region_budget: int = 100_000,
    allow_exact_batch: bool = False,
) -> Any:
    """Mass-produce counterfactuals for a dataset; see ``treecf.batch``.

    ``X`` is the factual dataset (one row per instance, aligned to the
    model's feature order); ``ids`` labels each row (defaults to its
    integer index) and must have one entry per row of ``X``.
    ``n_per_example`` alternatives per row via ``diversity="seeds"`` (distinct
    change-sets from different seeds, best-effort) or ``"lever-blocking"``
    (freeze each plan's biggest lever; also records essential levers).
    ``diversity="coalitions"`` instead produces one plan per named feature
    group in ``coalitions`` per row (``n_per_example`` unused; see
    ``explain_coalitions`` for ``coalitions``/``include_full`` semantics,
    which are only valid in this mode). The returned ``BatchResult`` supports
    save/load/for_id/to_frame. ``time_budget_s``, ``sparsity_weight``, and
    ``seed`` carry the same meaning as in ``Explainer.explain``, applied
    per solve (``seed`` is combined with each row's index to derive a
    distinct per-row seed).

    Solves run in parallel inside the Rust engine; ``time_budget_s`` is
    per solve, so a solve that hits its wall-clock budget while sharing
    cores may stop earlier than it would sequentially (results are
    otherwise identical to solving row by row).

    ``backend="exact"`` has no vectorized population to parallelize, so
    this loops the single-instance exact solve per row (and per plan, for
    lever-blocking) sequentially — expect roughly linear-in-rows wall
    time rather than the Rust engine's parallel wave scheduling, and each
    row still gets the full, undiminished ``time_budget_s``. Because that
    wall time is easy to underestimate, ``backend="exact"`` here is
    opt-in: without ``allow_exact_batch=True`` this raises ``ValueError``
    naming an estimate (rows × plans × ``time_budget_s``, hours-formatted)
    instead of silently running -- a floor, not a ceiling, since
    ``diversity="seeds"`` can retry each plan up to 3x on a seed
    collision; passing it through with any other backend also raises
    ``ValueError``. Opting in additionally
    replaces ``warm_start``'s N sequential per-row (or, in seeds mode,
    per-attempt) genetic warm passes with a single vectorized one across
    every row — see ``treecf.batch.explain_batch`` for exactly which
    modes it covers and which keep per-solve warm starts.
    ``node_budget``/``gap`` thread through to every solve unchanged; see
    ``Explainer.explain``. A ``KeyboardInterrupt`` during any batch solve
    discards whatever the batch has not yet finished — there is no
    partial ``BatchResult``. ``region=True`` attaches a certified
    ``RecourseRegion`` (``BatchRecord.region``) to every feasible record,
    at the same one-oracle-call-per-expansion cost.

    Returns
    -------
    A ``BatchResult`` holding one ``BatchRecord`` per (row, plan) pair
    — infeasible rows/plans get a record with ``feasible=False`` and
    no ``x_cf`` rather than being omitted.

    Raises
    ------
    TreecfError
        If ``target`` is a ``Target.bands`` ladder, if
        ``diversity`` is not ``"seeds"``, ``"lever-blocking"``, or
        ``"coalitions"``, if ``coalitions``/``include_full`` is given
        outside ``diversity="coalitions"`` (or omitted inside it), or
        if ``ids`` does not have one entry per row of ``X``.
    ValueError
        If ``warm_start``, ``node_budget``, ``gap``, or ``search`` is
        given a non-default value together with a ``backend`` other
        than ``"exact"``; if ``backend="exact"`` is requested without
        ``allow_exact_batch=True`` (message names the wall time
        estimate, a floor rather than a ceiling); or if
        ``allow_exact_batch=True`` is passed with a ``backend`` other
        than ``"exact"``.
    """
    from treecf.batch import explain_batch

    return explain_batch(
        self, X, target, n_per_example=n_per_example, diversity=diversity,
        ids=ids, backend=backend, time_budget_s=time_budget_s,
        sparsity_weight=sparsity_weight, seed=seed,
        coalitions=coalitions, include_full=include_full,
        warm_start=warm_start, node_budget=node_budget, gap=gap, search=search,
        region=region, region_mode=region_mode, region_budget=region_budget,
        allow_exact_batch=allow_exact_batch,
    )

explain_coalitions

explain_coalitions(
    x: FloatArray,
    target: Target,
    coalitions: Mapping[str, Sequence[str]],
    include_full: bool = False,
    backend: str = "genetic",
    time_budget_s: float = 10.0,
    sparsity_weight: float = 0.0,
    seed: int | None = None,
    warm_start: bool | None = None,
    node_budget: int | None = None,
    gap: float | None = None,
    search: str | None = None,
    region: bool = False,
    region_mode: str = "fast",
    region_budget: int = 100000,
) -> dict[str, Counterfactual | Infeasible]

One counterfactual per named feature coalition (opt-in mode).

x/target/backend/time_budget_s/sparsity_weight/ seed carry the same meaning as in Explainer.explain, applied once per coalition. coalitions maps a group name to the features it may change; each coalition is solved with every feature outside it frozen, so a plan only ever asks for changes within one group — grouped recourse instead of one plan that mixes unrelated levers. Coalitions may overlap; features in no coalition are never modified; an Infeasible for a coalition means that group alone cannot reach the target. include_full=True prepends an unrestricted baseline under the reserved key "(all levers)". One solve per coalition (milliseconds each); this mode is optional and never the default. warm_start/node_budget/gap/region thread through to every coalition's solve; see Explainer.explain. A degraded exact result (solver_stats["completed"] is False) in any coalition's solve is collapsed into one aggregate TreecfWarning for the whole call, rather than one per coalition.

RETURNS DESCRIPTION
``{coalition_name: Counterfactual | Infeasible}``, one entry per
key of ``coalitions`` plus ``"(all levers)"`` when
``include_full=True``, in that insertion order.
RAISES DESCRIPTION
TreecfError

If target is a Target.bands ladder, if coalitions is empty, names a coalition with no members, or references an unknown feature, or if include_full=True and a coalition is named "(all levers)" (the reserved key).

ValueError

If warm_start, node_budget, gap, or search is given a non-default value together with a backend other than "exact".

Source code in src/treecf/api.py
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
def explain_coalitions(
    self,
    x: FloatArray,
    target: Target,
    coalitions: Mapping[str, Sequence[str]],
    include_full: bool = False,
    backend: str = "genetic",
    time_budget_s: float = 10.0,
    sparsity_weight: float = 0.0,
    seed: int | None = None,
    warm_start: bool | None = None,
    node_budget: int | None = None,
    gap: float | None = None,
    search: str | None = None,
    region: bool = False,
    region_mode: str = "fast",
    region_budget: int = 100_000,
) -> dict[str, Counterfactual | Infeasible]:
    """One counterfactual per named feature coalition (opt-in mode).

    ``x``/``target``/``backend``/``time_budget_s``/``sparsity_weight``/
    ``seed`` carry the same meaning as in ``Explainer.explain``, applied
    once per coalition. ``coalitions`` maps a group name to the features
    it may change; each coalition is solved with every feature *outside*
    it frozen, so a plan only ever asks for changes within one group —
    grouped recourse instead of one plan that mixes unrelated levers.
    Coalitions may overlap; features in no coalition are never modified;
    an ``Infeasible`` for a coalition means that group alone cannot reach
    the target. ``include_full=True`` prepends an unrestricted baseline
    under the reserved key ``"(all levers)"``. One solve per coalition
    (milliseconds each); this mode is optional and never the default.
    ``warm_start``/``node_budget``/``gap``/``region`` thread through to every
    coalition's solve; see ``Explainer.explain``. A degraded exact result
    (``solver_stats["completed"] is False``) in any coalition's solve is
    collapsed into one aggregate ``TreecfWarning`` for the whole call,
    rather than one per coalition.

    Returns
    -------
    ``{coalition_name: Counterfactual | Infeasible}``, one entry per
    key of ``coalitions`` plus ``"(all levers)"`` when
    ``include_full=True``, in that insertion order.

    Raises
    ------
    TreecfError
        If ``target`` is a ``Target.bands`` ladder, if
        ``coalitions`` is empty, names a coalition with no members, or
        references an unknown feature, or if ``include_full=True`` and
        a coalition is named ``"(all levers)"`` (the reserved key).
    ValueError
        If ``warm_start``, ``node_budget``, ``gap``, or ``search`` is
        given a non-default value together with a ``backend`` other
        than ``"exact"``.
    """
    if target.bands_spec is not None:
        raise TreecfError(
            "Target.bands is not supported in explain_coalitions; loop bands explicitly"
        )
    normalized = _validate_coalitions(coalitions, self.ir.feature_names, include_full)
    results: dict[str, Counterfactual | Infeasible] = {}
    degraded: list[_Degradation] = []
    if include_full:
        results[_ALL_LEVERS] = self._explain_one(
            x, target, backend, time_budget_s, sparsity_weight, seed,
            warm_start=warm_start, node_budget=node_budget, gap=gap, search=search,
            region=region, region_mode=region_mode, region_budget=region_budget,
            degraded=degraded,
        )
    for name, clone in self._coalition_explainers(normalized).items():
        results[name] = clone._explain_one(
            x, target, backend, time_budget_s, sparsity_weight, seed,
            warm_start=warm_start, node_budget=node_budget, gap=gap, search=search,
            region=region, region_mode=region_mode, region_budget=region_budget,
            degraded=degraded,
        )
    message = _degraded_summary(degraded, len(degraded), len(results), "coalitions")
    if message is not None:
        warnings.warn(message, TreecfWarning, stacklevel=2)  # explain_coalitions <- user code
    return results

search_profile

search_profile(
    x: FloatArray, target: Target | None = None
) -> dict[str, object]

Size the exact search for one factual before running it.

Per feature: its kind ("numeric"/"categorical"), the number of atomic routing cells (or category blocks), the number of candidate values left after the instance bounds ("domain"), whether it is frozen, and whether the search would branch on it at all ("influential"). The totals "influential_features" and "log10_states" (the sum of log10 domain sizes over the influential features — the exponent of the number of complete assignments) size the space the classic search enumerates in the worst case. With target, the presolve filter runs too and adds the "presolved" size per feature, "log10_states_presolved", and "presolve_certified" (whether presolve alone certifies infeasibility). Cheap and deterministic: no search runs.

PARAMETER DESCRIPTION
x

The factual instance.

TYPE: FloatArray

target

Optional single-interval target; enables the presolve figures.

TYPE: Target | None DEFAULT: None

RETURNS DESCRIPTION
The profile as a plain ``dict``.
RAISES DESCRIPTION
TreecfError

If target is a Target.bands ladder.

Source code in src/treecf/api.py
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
def search_profile(
    self, x: FloatArray, target: Target | None = None
) -> dict[str, object]:
    """Size the exact search for one factual before running it.

    Per feature: its kind (``"numeric"``/``"categorical"``), the number of
    atomic routing cells (or category blocks), the number of candidate
    values left after the instance bounds (``"domain"``), whether it is
    frozen, and whether the search would branch on it at all
    (``"influential"``). The totals ``"influential_features"`` and
    ``"log10_states"`` (the sum of ``log10`` domain sizes over the
    influential features — the exponent of the number of complete
    assignments) size the space the classic search enumerates in the
    worst case. With ``target``, the presolve filter runs too and adds
    the ``"presolved"`` size per feature, ``"log10_states_presolved"``,
    and ``"presolve_certified"`` (whether presolve alone certifies
    infeasibility). Cheap and deterministic: no search runs.

    Parameters
    ----------
    x
        The factual instance.
    target
        Optional single-interval target; enables the presolve figures.

    Returns
    -------
    The profile as a plain ``dict``.

    Raises
    ------
    TreecfError
        If ``target`` is a ``Target.bands`` ladder.
    """
    x = np.asarray(x, dtype=np.float64)
    interval = None
    if target is not None:
        if target.bands_spec is not None:
            raise TreecfError("search_profile takes a single-interval target, not bands")
        interval = target.raw_interval(self.ir.link)
    return self._search_profile(x, interval)

recourse_menu

recourse_menu(
    x: FloatArray,
    target: Target,
    *,
    max_levers: int = 3,
    mode: str = "minimal",
    backend: str = "exact",
    search: str = "refine",
    seed: int | None = None,
    time_budget_s: float | None = None,
    total_budget_s: float | None = None,
    warm_start: bool | None = None,
    node_budget: int | None = None,
    gap: float | None = None,
) -> RecourseMenu

Every lever set up to max_levers solved as its own coalition.

The candidate levers are the features the search would branch on for x (see search_profile: not frozen, more than one candidate value, influential). Sets are enumerated by ascending size, sets of one size in lexicographic feature order, and each is solved with every other feature frozen — the same clone path explain_coalitions uses — so an entry is exactly the plan that changing only those levers admits, with the proof the backend attaches. Entries are keyed by the features the plan actually changed (sorted names joined by "+"); a set whose plan changed a strict subset is filed under the subset and listed in implied.

mode="minimal" (the default) skips every set that contains a set already found feasible — those are feasible by monotonicity and not minimal — and RecourseMenu.minimal lists the frontier; mode="all" solves every set. An Infeasible entry keeps its proof: "certified" from a completed exact search means no acceptance is reachable by changing only those levers.

time_budget_s caps each solve (explain's default when None); total_budget_s caps the whole enumeration, and sets not reached go to RecourseMenu.unresolved. One aggregate TreecfWarning reports unresolved and uncertified counts; the genetic backend never certifies, so it never warns about that and its menus are never complete. warm_start/node_budget/gap/ search are the exact backend's options, as in explain.

PARAMETER DESCRIPTION
x

The factual instance.

TYPE: FloatArray

target

A single-interval target.

TYPE: Target

max_levers

Largest lever-set size to enumerate.

TYPE: int DEFAULT: 3

mode

"minimal" or "all".

TYPE: str DEFAULT: 'minimal'

backend

"exact" (certifies) or "genetic".

TYPE: str DEFAULT: 'exact'

search

Exact search mode, "refine" (the default) or "classic".

TYPE: str DEFAULT: 'refine'

seed

Passed to every solve.

TYPE: int | None DEFAULT: None

time_budget_s

Per-solve wall budget; None for explain's default.

TYPE: float | None DEFAULT: None

total_budget_s

Wall budget for the whole menu; None for no cap.

TYPE: float | None DEFAULT: None

warm_start

Exact-backend options, as in explain.

TYPE: bool | None DEFAULT: None

node_budget

Exact-backend options, as in explain.

TYPE: bool | None DEFAULT: None

gap

Exact-backend options, as in explain.

TYPE: bool | None DEFAULT: None

RETURNS DESCRIPTION
The ``RecourseMenu``: a mapping from lever-set key to result, in
display order (minimal feasible sets by cost, other feasible sets by
cost, infeasible sets by size).
RAISES DESCRIPTION
TreecfError

If target is a Target.bands ladder, if the factual already satisfies the target (nothing to enumerate), or if two solves contradict monotonicity (a certified-infeasible set containing a feasible one — a solver inconsistency).

ValueError

If mode is unknown, max_levers is below one, total_budget_s is negative, or an exact-only option is given with another backend.

Source code in src/treecf/api.py
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
def recourse_menu(
    self,
    x: FloatArray,
    target: Target,
    *,
    max_levers: int = 3,
    mode: str = "minimal",
    backend: str = "exact",
    search: str = "refine",
    seed: int | None = None,
    time_budget_s: float | None = None,
    total_budget_s: float | None = None,
    warm_start: bool | None = None,
    node_budget: int | None = None,
    gap: float | None = None,
) -> RecourseMenu:
    """Every lever set up to ``max_levers`` solved as its own coalition.

    The candidate levers are the features the search would branch on for
    ``x`` (see ``search_profile``: not frozen, more than one candidate
    value, influential). Sets are enumerated by ascending size, sets of
    one size in lexicographic feature order, and each is solved with
    every other feature frozen — the same clone path
    ``explain_coalitions`` uses — so an entry is exactly the plan that
    changing only those levers admits, with the proof the backend
    attaches. Entries are keyed by the features the plan actually changed
    (sorted names joined by ``"+"``); a set whose plan changed a strict
    subset is filed under the subset and listed in ``implied``.

    ``mode="minimal"`` (the default) skips every set that contains a set
    already found feasible — those are feasible by monotonicity and not
    minimal — and ``RecourseMenu.minimal`` lists the frontier;
    ``mode="all"`` solves every set. An ``Infeasible`` entry keeps its
    proof: ``"certified"`` from a completed exact search means no
    acceptance is reachable by changing only those levers.

    ``time_budget_s`` caps each solve (``explain``'s default when
    ``None``); ``total_budget_s`` caps the whole enumeration, and sets
    not reached go to ``RecourseMenu.unresolved``. One aggregate
    ``TreecfWarning`` reports unresolved and uncertified counts; the
    genetic backend never certifies, so it never warns about that and its
    menus are never ``complete``. ``warm_start``/``node_budget``/``gap``/
    ``search`` are the exact backend's options, as in ``explain``.

    Parameters
    ----------
    x
        The factual instance.
    target
        A single-interval target.
    max_levers
        Largest lever-set size to enumerate.
    mode
        ``"minimal"`` or ``"all"``.
    backend
        ``"exact"`` (certifies) or ``"genetic"``.
    search
        Exact search mode, ``"refine"`` (the default) or ``"classic"``.
    seed
        Passed to every solve.
    time_budget_s
        Per-solve wall budget; ``None`` for ``explain``'s default.
    total_budget_s
        Wall budget for the whole menu; ``None`` for no cap.
    warm_start, node_budget, gap
        Exact-backend options, as in ``explain``.

    Returns
    -------
    The ``RecourseMenu``: a mapping from lever-set key to result, in
    display order (minimal feasible sets by cost, other feasible sets by
    cost, infeasible sets by size).

    Raises
    ------
    TreecfError
        If ``target`` is a ``Target.bands`` ladder, if the factual already
        satisfies the target (nothing to enumerate), or if two solves
        contradict monotonicity (a certified-infeasible set containing a
        feasible one — a solver inconsistency).
    ValueError
        If ``mode`` is unknown, ``max_levers`` is below one,
        ``total_budget_s`` is negative, or an exact-only option is given
        with another backend.
    """
    from treecf._menu import build_menu

    return build_menu(
        self, x, target, max_levers=max_levers, mode=mode, backend=backend,
        search=search, seed=seed, time_budget_s=time_budget_s,
        total_budget_s=total_budget_s, warm_start=warm_start, node_budget=node_budget,
        gap=gap,
    )

explain_diverse

explain_diverse(
    x: FloatArray,
    target: Target,
    *,
    k: int = 5,
    diversity: str = "levers",
    coalitions: Mapping[str, Sequence[str]] | None = None,
    max_levers: int = 3,
    **menu_kwargs: Any,
) -> DiverseSet

Up to k plans that reach the target through different lever sets.

Diversity means distinct changed feature sets. diversity="levers" takes the k cheapest entries of recourse_menu(mode="minimal", max_levers=...), so the plans are pairwise distinct in what they change by construction. diversity="coalitions" needs coalitions= in the explain_coalitions form: each declared coalition is solved as itself, and only when fewer than k are feasible are unions of two, then three, ... coalitions tried, level by level; plans from disjoint coalitions are the diverse set a customer can act on. menu_kwargs (backend, search, seed, time_budget_s, total_budget_s, warm_start, node_budget, gap) go to the menu or the ladder solves.

PARAMETER DESCRIPTION
x

The factual instance.

TYPE: FloatArray

target

A single-interval target.

TYPE: Target

k

How many plans to return at most.

TYPE: int DEFAULT: 5

diversity

"levers" or "coalitions".

TYPE: str DEFAULT: 'levers'

coalitions

{name: [features]}; required for "coalitions".

TYPE: Mapping[str, Sequence[str]] | None DEFAULT: None

max_levers

Largest lever-set size for the "levers" criterion.

TYPE: int DEFAULT: 3

RETURNS DESCRIPTION
The ``DiverseSet``, cheapest plan first; ``complete`` says whether
the criterion was exhausted with certificates before ``k`` was
reached.
RAISES DESCRIPTION
ValueError

If diversity is not one of the two criteria, k is below one, or coalitions= accompanies the "levers" criterion.

TreecfError

If diversity="coalitions" has no coalitions=, or for the reasons recourse_menu/explain_coalitions raise.

Source code in src/treecf/api.py
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
def explain_diverse(
    self,
    x: FloatArray,
    target: Target,
    *,
    k: int = 5,
    diversity: str = "levers",
    coalitions: Mapping[str, Sequence[str]] | None = None,
    max_levers: int = 3,
    **menu_kwargs: Any,
) -> DiverseSet:
    """Up to ``k`` plans that reach the target through different lever sets.

    Diversity means distinct changed feature sets. ``diversity="levers"``
    takes the ``k`` cheapest entries of ``recourse_menu(mode="minimal",
    max_levers=...)``, so the plans are pairwise distinct in what they
    change by construction. ``diversity="coalitions"`` needs
    ``coalitions=`` in the ``explain_coalitions`` form: each declared
    coalition is solved as itself, and only when fewer than ``k`` are
    feasible are unions of two, then three, ... coalitions tried, level by
    level; plans from disjoint coalitions are the diverse set a customer
    can act on. ``menu_kwargs`` (``backend``, ``search``, ``seed``,
    ``time_budget_s``, ``total_budget_s``, ``warm_start``,
    ``node_budget``, ``gap``) go to the menu or the ladder solves.

    Parameters
    ----------
    x
        The factual instance.
    target
        A single-interval target.
    k
        How many plans to return at most.
    diversity
        ``"levers"`` or ``"coalitions"``.
    coalitions
        ``{name: [features]}``; required for ``"coalitions"``.
    max_levers
        Largest lever-set size for the ``"levers"`` criterion.

    Returns
    -------
    The ``DiverseSet``, cheapest plan first; ``complete`` says whether
    the criterion was exhausted with certificates before ``k`` was
    reached.

    Raises
    ------
    ValueError
        If ``diversity`` is not one of the two criteria, ``k`` is below
        one, or ``coalitions=`` accompanies the ``"levers"`` criterion.
    TreecfError
        If ``diversity="coalitions"`` has no ``coalitions=``, or for the
        reasons ``recourse_menu``/``explain_coalitions`` raise.
    """
    from treecf._menu import build_diverse

    return build_diverse(
        self, x, target, k=k, diversity=diversity, coalitions=coalitions,
        max_levers=max_levers, menu_kwargs=dict(menu_kwargs),
    )

recourse_region

recourse_region(
    x: FloatArray,
    x_cf: FloatArray,
    target: Target,
    *,
    mode: str = "fast",
    budget: int = 100000,
    keep_witnesses: bool = False,
) -> RecourseRegion

Certify a per-feature box around an already-verified counterfactual.

x is the original factual and x_cf the counterfactual to widen (typically Counterfactual.x_cf from a prior explain call); target is the single interval x_cf was solved against. x_cf must independently pass the same float-space re-check explain runs on its own results; a row that fails it raises TreecfError naming the reason, since there is nothing sound to widen. Works for a counterfactual from any backend. Costs one oracle call — a full interval-tree walk of every ensemble tree — per attempted per-feature, per-direction expansion; see RecourseRegion. The returned region is certified but not monotone in target: a strictly narrower target can still grow a strictly wider region on some feature. See Certification.

A side no constraint bounds is grown no further than the explainer's background data reaches on that side (widened to include x_cf itself), so a feature without a Range does not come back unbounded; RecourseRegion.data_limited names those sides. With no background data every such side runs to infinity.

mode="fast" (the default) stops a side as soon as the conservative interval bound fails, so the region is sound but not necessarily maximal. mode="maximal" settles every such side with a budgeted search for a violating point in the next routing cell: the side extends when the search proves the slab empty, is marked proved in RecourseRegion.maximal when a witness is found, and is left unproven when the search spends its budget (search nodes per side). A proved side is maximal in a precise, local sense: the region cannot be extended into the next cell on that side without leaving the target or breaking a constraint, given every other coordinate ranges over the box as certified — a different box that also shrinks another feature is not excluded, and the maximal region need not contain the fast one. keep_witnesses=True keeps the violating points in RecourseRegion.witnesses. The maximal mode can cost up to budget search nodes per side per feature, each a partial ensemble walk.

RETURNS DESCRIPTION
The certified ``RecourseRegion`` around ``x_cf``.
RAISES DESCRIPTION
TreecfError

If target is a Target.bands ladder (pass the single band's own interval instead), if x_cf fails the float-space re-check against x/target — the message names the specific check that failed — or if mode is unknown.

ValueError

If budget is below one.

Source code in src/treecf/api.py
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
def recourse_region(
    self,
    x: FloatArray,
    x_cf: FloatArray,
    target: Target,
    *,
    mode: str = "fast",
    budget: int = 100_000,
    keep_witnesses: bool = False,
) -> RecourseRegion:
    """Certify a per-feature box around an already-verified counterfactual.

    ``x`` is the original factual and ``x_cf`` the counterfactual to widen
    (typically ``Counterfactual.x_cf`` from a prior ``explain`` call);
    ``target`` is the single interval ``x_cf`` was solved against. ``x_cf``
    must independently pass the same float-space re-check
    ``explain`` runs on its own results; a row that fails it raises
    ``TreecfError`` naming the reason, since there is nothing sound to
    widen. Works for a counterfactual from any backend. Costs one oracle
    call — a full interval-tree walk of every ensemble tree — per
    attempted per-feature, per-direction expansion; see
    ``RecourseRegion``. The returned region is certified but not monotone
    in ``target``: a strictly narrower target can still grow a strictly
    wider region on some feature. See
    [Certification](../concepts/certification.md#regions-certified-not-maximal-not-monotone).

    A side no constraint bounds is grown no further than the explainer's
    background data reaches on that side (widened to include ``x_cf``
    itself), so a feature without a ``Range`` does not come back
    unbounded; ``RecourseRegion.data_limited`` names those sides. With
    no background data every such side runs to infinity.

    ``mode="fast"`` (the default) stops a side as soon as the conservative
    interval bound fails, so the region is sound but not necessarily
    maximal. ``mode="maximal"`` settles every such side with a budgeted
    search for a violating point in the next routing cell: the side
    extends when the search proves the slab empty, is marked proved in
    ``RecourseRegion.maximal`` when a witness is found, and is left
    unproven when the search spends its ``budget`` (search nodes per
    side). A proved side is maximal in a precise, local sense: the region
    cannot be extended into the next cell on that side without leaving
    the target or breaking a constraint, given every other coordinate
    ranges over the box as certified — a different box that also shrinks
    another feature is not excluded, and the maximal region need not
    contain the fast one. ``keep_witnesses=True`` keeps the violating
    points in ``RecourseRegion.witnesses``. The maximal mode can cost up
    to ``budget`` search nodes per side per feature, each a partial
    ensemble walk.

    Returns
    -------
    The certified ``RecourseRegion`` around ``x_cf``.

    Raises
    ------
    TreecfError
        If ``target`` is a ``Target.bands`` ladder (pass the
        single band's own interval instead), if ``x_cf`` fails the
        float-space re-check against ``x``/``target`` — the message
        names the specific check that failed — or if ``mode`` is unknown.
    ValueError
        If ``budget`` is below one.
    """
    x = np.asarray(x, dtype=np.float64)
    x_cf = np.asarray(x_cf, dtype=np.float64)
    if target.bands_spec is not None:
        raise TreecfError(
            "Target.bands is not supported in recourse_region; pass the single "
            "band's own interval via Target.raw/probability/calibrated"
        )
    interval = target.raw_interval(self.ir.link)
    try:
        verification = self._verify(x, x_cf, interval)
    except ValueError as exc:
        # _verify's own raw_score re-check raises ValueError when x_cf's
        # path hits a split with no missing routing defined (an unrouted
        # NaN) -- surfaced here as the TreecfError this method's docstring
        # promises, since _verify itself stays a float-space re-check that
        # never wraps its own scoring call.
        raise TreecfError(
            f"cannot certify a region for an unverified counterfactual: {exc}"
        ) from exc
    if verification is not None:
        raise TreecfError(
            f"cannot certify a region for an unverified counterfactual: {verification}"
        )
    return self._region_for(
        x, x_cf, interval, mode=mode, budget=budget, keep_witnesses=keep_witnesses
    )

certificate

certificate(
    x: FloatArray,
    result: Counterfactual | Infeasible,
    target: Target,
    *,
    band: str | None = None,
    seed: int | None = None,
    node_budget: int | None = None,
    gap: float | None = None,
    search: str | None = None,
    time_budget_s: float | None = None,
    warm_start: bool | None = None,
) -> dict[str, object]

Issue an audit certificate for a stored result (post-hoc, like recourse_region).

A certificate is a reproducibility record plus a fresh verification — it binds the result to a model fingerprint, a constraint fingerprint, and the solve parameters, and re-verifies the returned plan at issue time; it does not cryptographically prove that a search ran or that a proof="optimal" claim is true — re-running with the recorded seed and budgets on a fingerprint-matching model is how a validator checks that. See Certification — audit certificates for the schema.

The certificate is a plain dict ("schema_version": 1) that serializes with json.dumps(cert, allow_nan=False, sort_keys=True); non-finite floats are encoded as the strings "NaN"/"Infinity"/ "-Infinity". Accepts a Counterfactual or an Infeasible — the certified "no" is exactly the case a validator cares most about. The verification block is computed fresh here, never copied from the solve: the plan's score, target membership, and constraint check are recomputed (plus the plausibility bound when configured, and a sampled set of region points when the result carries a region). A certificate whose fresh verification fails is still returned, with the failing booleans recorded — but a TreecfWarning names the failed check.

seed/node_budget/gap/time_budget_s/warm_start/ search are recorded under solve.declared when given: the result object does not carry them, so they are caller-supplied, and the block's name makes that provenance explicit. search is the one exception: an exact result reports the engine that ran in its own solver_stats, so it is recorded there even when not given, and a certificate never depends on the default of the release that issued it.

PARAMETER DESCRIPTION
x

The factual instance the result was solved from.

TYPE: FloatArray

result

The Counterfactual or Infeasible to certify.

TYPE: Counterfactual | Infeasible

target

The target the result was solved against.

TYPE: Target

band

For a Target.bands result, the band this result belongs to; required then, invalid otherwise.

TYPE: str | None DEFAULT: None

seed

The seed the solve ran with, if the caller wants it recorded.

TYPE: int | None DEFAULT: None

node_budget

The node budget the solve ran with, likewise.

TYPE: int | None DEFAULT: None

gap

The relative gap the solve ran with, likewise.

TYPE: float | None DEFAULT: None

time_budget_s

The time budget the solve ran with, likewise.

TYPE: float | None DEFAULT: None

warm_start

The warm-start setting the solve ran with, likewise.

TYPE: bool | None DEFAULT: None

search

The exact search mode ("classic" or "refine") the solve ran with; taken from the result's solver_stats when omitted.

TYPE: str | None DEFAULT: None

RETURNS DESCRIPTION
The certificate as a strict-JSON-serializable ``dict``.
RAISES DESCRIPTION
TreecfError

If target is a Target.bands ladder and band is missing or unknown, or if band is given for a plain-interval target.

Source code in src/treecf/api.py
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
def certificate(
    self,
    x: FloatArray,
    result: Counterfactual | Infeasible,
    target: Target,
    *,
    band: str | None = None,
    seed: int | None = None,
    node_budget: int | None = None,
    gap: float | None = None,
    search: str | None = None,
    time_budget_s: float | None = None,
    warm_start: bool | None = None,
) -> dict[str, object]:
    """Issue an audit certificate for a stored result (post-hoc, like
    ``recourse_region``).

    A certificate is a reproducibility record plus a fresh verification —
    it binds the result to a model fingerprint, a constraint fingerprint,
    and the solve parameters, and re-verifies the returned plan at issue
    time; it does not cryptographically prove that a search ran or that a
    ``proof="optimal"`` claim is true — re-running with the recorded seed
    and budgets on a fingerprint-matching model is how a validator checks
    that. See
    [Certification — audit certificates](../concepts/certification.md#audit-certificates)
    for the schema.

    The certificate is a plain ``dict`` (``"schema_version": 1``) that
    serializes with ``json.dumps(cert, allow_nan=False, sort_keys=True)``;
    non-finite floats are encoded as the strings ``"NaN"``/``"Infinity"``/
    ``"-Infinity"``. Accepts a ``Counterfactual`` or an ``Infeasible`` —
    the certified "no" is exactly the case a validator cares most about.
    The verification block is computed fresh here, never copied from the
    solve: the plan's score, target membership, and constraint check are
    recomputed (plus the plausibility bound when configured, and a sampled
    set of region points when the result carries a region). A certificate
    whose fresh verification fails is still returned, with the failing
    booleans recorded — but a ``TreecfWarning`` names the failed check.

    ``seed``/``node_budget``/``gap``/``time_budget_s``/``warm_start``/
    ``search`` are recorded under ``solve.declared`` when given: the
    result object does not carry them, so they are caller-supplied, and
    the block's name makes that provenance explicit. ``search`` is the one
    exception: an exact result reports the engine that ran in its own
    ``solver_stats``, so it is recorded there even when not given, and a
    certificate never depends on the default of the release that issued
    it.

    Parameters
    ----------
    x
        The factual instance the result was solved from.
    result
        The ``Counterfactual`` or ``Infeasible`` to certify.
    target
        The target the result was solved against.
    band
        For a ``Target.bands`` result, the band this result belongs
        to; required then, invalid otherwise.
    seed
        The seed the solve ran with, if the caller wants it recorded.
    node_budget
        The node budget the solve ran with, likewise.
    gap
        The relative gap the solve ran with, likewise.
    time_budget_s
        The time budget the solve ran with, likewise.
    warm_start
        The warm-start setting the solve ran with, likewise.
    search
        The exact search mode (``"classic"`` or ``"refine"``) the solve
        ran with; taken from the result's ``solver_stats`` when omitted.

    Returns
    -------
    The certificate as a strict-JSON-serializable ``dict``.

    Raises
    ------
    TreecfError
        If ``target`` is a ``Target.bands`` ladder and
        ``band`` is missing or unknown, or if ``band`` is given for a
        plain-interval target.
    """
    from treecf.audit import build_certificate

    return build_certificate(
        self, x, result, target, band=band, seed=seed, node_budget=node_budget,
        gap=gap, time_budget_s=time_budget_s, warm_start=warm_start, search=search,
    )

check_certificate

check_certificate(
    cert: dict[str, object],
    *,
    calibrator: object | None = None,
) -> dict[str, object]

Validate a stored certificate against this explainer.

Recomputes both fingerprints (model and constraints) against this explainer and re-runs the certificate's verification block from its stored factual/plan, so a tampered x_cf, a swapped model, or a changed constraint set each flips the corresponding boolean. This method reports — it never raises on a mismatch.

Without calibrator=, a calibrated-target certificate is still fully verifiable in plan geometry: the resolved raw_interval is stored, so this proves the plan reaches the stored interval — it does not prove which calibrator produced that interval. Passing calibrator= adds exactly that: the duck-typed fingerprint() is compared with the stored one, and the certificate's calibrated lo/hi are re-inverted through the supplied calibrator and compared with the stored interval (rtol 1e-9, infinities by identity). Neither mode requires treecf to import a calibration library.

PARAMETER DESCRIPTION
cert

A certificate produced by Explainer.certificate (a json.loads round trip of one works identically).

TYPE: dict[str, object]

calibrator

Optional duck-typed calibrator (the object handed to Target.calibrated) to additionally verify calibrator provenance against a calibrated-target certificate.

TYPE: object | None DEFAULT: None

RETURNS DESCRIPTION
``{"model_match": bool, "constraints_match": bool,
"verification_ok": bool, "mismatches": [...]}`` with one
human-readable string per mismatch, plus ``"calibrator_match":
bool`` when ``calibrator=`` was given.
Source code in src/treecf/api.py
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
def check_certificate(
    self, cert: dict[str, object], *, calibrator: object | None = None
) -> dict[str, object]:
    """Validate a stored certificate against *this* explainer.

    Recomputes both fingerprints (model and constraints) against this
    explainer and re-runs the certificate's verification block from its
    stored factual/plan, so a tampered ``x_cf``, a swapped model, or a
    changed constraint set each flips the corresponding boolean. This
    method reports — it never raises on a mismatch.

    Without ``calibrator=``, a calibrated-target certificate is still
    fully verifiable in *plan geometry*: the resolved ``raw_interval``
    is stored, so this proves the plan reaches the stored interval — it
    does not prove which calibrator produced that interval. Passing
    ``calibrator=`` adds exactly that: the duck-typed ``fingerprint()``
    is compared with the stored one, and the certificate's calibrated
    ``lo``/``hi`` are re-inverted through the supplied calibrator and
    compared with the stored interval (rtol 1e-9, infinities by
    identity). Neither mode requires treecf to import a calibration
    library.

    Parameters
    ----------
    cert
        A certificate produced by ``Explainer.certificate`` (a
        ``json.loads`` round trip of one works identically).
    calibrator
        Optional duck-typed calibrator (the object handed to
        ``Target.calibrated``) to additionally verify calibrator
        provenance against a calibrated-target certificate.

    Returns
    -------
    ``{"model_match": bool, "constraints_match": bool,
    "verification_ok": bool, "mismatches": [...]}`` with one
    human-readable string per mismatch, plus ``"calibrator_match":
    bool`` when ``calibrator=`` was given.
    """
    from treecf.audit import check_certificate

    return check_certificate(self, cert, calibrator=calibrator)

Counterfactual dataclass

Counterfactual(
    x_cf: FloatArray,
    changes: dict[str, tuple[float, float]],
    distance: float,
    n_changed: int,
    score_raw: float,
    score_prob: float | None,
    proof: str,
    solver_stats: dict[str, object] = dict(),
    snapped: dict[str, bool] = dict(),
    region: RecourseRegion | None = None,
    score_calibrated: float | None = None,
)

One verified counterfactual: the changed row, its cost, and how strong a claim the search makes about it being the cheapest one.

proof is always one of exactly three values: {"heuristic", "optimal", "optimal_within_gap"}. The genetic and python backends always report "heuristic" — they never claim optimality. The exact backend reports "optimal" when it proved no cheaper row exists, "optimal_within_gap" when gap > 0 and it only proved none exists more than that relative fraction cheaper, and — more rarely — "heuristic" for a row it is not claiming is cheapest: see Explainer.explain for when that happens. See Certification for the full proof taxonomy.

ATTRIBUTE DESCRIPTION
x_cf

The full counterfactual feature vector, same order and length as the factual; unchanged features keep the factual's own value.

TYPE: FloatArray

changes

{feature: (factual_value, counterfactual_value)} for every feature that actually differs (including a transition to or from NaN); unchanged features are omitted.

TYPE: dict[str, tuple[float, float]]

distance

The weighted, normalized sum of per-feature changes (sum(weight * |delta| / sigma), with AllowMissing's delta_miss/delta_from_miss pricing any NaN transition), excluding the sparsity term. When sparsity_weight > 0 the search minimizes distance + sparsity_weight * n_changed, so distance alone does not reproduce the search's own ranking.

TYPE: float

n_changed

len(changes) — the number of features actually changed.

TYPE: int

score_raw

The model's raw score at x_cf (pre-link, i.e. margin for a sigmoid-link model).

TYPE: float

score_prob

sigmoid(score_raw) for a sigmoid-link model, otherwise None.

TYPE: float | None

proof

The optimality claim this result makes; see above.

TYPE: str

solver_stats

Backend-specific diagnostics. Populated by the exact backend (nodes_expanded, nodes_pruned_score, nodes_pruned_cost, lower_bound, gap, completed, warm_start_used); empty or backend-specific for genetic/python.

TYPE: dict[str, object]

snapped

{feature: bool} for every feature under a value_policy that also changed — True when the genetic backend's post-hoc snap held, False when it was reverted (or never applied) to keep the result feasible. Empty when no changed feature carries a policy, or on the exact backend (policies are baked into its own search and never post-hoc snapped).

TYPE: dict[str, bool]

region

The certified box around x_cf, set only when the search ran with region=True (Explainer.explain/explain_batch/ explain_coalitions); None otherwise.

TYPE: RecourseRegion | None

score_calibrated

The calibrator's probability at x_cf — set only for a calibrated-space target whose calibrator exposes predict_proba; None otherwise. Presentational: the engine optimized and verified against the raw interval the calibrator's interval_inverse produced, never against this value.

TYPE: float | None

Infeasible dataclass

Infeasible(
    reason: str,
    proof: str = "search_exhausted",
    solver_stats: dict[str, object] = dict(),
)

No counterfactual returned — the search made no claim, or proved none exists.

proof is always one of exactly two values: {"search_exhausted", "certified"}. "search_exhausted" (the default) means the search ran out of budget, hit a heuristic dead end, or gave up an optimality certificate along the way — nothing is proven about whether a counterfactual exists at all. "certified" is exact-backend only: every assignment the searched grid allows was tried and none was feasible, so reason names the node count behind that proof. See Certification for the full proof taxonomy.

ATTRIBUTE DESCRIPTION
reason

Human-readable explanation of why no counterfactual was returned; names the node count behind a "certified" proof, or describes the exhaustion/repair cause for "search_exhausted".

TYPE: str

proof

The claim this non-result makes; see above.

TYPE: str

solver_stats

Backend-specific diagnostics, populated the same way as Counterfactual.solver_stats for the exact backend; empty for genetic/python.

TYPE: dict[str, object]

Batch production

BatchResult dataclass

BatchResult(
    feature_names: tuple[str, ...],
    diversity: str,
    records: tuple[BatchRecord, ...],
    essential_levers: dict[object, list[str]] = dict(),
)

Counterfactuals for a whole dataset, addressable by row id.

Returned by Explainer.explain_batch; supports len(), iteration over its records, id lookup (for_id), a JSON round trip (save/load), and a pandas view (to_frame).

ATTRIBUTE DESCRIPTION
feature_names

The model's feature names, in the order x_cf arrays are indexed by.

TYPE: tuple[str, ...]

diversity

The diversity mode explain_batch ran with ("seeds", "lever-blocking", or "coalitions").

TYPE: str

records

Every BatchRecord, feasible and infeasible, across every row and alternative/coalition; order matches the originating explain_batch call.

TYPE: tuple[BatchRecord, ...]

essential_levers

{row_id: [feature, ...]} — for diversity="lever-blocking" rows only, the features whose freezing made every alternative infeasible (so the primary plan has no substitute for that lever). Empty for other diversity modes.

TYPE: dict[object, list[str]]

for_id

for_id(row_id: object) -> list[BatchRecord]

Every record (all alternatives/coalitions) for one dataset row.

PARAMETER DESCRIPTION
row_id

A value from explain_batch's ids (or the row's integer index when ids was not given).

TYPE: object

RETURNS DESCRIPTION
The matching records, in their original order; ``[]`` if
``row_id`` is not present in this result.
Source code in src/treecf/batch.py
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def for_id(self, row_id: object) -> list[BatchRecord]:
    """Every record (all alternatives/coalitions) for one dataset row.

    Parameters
    ----------
    row_id
        A value from ``explain_batch``'s ``ids`` (or the row's
        integer index when ``ids`` was not given).

    Returns
    -------
    The matching records, in their original order; ``[]`` if
    ``row_id`` is not present in this result.
    """
    return [r for r in self.records if r.id == row_id]

save

save(path: str | PathLike[str]) -> None

Write this result to a portable JSON file, reloadable with load.

Every field is encoded explicitly (NaN/Infinity-safe floats via encode_floats), including region when set, so a round trip through save/load is lossless.

PARAMETER DESCRIPTION
path

Destination file path; overwritten if it already exists.

TYPE: str | PathLike[str]

Source code in src/treecf/batch.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
def save(self, path: str | os.PathLike[str]) -> None:
    """Write this result to a portable JSON file, reloadable with ``load``.

    Every field is encoded explicitly (NaN/Infinity-safe floats via
    ``encode_floats``), including ``region`` when set, so a round trip
    through ``save``/``load`` is lossless.

    Parameters
    ----------
    path
        Destination file path; overwritten if it already exists.
    """
    data = {
        "feature_names": list(self.feature_names),
        "diversity": self.diversity,
        "essential_levers": {str(k): v for k, v in self.essential_levers.items()},
        "essential_lever_ids": [encode_floats(k) for k in self.essential_levers],
        "records": [
            {
                "id": record.id,
                "k": record.k,
                "feasible": record.feasible,
                "x_cf": None if record.x_cf is None else encode_floats(record.x_cf),
                "changes": {
                    name: encode_floats(list(pair))
                    for name, pair in record.changes.items()
                },
                "distance": record.distance,
                "n_changed": record.n_changed,
                "score_raw": record.score_raw,
                "score_prob": record.score_prob,
                "seed": record.seed,
                "blocked_lever": record.blocked_lever,
                "coalition": record.coalition,
                "proof": record.proof,
                "calibrator_fingerprint": record.calibrator_fingerprint,
                "score_calibrated": record.score_calibrated,
                "solver_stats": {
                    key: encode_floats(value)
                    for key, value in record.solver_stats.items()
                },
                "region": (
                    None
                    if record.region is None
                    else {
                        "lo": encode_floats(record.region.lo),
                        "hi": encode_floats(record.region.hi),
                        "feature_intervals": {
                            name: encode_floats(list(pair))
                            for name, pair in record.region.feature_intervals.items()
                        },
                        "certified": record.region.certified,
                        "feature_categories": {
                            name: list(codes)
                            for name, codes in record.region.feature_categories.items()
                        },
                        "cat_sets": {
                            str(j): list(codes)
                            for j, codes in record.region.cat_sets.items()
                        },
                        "category_names": {
                            name: list(names)
                            for name, names in record.region.category_names.items()
                        },
                        "maximal": {
                            name: [bool(lo_ok), bool(hi_ok)]
                            for name, (lo_ok, hi_ok) in record.region.maximal.items()
                        },
                        "maximal_categories": {
                            name: bool(ok)
                            for name, ok in record.region.maximal_categories.items()
                        },
                        "witnesses": (
                            None
                            if record.region.witnesses is None
                            else {
                                key: encode_floats(point)
                                for key, point in record.region.witnesses.items()
                            }
                        ),
                        "integer_features": list(record.region.integer_features),
                        "data_limited": {
                            name: [bool(lo_ok), bool(hi_ok)]
                            for name, (lo_ok, hi_ok) in record.region.data_limited.items()
                        },
                    }
                ),
            }
            for record in self.records
        ],
    }
    with open(path, "w", encoding="utf-8") as fh:
        json.dump(data, fh)

load classmethod

load(path: str | PathLike[str]) -> BatchResult

Read a BatchResult previously written by save.

A file saved without region=True, or by a version of treecf before regions existed, loads with every record's region set to None; a file saved before coalition support loads with every record's coalition set to None; a file saved before per-record proofs existed loads with proof defaulted by feasibility ("heuristic"/"search_exhausted") and empty solver_stats.

PARAMETER DESCRIPTION
path

Path to a file written by save.

TYPE: str | PathLike[str]

RETURNS DESCRIPTION
The reconstructed ``BatchResult``.
Source code in src/treecf/batch.py
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
@classmethod
def load(cls, path: str | os.PathLike[str]) -> BatchResult:
    """Read a ``BatchResult`` previously written by ``save``.

    A file saved without ``region=True``, or by a version of treecf
    before regions existed, loads with every record's ``region`` set to
    ``None``; a file saved before coalition support loads with every
    record's ``coalition`` set to ``None``; a file saved before per-record
    proofs existed loads with ``proof`` defaulted by feasibility
    (``"heuristic"``/``"search_exhausted"``) and empty ``solver_stats``.

    Parameters
    ----------
    path
        Path to a file written by ``save``.

    Returns
    -------
    The reconstructed ``BatchResult``.
    """
    from treecf.regions import RecourseRegion

    with open(path, encoding="utf-8") as fh:
        data = json.load(fh)
    records = []
    for raw in data["records"]:
        raw_region = raw.get("region")  # absent key (pre-region files) -> None
        region = (
            None
            if raw_region is None
            else RecourseRegion(
                lo=np.asarray(decode_floats(raw_region["lo"]), dtype=np.float64),
                hi=np.asarray(decode_floats(raw_region["hi"]), dtype=np.float64),
                feature_intervals={
                    name: tuple(decode_floats(pair))
                    for name, pair in raw_region["feature_intervals"].items()
                },
                certified=bool(raw_region["certified"]),
                # absent in files written without category sets
                feature_categories={
                    name: tuple(int(c) for c in codes)
                    for name, codes in raw_region.get("feature_categories", {}).items()
                },
                cat_sets={
                    int(j): tuple(int(c) for c in codes)
                    for j, codes in raw_region.get("cat_sets", {}).items()
                },
                category_names={
                    name: tuple(str(n) for n in names)
                    for name, names in raw_region.get("category_names", {}).items()
                },
                # absent in files written before the maximal mode existed
                maximal={
                    name: (bool(lo_ok), bool(hi_ok))
                    for name, (lo_ok, hi_ok) in raw_region.get("maximal", {}).items()
                },
                maximal_categories={
                    name: bool(ok)
                    for name, ok in raw_region.get("maximal_categories", {}).items()
                },
                witnesses=(
                    None
                    if raw_region.get("witnesses") is None
                    else {
                        key: np.asarray(decode_floats(point), dtype=np.float64)
                        for key, point in raw_region["witnesses"].items()
                    }
                ),
                # absent in files written before integer phrasing existed
                integer_features=tuple(
                    str(n) for n in raw_region.get("integer_features", ())
                ),
                data_limited={
                    name: (bool(lo_ok), bool(hi_ok))
                    for name, (lo_ok, hi_ok) in raw_region.get("data_limited", {}).items()
                },
            )
        )
        records.append(
            BatchRecord(
                id=raw["id"],
                k=int(raw["k"]),
                feasible=bool(raw["feasible"]),
                x_cf=(
                    None
                    if raw["x_cf"] is None
                    else np.asarray(decode_floats(raw["x_cf"]), dtype=np.float64)
                ),
                changes={
                    name: tuple(decode_floats(pair))
                    for name, pair in raw["changes"].items()
                },
                distance=raw["distance"],
                n_changed=raw["n_changed"],
                score_raw=raw["score_raw"],
                score_prob=raw["score_prob"],
                seed=raw["seed"],
                blocked_lever=raw["blocked_lever"],
                coalition=raw.get("coalition"),  # absent in pre-coalition files
                region=region,
                # files without a proof field default by feasibility
                proof=raw.get(
                    "proof", "heuristic" if raw["feasible"] else "search_exhausted"
                ),
                # records without these fields default to None
                calibrator_fingerprint=raw.get("calibrator_fingerprint"),
                score_calibrated=raw.get("score_calibrated"),
                solver_stats=_decode_stats(raw.get("solver_stats", {})),
            )
        )
    essential_ids = [decode_floats(k) for k in data.get("essential_lever_ids", [])]
    essential_values = list(data.get("essential_levers", {}).values())
    return cls(
        feature_names=tuple(data["feature_names"]),
        diversity=data["diversity"],
        records=tuple(records),
        essential_levers=dict(zip(essential_ids, essential_values, strict=True)),
    )

to_frame

to_frame() -> Any

One row per (id, k), wide cf_<feature> columns (pandas, lazy import).

Every BatchRecord field except x_cf/changes/region/ solver_stats becomes its own column (solver_stats stays record-only — read it off the BatchRecord directly); x_cf is spread into one cf_<feature> column per model feature (NaN for an infeasible record, or an unchanged feature's factual-equal value); changes is summarized as a changed_features column (sorted feature names).

RETURNS DESCRIPTION
A pandas ``DataFrame`` with one row per record.
RAISES DESCRIPTION
TreecfError

If pandas is not installed.

Source code in src/treecf/batch.py
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
def to_frame(self) -> Any:
    """One row per (id, k), wide ``cf_<feature>`` columns (pandas, lazy import).

    Every ``BatchRecord`` field except ``x_cf``/``changes``/``region``/
    ``solver_stats`` becomes its own column (``solver_stats`` stays
    record-only — read it off the ``BatchRecord`` directly); ``x_cf`` is
    spread into one ``cf_<feature>`` column per model feature (``NaN`` for
    an infeasible record, or an unchanged feature's factual-equal value);
    ``changes`` is summarized as a ``changed_features`` column (sorted
    feature names).

    Returns
    -------
    A pandas ``DataFrame`` with one row per record.

    Raises
    ------
    TreecfError
        If pandas is not installed.
    """
    try:
        import pandas as pd
    except ImportError as exc:  # pragma: no cover - exercised without pandas
        raise TreecfError("to_frame() requires pandas: pip install pandas") from exc
    rows = []
    for record in self.records:
        row: dict[str, object] = {
            "id": record.id,
            "k": record.k,
            "feasible": record.feasible,
            "distance": record.distance,
            "n_changed": record.n_changed,
            "score_raw": record.score_raw,
            "score_prob": record.score_prob,
            "seed": record.seed,
            "blocked_lever": record.blocked_lever,
            "coalition": record.coalition,
            "proof": record.proof,
            "changed_features": sorted(record.changes),
        }
        for j, name in enumerate(self.feature_names):
            row[f"cf_{name}"] = (
                float(record.x_cf[j]) if record.x_cf is not None else math.nan
            )
        rows.append(row)
    return pd.DataFrame(rows)

BatchRecord dataclass

BatchRecord(
    id: object,
    k: int,
    feasible: bool,
    x_cf: FloatArray | None,
    changes: dict[str, tuple[float, float]],
    distance: float | None,
    n_changed: int | None,
    score_raw: float | None,
    score_prob: float | None,
    seed: int | None = None,
    blocked_lever: str | None = None,
    coalition: str | None = None,
    region: RecourseRegion | None = None,
    proof: str = "heuristic",
    solver_stats: dict[str, object] = dict(),
    calibrator_fingerprint: str | None = None,
    score_calibrated: float | None = None,
)

One counterfactual (or the infeasibility marker) for one dataset row.

Fields mirror Counterfactual (x_cf, changes, distance, n_changed, score_raw, score_prob, proof, solver_stats, region), plus batch bookkeeping: id and k place the record in the dataset, and feasible distinguishes a real plan from the infeasibility marker.

ATTRIBUTE DESCRIPTION
id

The row identifier this record belongs to (an element of explain_batch's ids, or the row's integer index when ids was not given).

TYPE: object

k

Rank of this plan among the row's feasible alternatives, 0-based, ascending by distance (0 is always the cheapest). For a wholly infeasible row (diversity="seeds"/ "lever-blocking"), the single infeasibility marker gets k=0; for diversity="coalitions", an infeasible coalition's marker instead continues the same row's ascending sequence after its feasible plans, so each coalition still gets a distinct k.

TYPE: int

feasible

False marks the infeasibility marker for a row (or coalition) that produced no plan; x_cf/changes/ distance/n_changed/score_raw/score_prob are then None/{} rather than real values.

TYPE: bool

x_cf

The full counterfactual feature vector, or None when feasible is False.

TYPE: FloatArray | None

changes

{feature: (factual_value, counterfactual_value)} for every feature that differs; {} when feasible is False.

TYPE: dict[str, tuple[float, float]]

distance

The weighted, normalized sum of per-feature changes, excluding the sparsity term (see Counterfactual.distance), or None when feasible is False.

TYPE: float | None

n_changed

len(changes), or None when feasible is False.

TYPE: int | None

score_raw

The model's raw score at x_cf, or None when feasible is False.

TYPE: float | None

score_prob

sigmoid(score_raw) for a sigmoid-link model, None for an identity-link model or when feasible is False.

TYPE: float | None

seed

The seed that produced this plan, set only for diversity="seeds"; None otherwise.

TYPE: int | None

blocked_lever

The feature frozen to produce this plan, set only for diversity="lever-blocking" alternatives (not the primary plan, k=0); None otherwise.

TYPE: str | None

coalition

The coalition name this plan belongs to, set only for diversity="coalitions" (including the reserved "(all levers)" baseline when include_full=True); None otherwise.

TYPE: str | None

region

The certified box around x_cf, set only when explain_batch ran with region=True and feasible is True; None otherwise.

TYPE: RecourseRegion | None

proof

The claim this record makes, mirroring the single-instance result that produced it: Counterfactual.proof ("heuristic" | "optimal" | "optimal_within_gap") for a feasible record, Infeasible.proof ("search_exhausted" | "certified") for an infeasibility marker.

TYPE: str

solver_stats

Exact-backend diagnostics for the solve behind this record, same keys as Counterfactual.solver_stats; empty for genetic/python solves (those engines report no per-row stats).

TYPE: dict[str, object]

calibrator_fingerprint

The duck-typed fingerprint() of the calibrated target's calibrator, when it exposes one; None for raw/probability targets or fingerprint-less calibrators. Repeated on every record so each file line is self-contained.

TYPE: str | None

score_calibrated

The calibrator's probability at x_cf for a calibrated target whose calibrator exposes predict_proba; presentational only — the engine optimized and verified on the resolved raw interval. None otherwise.

TYPE: float | None

Recourse menus

RecourseMenu dataclass

RecourseMenu(
    entries: dict[str, Counterfactual | Infeasible],
    minimal: tuple[str, ...],
    implied: dict[str, str],
    certified_infeasible: tuple[str, ...],
    unresolved: tuple[str, ...],
    complete: bool,
    mode: str,
    max_levers: int,
    levers: tuple[str, ...],
)

Bases: Mapping[str, 'Counterfactual | Infeasible']

Every lever set solved for one factual, keyed by the features its plan changed.

A mapping {key: Counterfactual | Infeasible} whose keys are the sorted feature names joined by "+" ("debt+income"), so any function that takes the explain_coalitions mapping shape — plot_recourse_map among them — takes a menu unchanged. Iteration order is the display order: the minimal feasible sets by ascending cost, then the remaining feasible sets by cost, then the infeasible sets by size.

ATTRIBUTE DESCRIPTION
entries

The mapping itself, in display order.

TYPE: dict[str, Counterfactual | Infeasible]

minimal

The frontier: feasible keys no other feasible key is a subset of. No listed set contains another.

TYPE: tuple[str, ...]

implied

{lever set: key} for every solved lever set whose plan changed only a strict subset of it; the set is feasible by monotonicity and its plan lives under the subset's key.

TYPE: dict[str, str]

certified_infeasible

Lever sets a completed exact search proved cannot reach the target on their own: "no acceptance is reachable by changing only these levers".

TYPE: tuple[str, ...]

unresolved

Lever sets the total time budget did not reach; they have no entry.

TYPE: tuple[str, ...]

complete

True iff nothing is unresolved and every entry is certified (optimal, optimal_within_gap, or a certified Infeasible): the menu then settles every lever set up to max_levers. Never True for the genetic backend.

TYPE: bool

mode

"minimal" or "all".

TYPE: str

max_levers

The largest set size enumerated.

TYPE: int

levers

The candidate lever names, sorted: the features the search would branch on for this factual (not frozen, more than one candidate value, influential).

TYPE: tuple[str, ...]

describe

describe() -> dict[str, str]

One sentence per entry, in display order.

A feasible entry reads "change a, b (cost 1.23, optimal)"; a certified-infeasible one "no acceptance is reachable by changing only a, b"; an uncertified one names the levers and says the search found no plan without a certificate.

RETURNS DESCRIPTION
``{key: sentence}``.
Source code in src/treecf/_menu.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def describe(self) -> dict[str, str]:
    """One sentence per entry, in display order.

    A feasible entry reads ``"change a, b (cost 1.23, optimal)"``; a
    certified-infeasible one ``"no acceptance is reachable by changing
    only a, b"``; an uncertified one names the levers and says the search
    found no plan without a certificate.

    Returns
    -------
    ``{key: sentence}``.
    """
    from treecf.api import Counterfactual

    out: dict[str, str] = {}
    for key, entry in self.entries.items():
        levers = ", ".join(sorted(_members(key)))
        if isinstance(entry, Counterfactual):
            out[key] = f"change {levers} (cost {entry.distance:.3g}, {entry.proof})"
        elif entry.proof == "certified":
            out[key] = f"no acceptance is reachable by changing only {levers}"
        else:
            out[key] = f"no plan found changing only {levers} (not certified)"
    return out

to_frame

to_frame() -> list[dict[str, object]]

One row per entry, in display order.

RETURNS DESCRIPTION
``[{"key", "size", "feasible", "distance", "proof", "changed"}, ...]``;
``distance`` is ``None`` and ``changed`` empty for an infeasible entry.
Source code in src/treecf/_menu.py
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
def to_frame(self) -> list[dict[str, object]]:
    """One row per entry, in display order.

    Returns
    -------
    ``[{"key", "size", "feasible", "distance", "proof", "changed"}, ...]``;
    ``distance`` is ``None`` and ``changed`` empty for an infeasible entry.
    """
    from treecf.api import Counterfactual

    rows: list[dict[str, object]] = []
    for key, entry in self.entries.items():
        feasible = isinstance(entry, Counterfactual)
        rows.append(
            {
                "key": key,
                "size": len(_members(key)),
                "feasible": feasible,
                "distance": entry.distance if isinstance(entry, Counterfactual) else None,
                "proof": entry.proof,
                "changed": sorted(entry.changes) if isinstance(entry, Counterfactual) else [],
            }
        )
    return rows

to_dict

to_dict() -> dict[str, object]

The menu as strict JSON (non-finite floats encoded as the certificate encodes them; readers tolerate unknown keys).

RETURNS DESCRIPTION
A plain dict with ``menu_schema_version`` 1.
Source code in src/treecf/_menu.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
def to_dict(self) -> dict[str, object]:
    """The menu as strict JSON (non-finite floats encoded as the
    certificate encodes them; readers tolerate unknown keys).

    Returns
    -------
    A plain dict with ``menu_schema_version`` 1.
    """
    from treecf.api import Counterfactual
    from treecf.audit import _json_float

    entries: dict[str, object] = {}
    for key, entry in self.entries.items():
        if isinstance(entry, Counterfactual):
            entries[key] = {
                "feasible": True,
                "proof": entry.proof,
                "distance": _json_float(entry.distance),
                "changed": sorted(entry.changes),
                "changes": {
                    name: [_json_float(before), _json_float(after)]
                    for name, (before, after) in sorted(entry.changes.items())
                },
                "x_cf": [_json_float(float(v)) for v in entry.x_cf],
            }
        else:
            entries[key] = {"feasible": False, "proof": entry.proof, "reason": entry.reason}
    return {
        "menu_schema_version": MENU_SCHEMA_VERSION,
        "mode": self.mode,
        "max_levers": self.max_levers,
        "levers": list(self.levers),
        "complete": self.complete,
        "minimal": list(self.minimal),
        "implied": dict(self.implied),
        "certified_infeasible": list(self.certified_infeasible),
        "unresolved": list(self.unresolved),
        "entries": entries,
    }

DiverseSet dataclass

DiverseSet(
    plans: dict[str, Counterfactual],
    criterion: str,
    complete: bool,
    jaccard: FloatArray,
    menu: RecourseMenu | None = None,
)

Bases: Mapping[str, 'Counterfactual']

Up to k plans that reach the target through different lever sets.

A mapping {key: Counterfactual} in ascending cost order. Under the "levers" criterion the keys are lever-set keys of the underlying menu; under "coalitions" they are coalition names, joined by "+" for a union.

ATTRIBUTE DESCRIPTION
plans

The mapping itself, cheapest first.

TYPE: dict[str, Counterfactual]

criterion

"levers" or "coalitions".

TYPE: str

complete

True iff fewer than k plans came back and the search that produced them was exhausted with every entry certified — no further distinct plan exists under the criterion. False whenever k plans were returned, since more may exist.

TYPE: bool

jaccard

Pairwise Jaccard distance between the plans' changed sets, in mapping order: zero on the diagonal, one for disjoint sets.

TYPE: FloatArray

menu

The RecourseMenu the plans were read from ("levers" only; None for the coalition criterion).

TYPE: RecourseMenu | None

to_frame

to_frame() -> list[dict[str, object]]

One row per plan, cheapest first.

RETURNS DESCRIPTION
``[{"key", "distance", "proof", "changed"}, ...]``.
Source code in src/treecf/_menu.py
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
def to_frame(self) -> list[dict[str, object]]:
    """One row per plan, cheapest first.

    Returns
    -------
    ``[{"key", "distance", "proof", "changed"}, ...]``.
    """
    return [
        {
            "key": key,
            "distance": plan.distance,
            "proof": plan.proof,
            "changed": sorted(plan.changes),
        }
        for key, plan in self.plans.items()
    ]

Regions

RecourseRegion dataclass

RecourseRegion(
    lo: FloatArray,
    hi: FloatArray,
    feature_intervals: dict[str, tuple[float, float]],
    certified: bool,
    feature_categories: dict[str, tuple[int, ...]] = dict(),
    cat_sets: dict[int, tuple[int, ...]] = dict(),
    category_names: dict[str, tuple[str, ...]] = dict(),
    maximal: dict[str, tuple[bool, bool]] = dict(),
    maximal_categories: dict[str, bool] = dict(),
    witnesses: dict[str, FloatArray] | None = None,
    integer_features: tuple[str, ...] = (),
    data_limited: dict[str, tuple[bool, bool]] = dict(),
)

A certified box around one verified counterfactual.

Every point z with lo <= z <= hi coordinate-wise (z_j = x_cf_j at a degenerate or NaN coordinate) is provably in-target, plausible when plausibility is configured, and feasible against every compiled constraint -- the same guarantees the counterfactual itself carries, not a heuristic neighbourhood around it.

lo/hi cover every feature (degenerate coordinates included, as a single point); feature_intervals keys only the non-degenerate ones by name, for display. Regions are certified but not monotone in the target interval (a strictly narrower target can still produce a strictly wider region on some feature: growth is greedy and order-dependent, so a feature that is forced to stop early frees room a later feature grows into), and in the default fast mode not maximal either (a larger sound box may exist). The maximal mode settles each stopped side with a budgeted search for a violating point and records what it proved in maximal/maximal_categories. See Certification.

ATTRIBUTE DESCRIPTION
lo

Lower bound per feature, same order as the model's features; equal to hi at a degenerate (never-widened) coordinate.

TYPE: FloatArray

hi

Upper bound per feature, same order as the model's features.

TYPE: FloatArray

feature_intervals

{feature: (lo, hi)} for every non-degenerate feature only, for display (describe() renders these as phrases).

TYPE: dict[str, tuple[float, float]]

certified

Always True in this release — every region returned by Explainer.recourse_region/explain(..., region=True) is a sound certificate; the field is reserved for a future relaxed mode.

TYPE: bool

maximal

{feature: (lower side proved, upper side proved)} for the non-degenerate numeric features, set by the maximal mode only. A proved side cannot be extended into the next routing cell without leaving the target or breaking a constraint (a witness point exists), or already sits at an instance bound or at infinity. A side left False stopped on a conservative bound or ran out of its budget — the fast mode's state for every side. Empty in fast mode.

TYPE: dict[str, tuple[bool, bool]]

maximal_categories

{feature: proved} per grown categorical feature, likewise: True when every excluded category block was proved impossible.

TYPE: dict[str, bool]

witnesses

{"feature:lo" | "feature:hi" | "feature:cat": point} — for each proved side, one point just past it that leaves the target or violates a constraint; None unless the region was asked to keep them.

TYPE: dict[str, FloatArray] | None

integer_features

Names of the features under an "integer" value policy when the region was built; describe() phrases their intervals on the integers.

TYPE: tuple[str, ...]

data_limited

{feature: (lower side, upper side)} for every feature some side of which stopped at the observed range of the explainer's background data rather than at a constraint or at the model. A feature with no Range constraint on a side is grown no further than the data reaches on that side (a counterfactual outside the data still lies in its box); the flag says which sides that was. Empty when the explainer has no background data.

TYPE: dict[str, tuple[bool, bool]]

contains

contains(x: FloatArray) -> bool

Whether x lies inside the region, coordinate by coordinate.

A degenerate coordinate (lo == hi, including NaN) requires x to match it exactly; every other coordinate requires lo <= x[j] <= hi[j].

PARAMETER DESCRIPTION
x

A feature vector, same order and length as the region.

TYPE: FloatArray

RETURNS DESCRIPTION
``True`` iff every coordinate of ``x`` satisfies the region's
bound.
Source code in src/treecf/regions.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
def contains(self, x: FloatArray) -> bool:
    """Whether ``x`` lies inside the region, coordinate by coordinate.

    A degenerate coordinate (``lo == hi``, including NaN) requires ``x``
    to match it exactly; every other coordinate requires
    ``lo <= x[j] <= hi[j]``.

    Parameters
    ----------
    x
        A feature vector, same order and length as the region.

    Returns
    -------
    ``True`` iff every coordinate of ``x`` satisfies the region's
    bound.
    """
    for j in range(len(self.lo)):
        xj = float(x[j])
        if math.isnan(self.lo[j]):
            if not math.isnan(xj):
                return False
            continue
        if j in self.cat_sets:
            if math.isnan(xj) or xj != int(xj) or int(xj) not in self.cat_sets[j]:
                return False
            continue
        if math.isnan(xj) or not (self.lo[j] <= xj <= self.hi[j]):
            return False
    return True

describe

describe() -> dict[str, str]

One human-readable phrase per non-degenerate feature.

One-sided ("≤ v"/"≥ v") when the other endpoint is infinite, two-sided ("in [lo, hi]") otherwise, and "unconstrained" when both endpoints are infinite; values are shown to three significant digits. A shown value never overstates the box: an endpoint that rounds to a value outside the interval is phrased strictly ("< 1", "in [0, 1)"), so a box that stops one float32 ulp below 1 does not read as if 1 were inside it. A feature in integer_features is phrased on the integers the box contains ("= 0", "≤ 0", "in [2, 4]"). A feature whose every side the maximal mode proved carries the suffix " (maximal)", and one some side of which stopped at the data range " (data-limited)" (" (maximal, data-limited)" for both).

RETURNS DESCRIPTION
``{feature: phrase}`` for every key of ``feature_intervals``.
Source code in src/treecf/regions.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def describe(self) -> dict[str, str]:
    """One human-readable phrase per non-degenerate feature.

    One-sided (``"≤ v"``/``"≥ v"``) when the other endpoint is
    infinite, two-sided (``"in [lo, hi]"``) otherwise, and
    ``"unconstrained"`` when both endpoints are infinite; values are
    shown to three significant digits. A shown value never overstates
    the box: an endpoint that rounds to a value outside the interval is
    phrased strictly (``"< 1"``, ``"in [0, 1)"``), so a box that stops
    one float32 ulp below 1 does not read as if 1 were inside it. A
    feature in ``integer_features`` is phrased on the integers the box
    contains (``"= 0"``, ``"≤ 0"``, ``"in [2, 4]"``). A feature whose
    every side the maximal mode proved carries the suffix
    ``" (maximal)"``, and one some side of which stopped at the data
    range ``" (data-limited)"`` (``" (maximal, data-limited)"`` for
    both).

    Returns
    -------
    ``{feature: phrase}`` for every key of ``feature_intervals``.
    """
    out: dict[str, str] = {}
    for name, (lo, hi) in self.feature_intervals.items():
        if name in self.integer_features:
            out[name] = _integer_phrase(lo, hi)
        else:
            out[name] = _interval_phrase(lo, hi)
        notes = []
        if self.maximal.get(name) == (True, True):
            notes.append("maximal")
        if any(self.data_limited.get(name, (False, False))):
            notes.append("data-limited")
        if notes:
            out[name] += f" ({', '.join(notes)})"
    for name, codes in self.feature_categories.items():
        names = self.category_names.get(name)
        rendered = (
            ", ".join(names[c] for c in codes)
            if names is not None
            else ", ".join(str(c) for c in codes)
        )
        out[name] = f"∈ {{{rendered}}}"
        if self.maximal_categories.get(name):
            out[name] += " (maximal)"
    return out

Audit

ir_fingerprint

ir_fingerprint(ir: EnsembleIR) -> str

SHA-256 fingerprint of an ensemble over a canonical byte encoding.

The encoding is positional bytes — the link name, the base score, the tree count, then every node of every tree in index order with fixed-width little-endian fields and fixed sentinel bytes where a field does not apply to the node kind — so the fingerprint is stable across Python versions, platforms, and dict ordering, and changes when any structural or numeric detail of the ensemble changes (a one-ulp leaf perturbation included).

PARAMETER DESCRIPTION
ir

The parsed ensemble to fingerprint (Explainer.ir).

TYPE: EnsembleIR

RETURNS DESCRIPTION
A 64-character SHA-256 hex digest.
Source code in src/treecf/audit.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def ir_fingerprint(ir: EnsembleIR) -> str:
    """SHA-256 fingerprint of an ensemble over a canonical byte encoding.

    The encoding is positional bytes — the link name, the base score, the
    tree count, then every node of every tree in index order with fixed-width
    little-endian fields and fixed sentinel bytes where a field does not
    apply to the node kind — so the fingerprint is stable across Python
    versions, platforms, and dict ordering, and changes when any structural
    or numeric detail of the ensemble changes (a one-ulp leaf perturbation
    included).

    Parameters
    ----------
    ir
        The parsed ensemble to fingerprint (``Explainer.ir``).

    Returns
    -------
    A 64-character SHA-256 hex digest.
    """
    hasher = hashlib.sha256()
    hasher.update(ir.link.name.encode("utf-8") + b"\x00")
    hasher.update(struct.pack("<d", ir.base_score))
    hasher.update(struct.pack("<I", len(ir.trees)))
    for tree in ir.trees:
        hasher.update(struct.pack("<I", len(tree.nodes)))
        for node in tree.nodes:
            if node.feature is None:  # leaf
                assert node.value is not None
                hasher.update(b"\x00" + struct.pack("<I", _NONE_U32) + _NONE_F64)
                hasher.update(b"\x00\x02")  # op / missing_left sentinels
                hasher.update(struct.pack("<II", _NONE_U32, _NONE_U32))
                hasher.update(struct.pack("<d", node.value))
            elif node.categories is not None:  # set-membership split
                assert node.missing_left is not None
                assert node.left is not None and node.right is not None
                hasher.update(b"\x02" + struct.pack("<I", node.feature))
                hasher.update(struct.pack("<B", 1 if node.missing_left else 0))
                hasher.update(struct.pack("<II", node.left, node.right))
                words = bitset_words(node.categories)
                hasher.update(struct.pack("<I", len(words)))
                for word in words:
                    hasher.update(struct.pack("<Q", word))
            else:
                assert node.threshold is not None and node.op is not None
                assert node.missing_left is not None
                assert node.left is not None and node.right is not None
                hasher.update(b"\x01" + struct.pack("<I", node.feature))
                hasher.update(struct.pack("<d", node.threshold))
                hasher.update(
                    bytes((1 if node.op is SplitOp.LT else 2, 1 if node.missing_left else 0))
                )
                hasher.update(struct.pack("<II", node.left, node.right))
                hasher.update(_NONE_F64)
    if ir.categorical:  # absent on numeric-only models, keeping their digests unchanged
        hasher.update(b"CAT" + struct.pack("<I", len(ir.categorical)))
        for j in sorted(ir.categorical):
            hasher.update(struct.pack("<II", j, ir.categorical[j].cardinality))
    return hasher.hexdigest()

constraints_fingerprint

constraints_fingerprint(explainer: Explainer) -> str

SHA-256 fingerprint of an explainer's effective objective and constraints.

Covers the compiled constraint set (type tags, resolved feature indices, parameters), the distance normalizers sigma, the per-feature weights, and every value-policy entry, all as canonical little-endian bytes. A callable value policy has no canonical encoding: it is hashed as a fixed unhashable_custom tag, and any certificate built from the explainer records "reproducible": false with a reason.

PARAMETER DESCRIPTION
explainer

The explainer whose constraint set to fingerprint.

TYPE: Explainer

RETURNS DESCRIPTION
A 64-character SHA-256 hex digest.
Source code in src/treecf/audit.py
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
def constraints_fingerprint(explainer: Explainer) -> str:
    """SHA-256 fingerprint of an explainer's effective objective and constraints.

    Covers the compiled constraint set (type tags, resolved feature indices,
    parameters), the distance normalizers ``sigma``, the per-feature
    ``weights``, and every value-policy entry, all as canonical little-endian
    bytes. A callable value policy has no canonical encoding: it is hashed as
    a fixed ``unhashable_custom`` tag, and any certificate built from the
    explainer records ``"reproducible": false`` with a reason.

    Parameters
    ----------
    explainer
        The explainer whose constraint set to fingerprint.

    Returns
    -------
    A 64-character SHA-256 hex digest.
    """
    encoding, _ = _constraints_encoding(explainer)
    return hashlib.sha256(encoding).hexdigest()

portfolio_report

portfolio_report(
    batch: BatchResult,
    groups: Sequence[object] | None = None,
    *,
    explainer: Explainer | None = None,
    path: str | PathLike[str] | None = None,
    format: str = "html",
    title: str | None = None,
    disparity: bool = False,
    reference_group: object | None = None,
    top_levers: int = 5,
    min_group_size: int = 10,
) -> dict[str, object]

Summarize a batch of counterfactuals as an auditable report.

The returned dict is strict-JSON-serializable (json.dumps(report, allow_nan=False); non-finite floats are the strings "NaN", "Infinity", "-Infinity", as in certificates) and carries "portfolio_schema_version": 1. Burdens and ratios compare costs under one declared cost model and constraint set; a difference between segments is a finding to investigate, not a fairness verdict.

PARAMETER DESCRIPTION
batch

The batch to report on.

TYPE: BatchResult

groups

One segment label per input row of the batch, in the batch's row order; None reports a single "all" segment.

TYPE: Sequence[object] | None DEFAULT: None

explainer

The explainer the batch came from. Adds the model and constraint fingerprints, scales numeric lever moves by the explainer's normalizers, and names categorical target categories.

TYPE: Explainer | None DEFAULT: None

path

Where to write the report; nothing is written when omitted.

TYPE: str | PathLike[str] | None DEFAULT: None

format

"html" (one self-contained file), "markdown" (figures written beside the file, in <stem>_figures/), or "json". HTML and Markdown need the viz extra.

TYPE: str DEFAULT: 'html'

title

The document title; defaults to a generic one.

TYPE: str | None DEFAULT: None

disparity

Add per-segment ratios of median burden and of no-recourse share against reference_group, each framed by the same fixed sentence.

TYPE: bool DEFAULT: False

reference_group

The segment the ratios compare against; required exactly when disparity is set.

TYPE: object | None DEFAULT: None

top_levers

How many dominant levers to list per segment.

TYPE: int DEFAULT: 5

min_group_size

Segments smaller than this are flagged small.

TYPE: int DEFAULT: 10

RETURNS DESCRIPTION
The report as a plain ``dict``.
RAISES DESCRIPTION
TreecfError

If groups does not have one label per batch row.

ValueError

If format is unknown, format="markdown" comes without path, reference_group is given without disparity (or missing or unknown with it).

MissingExtraError

If an HTML or Markdown render is requested without matplotlib.

Source code in src/treecf/_portfolio.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def portfolio_report(
    batch: BatchResult,
    groups: Sequence[object] | None = None,
    *,
    explainer: Explainer | None = None,
    path: str | os.PathLike[str] | None = None,
    format: str = "html",
    title: str | None = None,
    disparity: bool = False,
    reference_group: object | None = None,
    top_levers: int = 5,
    min_group_size: int = 10,
) -> dict[str, object]:
    """Summarize a batch of counterfactuals as an auditable report.

    The returned dict is strict-JSON-serializable (``json.dumps(report,
    allow_nan=False)``; non-finite floats are the strings ``"NaN"``,
    ``"Infinity"``, ``"-Infinity"``, as in certificates) and carries
    ``"portfolio_schema_version": 1``. Burdens and ratios compare costs under
    one declared cost model and constraint set; a difference between segments
    is a finding to investigate, not a fairness verdict.

    Parameters
    ----------
    batch
        The batch to report on.
    groups
        One segment label per input row of the batch, in the batch's row
        order; ``None`` reports a single ``"all"`` segment.
    explainer
        The explainer the batch came from. Adds the model and constraint
        fingerprints, scales numeric lever moves by the explainer's
        normalizers, and names categorical target categories.
    path
        Where to write the report; nothing is written when omitted.
    format
        ``"html"`` (one self-contained file), ``"markdown"`` (figures written
        beside the file, in ``<stem>_figures/``), or ``"json"``. HTML and
        Markdown need the ``viz`` extra.
    title
        The document title; defaults to a generic one.
    disparity
        Add per-segment ratios of median burden and of no-recourse share
        against ``reference_group``, each framed by the same fixed sentence.
    reference_group
        The segment the ratios compare against; required exactly when
        ``disparity`` is set.
    top_levers
        How many dominant levers to list per segment.
    min_group_size
        Segments smaller than this are flagged ``small``.

    Returns
    -------
    The report as a plain ``dict``.

    Raises
    ------
    TreecfError
        If ``groups`` does not have one label per batch row.
    ValueError
        If ``format`` is unknown, ``format="markdown"`` comes without
        ``path``, ``reference_group`` is given without ``disparity`` (or
        missing or unknown with it).
    MissingExtraError
        If an HTML or Markdown render is requested without matplotlib.
    """
    if format not in _FORMATS:
        raise ValueError(f"format must be one of {_FORMATS}, got {format!r}")
    if format == "markdown" and path is None:
        raise ValueError('format="markdown" requires path (figures are written next to it)')
    if disparity and reference_group is None:
        raise ValueError("reference_group is required when disparity=True")
    if not disparity and reference_group is not None:
        raise ValueError("reference_group is only valid with disparity=True")

    report = _build(batch, groups, explainer, disparity, reference_group, top_levers,
                    min_group_size)
    if path is None:
        return report
    if format == "json":
        with open(path, "w", encoding="utf-8") as fh:
            json.dump(report, fh, allow_nan=False, indent=1, sort_keys=True)
        return report
    text = _render(report, batch, groups, format, Path(path), title, min_group_size)
    with open(path, "w", encoding="utf-8") as fh:
        fh.write(text)
    return report