Design contract (the "constitution")
These are the core rules every effector class follows after the 2026-07
homogenization refactor (PLAN Part III §1, LOGBOOK #3–#6, #13). New features
must follow them too; the contract-layer tests (tests/test_contract_*.py)
enforce most of them mechanically.
R1 — Lifecycle
fit does the hard work once and stores everything under
feature_effect["feature_{i}"]. eval(feature, xs, centering=<class default>)
returns the mean effect only — one return type, always — and never
recomputes unless requires_refit. plot is a thin wrapper over eval /
stored state plus one vis.* call: the plot layer draws, it does not compute.
R2 — Heterogeneity semantics
Heterogeneity does not go through eval; it has its own surface, an
aggregation ladder with one consumer per level:
payload(feature)→ dict — the method's honest raw object (ICE table, per-bin variances, shap cloud) from stored state.eval_heter(feature, xs)→(T,)— the heterogeneity curve h(xs). h is the variance of the method's own per-instance effect object (PDP: centered ICE levels; DerPDP: d-ICE slopes; (RH)ALE: per-bin slope variance as a step function; ShapDP: the residual spline). Variance internally, std only at the plot layer. No centering kwarg — h is invariant to centering, and the signature enforces it. Every plotted band/error-bar equalseval_heter(R1 extended to heterogeneity). Regional twin:eval_heter(feature, node_idx, xs).heter_score(feature)→ float ≥ 0 — the one method-agnostic scalar, consumed by regional splitting and the interaction submodule.
R3 — Centering vocabulary
{False, "zero_integral" (=True), "zero_start"}, normalized once by
helpers.prep_centering. Each class declares its default once as the
DEFAULT_CENTERING class attribute; fit/eval/plot signatures use
centering=None → class default instead of per-method literals.
R4 — State schema
feature_effect["feature_{i}"] always contains norm_const: float | None
(None means "not centered" — never a sentinel value) plus the
method-specific payload; fit_args["feature_{i}"] records the kwargs needed
to detect refit.
R5 — One method registry
effector.method_registry holds the single
{canonical_name: (cls, needs_jac, uses_data_effect, display_name)} table
plus aliases. FeatureEffect, RegionalEffectBase._create_fe_object, and
plot titles all read it; per-method if/elif chains are a bug.
R6 — String-argument registries
Exactly one alias table per concept, resolved by one return_default-style
function, and validation always goes through the resolver (binning:
"fixed" | "greedy" | "dp"; partitioner: "best" | "best_level_wise").
R7 — Plot contract
Every vis.* function and every public .plot returns (fig, ax) when
show_plot=False and None otherwise — uniformly, global and regional.
R8 — Constructor contract
Canonical parameter order data, model, model_jac=None, *, data_effect,
nof_instances, axis_limits, schema, random_state, ... — everything after
model_jac keyword-only, so positional shuffles can't bite. data is a
numpy array and model/model_jac are numpy→numpy callables (R10). All input
metadata (names, types, target name, scaling) lives in the single schema
argument (R10); metadata never appears as separate constructor kwargs.
Reproducibility is contractual: every constructor takes random_state
(default 21; None opts into fresh randomness), two identical
constructions give identical eval/fit/plot output, and no effect-class
code touches the global np.random state — every sampling site creates its
own np.random.default_rng(random_state). (The one exception is
datasets.IndependentUniform, which keeps its legacy seeded global draw so
the executed notebooks stay a valid regression oracle.)
R9 — Errors & messages
ValueError/TypeError (not bare assert) for user input; warnings.warn
or logging (not print) inside heterogeneity functions.
R10 — Input contract
Accepted data type. A 2-D numeric numpy array. Anything else →
TypeError; a pandas DataFrame is rejected with a pointer to
effector.from_dataframe. effector is numpy-only — data, model, and
model_jac all live in numpy, so the model is called exactly as given and is
never wrapped. pandas is never a dependency of the compute path (the numpy door
never imports it, proven by a subprocess test).
Model contract. model is Callable[[np.ndarray[N, D]], np.ndarray[N]]
and model_jac (optional) is Callable[[np.ndarray[N, D]], np.ndarray[N, D]].
A model trained on a DataFrame, a torch/tf tensor, or an sklearn Pipeline is
the user's to wrap into a numpy→numpy callable — dtype, device, batching, and
any DataFrame reconstruction included. See the "effector is purely numpy
based" quickstart guide.
One metadata argument. All input metadata travels in schema= — an
effector.Schema (frozen dataclass) or a plain dict with the same keys:
feature_names, feature_types, cat_limit (default 10), target_name,
scale_x_list, scale_y, category_names — every field optional. Unknown dict keys →
ValueError listing the valid ones. A schema is reusable across method
constructions.
Define-or-infer. Precedence per field: explicit schema field > numpy
heuristic > synthesized default (x_0…, "y").
Feature-type taxonomy — three-way, "continuous" | "ordinal" | "nominal",
with door-normalized aliases "cont" → continuous, "cat" → nominal. From a
numpy column:
| source | rule |
|---|---|
numpy column, integer-valued, nunique < cat_limit |
ordinal |
| numpy column, otherwise | continuous — nominal is never inferred from numpy |
Types decided by the cardinality heuristic (the int rule) and not declared in
the schema trigger one UserWarning naming the columns and the one-line
schema={"feature_types": [...]} fix.
from_dataframe convenience. X, schema = effector.from_dataframe(df)
reads a DataFrame's column names, dtypes, and category levels into (X, Schema)
so you can call any constructor as Method(X, model, schema=schema). It
converts data only — it never touches the model. dtype → type mapping:
| DataFrame column | inferred type |
|---|---|
| float | continuous |
| int | ordinal if nunique < cat_limit, else continuous |
| bool | ordinal (codes 0/1) |
Categorical(ordered=True) |
ordinal, declared category order kept |
| unordered category / object / string | nominal (codes via astype("category")) |
| datetime / other | ValueError |
NaN anywhere → ValueError naming the column. The returned schema is a
proposal to inspect: the int-column guess (ordinal vs continuous vs a
label-encoded nominal) is the one thing no extractor can know for sure.
Scaling precedence. scale_x_list/scale_y in the schema are
construction-time defaults; a plot-time scale_x/scale_y dict overrides,
False at plot time explicitly disables an inherited scale.
One validation point. effector.ingestion.validate_metadata (R9 style)
checks name/type list lengths against dim, canonical type values, scale
dict shapes ({"mean","std"}, std != 0), cat_limit sanity, and
category_names lengths against the observed levels.