Skip to content

Fix cluster parallelism with OpenEye evaluators (v1.2.0), simplify README, clean up repo - #21

Merged
aakankschit merged 8 commits into
mainfrom
cleanup-and-cluster-bugfix
Jul 18, 2026
Merged

aakankschit merged 8 commits into
mainfrom
cleanup-and-cluster-bugfix

Conversation

@aakankschit

Copy link
Copy Markdown
Owner

Summary

Fixes the three bugs from the 2026-06-23 report by Donald van Pinxteren, which together made processes > 1 non-functional with Fred docking. Also lands outstanding local research work, simplifies the README, and cleans up accumulated generated artifacts.

Bumps to v1.2.0 and adds a CHANGELOG.md.

The three cluster bugs

Bug 1 — parallelism could not be enabled. from_config() hardcoded processes=1 and ThompsonSamplingConfig had no field for it, so a 1000-iteration docking screen ran on one core of a 128-core allocation with no error or warning. Added processes / min_cpds_per_core to the config and wired them through.

Bug 2 — processes > 1 always crashed with Fred/ROCS. pool.map(sampler.evaluate, ...) pickled the bound method → the sampler → the evaluator → the SWIG-wrapped OEDock, raising TypeError: cannot pickle 'SwigPyObject' object on the first batch.

Workers now build their own evaluator instead of receiving one. The pool is created with an initializer that constructs the evaluator inside each worker from its picklable Pydantic config — once per worker, not once per molecule — so the OpenEye object never crosses the pipe. ThompsonSampler gained __getstate__/__setstate__ dropping the evaluator, pool and logger.

Unlike a fork-inheritance workaround, this is correct under spawn as well as fork, so it works on macOS/Windows too. No OpenEye code is modified — only the picklable config crosses the pipe.

Bug 3 — segfault on import. OpenEye was imported at module scope, initialising global libexpat state; tqdm → prompt_toolkit then re-entered expat via pyexpat at module scope, killing the interpreter (exit 139) during import TACTICS. OpenEye is now imported lazily on first construction of an OpenEye evaluator.

Also fixes joblib, which was imported inside the OpenEye try/except and so was undefined for MLClassifierEvaluator on any machine without OpenEye.

Verification

  • Real OEDock, not a mock. Confirmed the evaluator is still unpicklable (the exact reported TypeError), then ran real Fred docking with processes=2 under spawn — the harder start method than the cluster's fork. 4/4 products docked across 2 workers.
  • Behavior-neutral. Seeded thrombin searches are bit-identical across this branch, the pre-bugfix commit, and published main (SHA over the full evaluated-product sequence). processes defaults to 1 and the sequential path is untouched.
  • 306 passed / 8 skipped, up from a 283/8 baseline — 23 new tests, zero regressions. The softmax regression tests were confirmed to fail against the previous naive form.

Also included

  • default_score for LookupEvaluator — sparse libraries (DEL read counts) where an absent combination is a true non-binder, not missing data. Defaults to None, preserving existing NaN behaviour.
  • Numerically stable CATS softmax — subtract max(z) before exp(); shift-invariant, so probabilities are unchanged, but overflow becomes impossible.
  • TT-TS GMIC min-observation gate removed for consistency with RouletteWheelSelection. min_observations is now inert but still accepted; both docstrings updated rather than left describing a gate that no longer exists.
  • Layer-1/layer-2 search diagnostics.
  • README rewritten as a front door: 410 → 229 lines. This also corrected real errors — it documented a get_preset("parallel_batch", ...) preset that does not exist and would raise, listed two removed tutorials, and claimed 5 presets when there are 4.
  • Repo cleanup: jobs/, plots/, *.bak, and OpenEye run artifacts are now gitignored (CLAUDE.md already claimed jobs/ was ignored, but no rule existed); scratch files removed.
  • Research records tracked: docs/handoffs/, research/blueprints/, research/STATUS.md.

Notes for review

  • Not yet validated at cluster scale. Verified with real docking under spawn locally, but not at the reporter's scale (1000 iterations, 128 cores, ~5.96M compounds). A short real-cluster run is the last check before telling users it is fixed.
  • Donald can drop both workarounds after upgrading: the ForkAwareParallelEvaluator and the pyexpat/xml.dom.minidom pre-import shim.
  • CLAUDE.md is gitignored (.gitignore:163), so its code-map and parallel-evaluation updates are local only and not in this PR.
  • Seeding: ThompsonSamplingConfig.seed does not fully control run-to-run variation — the default EnhancedWarmup uses unseeded global RNGs. Deliberately not changed: TS is intrinsically stochastic and the 20-replicate design characterises that variation, so published results are unaffected.
  • Two pre-existing preset bugs are out of scope here: get_preset("baseline"/"legacy_rws", ..., batch_size=N) raises TypeError despite the docstring advertising batch_size.

