refactor!: one conformer filter, gated writers, and drop reasons that say why - #142
Merged
Merged
Conversation
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.
…fication-and-parsers
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wave 5: the behavior changes the file splits made safe to do. The suite now has zero
xfailed tests — the
E_totdivergence 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
KeyErroron a record with noE_tot, thelegacy all-pairs one tolerated it. That was pinned as
xfail(strict=True)in wave 1specifically 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_totgets sort key(True, 0.0)against(False, energy), so it sortslast and the
0.0never participates in a comparison. That is deliberate:E_totis auser-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'sreference conformer.
Such records are not dropped. They reach the within-cluster filter, whose energy guard already
treats
Noneas "cannot apply — RMSD alone decides". Since no energy gap can prove such arecord 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_idsandselect_tautomerstake a keyword-onlyoverwrite— permissive where the caller named the file, restrictive where Auto3D inventedit. The concrete hazard:
select_tautomers("/data/results.sdf", k=1)truncated/data/results_top_tautomers.sdfwith no gate at all. It now resolves and gates the pathbefore 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.pyandtest_pipeline_e2e.py.Drop reasons that say why
ranking.pylogged "No structure converged" whenever nothing survived — including whenevery conformer was dropped for stereochemistry. Filters now return a frozen
FilterResult(kept, dropped)that rejects unknown reason keys at construction, and thatliteral survives only when
unconvergedis 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 seesthem). Both were rewritten until they discriminated.
Selector dispatch
The
k/windowif/elifchain becomes a registry checked againstconfig.SELECTOR_FIELDSat 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 assertsrun()dispatches to it. The mutation is red.
Five
.smiparsers become oneiter_smi_recordsis the survivor. The differences found before switching, since this clusterhas already produced two pairs of "identical-looking" functions that differed subtly:
check_smi_format's hand-rolled loop already matched almost exactly — same blank/commentskip, same tolerance for extra columns. Only an unasserted message string changed.
remove_enantiomersandamend_configurationhad no guard at all. A blank line raised abare
IndexErrororValueError: 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 newcross-agreement test against
iter_smi_recordsso the two cannot drift. That test also pinsthe one deliberate divergence: pandas does not skip
#comment lines.Also
create_enantiomer'slen(keys)==1special case and its post-loop read of a loopvariable — 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.pyusedatoms.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 thatcouldn't identify it without the manifest text, and was right to leave it rather than guess.)
output_guardre-export inutils/validation.pyis deleted now that every call sitenames
utils/output_guarddirectly.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_outputreported "safe" — butASE/geometry.pyimported 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_filteringremoved;energy_cluster_windowand
overwriteeach shift down one positional slot (no positional callers in-tree). Defaultis now
DEFAULT_ENERGY_CLUSTER_WINDOW.Auto3D.filtering.filter_uniquedeleted.filter_conformers,FilterResultandDROP_REASONSadded.ConformerRanker._filter_molsreturnsFilterResult, notlist.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'sk == 1fast path appliesconverged_or_unfiltered, so its predicates matchfilter_conformers. No pipeline effect —run()already filters convergence upstream — buta direct
top_k(group, k=1)call withConverged=falserecords 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.