Contributing to effector
Thanks for wanting to make effector better! 🎉
Our workflow fits in two simple concept:
mainis always shippable. For a commit to be accepted in main, it must be reviewed and pass tests. by taging (vX.Y.Z) a commit onmain, we automatically run another round of tests and, if they pass, publish to PyPI.
The mental model
main is the stable integration branch; we always keep it publishing-ready.
Therefore, we never commit to main directly.
All changes come through a PR, which is reviewed and tested before merging.
Every change gets its own small branch:
you spin one up for a single idea, do the work, get it merged, and throw the branch away.
Next idea starts with a fresh new branch from an up-to-date main.
The concept is one idea = one branch = one PR.
"one idea" is normally a change that you can describe in a single sentence without saying "and".
A release is a separate, deliberate step: we tag a commit on main, and that tag is what
ships to PyPI and what people pip install.
The idea is main moves often, but releases happen less frequently.
Pull Requests
Use a Pull Request (PR) to join main.
- The diff: shows exactly what you changed, for anyone to read.
- The tests: runs the the full CI suite against your change automatically.
- The review:a maintainer looks it over and approves or asks for tweaks.
The workflow, step by step
# 1. Start fresh from main
git checkout main && git pull
# 2. Branch for your one idea
git checkout -b fix/short-description
# 3. Do the work, commit as you go
git commit -am "wip" # use as many commits as you like, they will be squashed later
# 4. Push your branch
git push -u origin fix/short-description
# 5. Open the PR
gh pr create --fill # or use the GitHub website
# 6. Let CI run, address review, then it merges (squashed) and the branch is deleted
Branch names
Name the branch after the intent of the change, in kebab-case:
| Prefix | Use it for | Example |
|---|---|---|
feat/ |
a new feature | feat/regional-importance |
fix/ |
a bug fix | fix/centering-default |
chore/ |
tooling / deps / CI | chore/switch-to-uv |
docs/ |
documentation | docs/contributing |
refactor/ |
internal change, no behavior change | refactor/split-partitioning |
Commits & the changelog
We squash-merge, so all the little commits on your branch collapse into a
single commit on main; the PR title becomes that commit.
So,
- make your PR title a good one, this is what goes into history.
- be more descriptive in
CHANGELOG.md, that's where we keep the descriptive record of what changed and why, not in commit messages.
Two protection layers
They guard two different actions:
- Merging to
main. A PR can't merge until CI is green and a maintainer approved it. - Releasing to PyPI. You can't publish to PyPI without a tag. If you add a tag, the publish workflow runs tests again on that commit. If they pass, it publishes to PyPI.
Versioning & releases
We follow Semantic Versioning and release early and often:
- Bug fixes / docs / cleanups → a patch (
0.2.1,0.2.2, …) - New backward-compatible features → a minor (
0.3.0, …)
A release is just a normal PR merged to main, followed by a vX.Y.Z tag.
The tag triggers the publish to PyPI automatically.
Running things locally
We use uv to manage the environment. Install it once (instructions), then:
uv sync # create .venv and install all dev dependencies
make test # run the fast test suite (the merge gate)
make test-all # run the full suite, including slow tests
make format # format the code (ruff)
make lint # lint the code (ruff)
make docs-serve # preview the docs locally
Design contract
Code changes must follow the core rules (R1–R14) in
docs/design.md — lifecycle, heterogeneity surface, centering
vocabulary, registries, plot/constructor/error contracts, and the two-block
lifecycle constitution (R14: every effect object is two caches and one config,
owned entirely by GlobalEffectBase). The tests/test_contract_*.py layer
enforces most of them mechanically — tests/test_contract_lifecycle.py pins
the exact model-call budget of every lifecycle step, so an extra model touch
or a lost cache hit is a failing test, not a slow regression.
Writing a new effect method
A method is a frame declaration plus three pure kernels — nothing else. The base class owns all caching, retriggering, masking, centering, and the regional machinery; a subclass that checks a cache is a bug by definition.
-
Answer the one semantic question: what is your frame? The frame is the discretization your local effect is defined on — the config parameters whose change makes your cached local effects meaningless. ALE's is its fixed bin grid, categorical (RH)ALE's is the level order; RHALE and ShapDP have none (their local effects are instance-anchored). Declare it in
_frame_from_config(feature) -> tupleof primitives. The base compares it on every access and recomputes/bumps the epoch when it changes — that is the entire invalidation story, and you never write any of it. -
Implement the three kernels (all independently testable with plain numpy arrays):
-
_compute_local(feature, frame) -> dict— the ONLY kernel that may call the model. Return{"frame": frame, ...instance-aligned arrays}so any boolean(N,)mask can slice your local effects in one expression. Feature-independent raw material (a jacobian, a SHAP table) is computed once per object and shared across features. _summarize(feature, mask=None, **config) -> dict— pure numpy: derive the payload (whatevereval/plotread) from the cached local effects restricted tomask.-
_eval_payload(feature, params, x, heterogeneity=False)— pure reader: the uncentered mean effect (and h(x)) read off a payload. Same payload + same x → same answer; no model, no state. -
Expose the public surface as thin declarations:
fitcallsself._fit_loop(features, centering, **your_config);plotreadsself._summary(feature, mask)/self._centering_const(...)and draws. With that,eval,eval_heter,heter_score,importance,find_regions, themask=surfaces and thePartitionsugar all work without another line of code. -
Copy
tests/toy_method.py— the ~60-line reference implementation the contract suite itself runs — as your starting point, and register the method ineffector/method_registry.py; the contract tests parametrize over the registry and referee compliance. -
If your method doesn't fit the mold, override a named hook — never bypass the gates. Legal override points:
_eval_mean(exact evaluation off the payload, like (d-)PDP),_importance(a method-canonical scalar — the one live override is DerPDP's bridgedmean(|derivative|), because its mean effect is already a derivative; whatever you return must still be a std-type quantity in output units, per R2/R13),_compute_norm_const/_mean_norm_const(a non-scalar centering constant, like PDP's per-instance array).
Questions? Open an issue or start a draft PR and ask there.