🤖 Generated with Claude Code

Aakankschit Nandkeolyar and others added 8 commits July 18, 2026 12:02
The repo had accumulated generated output and scratch files at the root
and in tutorials/. CLAUDE.md already documented jobs/ as gitignored, but
no rule existed, so 80 job files plus 53 generated figures showed up as
untracked noise on every status check.

Add ignore rules for:
- jobs/ and plots/ (generated Slurm files and figures)
- *.bak / *.bak2 (manual editor backups)
- OpenEye run artifacts (oeomega_*.txt, *_settings.param, *_status.txt, *.oedu)

Scratch files removed from the tree (preserved outside the repo): a stray
38-byte note named "4", five OpenEye log/parameter dumps, and five
tutorial .bak/.bak2 backups now superseded by git history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LookupEvaluator returned np.nan for any product code absent from the
reference table, and the sampler skips NaN evaluations. That is correct
when the table is a complete enumeration and a miss means missing data.

It is wrong for sparse experimental libraries such as DEL read counts,
where only a small fraction of the combinatorial space is measured and an
absent combination is a true non-binder (score 0). Skipping those draws
silently discards most of the search.

Add an optional default_score to LookupEvaluator and LookupEvaluatorConfig,
wired through create_evaluator. Default stays None, which preserves the
existing NaN behavior, so no current run changes. A measured 0.0 is still
returned from the table and is not confused with an absent code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Boltzmann weight is exp(z) with z = (score - mean) / std / temperature.
Because z is standardized it is normally O(1), but on a heavy-tailed,
zero-inflated landscape (most reagents scoring 0, a few very high, as in
DEL read counts) a single outlier pushes max(z) toward sqrt(n), and a small
CATS temperature divides that further. exp() then overflows to inf and
inf/sum(inf) = nan, so rng.choice raises "probabilities contain NaN" and
the search dies mid-run.

Subtract max(z) before exponentiating in both the select_reagent and
select_batch paths. This is shift-invariant, so the normalized
probabilities are mathematically identical, but every exponent is now <= 0
and the largest weight is exp(0) = 1, making overflow impossible.

Regression tests confirmed to fail against the previous naive form.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TopTwoSelection._calculate_gmic returned 0.0 whenever the least-observed
active reagent had fewer than min_observations samples. RouletteWheelSelection
never had this gate, so the two strategies computed criticality differently.

On large components the gate fired almost every cycle: a single sub-5-obs
straggler among hundreds of reagents pinned the whole component's GMIC to
zero, which then over-weighted that component in the GMIC rotation. A scan
found the gate firing on 25 of 28 benchmark libraries.

A paired counterfactual (20 replicates, monkeypatched, shipped code
untouched) showed removing it lifts adenine TT-TS top-100 recovery from
87.1 to 93.2 and cuts replicate variance roughly in half (sd 18.4 -> 10.6),
rescuing 3 of 4 bimodal failures, with no change on the 3 libraries where
the gate never fired.

min_observations is now inert in TopTwoSelection. It is still accepted and
stored so existing configs keep working, and both docstrings now say so
rather than describing a gate that no longer exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds diagnostic plots that make the criticality-directed search visible as
a signal -> decision -> result chain, so a run can be inspected rather than
inferred from recovery numbers alone.

Layer 1 (which component to explore):
- plot_gmic_directed_exploration: per-component GMIC (signal) -> heated
  component strip (decision) -> cumulative per-component coverage (result)
- plot_gmic_vs_oracle / compute_oracle_flexibility: GMIC against a
  ground-truth flexibility reference

Layer 2 (how hard to explore within that component):
- plot_adaptive_intensity: the per-component intensity knob as a multiplier
  centered on 1.0 (RWS cats_multiplier, TT-TS adaptive heated_scale) ->
  rolling heating probability -> new reagents per batch, with automatic
  detection of which intensity column the run recorded

- plot_reagent_usage_action_panel: per-batch reagent usage against the
  activity cutoff, with component names inferred from reagent files

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes the three defects from the 2026-06-23 bug report, which together made
processes > 1 non-functional with Fred docking out of the box.

