From 77044d1406bb0a6abfd5920cad2b2c6da3750ce3 Mon Sep 17 00:00:00 2001 From: Anton Morgunov Date: Thu, 12 Mar 2026 18:49:51 -0400 Subject: [PATCH 1/2] perf: add fast-path substring guards and fix short-circuit ordering in all parser matches() methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'if KEYWORD not in line: return False' fast-path guards to 22 parsers (15 Q-Chem + 7 ORCA), reducing matches() cost from O(regex) to O(substring) on non-matching lines for the common case - Fix reversed short-circuit ordering in 5 parsers where regex ran before the state flag check: GeometryParser (qchem), MullikenParser, HirshfeldParser, Cm5Parser, MultipoleParser — state flag (O(1)) now always precedes regex - Add combined state guard to ORCA ChargesParser (no guard existed): stops after both Mulliken and Loewdin are parsed; also adds MULLIKEN/LOEWDIN fast-path keyword check - Q-Chem TerminationParser intentionally left without a fast-path guard: its ERROR_TERM_PAT is a broad 4-keyword IGNORECASE catch-all; the existing termination_status guard already short-circuits once status is decided --- src/calcflow/io/orca/blocks/charges.py | 6 +++++- src/calcflow/io/orca/blocks/dispersion.py | 2 ++ src/calcflow/io/orca/blocks/finalization.py | 4 ++++ src/calcflow/io/orca/blocks/geometry.py | 2 ++ src/calcflow/io/orca/blocks/orbitals.py | 2 ++ src/calcflow/io/orca/blocks/timing.py | 2 ++ src/calcflow/io/qchem/blocks/adc/excited_states.py | 2 ++ src/calcflow/io/qchem/blocks/adc/ground_state.py | 2 ++ src/calcflow/io/qchem/blocks/charges.py | 4 +++- src/calcflow/io/qchem/blocks/cm5.py | 4 +++- src/calcflow/io/qchem/blocks/geometry.py | 6 ++++-- src/calcflow/io/qchem/blocks/hirshfeld.py | 4 +++- src/calcflow/io/qchem/blocks/metadata.py | 2 ++ src/calcflow/io/qchem/blocks/multipole.py | 4 +++- src/calcflow/io/qchem/blocks/orbitals.py | 2 ++ src/calcflow/io/qchem/blocks/scf.py | 2 ++ src/calcflow/io/qchem/blocks/tddft/excitations.py | 2 ++ src/calcflow/io/qchem/blocks/tddft/gs_ref.py | 2 ++ src/calcflow/io/qchem/blocks/tddft/nto.py | 2 ++ src/calcflow/io/qchem/blocks/tddft/trans_dm.py | 2 ++ src/calcflow/io/qchem/blocks/tddft/unrel_dm.py | 2 ++ src/calcflow/io/qchem/blocks/timing.py | 2 ++ 22 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/calcflow/io/orca/blocks/charges.py b/src/calcflow/io/orca/blocks/charges.py index 7ad0f94..0271357 100644 --- a/src/calcflow/io/orca/blocks/charges.py +++ b/src/calcflow/io/orca/blocks/charges.py @@ -40,8 +40,12 @@ def matches(self, line: str, state: ParseState) -> bool: Check if this line marks the beginning of an atomic charges block. Returns True for either MULLIKEN or LOEWDIN atomic charges headers. - Note: No state flag check - allows parser to run multiple times (once per method). + Allows the parser to run twice (once per method); stops after both are parsed. """ + if state.parsed_mulliken and state.parsed_loewdin: + return False + if "MULLIKEN" not in line and "LOEWDIN" not in line: + return False return bool(MULLIKEN_START_PAT.search(line)) or bool(LOEWDIN_START_PAT.search(line)) def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: diff --git a/src/calcflow/io/orca/blocks/dispersion.py b/src/calcflow/io/orca/blocks/dispersion.py index e250123..bd802be 100644 --- a/src/calcflow/io/orca/blocks/dispersion.py +++ b/src/calcflow/io/orca/blocks/dispersion.py @@ -51,6 +51,8 @@ def matches(self, line: str, state: ParseState) -> bool: """ Check if this line marks the start of a dispersion block. """ + if "DISPERSION CORRECTION" not in line: + return False if state.parsed_dispersion: return False return bool(START_PAT.search(line)) diff --git a/src/calcflow/io/orca/blocks/finalization.py b/src/calcflow/io/orca/blocks/finalization.py index 98f36a5..579bba9 100644 --- a/src/calcflow/io/orca/blocks/finalization.py +++ b/src/calcflow/io/orca/blocks/finalization.py @@ -9,6 +9,8 @@ class FinalEnergyParser: def matches(self, line: str, state: ParseState) -> bool: + if "FINAL SINGLE POINT ENERGY" not in line: + return False return state.final_energy is None and bool(FINAL_ENERGY_PAT.search(line)) def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: @@ -19,6 +21,8 @@ def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) class TerminationParser: def matches(self, line: str, state: ParseState) -> bool: + if "ORCA TERMINATED" not in line: + return False return state.termination_status == "UNKNOWN" and bool(NORMAL_TERM_PAT.search(line)) def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: diff --git a/src/calcflow/io/orca/blocks/geometry.py b/src/calcflow/io/orca/blocks/geometry.py index 8b50177..5fe6eee 100644 --- a/src/calcflow/io/orca/blocks/geometry.py +++ b/src/calcflow/io/orca/blocks/geometry.py @@ -13,6 +13,8 @@ class GeometryParser: def matches(self, line: str, state: ParseState) -> bool: + if "CARTESIAN COORDINATES" not in line: + return False return not state.parsed_geometry and bool(GEOMETRY_START_PAT.search(line)) def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: diff --git a/src/calcflow/io/orca/blocks/orbitals.py b/src/calcflow/io/orca/blocks/orbitals.py index 4500251..159ec25 100644 --- a/src/calcflow/io/orca/blocks/orbitals.py +++ b/src/calcflow/io/orca/blocks/orbitals.py @@ -50,6 +50,8 @@ def matches(self, line: str, state: ParseState) -> bool: Returns: True if this is the start of an orbital block and hasn't been parsed yet. """ + if "ORBITAL ENERGIES" not in line: + return False if state.parsed_orbitals: return False return bool(ORBITAL_ENERGIES_START_PAT.search(line)) diff --git a/src/calcflow/io/orca/blocks/timing.py b/src/calcflow/io/orca/blocks/timing.py index 3976add..195aff9 100644 --- a/src/calcflow/io/orca/blocks/timing.py +++ b/src/calcflow/io/orca/blocks/timing.py @@ -35,6 +35,8 @@ def __init__(self): def matches(self, line: str, state: ParseState) -> bool: """Matches on total wall time line or module timing lines.""" + if "..." not in line and "TOTAL RUN TIME" not in line: + return False if state.parsed_timing: return False return bool(TOTAL_TIME_PAT.search(line)) or bool(MODULE_TIME_PAT.search(line)) diff --git a/src/calcflow/io/qchem/blocks/adc/excited_states.py b/src/calcflow/io/qchem/blocks/adc/excited_states.py index 4955fc6..54edf63 100644 --- a/src/calcflow/io/qchem/blocks/adc/excited_states.py +++ b/src/calcflow/io/qchem/blocks/adc/excited_states.py @@ -56,6 +56,8 @@ class AdcExcitedStatesParser(BlockParser): START_PAT: ClassVar[re.Pattern] = re.compile(r"Excited State Summary") def matches(self, line: str, state: ParseState) -> bool: + if "Excited State Summary" not in line: + return False if state.parsed_adc_excited: return False return bool(self.START_PAT.search(line)) diff --git a/src/calcflow/io/qchem/blocks/adc/ground_state.py b/src/calcflow/io/qchem/blocks/adc/ground_state.py index 861f40d..1a6fa3c 100644 --- a/src/calcflow/io/qchem/blocks/adc/ground_state.py +++ b/src/calcflow/io/qchem/blocks/adc/ground_state.py @@ -41,6 +41,8 @@ class AdcGroundStateParser(BlockParser): DAVIDSON_PAT: ClassVar[re.Pattern] = re.compile(r"Starting Davidson for excited states") def matches(self, line: str, state: ParseState) -> bool: + if "A D C" not in line: + return False if state.parsed_adc_gs: return False return bool(self.BANNER_PAT.search(line)) diff --git a/src/calcflow/io/qchem/blocks/charges.py b/src/calcflow/io/qchem/blocks/charges.py index 320f7ec..4947e24 100644 --- a/src/calcflow/io/qchem/blocks/charges.py +++ b/src/calcflow/io/qchem/blocks/charges.py @@ -40,7 +40,9 @@ def matches(self, line: str, state: ParseState) -> bool: Returns True only if we haven't already parsed Mulliken charges and the line contains the Mulliken charges header. """ - return bool(MULLIKEN_START_PAT.search(line)) and not state.parsed_mulliken + if "Mulliken" not in line: + return False + return not state.parsed_mulliken and bool(MULLIKEN_START_PAT.search(line)) def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: """ diff --git a/src/calcflow/io/qchem/blocks/cm5.py b/src/calcflow/io/qchem/blocks/cm5.py index 4d94187..447a535 100644 --- a/src/calcflow/io/qchem/blocks/cm5.py +++ b/src/calcflow/io/qchem/blocks/cm5.py @@ -32,7 +32,9 @@ class Cm5Parser: """ def matches(self, line: str, state: ParseState) -> bool: - return bool(CM5_START_PAT.search(line)) and not state.parsed_cm5 + if "Charge Model 5" not in line: + return False + return not state.parsed_cm5 and bool(CM5_START_PAT.search(line)) def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: logger.debug("Parsing QChem CM5 charges block.") diff --git a/src/calcflow/io/qchem/blocks/geometry.py b/src/calcflow/io/qchem/blocks/geometry.py index deb55bb..39f8274 100644 --- a/src/calcflow/io/qchem/blocks/geometry.py +++ b/src/calcflow/io/qchem/blocks/geometry.py @@ -34,12 +34,14 @@ class GeometryParser: def matches(self, line: str, state: ParseState) -> bool: """Checks if the line starts either the input or standard geometry block.""" - if INPUT_GEOM_START_PAT.search(line) and not state.parsed_geometry: + if "$molecule" not in line.lower() and "Standard Nuclear Orientation" not in line: + return False + if not state.parsed_geometry and INPUT_GEOM_START_PAT.search(line): # We use a single 'parsed_geometry' flag for the input geometry return True # The final geometry is part of the main calculation, not a separate block to be parsed once # For now, let's assume we only parse the final geometry once. - return (STANDARD_GEOM_START_PAT.search(line) is not None) and state.final_geometry is None + return state.final_geometry is None and STANDARD_GEOM_START_PAT.search(line) is not None def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: """Delegates to the appropriate parsing method based on the start line.""" diff --git a/src/calcflow/io/qchem/blocks/hirshfeld.py b/src/calcflow/io/qchem/blocks/hirshfeld.py index 89a5219..56fcbb0 100644 --- a/src/calcflow/io/qchem/blocks/hirshfeld.py +++ b/src/calcflow/io/qchem/blocks/hirshfeld.py @@ -34,7 +34,9 @@ class HirshfeldParser: """ def matches(self, line: str, state: ParseState) -> bool: - return bool(HIRSHFELD_START_PAT.search(line)) and not state.parsed_hirshfeld + if "Hirshfeld" not in line: + return False + return not state.parsed_hirshfeld and bool(HIRSHFELD_START_PAT.search(line)) def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: logger.debug("Parsing QChem Hirshfeld charges block.") diff --git a/src/calcflow/io/qchem/blocks/metadata.py b/src/calcflow/io/qchem/blocks/metadata.py index 4109f99..54b4994 100644 --- a/src/calcflow/io/qchem/blocks/metadata.py +++ b/src/calcflow/io/qchem/blocks/metadata.py @@ -23,6 +23,8 @@ def matches(self, line: str, state: ParseState) -> bool: """ if state.metadata.software_version is not None: return False + if "Q-Chem" not in line: + return False return QCHEM_VERSION_PAT.search(line) is not None def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: diff --git a/src/calcflow/io/qchem/blocks/multipole.py b/src/calcflow/io/qchem/blocks/multipole.py index f623e05..b09040b 100644 --- a/src/calcflow/io/qchem/blocks/multipole.py +++ b/src/calcflow/io/qchem/blocks/multipole.py @@ -60,7 +60,9 @@ class MultipoleParser: def matches(self, line: str, state: ParseState) -> bool: """Check if this line marks the beginning of the multipole moments block.""" - return bool(MULTIPOLE_START_PAT.search(line)) and not state.parsed_multipole + if "Multipole Moments" not in line: + return False + return not state.parsed_multipole and bool(MULTIPOLE_START_PAT.search(line)) def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: """ diff --git a/src/calcflow/io/qchem/blocks/orbitals.py b/src/calcflow/io/qchem/blocks/orbitals.py index 7513703..b3aa9c1 100644 --- a/src/calcflow/io/qchem/blocks/orbitals.py +++ b/src/calcflow/io/qchem/blocks/orbitals.py @@ -39,6 +39,8 @@ class OrbitalsParser: def matches(self, line: str, state: ParseState) -> bool: """Check if this line marks the start of the orbital energies block.""" + if "Orbital Energies" not in line: + return False if state.parsed_orbitals: return False return bool(ORBITAL_BLOCK_START_PAT.search(line)) diff --git a/src/calcflow/io/qchem/blocks/scf.py b/src/calcflow/io/qchem/blocks/scf.py index e985b77..6debb4d 100644 --- a/src/calcflow/io/qchem/blocks/scf.py +++ b/src/calcflow/io/qchem/blocks/scf.py @@ -33,6 +33,8 @@ class ScfParser: """Parses the main SCF block, including iterations, final energies, and SMD summaries.""" def matches(self, line: str, state: ParseState) -> bool: + if "General SCF" not in line: + return False return not state.parsed_scf and bool(SCF_START_PAT.search(line)) def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: diff --git a/src/calcflow/io/qchem/blocks/tddft/excitations.py b/src/calcflow/io/qchem/blocks/tddft/excitations.py index 1dc83b3..6383cdf 100644 --- a/src/calcflow/io/qchem/blocks/tddft/excitations.py +++ b/src/calcflow/io/qchem/blocks/tddft/excitations.py @@ -56,6 +56,8 @@ def matches(self, line: str, state: ParseState) -> bool: Matches either TDA or full TDDFT block start, and only if not already parsed. """ + if "Excitation" not in line: + return False if state.parsed_tddft_tda and state.parsed_tddft_full: return False diff --git a/src/calcflow/io/qchem/blocks/tddft/gs_ref.py b/src/calcflow/io/qchem/blocks/tddft/gs_ref.py index e0b9ac4..4cea89e 100644 --- a/src/calcflow/io/qchem/blocks/tddft/gs_ref.py +++ b/src/calcflow/io/qchem/blocks/tddft/gs_ref.py @@ -65,6 +65,8 @@ def matches(self, line: str, state: ParseState) -> bool: Matches "Ground State (Reference) :" and only if not already parsed. """ + if "Ground State" not in line: + return False if state.parsed_tddft_gs_ref: return False diff --git a/src/calcflow/io/qchem/blocks/tddft/nto.py b/src/calcflow/io/qchem/blocks/tddft/nto.py index 0ecef71..90c6978 100644 --- a/src/calcflow/io/qchem/blocks/tddft/nto.py +++ b/src/calcflow/io/qchem/blocks/tddft/nto.py @@ -54,6 +54,8 @@ def matches(self, line: str, state: ParseState) -> bool: Returns False if already parsed. """ + if "NTO" not in line: + return False if state.parsed_nto: return False diff --git a/src/calcflow/io/qchem/blocks/tddft/trans_dm.py b/src/calcflow/io/qchem/blocks/tddft/trans_dm.py index 8ce2643..f6619f7 100644 --- a/src/calcflow/io/qchem/blocks/tddft/trans_dm.py +++ b/src/calcflow/io/qchem/blocks/tddft/trans_dm.py @@ -46,6 +46,8 @@ class TransitionDensityMatrixParser(BlockParser): END_PAT: ClassVar[re.Pattern] = re.compile(r"^\s*-{5,}\s*$") def matches(self, line: str, state: ParseState) -> bool: + if "Transition Density" not in line: + return False if state.parsed_tddft_trans_dm: return False return bool(self.START_PAT.search(line)) diff --git a/src/calcflow/io/qchem/blocks/tddft/unrel_dm.py b/src/calcflow/io/qchem/blocks/tddft/unrel_dm.py index 7f9ffac..2f23472 100644 --- a/src/calcflow/io/qchem/blocks/tddft/unrel_dm.py +++ b/src/calcflow/io/qchem/blocks/tddft/unrel_dm.py @@ -46,6 +46,8 @@ class UnrelaxedDensityMatrixParser(BlockParser): END_PAT: ClassVar[re.Pattern] = re.compile(r"^\s*-{5,}\s*$") def matches(self, line: str, state: ParseState) -> bool: + if "Unrelaxed" not in line: + return False if state.parsed_tddft_unrelaxed_dm: return False return bool(self.START_PAT.search(line)) diff --git a/src/calcflow/io/qchem/blocks/timing.py b/src/calcflow/io/qchem/blocks/timing.py index 8bdc185..dc941a5 100644 --- a/src/calcflow/io/qchem/blocks/timing.py +++ b/src/calcflow/io/qchem/blocks/timing.py @@ -23,6 +23,8 @@ class TimingParser: def matches(self, line: str, state: ParseState) -> bool: """Matches on the total job time line.""" + if "Total job time" not in line: + return False if state.parsed_timing: return False return bool(TOTAL_TIME_PAT.search(line)) From 48e69b0c063e5ce4a91c260e0b6c502d5bd92019 Mon Sep 17 00:00:00 2001 From: Anton Morgunov Date: Thu, 12 Mar 2026 18:58:21 -0400 Subject: [PATCH 2/2] perf: address review comments on ChargesParser, GeometryParser, MetadataParser - ORCA ChargesParser: check each flag individually before its regex so Loewdin regex is skipped after Mulliken is parsed (and vice versa), rather than waiting until both flags are set - Q-Chem GeometryParser, MetadataParser: replace 'is not None' with bool() for consistency with all other parsers in the codebase --- src/calcflow/io/orca/blocks/charges.py | 4 +++- src/calcflow/io/qchem/blocks/geometry.py | 2 +- src/calcflow/io/qchem/blocks/metadata.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/calcflow/io/orca/blocks/charges.py b/src/calcflow/io/orca/blocks/charges.py index 0271357..d6a284d 100644 --- a/src/calcflow/io/orca/blocks/charges.py +++ b/src/calcflow/io/orca/blocks/charges.py @@ -46,7 +46,9 @@ def matches(self, line: str, state: ParseState) -> bool: return False if "MULLIKEN" not in line and "LOEWDIN" not in line: return False - return bool(MULLIKEN_START_PAT.search(line)) or bool(LOEWDIN_START_PAT.search(line)) + if not state.parsed_mulliken and MULLIKEN_START_PAT.search(line): + return True + return not state.parsed_loewdin and bool(LOEWDIN_START_PAT.search(line)) def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: """ diff --git a/src/calcflow/io/qchem/blocks/geometry.py b/src/calcflow/io/qchem/blocks/geometry.py index 39f8274..0a59e2d 100644 --- a/src/calcflow/io/qchem/blocks/geometry.py +++ b/src/calcflow/io/qchem/blocks/geometry.py @@ -41,7 +41,7 @@ def matches(self, line: str, state: ParseState) -> bool: return True # The final geometry is part of the main calculation, not a separate block to be parsed once # For now, let's assume we only parse the final geometry once. - return state.final_geometry is None and STANDARD_GEOM_START_PAT.search(line) is not None + return state.final_geometry is None and bool(STANDARD_GEOM_START_PAT.search(line)) def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: """Delegates to the appropriate parsing method based on the start line.""" diff --git a/src/calcflow/io/qchem/blocks/metadata.py b/src/calcflow/io/qchem/blocks/metadata.py index 54b4994..ebbc734 100644 --- a/src/calcflow/io/qchem/blocks/metadata.py +++ b/src/calcflow/io/qchem/blocks/metadata.py @@ -25,7 +25,7 @@ def matches(self, line: str, state: ParseState) -> bool: return False if "Q-Chem" not in line: return False - return QCHEM_VERSION_PAT.search(line) is not None + return bool(QCHEM_VERSION_PAT.search(line)) def parse(self, iterator: PeekableIterator, start_line: str, state: ParseState) -> None: """