Skip to content

Commit e3f339d

Browse files
authored
Merge pull request #209 from ReactionMechanismGenerator/i032-rank-by-distrust
feat(pdep): rank PES-loop QM candidates by distrust, not by structurally-blind sensitivity
2 parents f7d32b3 + a440dd9 commit e3f339d

6 files changed

Lines changed: 795 additions & 15 deletions

File tree

t3/pdep/distrust.py

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
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

Comments
 (0)