Bug 1 -- parallelism could not be enabled.
from_config() hardcoded processes=1, and ThompsonSamplingConfig had no field
for it, so there was no API-level way to turn on evaluation parallelism. A
1000-iteration docking screen ran on one core of a 128-core allocation, with
no error or warning. Add processes and min_cpds_per_core to the config and
pass them through from_config().

Bug 2 -- processes > 1 always crashed with Fred/ROCS.
ParallelEvaluator called pool.map(sampler.evaluate, ...). That pickles the
bound method, hence the sampler, hence the evaluator, hence the SWIG-wrapped
OEDock it holds -- which raises TypeError: cannot pickle 'SwigPyObject'
object. The run died on the first batch, before any docking.

Workers now construct their own evaluator instead of receiving one. The pool
is created with an initializer that builds the evaluator inside each worker
from its picklable Pydantic config, once per worker rather than once per
molecule, so the OpenEye object never crosses the pipe. ThompsonSampler gains
__getstate__/__setstate__ dropping the evaluator, pool and logger (the logger
is rebuilt worker-side). Unlike a fork-inheritance workaround this is correct
under spawn as well as fork, so it works on macOS and Windows too.

Verified end to end with a real OEDock under spawn: the evaluator is confirmed
unpicklable, and all 4 products still dock correctly across 2 workers.

When no evaluator_config is available (a manually constructed sampler), the
old ship-the-callable path is kept for picklable evaluators, and an
unpicklable one now fails with an actionable message instead of a raw
TypeError.

Bug 3 -- segfault on import.
evaluators.py imported OpenEye at module level, initialising the global
libexpat parser. tqdm pulls in prompt_toolkit, whose formatter calls
xml.dom.minidom.parseString() at module level and re-enters libexpat via
pyexpat, conflicting with OpenEye's initialisation and killing the
interpreter (exit 139) during import TACTICS. OpenEye is now imported lazily
via _ensure_openeye() on first construction of an OpenEye evaluator.

Also fixes joblib, which was imported inside the OpenEye try/except and so
was undefined for MLClassifierEvaluator whenever OpenEye was absent.

Adds 14 tests covering all three bugs, using an unpicklable stand-in so the
SWIG path is exercised without an OpenEye licence. Bumps to 1.2.0 and adds a
CHANGELOG.

Reported-by: Donald van Pinxteren
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Documentation and notebooks that existed only in the working tree are now
tracked.

Added:
- docs/handoffs/ -- session archives for 2026-03 through 2026-05 plus two
  research handoff documents, referenced from research/STATUS.md
- research/blueprints/seh_caix_smarts.md -- recovered SMARTS recipes
- research/STATUS.md -- current research state

Notebook reorganization: the single manuscript_plots.py was split by scoring
type and analysis, so it and two superseded comparison notebooks are removed
in favour of manuscript_plots_docking.py, manuscript_plots_ROCS.py,
manuscript_sar_plots.py, diagnostic_benchmark_plots.py and
interactive_sar_explorer.py.

Note: CLAUDE.md is gitignored (.gitignore:163) and stays local, so its
code-map and parallel-evaluation updates are not part of this commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The README had grown to 410 lines with duplicated structure -- two "Quick
Start" sections, two "Installation" sections, and both a "Package Structure"
and a "Repository Structure" tree -- so a newcomer could not quickly tell what
TACTICS does or how to start.

Restructured to a linear path: what it is -> whether it fits your problem ->
install -> a quickstart that runs -> parallelism for slow evaluators -> what
you can plug in -> where to go deeper. Advanced configuration is preserved in
collapsible sections rather than dropped, and the detailed API reference is
left to the Sphinx docs instead of duplicated here. 410 -> 229 lines.

Also corrects content that was wrong, not merely verbose:

- The parallel-processing example called get_preset("parallel_batch", ...),
  a preset that does not exist and would raise. It now shows the real
  config.processes field added in 1.2.0 (and that example could not have
  worked before regardless, because from_config ignored processes).
- The tutorial table listed two notebooks that have been removed, and "Key
  Features" claimed 5 presets when there are 4.
- Documents that Lookup/DB evaluators leave SMILES as "FAIL" because molecule
  generation is skipped, and that macOS/Windows need an
  if __name__ == "__main__" guard under the spawn start method.

Every API name in the README was checked against the package, and the
quickstart was executed end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aakankschit
aakankschit merged commit 1ec2d9a into main Jul 18, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant