|
| 1 | +""" |
| 2 | +The pre-QM "rank by distrust" selection criterion for the PES exploration loop (I-032). |
| 3 | +
|
| 4 | +Why this exists -- the criterion it replaces is structurally blind |
| 5 | +----------------------------------------------------------------- |
| 6 | +The loop's other selector ranks transition states by master-equation E0-sensitivity: perturb a |
| 7 | +saddle's ``E0``, measure the ln(k) response, queue the movers. That criterion cannot see the very |
| 8 | +channels a mechanism most needs. A transition state in a reduced network file carries no statmech |
| 9 | +``modes`` (nobody has run the QM yet -- that IS the queue), so ``Reaction.can_tst()`` is False and |
| 10 | +every k(E) comes from the inverse Laplace transform, where the saddle's ``E0`` enters only as a |
| 11 | +discrete threshold gate (``rmgpy/pdep/reaction.pyx``). For a bimolecular association/dissociation |
| 12 | +channel that gate never binds -- the rate is fixed by the bimolecular asymptote, the |
| 13 | +detailed-balance clamp, and high-pressure-limit renormalization -- so perturbing the saddle's |
| 14 | +``E0`` is an exact no-op and the sensitivity is a STRUCTURAL ZERO (``0.0`` or ~1e-18), not a |
| 15 | +measurement of low leverage. The blindness is worst exactly for the low bimolecular entrance |
| 16 | +channels that carry the reactive flux. You cannot use a quantity's sensitivity to decide whether to |
| 17 | +compute that quantity when not having computed it is what makes the sensitivity meaningless. |
| 18 | +
|
| 19 | +What distrust ranks by instead |
| 20 | +------------------------------ |
| 21 | +Every input here is present in the reduced network file BEFORE any QM runs, and none of it is a |
| 22 | +sensitivity value -- so no structural zero can suppress a candidate, and the "invert the |
| 23 | +sensitivity" trap (which inherits the same zero) is avoided by construction. A candidate is ranked |
| 24 | +by how little we trust the barrier we currently hold: |
| 25 | +
|
| 26 | +* **Flat energy window (the gate).** A saddle far above the lowest one on the surface carries |
| 27 | + negligible flux at the temperatures of interest, so it is declined regardless of provenance. Only |
| 28 | + saddles within ``energy_window_kj`` of the lowest surface saddle stay eligible. This is what makes |
| 29 | + the criterion a screen rather than a firehose: it declines the high isomerization saddle on the |
| 30 | + r002 CHO2 surface while keeping the low ``[H] + O=C=O`` entrance channels. |
| 31 | +* **Provenance (dominant rank term).** A family/rate-rule estimate is distrusted; a library or |
| 32 | + already-computed value is trusted and not re-queued. Read from the kinetics comment. |
| 33 | +* **RMG's own variance (secondary rank term).** When RMG produced the rate by decision-tree node |
| 34 | + averaging it attaches ``RateUncertainty(var=...)`` -- its own voice saying "I guessed this one". |
| 35 | + Larger ``var`` -> more distrust. Optional: absent on a plain estimate, in which case it simply |
| 36 | + contributes nothing. |
| 37 | +* **Barrier height (tiebreak).** Among equally-provenanced, equal-variance saddles, the lower |
| 38 | + barrier carries more flux and so is worth getting right first. |
| 39 | +
|
| 40 | +Nothing here is computed, estimated, or invented: the energies and comments are read verbatim from |
| 41 | +the network file, and a missing datum leaves its term neutral rather than being guessed at. |
| 42 | +""" |
| 43 | + |
| 44 | +from dataclasses import dataclass, replace |
| 45 | + |
| 46 | +from t3.pdep.parser import PDepNetworkE0, PDepPathReaction |
| 47 | +from t3.pdep.pes_rounds import (CandidateSplit, QMCandidate, SkippedChannel, |
| 48 | + SKIP_NO_EVIDENCE, SKIP_OUTSIDE_WINDOW, SKIP_TRUSTED_PROVENANCE, |
| 49 | + SKIP_UNMEASURABLE) |
| 50 | + |
| 51 | +# The flat energy window half-width, in kJ/mol: a saddle more than this above the lowest saddle on |
| 52 | +# the surface is declined. ~30 kJ/mol is the figure discussed for the r002 CHO2 network -- wide |
| 53 | +# enough to hold both low entrance channels, tight enough to decline the ~114 kJ/mol-higher |
| 54 | +# isomerization saddle. |
| 55 | +DEFAULT_ENERGY_WINDOW_KJ = 30.0 |
| 56 | + |
| 57 | +# Provenance classes of the current barrier, read from the kinetics comment. |
| 58 | +PROVENANCE_LIBRARY = 'library' |
| 59 | +PROVENANCE_ESTIMATE = 'estimate' |
| 60 | + |
| 61 | +# Case-insensitive substrings that mark a barrier as a trusted library/QM value rather than a |
| 62 | +# family/rate-rule estimate. RMG writes "Reaction library: '<name>'" for a value taken from a |
| 63 | +# kinetics library (which is where a completed QM job's fitted rate is stored, e.g. 'kineticsjobs'). |
| 64 | +_LIBRARY_MARKERS = ('reaction library', 'library:') |
| 65 | + |
| 66 | +# The size of the provenance rank gap. It must exceed any realistic sum of the variance and barrier |
| 67 | +# terms so that an estimate always outranks a library value: RMG's ln-space ``var`` is O(10) and the |
| 68 | +# barrier tiebreak is in [0, 1), so 1e6 is never crossed. |
| 69 | +_PROVENANCE_GAP = 1.0e6 |
| 70 | + |
| 71 | + |
| 72 | +@dataclass(frozen=True) |
| 73 | +class DistrustParams: |
| 74 | + """ |
| 75 | + The knobs of the distrust criterion. |
| 76 | +
|
| 77 | + Attributes: |
| 78 | + energy_window_kj (float): The flat energy window half-width (kJ/mol). A candidate saddle |
| 79 | + more than this above the lowest saddle on the surface is declined. |
| 80 | + """ |
| 81 | + energy_window_kj: float = DEFAULT_ENERGY_WINDOW_KJ |
| 82 | + |
| 83 | + |
| 84 | +@dataclass(frozen=True) |
| 85 | +class DistrustScore: |
| 86 | + """ |
| 87 | + The transparent components behind one candidate's distrust ranking. |
| 88 | +
|
| 89 | + Every field is derived from the pre-QM network file, so the ranking is auditable term by term |
| 90 | + rather than collapsed into an opaque scalar. |
| 91 | +
|
| 92 | + Attributes: |
| 93 | + ts_label (str): The network-local transition state label. |
| 94 | + barrier_kj (float | None): The saddle's height above its lower-energy adjacent configuration |
| 95 | + (kJ/mol), or ``None`` if a side's species energy was missing from the file. |
| 96 | + height_above_lowest_saddle_kj (float | None): The saddle's E0 minus the lowest saddle E0 on |
| 97 | + the surface (kJ/mol), the flat-window quantity, or ``None`` if this saddle's E0 was |
| 98 | + missing. |
| 99 | + in_window (bool): Whether the saddle is within ``energy_window_kj`` of the lowest surface |
| 100 | + saddle. A saddle whose E0 could not be read is kept (``True``) rather than silently |
| 101 | + dropped -- we cannot place it, so we do not rule it out. |
| 102 | + provenance (str): ``PROVENANCE_LIBRARY`` (trusted) or ``PROVENANCE_ESTIMATE`` (distrusted). |
| 103 | + kinetics_var (float | None): RMG's ``RateUncertainty(var=...)`` for this rate, or ``None``. |
| 104 | + score (float): The distrust score; higher means less trusted and so more worth computing. |
| 105 | + """ |
| 106 | + ts_label: str |
| 107 | + barrier_kj: float | None |
| 108 | + height_above_lowest_saddle_kj: float | None |
| 109 | + in_window: bool |
| 110 | + provenance: str |
| 111 | + kinetics_var: float | None |
| 112 | + score: float |
| 113 | + |
| 114 | + |
| 115 | +def classify_provenance(kinetics_comment: str) -> str: |
| 116 | + """ |
| 117 | + Classify a barrier's provenance from its kinetics comment. |
| 118 | +
|
| 119 | + A comment naming a kinetics library (``"Reaction library: '...'"``) -- which is where a completed |
| 120 | + QM job's fitted rate is stored -- is a value we already trust and would not spend QM on again. A |
| 121 | + comment that does not is treated as a distrusted estimate; this deliberately fails toward |
| 122 | + computing, since a barrier whose provenance we cannot read is not one we should trust by default. |
| 123 | +
|
| 124 | + Args: |
| 125 | + kinetics_comment (str): The reaction's kinetics ``comment`` text (possibly ``''``). |
| 126 | +
|
| 127 | + Returns: |
| 128 | + str: ``PROVENANCE_LIBRARY`` or ``PROVENANCE_ESTIMATE``. |
| 129 | + """ |
| 130 | + lowered = (kinetics_comment or '').lower() |
| 131 | + if any(marker in lowered for marker in _LIBRARY_MARKERS): |
| 132 | + return PROVENANCE_LIBRARY |
| 133 | + return PROVENANCE_ESTIMATE |
| 134 | + |
| 135 | + |
| 136 | +def _side_energy(labels: tuple, species_e0: dict) -> float | None: |
| 137 | + """ |
| 138 | + Sum the E0 of one reaction side, or ``None`` if any species' energy is missing. |
| 139 | +
|
| 140 | + Args: |
| 141 | + labels (tuple): The species labels on one side of the reaction. |
| 142 | + species_e0 (dict): Species label -> E0 (kJ/mol), from ``PDepNetworkE0.species``. |
| 143 | +
|
| 144 | + Returns: |
| 145 | + float | None: The summed side energy (kJ/mol), or ``None`` if a label had no E0. |
| 146 | + """ |
| 147 | + if not labels: |
| 148 | + return None |
| 149 | + total = 0.0 |
| 150 | + for label in labels: |
| 151 | + if label not in species_e0: |
| 152 | + return None |
| 153 | + total += species_e0[label] |
| 154 | + return total |
| 155 | + |
| 156 | + |
| 157 | +def compute_distrust(path_reaction: PDepPathReaction, |
| 158 | + ts_label: str, |
| 159 | + network_e0: PDepNetworkE0, |
| 160 | + lowest_saddle_e0_kj: float | None, |
| 161 | + params: DistrustParams) -> DistrustScore: |
| 162 | + """ |
| 163 | + Score one candidate by distrust from the pre-QM network file. |
| 164 | +
|
| 165 | + Args: |
| 166 | + path_reaction (PDepPathReaction): The parsed path reaction (its species and kinetics comment |
| 167 | + and any ``RateUncertainty(var=...)``). |
| 168 | + ts_label (str): The candidate's network-local transition state label. |
| 169 | + network_e0 (PDepNetworkE0): The declared species and transition-state E0 values (kJ/mol). |
| 170 | + lowest_saddle_e0_kj (float | None): The lowest transition-state E0 on the surface (kJ/mol), |
| 171 | + or ``None`` if the file declared no transition-state E0 at all. |
| 172 | + params (DistrustParams): The window and any other knobs. |
| 173 | +
|
| 174 | + Returns: |
| 175 | + DistrustScore: The transparent components and the scalar score for this candidate. |
| 176 | + """ |
| 177 | + ts_e0 = network_e0.transition_states.get(ts_label) |
| 178 | + reactant_side = _side_energy(path_reaction.reactants, network_e0.species) |
| 179 | + product_side = _side_energy(path_reaction.products, network_e0.species) |
| 180 | + |
| 181 | + # Barrier over the lower-energy adjacent configuration: direction-robust and non-negative for a |
| 182 | + # real saddle. Either side may be unreadable; use whichever is present, and leave the barrier |
| 183 | + # None only when neither is. |
| 184 | + lower_side = None |
| 185 | + for side in (reactant_side, product_side): |
| 186 | + if side is not None and (lower_side is None or side < lower_side): |
| 187 | + lower_side = side |
| 188 | + barrier_kj = None |
| 189 | + if ts_e0 is not None and lower_side is not None: |
| 190 | + barrier_kj = ts_e0 - lower_side |
| 191 | + |
| 192 | + height_kj = None |
| 193 | + if ts_e0 is not None and lowest_saddle_e0_kj is not None: |
| 194 | + height_kj = ts_e0 - lowest_saddle_e0_kj |
| 195 | + # A saddle whose E0 could not be read is kept rather than dropped: we cannot place it on the |
| 196 | + # surface, so we do not rule it out of the window. |
| 197 | + in_window = height_kj is None or height_kj <= params.energy_window_kj |
| 198 | + |
| 199 | + provenance = classify_provenance(path_reaction.kinetics_comment) |
| 200 | + kinetics_var = path_reaction.kinetics_uncertainty_var |
| 201 | + |
| 202 | + provenance_term = _PROVENANCE_GAP if provenance == PROVENANCE_ESTIMATE else 0.0 |
| 203 | + variance_term = kinetics_var if kinetics_var is not None else 0.0 |
| 204 | + # Barrier tiebreak in [0, 1): monotone decreasing in the barrier, so a lower barrier scores |
| 205 | + # higher. A barrier we could not read is treated as maximally distrusted (1.0). |
| 206 | + flux_term = 1.0 if barrier_kj is None else 1.0 / (1.0 + max(barrier_kj, 0.0)) |
| 207 | + score = provenance_term + variance_term + flux_term |
| 208 | + |
| 209 | + return DistrustScore(ts_label=ts_label, barrier_kj=barrier_kj, |
| 210 | + height_above_lowest_saddle_kj=height_kj, in_window=in_window, |
| 211 | + provenance=provenance, kinetics_var=kinetics_var, score=score) |
| 212 | + |
| 213 | + |
| 214 | +def rank_candidates_by_distrust(candidates: tuple, |
| 215 | + network_e0: PDepNetworkE0, |
| 216 | + params: DistrustParams) -> tuple: |
| 217 | + """ |
| 218 | + Score every candidate and split them into the eligible (in-window), ranked by distrust, and the |
| 219 | + declined (outside the window). |
| 220 | +
|
| 221 | + This is the pure ranking core, with no evidence-stamping or ``CandidateSplit`` bookkeeping, so |
| 222 | + it can be replayed and unit-tested directly on a parsed network. |
| 223 | +
|
| 224 | + Args: |
| 225 | + candidates (tuple): ``QMCandidate`` objects to rank. |
| 226 | + network_e0 (PDepNetworkE0): The declared E0 values (kJ/mol). |
| 227 | + params (DistrustParams): The window and any other knobs. |
| 228 | +
|
| 229 | + Returns: |
| 230 | + tuple: ``(eligible, declined)`` where ``eligible`` is a list of ``(QMCandidate, |
| 231 | + DistrustScore)`` in descending distrust order (ties keeping input order, which is |
| 232 | + network-file order), and ``declined`` is a list of ``(QMCandidate, DistrustScore)`` for the |
| 233 | + out-of-window candidates in input order. |
| 234 | + """ |
| 235 | + lowest_saddle_e0_kj = (min(network_e0.transition_states.values()) |
| 236 | + if network_e0.transition_states else None) |
| 237 | + scored = [(candidate, |
| 238 | + compute_distrust(candidate.path_reaction, candidate.ts_label, network_e0, |
| 239 | + lowest_saddle_e0_kj, params)) |
| 240 | + for candidate in candidates] |
| 241 | + eligible = [pair for pair in scored if pair[1].in_window] |
| 242 | + declined = [pair for pair in scored if not pair[1].in_window] |
| 243 | + # sorted() is stable, so equal scores keep network-file order -- the same determinism the |
| 244 | + # sensitivity path promises. |
| 245 | + eligible.sort(key=lambda pair: -pair[1].score) |
| 246 | + return eligible, declined |
| 247 | + |
| 248 | + |
| 249 | +def _outside_window_reason(candidate: QMCandidate, score: DistrustScore, |
| 250 | + params: DistrustParams) -> str: |
| 251 | + """Prose for a candidate declined because its saddle sits above the flat energy window.""" |
| 252 | + height = ('unknown' if score.height_above_lowest_saddle_kj is None |
| 253 | + else f'{score.height_above_lowest_saddle_kj:.1f}') |
| 254 | + return (f"'{candidate.path_reaction.label}': transition state {candidate.ts_label} sits " |
| 255 | + f'{height} kJ/mol above the lowest saddle on the surface, outside the ' |
| 256 | + f'{params.energy_window_kj:.1f} kJ/mol flat energy window; it carries too little ' |
| 257 | + f'reactive flux to justify the QM spend, whatever the (dis)trust in its barrier.') |
| 258 | + |
| 259 | + |
| 260 | +def select_by_distrust(split: CandidateSplit, |
| 261 | + evidence_by_ts_label: dict, |
| 262 | + network_e0: PDepNetworkE0, |
| 263 | + params: DistrustParams) -> CandidateSplit: |
| 264 | + """ |
| 265 | + Select and rank a network's QM candidates by distrust, in place of the sensitivity floor. |
| 266 | +
|
| 267 | + This is the distrust counterpart to ``t3.pdep.pes_rounds.attach_sensitivity_evidence`` and is |
| 268 | + called from ``t3.pdep.pes_loop`` when ``qm.scope == 'distrust'``. It does three things: |
| 269 | +
|
| 270 | + 0. **Skips trusted (library / already-computed) candidates** before anything else. A barrier |
| 271 | + whose kinetics comment names a reaction library is a value distrust trusts and would not spend |
| 272 | + QM on again (the module contract); it is appended to ``skipped`` as ``SKIP_TRUSTED_PROVENANCE`` |
| 273 | + and never ranked, so an all-library network queues nothing rather than redundantly recomputing |
| 274 | + what it already holds. |
| 275 | + 1. **Stamps the measured sensitivity coefficient** (from ``evidence_by_ts_label``) onto every |
| 276 | + remaining candidate that has a finite row, exactly as the sensitivity path does. The coefficient is NOT |
| 277 | + the selection basis here -- distrust is -- but it is still carried so ``t3.pdep.capture`` (a |
| 278 | + fail-closed guard predating this scope) accepts the queued artifact, and so the round record |
| 279 | + keeps the sensitivity value visible (I-031: stop ranking on the structural zero, do not hide |
| 280 | + it). A candidate with no finite row is still skipped, since capture has no coefficient to |
| 281 | + record and inventing one is forbidden; it is classified ``SKIP_UNMEASURABLE`` when it was the |
| 282 | + structurally-unmeasurable kind, else ``SKIP_NO_EVIDENCE`` -- unchanged from the sensitivity |
| 283 | + path. |
| 284 | + 2. **Declines out-of-window candidates**, appending each to ``skipped`` as |
| 285 | + ``SKIP_OUTSIDE_WINDOW`` with its height above the lowest saddle in the reason. This is the |
| 286 | + negative control: the criterion is a screen, not a firehose. |
| 287 | + 3. **Ranks the survivors by descending distrust** and returns them in that order, each carrying |
| 288 | + its ``distrust_score``. ``t3.pdep.pes_loop._trim_candidates`` under this scope keeps that |
| 289 | + order and takes the first ``max_transition_states_per_round``. |
| 290 | +
|
| 291 | + Args: |
| 292 | + split (CandidateSplit): The split from ``split_qm_candidates``. |
| 293 | + evidence_by_ts_label (dict): Network-local TS label -> ``(coefficient, delta_ln_k)``, from |
| 294 | + ``t3.pdep.pes_sa.run_round_me_sensitivity``. |
| 295 | + network_e0 (PDepNetworkE0): The declared species/TS E0 values (kJ/mol), from the same |
| 296 | + network file. |
| 297 | + params (DistrustParams): The window and any other knobs. |
| 298 | +
|
| 299 | + Returns: |
| 300 | + CandidateSplit: The in-window candidates in descending distrust order, each stamped with its |
| 301 | + coefficient/``delta_ln_k`` and ``distrust_score``; every other candidate appended to |
| 302 | + ``skipped`` with its reason and classification. |
| 303 | + """ |
| 304 | + skipped = list(split.skipped) |
| 305 | + stamped = [] |
| 306 | + for candidate in split.candidates: |
| 307 | + if classify_provenance(candidate.path_reaction.kinetics_comment) == PROVENANCE_LIBRARY: |
| 308 | + # A trusted library/QM value: distrust does not re-queue it. Skipped before ranking so an |
| 309 | + # all-library network queues nothing rather than spending QM on what it already holds. |
| 310 | + skipped.append(SkippedChannel( |
| 311 | + label=candidate.path_reaction.label, |
| 312 | + reason=(f"'{candidate.path_reaction.label}': transition state {candidate.ts_label} " |
| 313 | + f'already carries a trusted library / already-computed (QM) rate, which ' |
| 314 | + f'distrust does not re-queue; computing it again would be redundant.'), |
| 315 | + ts_label=candidate.ts_label, classification=SKIP_TRUSTED_PROVENANCE)) |
| 316 | + continue |
| 317 | + pair = evidence_by_ts_label.get(candidate.ts_label) |
| 318 | + if pair is None: |
| 319 | + # No finite sensitivity row: capture would refuse the artifact, so this cannot be |
| 320 | + # queued regardless of distrust. Same skip the sensitivity path takes; the classification |
| 321 | + # only records whether the absence was the structural kind. |
| 322 | + classification = (SKIP_UNMEASURABLE if not candidate.e0_sensitivity_measurable |
| 323 | + else SKIP_NO_EVIDENCE) |
| 324 | + skipped.append(SkippedChannel( |
| 325 | + label=candidate.path_reaction.label, |
| 326 | + reason=(f"'{candidate.path_reaction.label}': transition state {candidate.ts_label} " |
| 327 | + f'has no finite sensitivity row in this round, and a captured artifact must ' |
| 328 | + f'carry the coefficient that will be recorded for it (t3.pdep.capture); not ' |
| 329 | + f'queueing it rather than inventing a number.'), |
| 330 | + ts_label=candidate.ts_label, classification=classification)) |
| 331 | + continue |
| 332 | + coefficient, delta_ln_k = pair |
| 333 | + stamped.append(replace(candidate, coefficient=coefficient, delta_ln_k=delta_ln_k)) |
| 334 | + |
| 335 | + eligible, declined = rank_candidates_by_distrust(tuple(stamped), network_e0, params) |
| 336 | + for candidate, score in declined: |
| 337 | + skipped.append(SkippedChannel( |
| 338 | + label=candidate.path_reaction.label, |
| 339 | + reason=_outside_window_reason(candidate, score, params), |
| 340 | + ts_label=candidate.ts_label, classification=SKIP_OUTSIDE_WINDOW, |
| 341 | + coefficient=candidate.coefficient, delta_ln_k=candidate.delta_ln_k)) |
| 342 | + ranked = tuple(replace(candidate, distrust_score=score.score) for candidate, score in eligible) |
| 343 | + return CandidateSplit(candidates=ranked, skipped=tuple(skipped)) |
0 commit comments