Skip to content

refactor!: one conformer filter, gated writers, and drop reasons that say why - #142

Merged
isayev merged 3 commits into
mainfrom
debt/wave5-filter-unification-and-parsers
Aug 4, 2026
Merged

refactor!: one conformer filter, gated writers, and drop reasons that say why#142
isayev merged 3 commits into
mainfrom
debt/wave5-filter-unification-and-parsers

Conversation

@isayev

@isayev isayev commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Wave 5: the behavior changes the file splits made safe to do. The suite now has zero
xfailed tests
— the E_tot divergence pinned four waves ago is resolved.

One conformer filter

Auto3D carried two duplicate filters with the same criterion but different behavior on
malformed input
: the energy-clustered one raised KeyError on a record with no E_tot, the
legacy all-pairs one tolerated it. That was pinned as xfail(strict=True) in wave 1
specifically so it could not be forgotten or silently "fixed". The marker is gone.

The order was load-bearing: harden the survivor, pin the oracle with recorded values while
both filters still existed, then delete the legacy one.
Deleting first would have destroyed
the oracle its own tests compared against.

What the tolerant path does, and why the naive version would be a bug

A record missing E_tot gets sort key (True, 0.0) against (False, energy), so it sorts
last and the 0.0 never participates in a comparison. That is deliberate: E_tot is a
user-supplied property, so if the placeholder took part in the comparison, a genuine
positive energy would let a missing-energy record sort first and become top_k's
reference conformer.

Such records are not dropped. They reach the within-cluster filter, whose energy guard already
treats None as "cannot apply — RMSD alone decides". Since no energy gap can prove such a
record is not a duplicate, the presence of one switches clustering off and compares all
pairs — which is exactly what the deleted filter did, so its verdicts are preserved.

The inverse assertions

A dedup guard that declared everything distinct would satisfy every "these must not merge"
test while silently disabling deduplication entirely. So: two energy-less duplicates still
collapse to one, and an energy-less record still merges with an energy-bearing duplicate.

Overwrite gates on the public writers

smiles2smi, encode_ids, decode_ids and select_tautomers take a keyword-only
overwrite — permissive where the caller named the file, restrictive where Auto3D invented
it. The concrete hazard: select_tautomers("/data/results.sdf", k=1) truncated
/data/results_top_tautomers.sdf with no gate at all.
It now resolves and gates the path
before reading its input.

Nine of seventeen new tests failed first. Thirteen mutations verified, including the
over-fire direction — a gate that refuses correctly but blocks legitimate writes is not an
improvement. The free paths are confirmed by coverage rather than assumed: each gate's
line is executed by the real orchestrator finalize path in test_workflow.py and
test_pipeline_e2e.py.

Drop reasons that say why

ranking.py logged "No structure converged" whenever nothing survived — including when
every conformer was dropped for stereochemistry.
Filters now return a frozen
FilterResult(kept, dropped) that rejects unknown reason keys at construction, and that
literal survives only when unconverged is the sole reason.

The final warning previously hard-coded "the rest were dropped by the connectivity,
stereochemistry or energy-window filters"
— a hand-maintained disjunction listing reasons it
had no way to know applied. It now reports the actual tally.

Two of the new tests for this were themselves vacuous on first writing (one built the merged
result it then asserted on; another used records run() drops before the filter ever sees
them). Both were rewritten until they discriminated.

Selector dispatch

The k/window if/elif chain becomes a registry checked against config.SELECTOR_FIELDS
at import, so adding a selector to config without wiring it fails at import rather than
being silently ignored.

The mutation for this first survived — reverting to the hand-written chain is behaviorally
identical when there are only two selectors, so the new code was unproven. A test now wires a
genuine third selector (percentile) through config, registry and method and asserts run()
dispatches to it. The mutation is red.

Five .smi parsers become one

iter_smi_records is the survivor. The differences found before switching, since this cluster
has already produced two pairs of "identical-looking" functions that differed subtly:

  • check_smi_format's hand-rolled loop already matched almost exactly — same blank/comment
    skip, same tolerance for extra columns. Only an unasserted message string changed.
  • remove_enantiomers and amend_configuration had no guard at all. A blank line raised a
    bare IndexError or ValueError: not enough values to unpack, and neither skipped #
    comments. Both now raise a named InputValidationError, and blank and comment lines skip —
    previously fatal. Malformed non-blank rows still raise, so this is added robustness, not a
    silent behavior change.
  • chunk_manager.py's pandas reader stays separate for performance, with a new
    cross-agreement test against iter_smi_records so the two cannot drift. That test also pins
    the one deliberate divergence: pandas does not skip # comment lines.

Also

  • create_enantiomer's len(keys)==1 special case and its post-loop read of a loop
    variable — which worked only by a scoping accident
    — become one cursor-based pass, verified
    bit-identical across 0 to 4 stereocenters including ring and leading positions.
  • ASE/thermo.py used atoms.set_calculator/get_calculator, deprecated in ASE 3.22.1,
    in three places. Now atoms.calc. (This finding was left open by an earlier pass that
    couldn't identify it without the manifest text, and was right to leave it rather than guess.)
  • The output_guard re-export in utils/validation.py is deleted now that every call site
    names utils/output_guard directly.
  • Six docstrings claimed changes landed in 4.0.0 or 4.1.0 — versions that will not exist,
    since this ships as 3.0.0.

One process note on that last item

When checking whether the re-export was safe to delete, a single-line grep for
from Auto3D.utils.validation import.*check_output reported "safe" — but ASE/geometry.py
imported it in a multi-line parenthesized block, which that pattern cannot match. Collection
broke in two modules. Fixed by repointing the import. Worth recording because two independent
checks made the identical false-negative: a grep for an import must match the parenthesized
form, or it under-reports.

Public signature changes

  • ConformerRanker.__init__: use_optimized_filtering removed; energy_cluster_window
    and overwrite each shift down one positional slot (no positional callers in-tree). Default
    is now DEFAULT_ENERGY_CLUSTER_WINDOW.
  • Auto3D.filtering.filter_unique deleted. filter_conformers, FilterResult and
    DROP_REASONS added.
  • ConformerRanker._filter_mols returns FilterResult, not list.
  • smiles2smi(smiles, path, *, overwrite=True), encode_ids(path, out_dir=None, *, overwrite=False),
    decode_ids(path, mapping, *, overwrite=False),
    select_tautomers(sdf, k=None, window=None, *, overwrite=False).
  • encode_ids' refusal message now matches the shared one.
  • top_k's k == 1 fast path applies converged_or_unfiltered, so its predicates match
    filter_conformers. No pipeline effect — run() already filters convergence upstream — but
    a direct top_k(group, k=1) call with Converged=false records now returns [].

Verification

1622 passed, 9 skipped, 0 xfailed, ruff clean. Every fix mutation-verified by reverting it
and confirming the named test goes red.

isayev added 3 commits August 4, 2026 10:29
The assertion was strengthened from "any valid molecule produced a result" to
"both did", which is the right assertion -- a batch that aborts right after the
corrupt record satisfies the weaker one. But it was derived from reading the
source, and the test needs a real NNP, so it could not be run where it was
written. CI's slow tier then failed with {'ethanol'} != {'ethanol', 'propanol'}.

The code was never the problem. The input was. RDKit's SDMolSupplier, given
"this is not a molecule\n$$$$\n" between two valid records, logs "moving to the
beginning of the next molecule" and consumes the following record while
resynchronizing: the supplier yields [ethanol, None] and propanol is never
handed over at all. No implementation of calc_thermo could have produced a
propanol result from that file.

The corrupt block is now a well-delimited record -- header, counts line
promising two atoms, garbage where the coordinates belong, M END, $$$$ -- so
the supplier yields exactly [ethanol, None, propanol]. Measured against this
repo's RDKit 2025.09.6, along with a check that iter_thermo_records skips the
None and keeps both valid records, so the guarded path is confirmed to fire
without loading a model.

The docstring now records both supplier behaviors the input depends on, since
each one silently makes the test vacuous in a different way.
… say why

## One filter

Auto3D carried two duplicate filters with the same criterion but different
behavior on malformed input: the energy-clustered one raised KeyError on a
record with no E_tot, the legacy all-pairs one tolerated it. That divergence
was pinned as a strict xfail three waves ago; it is now fixed and the marker
is gone. The suite has no xfailed tests.

The order was load-bearing: harden the survivor, pin the oracle with recorded
values while both filters still existed, then delete the legacy one. Deleting
first would have destroyed the oracle its own tests compared against.

A record missing E_tot sorts last, via a (True, 0.0) key against
(False, energy), and the 0.0 never participates in a comparison. That is
deliberate: E_tot is user-supplied, so a genuine positive energy would
otherwise let the placeholder sort first and become top_k's reference
conformer. Such records are not dropped -- they reach the within-cluster
filter, whose energy guard already treats None as "cannot apply, RMSD alone
decides". Since no energy gap can prove such a record is not a duplicate, one
of them switches clustering off and compares all pairs, which is what the
deleted filter did, so its verdicts are preserved.

Inverse assertions included, because a dedup guard that declared everything
distinct would satisfy every "these must not merge" test while silently
disabling deduplication: two energy-less duplicates still collapse, and an
energy-less record still merges with an energy-bearing duplicate.

## Gated writers

smiles2smi, encode_ids, decode_ids and select_tautomers take a keyword-only
overwrite. Permissive where the caller named the file, restrictive where
Auto3D invented it: select_tautomers("/data/results.sdf", k=1) truncated
/data/results_top_tautomers.sdf with no gate at all. It now resolves and
gates the path before reading its input.

Both directions are tested. A gate that refuses correctly but also blocks
legitimate writes is not an improvement, so the free paths are asserted too,
and confirmed by coverage rather than assumed: each gate's line is executed
by the real orchestrator finalize path.

## Drop reasons

ranking.py logged "No structure converged" whenever nothing survived,
including when every conformer was dropped for stereochemistry. Filters now
return a frozen FilterResult(kept, dropped) that rejects unknown reason keys,
and that literal survives only when unconverged is the sole reason.

The final warning previously hard-coded "the rest were dropped by the
connectivity, stereochemistry or energy-window filters" -- a hand-maintained
disjunction listing reasons it could not know applied. It now reports the
actual tally.

## Selector dispatch

The k/window if-elif chain becomes a registry checked against config's
SELECTOR_FIELDS at import, so adding a selector to config without wiring it
fails at import instead of being silently ignored.

Reverting the chain first survived the mutation, since two selectors are
behaviorally identical either way. A test now wires a genuine third selector
through config, registry and method, which is what makes the mutation red.

## Also

Five .smi parsers become one. remove_enantiomers and amend_configuration had
no input guard at all: a blank line raised a bare IndexError or a tuple-unpack
ValueError, and neither skipped comments. Both now raise a named
InputValidationError, and blank and comment lines skip.

create_enantiomer's len(keys)==1 special case and its post-loop read of a
loop variable -- which worked only by a scoping accident -- become one
cursor-based pass, verified bit-identical across 0 to 4 stereocenters
including ring and leading positions.

ASE/thermo.py used atoms.set_calculator/get_calculator, deprecated in ase
3.22.1, in three places.

The output-guard re-export in utils/validation.py is gone now that every call
site names utils/output_guard directly. Six docstrings claimed changes landed
in 4.0.0 or 4.1.0, versions that will not exist.

Verified: 1622 passed, 9 skipped, 0 xfailed, ruff clean.
@isayev
isayev merged commit 1d07827 into main Aug 4, 2026
8 checks passed
@isayev
isayev deleted the debt/wave5-filter-unification-and-parsers branch August 4, 2026 17:06
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