Skip to content

DO NOT MERGE (draft): PDep→QM network selection and hybrid Arkane input generation (I-008) - #179

Draft
alongd wants to merge 121 commits into
mainfrom
pdep_qm
Draft

DO NOT MERGE (draft): PDep→QM network selection and hybrid Arkane input generation (I-008)#179
alongd wants to merge 121 commits into
mainfrom
pdep_qm

Conversation

@alongd

@alongd alongd commented Jul 29, 2026

Copy link
Copy Markdown
Member

⛔ DO NOT MERGE — for @alongd's review

Draft on purpose. This is a large, staged increment and I want your eyes on the design decisions
before anything lands. See Open questions for you at the bottom.

What this adds (I-008 / D-020)

A T3 layer that decides which whole pressure-dependent (PDep) reaction networks deserve
expensive QM (PES exploration + master-equation refinement), and then computes them. The selection
criterion is observable-sensitivity × uncertainty.

The key architectural choice: this reasoning is network-level (ME solver, PES explorer). ARC
stays per-species / per-reaction and never learns that networks exist.
Nothing in ARC was
touched; arc.* is consumed read-only.

The end-to-end capability, and the property the whole capture layer exists to provide:
T3.process_arc_run() writes hybrid Arkane P-dep network inputs — QM/RRKM for transition states
that were successfully computed, RMG/ILT for the rest — from the capture alone, with the ARC
project directory absent entirely.

New module t3/pdep/: discovery, join, capture, hybrid, selector, parser,
energy_settings, me_success, cache, yaml_safe, api, and mesolver/ (adapter + Arkane
implementation + factory).

Constraints held throughout

  • No rmgpy and no arkane imports in T3. arc.* only; arc.molecule replaces
    rmgpy.molecule.
  • RMG/Arkane .py files are parsed with ast.parse, never exec/eval. Parsing an RMG
    network.py would otherwise mean executing the RMG/Arkane DSL.
  • Fail closed. Every ambiguous or unverifiable state refuses rather than proceeding.

Testing

Full suite: 1089 passed, 1 failed (51m37s). The single failure is
tests/test_functional.py::test_computing_thermo (CanteraError) — pre-existing on main, not
from this branch.

Roughly 1000 lines of the diff are tests. Beyond assertions, the new guards were mutation-tested:
each guard was individually neutered and a test confirmed to fail, then reverted. Where a mutation
was reported without evidence, it was re-run independently before being believed.

Review-driven hardening

This branch went through 25 adversarial review rounds. That process found — and this PR fixes —
thirteen instances of the same fail-open / vacuous-truth defect, where a guard silently passed
on the case it existed to catch. Representative examples:

  • verify_capture() never read the status field at all, so a manifest entry claiming
    status: usable with a null artifact path verified clean, counted zero artifacts, pruned the
    output tree, returned success, and let the finalization marker be written.
  • The writer called verify_capture() and then separately re-read the same manifest, so path
    confinement applied to one parse while the use applied to another.
  • use_atom_corrections='False' (a non-empty, therefore truthy, string) slipped past a falsy check
    while the generated file rendered a bare useAtomCorrections = False.
  • A hybrid input could be written that turns a correction ON without pinning its values.
  • The finalization marker was fsync+os.replace'd while input.py was a plain open(...,'w')
    the marker was more durable than the output it certifies, so a torn file could be certified
    as complete.

Known limitation, recorded rather than hidden

The versioned finalization marker relies on every step of process_arc_run() being idempotent. The
RMG library append's contribution to that is dedup-by-label: verified to add no duplicates, but
it would silently keep stale values if a source library ever carried the same labels with different
values. No path in this feature produces that, it is pre-existing behavior, and it is documented at
the claim site rather than left implicit.

Open questions for you

  1. Durable refusal records. When a network is refused (partial QM coverage), the only durable
    state is "no input.py plus a finalization marker" — indistinguishable later from not-selected,
    zero-artifact, manually deleted, or pruned. The reason exists only in logs. Review flagged this
    twice. I did not design it unilaterally because it interacts with the prune preflight and I
    think the shape should be your call.
  2. t3/utils/libraries.py has two pre-existing defects I deliberately did not fix here, since
    they are outside I-008 and deserve their own PR: description_to_append is extracted from the
    destination's own longDesc and checked against it, so it never consults the source and is
    effectively a no-op; and the shared-library lock path is keyed by library_name, so two
    projects writing the same shared_library_name under different local library_names take
    different locks — a real concurrency hole in shared-library writes.
  3. explore=True still raises NotImplementedError (t3/pdep/api.py). The
    ArkaneExplorerAdapter is the remaining unbuilt piece and has no design yet.

Related

Depends on RMG-Py PR ReactionMechanismGenerator/RMG-Py#2990 (open, awaiting review) for the
P-dep sensitivity / ILT support this consumes.

alongd and others added 30 commits July 26, 2026 19:22
T3's reaction-uncertainty logic lived inline in reaction_requires_refinement,
conflating two things: a dedup gate (has this reaction already been queued?)
and the actual uncertainty semantics (library / exact-match rate rule => certain).

Extract the uncertainty half into t3/utils/uncertainty.py so every refinement
path -- species, reactions, and the upcoming PDep network selector -- shares one
definition. The core operates on a bare kinetics-comment string because the
network selector reads provenance straight out of an RMG pdep network file's
reaction() blocks, where no T3Reaction object exists.

Behavior is unchanged: a characterization test was written and confirmed green
against the unmodified code first, pinning the permissive default that the
legacy (vacuously true) `or '' in kinetics_comment` clause produced.
The shared uncertainty predicate previously recognized provenance only via an
explicit ``kinetics_method=LIBRARY`` argument or the exact phrase "Exact match
found for rate rule". Callers that hold only a bare kinetics comment - notably
the upcoming PDep network selector, which reads comments straight out of an RMG
``network.py`` - therefore misclassified real library and training reactions as
uncertain, and missed several RMG estimate forms entirely.

Provenance is now read from the comment text itself.

Certain (real data for *this* reaction):
  * "Exact match found" (shortened from "... for rate rule": RMG wraps some
    comments with a literal line continuation that splits "for rate\ rule",
    and RMG's own parser uses the short form)
  * "Matched reaction <n> ... in <Family>/training" - this reaction IS the
    training reaction. Matched with a bounded regex, not a bare "/training"
    test: RMG also emits "From training reaction <n> used <rule>", which means
    the rate RULE was fitted from a training reaction and then applied by
    analogy, and must stay uncertain.
  * "Library reaction:", "... from reaction library ...", "Seed mechanism:"

Uncertain (an estimate, and these override a certain marker):
  * "Estimated using ..." and "Estimated from node ..."
  * "BM rule fitted to <n> training reactions" - an exact match to a fitted
    tree node is not real data. Every such comment in tests/data reports
    Total Standard Deviation in ln(k) between 1.09 and 24.6 (median 11.5).
  * "... to match endothermicity of reaction" - RMG raised the barrier. Keyed
    on the endothermicity clause rather than "Ea raised from", so the no-op
    "Ea raised from -0.0 to 0 kJ/mol." form does not count.

Per-path-reaction "High-P limit: ..." PDep debug lines (rmgpy/chemkin.pyx) are
stripped before scanning, in both directions, so one library path reaction
cannot make a whole PDep net reaction certain.

Method strings are normalized case-insensitively with an alias map, and an
unrecognized string now falls through to comment analysis instead of being
coerced to UNKNOWN and thus uncertain. Only LIBRARY and TRAINING_SET short
circuit at the method level; QM and USER deliberately do not, so a failed,
placeholder, or stale QM/user reaction can still be re-queued.

BEHAVIOR CHANGE for existing T3 users: this also changes
``T3.reaction_requires_refinement``. Measured over the 16091 reaction comments
in tests/data, versus the previous predicate: 1576 reactions flip
uncertain -> certain (1372 library-in-text, 204 matched-training - these were
being sent to expensive QM despite having real data), and 430 flip
certain -> uncertain (368 BM tree-node rules, 62 Ea-raised-to-match-
endothermicity - genuinely uncertain rates that were being skipped).

The dedup gate stays in ``T3.reaction_requires_refinement``.
Add a `t3/pdep/` layer that decides which whole pressure-dependent reaction
networks are worth expensive QM, rather than only picking individual wells.

A network qualifies when both hold:
(a) an observable is sensitive to one of its reactions (established by T3's
    existing top-SA machinery), and
(b) the network's own k(T,P) is in turn sensitive to a transition state whose
    kinetics is merely an estimate, judged by the shared provenance predicate.

New modules:
- `parser.py`  reads RMG `network*.py` with `ast.parse`. These files are
  executable Arkane DSL and are never executed, imported, or `exec`ed; only
  their declarative structure is read. A regression test proves a top-level
  side effect in a network file is not run.
- `selector.py` the pure decision core, holding no T3 state and doing no I/O,
  so the in-run path and the upcoming public API cannot disagree. Returns a
  `PDepNetworkSelection` carrying the verdict, the evidence, the thresholds
  used, and any warnings.
- `cache.py`   a T3-owned sidecar that makes reuse of an existing Arkane
  sensitivity analysis safe.

Numerics, both deliberate:
- The relative gate's denominator is over transition-state rows only. Well rows
  and TS rows are different derivative coordinates, so a shared denominator
  would compare unlike quantities and make the selected TS set move whenever an
  unrelated thermo row moved.
- The relative gate alone cannot tell a real derivative from a denormal ~1e-18
  structural zero, so an absolute floor is applied too. It is expressed as a
  dimensionless rate response (the new `pdep_min_delta_ln_k`, default 1e-3) and
  converted using the perturbation size, so it keeps its meaning if Arkane's
  2 kcal/mol perturbation ever changes.

Behavior changes for existing users:
- Well selection now ranks by absolute value. It previously compared signed
  coefficients against a signed maximum, so a strongly negative sensitivity --
  just as real as a positive one -- was skipped. The same absolute floor is
  applied to wells, which keeps denormal rows out.
- `run_arkane_job` no longer accepts any `.yml` under `sensitivity/` as proof of
  success. A stale file from an earlier run is not evidence, so the target is
  removed before Arkane is invoked and must exist afterwards.

Why the cache needs a sidecar: nothing in Arkane's own output identifies which
Arkane wrote it or which network it came from. An analysis whose TS perturbation
never reached the rate expression writes structurally dead TS rows that read
exactly like "nothing here is sensitive", so trusting one would drop the network
from QM with no visible symptom. The sidecar records the selector version, the
perturbation, and hashes of both the network file and the sensitivity output,
and additionally records the largest absolute TS coefficient present. A cache
whose TS rows carry no signal is rejected rather than believed, and a cache with
no sidecar is never trusted. The cost of that choice is real: against such an
Arkane the analysis is regenerated every iteration instead of cached. A loud,
expensive failure is preferred here to a quiet wrong answer, and the warning
names the likely cause.

The two directions of a reaction appear as separate sensitivity keys, so the
requested direction is preferred, falling back through label canonicalization to
the opposite direction. Measured on the checked-in fixture, forward and reverse
coefficients agree to 4.6e-11 relative for TS rows above the floor, so the
fallback answers the same question; a test pins that premise so a future change
fails loudly instead of the fallback going quietly wrong.

The sensitivity fixture is real patched-Arkane output for `network4_2`. It lives
under `tests/data/pdep_sa/` rather than mirroring the runtime layout, because
`tests/data/pdep_network/iteration_1/PDep_SA` is `t3.paths['PDep SA']` and is
deleted by an existing teardown -- which made the new tests order-dependent
until the fixture was moved.

Qualifying networks are recorded and logged but do not yet trigger work; the
master-equation and explorer adapters that consume the decision follow.
Add `t3/pdep/api.py`, the standalone entrypoint an external client imports to
ask whether a PDep network deserves QM refinement without running T3. This is
the trigger channel described in the design's D3/D4, kept deliberately separate
from the diagnostics schema.

It never reimplements the decision. Every answer comes from the same pure core
the in-run path calls, so the two channels cannot drift apart, and the parity
test compares the API's decision against a real
`determine_species_from_pdep_network` run rather than against the core it
already shares -- otherwise the test could not catch a divergence in defaults,
which is the only way the two would realistically disagree.

For the same reason the API's thresholds are read off `T3Sensitivity`'s own
field defaults instead of being copied literals, with a test asserting they
match. A copied default is exactly the kind of thing that drifts silently.

Entry points:
- `select_pdep_network()` accepts an RMG `network.py` path or an already-parsed
  network. Naming a reaction asks T3's narrower in-run question; omitting one
  asks whether the network is worth refining at all, aggregating over every
  reaction in the sensitivity output.
- `rank_pdep_networks()` orders networks most-deserving first, by the largest
  ln(k) response among the uncertain path reactions.
- `save_pdep_network_selections()` writes the result for an external reader.

The design note this implements mentions accepting "a T3-standardized YAML" for
the network. No such schema exists in this repository -- it was floated once and
never built -- so only the real `network.py` form is supported, and the module
docstring says so rather than inventing a format nothing produces.

Decisions worth stating, since each rules out a quieter alternative:
- A rejected cache returns a decision marked `not_evaluated`, not a plain "no".
  A standalone caller cannot regenerate the analysis, so "I could not evaluate
  this" and "this is safely not worth QM" must not look identical. Ranking
  returns those entries too, instead of dropping them.
- `explore=True` raises rather than being accepted and ignored. The explorer
  adapter lands in a later commit; accepting the argument now and doing nothing
  would be a lie the caller cannot detect. The signature is stable so that
  commit only has to fill it in.
- Sensitivity YAML is read through a restricted loader that understands the
  `!!python/tuple` condition keys Arkane emits and refuses every other non-safe
  tag. The unrestricted loader is fine for T3's own output but not for a public
  entrypoint reading a caller-supplied path.
- Combining per-reaction decisions refuses to merge different networks, and
  drops the method and cache status to unset when components disagree, so an
  aggregate cannot claim more provenance than its parts had.
…iterion

Adds the first of the two adapter families from D5: an ME-solver family that solves a
GIVEN pressure-dependent network and returns k(T,P), with Arkane as the sole concrete
implementation this round. Mirrors the existing t3/simulate registry idiom (module-level
dict + a free register_*_adapter() called bare at the bottom of each concrete module +
eager imports in the package __init__), not a decorator.

The substantive work is the success criterion. T3's existing Arkane gate is SA-specific
(it requires sensitivity/sa_coefficients.yml), and a plain ME solve produces no such file,
so it needed its own. Three real Arkane runs established what that criterion has to be:

  - Arkane creates output.py BEFORE any job runs (arkane/main.py:186-187), so a hard crash
    leaves a 0-byte output.py behind. Existence proves nothing.
  - A failing CSE solve exits 0 with EMPTY stderr and writes a syntactically perfect
    pdepreaction(...) block whose Chebyshev coefficients are all None; chem.inp carries
    literal nan. Arkane's own "Negative rate coefficient generated; rejecting result."
    goes to stdout, not stderr.

So exit status, stderr, and file existence all pass a run that produced nothing usable.
Only parsing the numeric payload discriminates. No Arkane/RMG API reads output.py back
(pdepreaction is write-only and absent from the input DSL), so it is scraped with ast,
consistent with the never-exec rule and with T3 shedding its RMG-Py API dependencies.

check_arkane_me_success() therefore gates on the RATE PAYLOAD specifically -- the coeffs /
A,n,Ea leaves -- and not on every numeric leaf of the kinetics call. Tmin/Tmax/Pmin/Pmax
are themselves finite numbers, so a Chebyshev(coeffs=[], Tmin=..., Tmax=...) would satisfy
an "all leaves finite, at least one present" check while carrying no rate information at
all. The parser records separately which kinetics keywords were omitted because they could
not be literal-evaluated, so an unparseable coeffs is distinguishable from an absent one.
Coefficients are deliberately NOT required to be positive: Chebyshev coefficients are
log-space and legitimately negative; positivity is a property of k(T,P), not of the fit.

Two refactors make room for this, both behavior-preserving for their existing SA callers:
write_arkane_network_input_file() is extracted from write_pdep_network_file() with a flag
controlling the sensitivity directive, and run_arkane_job() gains a required_artifact
parameter defaulting to the current SA path. The artifact is deleted before the run and
required after, so it is confined to the job directory -- an absolute or traversing value
is rejected rather than deleting a file outside it.

Known and deliberate: expected_reactions is plumbed through but defaults to None, so output
completeness is not verified unless a caller supplies it; deriving it from network topology
is deferred, since the number of net reactions is not trivially the number of path
reactions and guessing wrong would reject good solves. set_up() runs from __init__ to match
the simulate adapters, so constructing an adapter writes input.py and output_directory must
be unique per instance. allow_ilt_complement is enforced in the factory only; that becomes
load-bearing when a non-Arkane adapter exists, and the check should move into the adapters
then.

Reviewed adversarially (spar round 8): 3 P1, 3 P2, 3 P3, all real, all fixed here.
Build the network input file that hands Arkane a QM transition state for the
path reactions we refined while leaving the rest on ILT, so a single master
equation solve mixes both sources.

Two invariants make the result trustworthy rather than merely plausible:

- Every QM'd path reaction loses its `kinetics = Arrhenius(...)` entry. Left
  in place, Arkane silently prefers it and falls back to ILT, and the caller
  believes it received QM when it did not. A `transitionState=` that is
  present but cannot be evaluated as a literal is refused outright for the
  same reason: we cannot tell whether that reaction needs its kinetics
  dropped, and guessing would break the invariant silently.
- Artifacts are vendored, not referenced. ARC deletes and recreates
  `calcs/statmech/kinetics` on every rate pass, so a pointer into it is
  ephemeral. Vendoring stages into a sibling directory and atomically renames
  it into place, so a source that vanishes mid-copy leaves the previous good
  `qm/` intact instead of destroying it and leaving `input.py` aimed at a
  half-populated tree.

Every path resolved out of a `Log(...)` call is confined to an explicit root
with `realpath` + `commonpath` before it is copied. Vendoring copies these
files, so an artifact naming an arbitrary absolute path would otherwise pull
it into the run directory.

This commit also hardens `rewrite_arkane_method_line`, because it introduces
the helper's second call site and cannot be correct without it.
`rewrite_arkane_method_line` returned the line untouched whenever it could
not parse it, and both call sites gated on the literal substring
`'method = '`. Three ordinary inputs therefore kept the source file's method
silently: a double-quoted `method = "modified strong collision"`, an unspaced
`method='CSE'`, and a network file carrying no method line at all. Since each
network is solved once per ME method, a silent no-op makes both passes solve
the same method while the SA cache records the one merely requested, and
`validate_sa_cache` then vouches for that mismatch on every later run. The
assignment is now matched with a regex accepting either quote style and any
spacing, both call sites count the rewrites, and anything other than exactly
one rewrite raises. A `method = ` line inside a triple-quoted string is still
matched; line-based scanning cannot tell it from a real assignment, and that
limitation is stated in the docstring.
…count

`check_arkane_me_success` accepts an `expected_reactions` count to catch a
truncated Arkane `output.py` -- a solve that died partway leaves fewer
`pdepreaction(...)` entries, each of them syntactically perfect and numerically
finite, so no per-entry check can see it. Nothing ever passed the argument, so
the hole stayed open. This makes the count derivable from the network file.
Wiring a production caller to pass it is the next commit.

Arkane enumerates net reactions in `PressureDependenceJob.save()` as a double
loop whose source index runs over the isomers and reactant channels only and
whose destination index runs over those plus the product channels, skipping the
diagonal, then comments out any entry that duplicates an already-printed one.
No branch of that loop omits an entry -- a failed fit raises, and a rejected fit
still writes its entry with empty coefficients -- so the live count is exact
rather than an upper bound, which is what makes it usable as a gate.

Two things make the count harder than it looks, and both were found by
measurement rather than by reading the source.

First, P. The parser read the `products` keyword literally, and no
RMG-generated network file ever writes that keyword: Arkane derives the product
channels from the path reactions instead. Every real fixture therefore reported
P = 0, which would have predicted 3 net reactions for `network4_1` where Arkane
writes 12, and REJECTED good complete solves -- a worse failure than the
truncation being closed. `_derive_product_channels` now mirrors Arkane's
derivation, and `PDepNetwork.product_channels` holds the resolved channels, with
`product_channels_declared` recording which branch produced them.

Second, the duplicate suppression is not a closed form. `S*(S-1)/2 + S*P`
assumes duplicates arise only between the two directions of a source-source
pair, but Arkane compares each candidate against EVERY previously printed
reaction, by label and in either direction. One configuration can appear in both
channel lists -- a declared unimolecular reactant channel is also derived as a
product channel, since the derivation's unimolecular branch tests only against
the isomers -- and then the entries reaching it are commented out as duplicates
of the entries leaving it. `expected_net_reaction_count` therefore simulates the
loop rather than reducing it.

The derivation's two arity branches are asymmetric, and deliberately kept so:
Arkane tests a unimolecular side against the isomers only and a multi-species
side against the reactant channels only, despite its own comment claiming both
are tested against both. Unifying them reads like a cleanup and silently drops
channels, so `network_arity_asymmetry.py` pins both consequences.

Verified against real Arkane MSC runs, not against the source alone. Its log
named the three derived product channels of `network4_1` and its `output.py`
holds exactly the predicted 12 live entries; a second run of the same network
with one channel deliberately made to appear in both lists holds 15 live and 9
commented, where the closed form predicts 18. Both outputs are checked in under
`tests/data/pdep_me/` (the previous ME fixtures all come from a degenerate
one-isomer network with a single entry, which cannot distinguish these models
from several others), and the end-to-end tests derive the count from the network
file rather than restating it, so they fail if the derivation and Arkane ever
drift apart.

The derivation, the branch asymmetry and the duplicate suppression are each
mutation-tested.

Targeted subset (tests/test_pdep, tests/test_runners, tests/test_utils/
test_writer.py): 204 passed, up from 194.
…label

A PDep network file names its transition states in RMG's namespace (TS1, TS3,
...), local to one file; ARC names them in its own, from the position of the
reaction in ARC's reaction list. The two collide -- TS1 in network4_1.py is
almost never ARC's TS1 -- so finding the Arkane statmech .py ARC wrote for a
selected transition state needs an explicit, durable tie.

Make the tie by CHOOSING ARC's name rather than predicting it.
ARCReaction.ts_label is honored when already set: ARC only falls back to
f'TS{rxn.index}' when it is None (arc/scheduler.py:379). Unlike T3's own
t3_index, ts_label also survives ARCReaction.copy(), which is what
T3.add_reaction puts on the QM queue -- copy() is hardcoded to return an
ARCReaction, not type(self), so every T3-only field on the queued object is
dropped. So T3 assigns a deterministic namespaced label up front and the join
is known at queue time, without depending on ARC's indexing at all.

Labels are refused, never sanitized. Rewriting an unsafe character would let
two distinct networks (or transition states) collapse onto one ARC label and
silently join a network to another network's quantum chemistry. The same
one-to-one requirement is enforced in both directions, on write AND on read:
the sidecar sits on disk between two ARC-length steps, so the reader cannot
assume the writer's guarantees still hold.

This commit is the pure, standalone half of the work; nothing calls it yet.
Wiring it into t3/main.py follows separately.
…o ARC

Wire t3.pdep.join into the in-run path. When a network qualifies for QM
refinement, each of its uncertain transition states is queued to ARC as its own
reaction, tagged up front with the deterministic ARC label that ties its
eventual quantum chemistry back to the network transition state it came from.
The join sidecar is written into the iteration's ARC project directory.

The parsed network is label-only, so the species ARC needs are built from the
sensitivity output's 'structures' mapping -- the one source that covers edge
species as well as core ones.

Every selected transition state produces a record, including the ones that
could NOT be queued, because a silently dropped transition state is later
indistinguishable from one whose quantum chemistry failed and the two call for
opposite responses. Three cases are recorded rather than queued:

- no path reaction declares the transition state;
- several path reactions share it, so which reaction it refers to is ambiguous.
  Computing a TS for A <=> B and then using it for C <=> D is silently wrong
  chemistry, not an approximation, and there is no basis for picking one;
- a species has no adjacency list, so the reaction cannot be built.

A reaction T3 already knows is recorded as such with NO expected artifact path:
add_reaction does not re-queue it, and if it was added in an earlier iteration
its QM entry has since been cleared, so ARC will not recompute it. Claiming a
path there would make a never-computed transition state look merely unfinished.

The records are emptied at the start of each pass. They describe one
iteration's queue and are written to that iteration's project directory, so a
carried-over record would advertise a previous iteration's artifact path.
…able

ARC does not converge every transition state T3 sends it. Across the real ARC
projects on hand only 4 of 9 transition states ended up with a statmech artifact,
so "the artifact is missing" is the common case rather than the exception, and the
PDep layer needs an honest answer for it before a hybrid network is written.

Discovery recomputes each ARC label and artifact path from the join sidecar rather
than trusting the path recorded in it, confines the result to the ARC project
directory, and calls a transition state usable only when ARC did not report it as
failed, the artifact exists, and it parses with every referenced log resolvable.
Existence alone is not convergence: a stale file can survive a previous pass and a
referenced ESS log can be cleaned up, and preflighting here is what keeps the
hybrid writer from raising late, after the policy already counted the state.

Convergence is read from status.yml in preference to output.yml, which collapses
"explicitly failed" and "not yet determined" into one value; when only the lossier
source is available the verdict fails closed but says it could not tell the two
apart. A project with neither file leaves convergence unknown, which stays usable
if the artifact itself is sound.

Acceptance is strict by default: a network is refined only when every selected
transition state is usable. A partial result is still described, carrying its
coverage and flagging when the state it is missing is the one the rate is most
sensitive to, but taking it as the refined network is the caller's explicit choice.

Nothing calls this yet; wiring it into t3/main.py follows separately.
Copy the transition-state artifacts ARC produced, and the logs they point
at, into a capture directory T3 owns, so everything downstream reads a
frozen tree instead of ARC's live one.

ARC deletes and recreates `<project>/calcs/statmech/kinetics/` at the top of
every high-pressure rate pass, so the pointer `.py` files there are
ephemeral. Their `Log(...)` targets live in a sibling tree and do survive,
but the pointer itself is not mere path indirection: it carries the
`linear`/`spinMultiplicity`/rotor selections and the sp/freq bindings ARC
rendered, none of which is recoverable from the logs without redoing that
rendering. Capturing is therefore not an optimization.

Discovery, capture, and the manifest write are one entry point rather than
three composable ones. A public "remember to capture later" helper would
only relocate the bug: if T3 dies after ARC terminates but before capture, a
restart does not enter `handle_arc_restart()`, because ARC already looks
terminated, and the iteration replays against a wiped pointer tree.

Two things are frozen rather than referenced:

- Convergence is resolved while ARC's state is still live and written into
  the manifest as a verdict. `output/status.yml` and `output/output.yml` are
  deliberately NOT copied: vendoring copies with `shutil.copyfile`, which
  does not preserve mtime, and discovery breaks a status/output disagreement
  by comparing their mtimes. Copying both would land them at the same
  instant and silently collapse exactly that discrimination.
- The hashes are load-bearing, not provenance decoration. Captured bytes are
  verified against their sources immediately after the copy, and
  `verify_capture()` re-checks a capture against its own manifest so a
  consumer can validate one it did not create. The vendored `qm/` tree and
  the manifest are two objects written at different moments, so a crash
  between them leaves them disagreeing; that torn state is now detected and
  refused instead of silently consumed.

The pointer file is exempt from byte-equality on purpose, since vendoring
rewrites its `Log(...)` arguments to relative paths. It is verified
structurally instead: re-reading the captured pointer must resolve the same
number of logs, with the same set of hashes, as the source did.

A capture directory is refused if it sits inside the ARC project directory,
which would defeat the durability the capture exists for, and refused if it
holds content this module did not put there, so the dedicated-directory
requirement is enforced rather than left to the caller.
Call the capture boundary from T3's ARC finalization, and make a restart
notice when finalization never happened.

The gap this closes is narrow and silent. `restart()` derives `run_arc` from
`rmg_terminated and not arc_began`, and `restart_arc` from `arc_began and
not arc_terminated`. A crash after ARC wrote its own termination line but
before T3 finalized leaves `arc_began` and `arc_terminated` both true, so
neither flag fires, `handle_arc_restart()` is never entered, and the run
falls through to "skipping RMG". The iteration then replays through ARC with
the previous run's artifacts never captured -- and ARC deletes
`calcs/statmech/kinetics/` at the top of its next rate pass, so by the time
anything looks for them they are gone.

Finalization is therefore gated on a completion marker T3 owns, rather than
on ARC's own log. The marker sits at the iteration level, outside the ARC
project directory, because anything stored inside it is exactly what ARC is
liable to remove.

The new branch fires only for iterations that left a P-dep join sidecar. An
absent marker cannot by itself tell "crashed before finalizing" apart from
"finalized by an earlier T3, which never wrote markers", so keying off the
marker alone would re-finalize every project completed before this existed,
re-appending library entries and advancing the iteration under them. The
sidecar is the positive evidence that finalization has something to rescue;
without one, behavior is exactly what it was.

Capture runs first within the boundary. It is the only step whose inputs are
ephemeral -- everything after it reads durable state -- so sequencing it
after the ARC-info parse or the library append would let a raise there
destroy artifacts that were still recoverable.

The marker is written last, atomically, and its contents are checked when
read. Any exception from capture propagates rather than being swallowed, so
a failed capture leaves the marker absent and the work is retried; marking
completion first would turn a recoverable crash into permanent data loss,
and reading mere file existence as proof would let a truncated marker skip
finalization forever. `process_arc_run()` consults the marker itself, so a
redundant call is a no-op instead of appending the same results twice, and
starting a new ARC run clears the marker its predecessor left behind.

A missing sidecar means "none were queued" only if ARC also produced no
T3-labelled artifacts. When such artifacts do exist with no sidecar naming
them, the join is lost rather than empty, and finalization refuses instead
of silently abandoning completed QM work.

Capture is invoked from `process_arc_run()` alone, which all three call
sites already funnel through, so the three sites are untouched. It reads the
join sidecar rather than `self.qm`: the queue holds `reaction.copy()`, a
plain `ARCReaction` on which only `ts_label` survives, so it cannot carry
the provenance capture needs, and the sidecar is written before ARC starts.

The marker and its helpers are named for ARC finalization, not for P-dep,
because the flag gates finalization generally -- a P-dep name on a file that
governs the restart path would misdirect the next reader.
A capture exists to outlive the ARC project directory it came from, but it
could not describe the energy reference its own QM artifacts were computed on.
ARC's per-transition-state statmech artifact carries only geometry, frequencies
and Log(...) pointers; the model chemistry lives solely in the sibling
calcs/statmech/<subdir>/input.py and output/output.yml, both inside the ARC
directory. So write_hybrid_network_input_file(), which refuses a blank
model_chemistry, could never be driven from a capture alone.

Freeze it instead. read_arc_energy_settings() resolves the settings from ARC's
own statmech directives at capture time and capture_ts_artifacts() records them
under the manifest's new, AUTHORITATIVE 'energy_settings' key -- the same
treatment the status/converged/reason verdicts already get, and deliberately
unlike the inert 'provenance' archive, which stays unreadable as authority.
Values are frozen, never lookup keys: ARC/data/AEC.yml is version-dependent, so
storing only a model chemistry label would let a later edit shift a transition
state's E0 while every capture hash still verified.

input.py is parsed with ast.parse over module-level assignments only, never
exec'd -- the file is executable Arkane DSL that also calls species() and
reaction(). output.yml is read with yaml.safe_load. The two are cross-checked
on the frequency scale factor and bond-correction type, and disagreement is
fatal; their method and basis NAMES are deliberately not compared, because ARC
de-hyphenates and year-suffixes them in input.py only. A null correction block
means unknown, not absent: ARC computes those in a subprocess wrapped in a bare
except.

Two defects in QMEnergySettings surfaced while wiring this up:

model_chemistry could not represent its only real data source. The validator
rejected quotes and newlines, but ARC emits a bare LevelOfTheory(...) call, or
a multi-line CompositeLevelOfTheory(...) -- both quoted, one multi-line, so
both were refused outright. It now validates structurally via ast.parse, and
_build_energy_header renders an object expression bare and a plain label
quoted; emitting a quoted LevelOfTheory(...) would silently hand Arkane an
inert string where it expects evaluable syntax.

useAtomCorrections was never emitted at all, so the header's documented promise
to fail loudly rather than silently disable atom corrections was unenforced.
The field now exists and False raises, rather than producing a hybrid network
whose QM'd transition state sits on a different energy scale than the RMG wells
around it.

The plain-label form is frozen as its string value, not its source segment.
Freezing the segment kept the surrounding quotes, which the hybrid writer then
refused -- two individually-correct behaviours composing into a hole that would
only surface once the ARC directory was already gone.
Completes the capture contract. With the energy reference already frozen, two
inputs to write_hybrid_network_input_file() were still unreachable once ARC
finished: the original RMG network.py (no network_id-to-path mapping was ever
persisted) and the master-equation method, which lived only in memory on
self.pdep_network_selections and so was gone after any restart.

Both are now frozen where they are actually known: at join-sidecar write time,
inside determine_species_from_pdep_network, which already computes the network
path and appends the selection. The sidecar gains a networks block carrying the
source path, its hash_file() fingerprint, and the method, cross-referenced
against the transition-state records in both directions -- a sidecar naming a
transition state whose network has no source is unusable downstream, so it is
refused rather than carried.

The capture then re-verifies each network against that fingerprint and VENDORS
the file into networks/<network_id>.py. Hashing alone would have been the
cheaper change and the wrong one: a hash detects that RMG regenerated the
network between queueing and capture, but it cannot supply the file once the
pdep directory is gone, which is precisely the situation the capture exists to
survive. A mismatch is fatal, because the frozen transition state labels may no
longer name the same reactions.

There is now one test that proves the whole point rather than its parts: with
both the RMG pdep directory and the ARC project directory deleted, a capture
alone still drives a complete hybrid network write. It uses the real network
fixture deliberately. The stub written by the test helper hashes and vendors
perfectly while having no reaction(...) entries at all, so it is unparseable by
the writer that has to consume it -- byte-identity is not usability, and every
existing vendoring assertion only checked bytes.

The shared energy-settings fixture had set useAtomCorrections False for test
convenience. ARC computes that flag as bool(model_chemistry or atom_energies)
and so can never emit False alongside a modelChemistry line, making it an
ARC-impossible state that the hybrid writer correctly refuses. The fixture is
now faithful, which is what lets the end-to-end path be exercised at all.

Wiring sensitivity_by_ts through to the manifest is deliberately not included.
_capture_pdep_ts_artifacts also runs on the restart path, where the in-memory
selections are empty, so the obvious wiring would silently record absent
evidence exactly when a capture is replayed. Freezing the coefficients onto the
join records at queue time is the real fix and belongs with its own tests.
A boolean Arkane directive that is not actually a bool silently inverts itself. The
dangerous shape is a non-empty string: `useAtomCorrections = 'False'` is TRUTHY, so
`if not energy_settings.use_atom_corrections` never fires and the guard added in
32c17e6 is bypassed entirely -- while `_build_energy_header`'s f-string renders it
straight back out as a bare `useAtomCorrections = False` that Arkane evaluates as the
boolean. The guard is defeated and the directive inverted in one step, and the hybrid
network lands silently on the wrong energy scale.

Enforce exact types at all three boundaries the value crosses, since each is
independently reachable:

* `read_arc_energy_settings` (producer): every `ast.literal_eval`'d directive is
  type-checked against `_LITERAL_FIELD_TYPES`.
* `validate_frozen_energy_settings` (new, in energy_settings.py): the one
  authoritative definition of a frozen block's shape, called by `verify_capture` --
  the manifest is a file, so it can be hand-edited into any shape between being
  written and being read back.
* `write_hybrid_network_input_file` (consumer): `QMEnergySettings` is a plain
  dataclass whose annotations enforce nothing at runtime, and its values come from a
  YAML-loaded manifest.

Types are compared by exact `type()` membership rather than `isinstance`:
`isinstance(True, int)` is True in Python's numeric tower, so an isinstance check
accepts `frequencyScaleFactor = True` and silently scales every frequency by 1.0.
That distinction is mutation-tested -- swapping in isinstance kills only the
`frequency_scale_factor=True` case, which is exactly the case that matters.

Round-22 P1 #1. Eleventh fail-open defect on this branch, same shape as the other ten.
The AST-structural allowlist for `modelChemistry` was too permissive to be a
validator for a value that is rendered BARE (unquoted, executable) into a generated
Arkane input file that Arkane then executes. It accepted any keyword NAME and any
`ast.Constant` type, so `LevelOfTheory(__class__='x')`, `LevelOfTheory(method=True)`,
`LevelOfTheory(method=...)` (Ellipsis) and a vacuous `CompositeLevelOfTheory()` with
no freq/energy at all all passed structural validation and failed only inside Arkane,
i.e. after the file was written.

Bind the allowlist to Arkane's actual constructor schema (arkane/modelchem.py:98-121,
173-191, read-only): `LevelOfTheory`'s fields are method (required) plus optional
basis/auxiliary_basis/cabs/software/solvent/solvation_method/args, all str, with
software_version the one genuinely non-str field (int|float|str); and
`CompositeLevelOfTheory` takes exactly freq and energy, both required, both
`LevelOfTheory`. Required fields are now enforced, keyword names are restricted to
that field set, each value's type is checked by exact `type()` membership (so a bool
cannot pass as an int), and duplicate keywords are rejected.

Round-22 P1 #4.
Two defects that both let a capture be trusted when it should not be.

Supersession was not transactional. `capture_ts_artifacts` wrote straight into
`capture_dir`, so a run that died partway through left the previously good capture
already destroyed and the new one incomplete, with no way back -- demonstrated with a
probe test before the fix. The whole capture (qm/, networks/, provenance/, manifest)
is now built in a sibling `.capture-staging-*` directory, self-checked with
`verify_capture` while still staged, and only then swapped in: rename any existing
capture_dir aside, `os.replace` the staged tree into place, and remove the
renamed-aside tree only once the new one is confirmed. If the replace itself fails,
the old tree is renamed straight back. `capture_dir` is therefore always either the
complete old capture or the complete new one.

`verify_capture` did not confine manifest-supplied paths. `captured_artifact_path`,
every `captured_log_sha256` key, and the networks block's `captured_path` were joined
onto `capture_dir` with no confinement, and `os.path.join(a, '/abs')` returns `/abs`
-- so a tampered manifest could make verification "pass" against an external file that
happened to match, rather than the capture's own vendored copy. All three now go
through `_confine_to_capture_dir` (realpath + commonpath), mirroring
`t3.pdep.discovery._confine_to_project`.

Also fixes a vacuous test found while mutation-testing this: the pre-existing
dotdot-escape test used two `..` components and overshot to a nonexistent path, so it
passed on an unrelated "no longer exists" error whose message merely happened to
contain the substring it matched on. It passed with confinement removed entirely.

Round-22 P1 #2 and #3.
Establishes one rule: every correction the generated Arkane input turns ON must have
its VALUES pinned in that same file. Anything else silently delegates the QM'd TS's
energy reference to whatever Arkane database happens to be installed at solve time,
so the TS drifts against the RMG wells around it whenever that database changes --
the exact failure this module family exists to prevent, and one no downstream test
would catch.

Two states are now refused:

* `use_atom_corrections=True` with `atom_energies=None`. This was live and reachable
  from real ARC projects: `_resolve_atom_energies` may legitimately return None while
  the flag is True, and `_build_energy_header` emits `atomEnergies` only when it is
  not None -- so the generated file turned AEC on and pinned nothing. Twelfth
  fail-open on this branch.
* `use_bond_corrections=True`, unconditionally. Arkane's input DSL has no directive
  to pin BAC values at all (arkane/input.py:670-700: `useBondCorrections` is a bool
  and `bondCorrectionType` is a KEY selecting a parameterization; `atomEnergies` is
  the only corrections-VALUES directive that exists), so a hybrid network with BACs
  on cannot be put on a verifiable energy scale from an input file. This is a
  limitation of Arkane's input language, not a T3 policy preference, and the error
  message says so. BACs are applied to transition states, not only thermo species, so
  the resulting TS-vs-well drift is directly material to a P-dep rate calculation.

`use_atom_corrections=False` was already refused, so the only accepted state is now
atom corrections on with values pinned, bond corrections off.

Fixing this exposed that most of test_hybrid.py's fixtures were constructing exactly
the unpinnable state -- `DEFAULT_ENERGY_SETTINGS` had `atom_energies=None`. The
fixtures were given realistic ARC-shaped atom energies rather than the guard being
weakened, and the end-to-end capture-drives-a-hybrid-write test now threads
`atom_energies` from the frozen manifest through to the writer, which it previously
dropped entirely.

Also fixes the same bug class one line over: `_cross_validate_frequency_scale_factor`
used `isinstance(output_value, (int, float))`, so a YAML `freq_scale_factor: true` in
output.yml was accepted and frozen as 1.0, silently scaling every frequency by unity.
The rest of this module already uses exact `type()` membership for that reason; this
site was missed.

Round-23 P1 #2 and P2 #5, and the BAC half of round-22 finding #5 (resolved as
fail-closed after establishing that freeze-and-verify would require importing Arkane
into T3, which is forbidden).
Two gaps left by 6e75abe's staging-and-swap.

The swap was not crash-atomic. It renames the existing capture_dir aside and then
os.replace's the staged tree into the now-vacant path; process death BETWEEN those two
calls leaves capture_dir absent and the only good copy orphaned under a
`.capture-old-*` name nothing looked for. The existing test raised an exception the
in-process `except` already handled, so it never exercised that window at all.

os.replace cannot atomically replace a non-empty directory, so a true single-syscall
content swap is not available on POSIX. Instead, `_recover_capture_swap_state` runs at
the top of every capture while holding the lock, and restores a valid orphaned
`.capture-old-*` when capture_dir is absent or has no valid manifest. It refuses to
guess when several ambiguous candidates exist. The guarantee this gives is stated
plainly in the code: capture_dir is never permanently lost and self-heals on the next
lock-holding call, but this is NOT real-time atomicity -- an outside observer can
still transiently see capture_dir absent between the crash and that call. That window
is fail-closed (an absent capture reads as "no capture yet", which costs an ARC re-run
rather than producing a wrong answer).

The crash test now forks a real child that monkeypatches os.rename to os._exit(1)
immediately after the real rename completes -- genuine process death, no cleanup, no
caught exception.

There was also no single-writer guarantee: two T3 processes capturing the same
network could interleave staging and swap. `_acquire_capture_lock` now uses
os.open(O_CREAT|O_EXCL) with the owning PID written in for diagnosability, released in
a finally. A lock is reclaimed only when its recorded PID is confirmed dead via
os.kill(pid, 0); a PermissionError (alive under another uid) is treated as NOT stale.
Contention fails closed with a loud RuntimeError rather than proceeding or skipping.

Round-23 P1 #3 and #4.
`_pdep_capture_is_authoritative` compared only the ARC directory and the
(network_id, network_ts_label) key set. So a capture stayed "authoritative" when the
RMG network file had been regenerated or the master-equation method had changed, as
long as the ARC directory and TS labels were untouched -- and T3 replayed frozen
artifacts describing a different calculation, silently attributing the result to
inputs that never produced it. Confirmed with a probe before fixing.

`_pdep_capture_networks_match_current_join` now compares the manifest's frozen
`networks` block against the current join sidecar's on both `source_sha256` and
`method`, for every network the capture covers. It fails closed: a block that is
missing, not a mapping, or missing an entry on either side counts as NOT matching,
never as vacuously matching. A mismatch returns False rather than raising -- unlike
the ARC-directory and TS-set checks, where reuse would be ambiguous, a network or
method change unambiguously means the capture is stale, so a normal fresh capture
should just proceed and overwrite it.

Round-23 P1 #1 (and round-22 finding #6).
The frozen energy-settings shape was defined three times -- `_FROZEN_FIELD_TYPES`
(the manifest shape, in energy_settings.py), `_ENERGY_SETTINGS_FIELD_TYPES` (the
writer shape, in hybrid.py), and `QMEnergySettings`'s own dataclass fields -- and had
already drifted: `QMEnergySettings` had no `bond_additivity_corrections` field at all,
so the manifest froze that value and the writer dropped it on the floor, and threading
`atom_energies` from a manifest into the writer was something each caller had to
remember to do by hand.

`QMEnergySettings` is now the single definition. Each field carries its own
`metadata={'types', 'none_allowed', 'in_frozen_manifest'}`, read by two classmethods:
`field_types(frozen=...)` (the one type table, filterable to the fields that live in a
manifest block -- `tunneling` is write-time-only) and `from_frozen(...)` (the one place
a frozen dict becomes a validated object). Both former tables are deleted;
`_validate_energy_settings_types` and `validate_frozen_energy_settings` now consult
the dataclass. Adding a field is one edit instead of three plus tests.

`bond_additivity_corrections` becomes a real field, documented as provenance-only:
Arkane's input DSL cannot pin BAC values, and the writer refuses
`use_bond_corrections=True` unconditionally, so it is never rendered. That is now
explicit rather than a silent drop. The end-to-end capture-drives-a-write test uses
`QMEnergySettings.from_frozen(...)` instead of a hand-rolled seven-field constructor
call, making the manifest-to-writer mapping structural.

Mutation-testing the refactor surfaced two real test-coverage gaps, both now closed:
the `use_atom_corrections=False` refusal was only ever exercised together with the
adjacent `atom_energies is None` refusal, so it could have been deleted with no test
failing; and the fail-closed "missing required frozen key" path had no isolating test
at all. Same guard-composition shape this branch keeps producing, this time in the
tests rather than the code.

No behaviour change. Round-23 P2 #6.
…d TS

A capture is supposed to let a replay decide, from the capture alone, whether it is
partial and whether the dominant transition state is missing. It could not: the
manifest recorded no sensitivity evidence at all.

The obvious wiring was rejected earlier because `_capture_pdep_ts_artifacts` also runs
on the RESTART path, where `self.pdep_network_selections` is empty in memory -- so
reading the evidence at capture time would record it as ABSENT exactly when a capture
is replayed. That deferral was wrong (adversarial review rejected it twice: absent
evidence in the manifest actively endangers the very decisions the capture exists to
support), but the hazard it identified was real.

So the evidence is frozen where it is definitely in memory -- at QUEUE time, in
`queue_pdep_transition_states`, onto the `TSJoinRecord` -- and flows from there into
the durable join sidecar. `_capture_pdep_ts_artifacts` now reads it off the join
records, never off `self`, so a restart has it for the same reason it has the records
and the networks block: it was persisted, not re-derived.

Several `uncertain_path_reactions` entries can share one `ts_label` (a shared TS
reached via more than one path reaction, or evaluated under more than one condition);
the largest |coefficient| wins, mirroring the selector's own documented ranking
convention.

Fails closed throughout, per this branch's rule:
* `coefficient` and `delta_ln_k` describe one piece of evidence, so supplying exactly
  one of them is refused rather than half-persisted.
* Both must be finite real numbers. `bool` is excluded despite being an `int` subtype,
  so a stray True/False cannot become a 1/0 sensitivity; NaN/inf are refused because
  YAML round-trips them but they carry no usable information.
* `verify_capture` refuses a manifest entry that has a captured artifact but no valid
  evidence -- a captured TS with no recorded justification is malformed, not tolerable.
* Queueing a TS reported uncertain but with no matching evidence entry raises rather
  than queueing it silently unjustified.

No back-compat shim: a manifest predating this format raises, matching how the
`networks` block was introduced.

Round-22 finding #8.
The payload of this branch. `T3.process_arc_run()` now writes one hybrid Arkane P-dep
input per network -- QM/RRKM for the transition states whose captured artifact status
is USABLE, RMG/ILT for everything else -- built entirely from the capture directory.

The property that motivates the whole capture layer now holds on the PRODUCTION path,
not just in a hand-built test: the hybrid write succeeds with the ARC project
directory absent entirely. That is guaranteed structurally rather than only tested.
`_write_pdep_hybrid_network_inputs` reads exactly two `self.paths` keys ('PDep
capture' and the new 'PDep hybrid'); every path handed to the writer is joined onto
`capture_dir` from manifest-relative fields, the network source is the VENDORED copy
under `networks/`, the energy settings come from the frozen manifest block via
`QMEnergySettings.from_frozen(...)`, and `qm_artifacts_root=capture_dir` confines
`Log(...)` resolution inside the capture. `self.paths['ARC']` and
`self.paths['RMG PDep']` are never touched.

This also gives `evaluate_pdep_hybrid()` (t3/pdep/discovery.py) its first production
caller -- it was written as the per-network accept/refuse gate and had none, so the
partial-coverage decision was simply not being made. A network whose selected TS set
is not fully usable is now refused as a whole and stays entirely RMG/ILT, logged
loudly, rather than being silently written as a partial hybrid.

The ARC finalization marker is versioned (`ARC_FINALIZATION_MARKER_VERSION = 2`) via
one constant shared by writer and reader so they cannot drift. A legacy unversioned
marker (implicitly v1: written by finalizations that never produced hybrid inputs) and
any future-version marker both read as "not finalized", so finalization is redone
rather than silently skipped. That redo is safe because every step of
process_arc_run() is individually idempotent, each for its own reason -- not, as an
earlier draft of this message claimed, because of the authoritative-capture guard,
which only ever covered the capture step. The step that redo actually re-executes on
already-appended results is the RMG library append, and it is idempotent because
append_to_rmg_library() skips any source entry whose label is already present in the
destination. That was asserted rather than verified, so it is now tested directly, on
both the merge path and the create-then-redo path that a first iteration takes, for
thermo and kinetics alike. The trailing parenthesis in '(v2)' keeps the prefix check
collision-proof against a future '(v20)'.
This test has been dead on this branch. It hardcoded a 'T3/Projects/...' prefix, so
it failed on the very first key in any worktree not literally named 'T3' (here
'T3-pdep-qm') -- long before reaching anything it was actually meant to check. Drop
the worktree-name component from the expected substrings.

Reviving it exposed what it had been unable to report: its expected dict never gained
the 'PDep capture', 'PDep hybrid' or 'ARC finalization marker' keys added by this
branch. Since the loop iterates the LIVE `t3.paths`, a key present in `set_paths()`
but absent here would have surfaced as a bare KeyError. Add the three keys, and assert
the key sets match up front so future drift reports which side is missing what instead
of dying in a dict lookup.
_write_pdep_hybrid_network_inputs() never removed hybrid outputs left by a PRIOR
call over the same iteration -- reachable in production because an unversioned or
future-version finalization marker forces process_arc_run() to redo finalization
(capture + hybrid write) over a directory that may already carry outputs from an
earlier pass. Three leaks followed from this: a network refused on this pass (its
selected TS set no longer fully usable) left the earlier pass's input.py in place;
a network dropped from the manifest entirely between passes was never revisited
because the write loop only iterates networks the CURRENT manifest references; and
the zero-captured-artifact early return skipped straight to `return dict()`,
leaving the whole prior 'PDep hybrid' tree untouched. Any of the three lets a
downstream consumer read a hybrid QM/RRKM input for a network T3 no longer stands
behind on this pass.

Fixed with a new _prune_stale_pdep_hybrid_outputs() helper that removes every
per-network directory under 'PDep hybrid' not in the set of network_ids accepted
THIS pass. It is called from both the zero-artifact early return (with an empty
accepted set, since nothing is being hybridized) and the main body, where the
accept/refuse verdict for every network is now evaluated up front so pruning can
happen BEFORE the write loop -- reversing that order would risk a refused
network's stale directory surviving a raise partway through the loop, or an
accepted network's fresh write being deleted by a prune that runs too late.

The helper only ever joins names returned by os.listdir() onto the hybrid root --
never a manifest-supplied network_id -- so it is confinement-safe by construction,
independent of the network_id confinement fix that follows in the next commit. It
also fails closed on anything under the hybrid root that is not a plain directory
(a file or a symlink), since that is not something this code would ever have
written there itself.

Tests added to TestWritePdepHybridNetworkInputsPruning in test_main_wiring.py cover
all three leaks plus a no-over-deletion-regression check that an accepted network's
output still gets written.
_write_pdep_hybrid_network_inputs() built dest_path with
os.path.join(self.paths['PDep hybrid'], network_id, 'input.py') using network_id
straight from the capture manifest -- untrusted for use as a raw filesystem path
segment even though its CONTENTS are otherwise trusted. A manifest carrying
network_id='../escaped' walks the write out of the hybrid root via os.path.join()
join semantics; a manifest carrying an absolute network_id causes os.path.join()
to discard the hybrid root entirely and write wherever the absolute path points.
Both are demonstrated by the two new tests, which showed T3 actually writing
"Wrote a hybrid P-dep network input for '<escaped path>'" to a location outside
'PDep hybrid' before this fix (confirmed via captured stdout, not merely a raised
exception under mutation).

Fixed with a realpath()+commonpath() confinement check immediately before
dest_path is built, following the codebase's existing (deliberately
per-module-duplicated) confinement idiom used in capture.py's
_confine_to_capture_dir, discovery.py's _confine_to_project, and the inline
checks in hybrid.py and rmg_runner.py. It deliberately does NOT reuse capture.py's
private _confine_to_capture_dir (its error message talks about "capture
directory" / "verify", which is a non-sequitur in this write path) and does NOT
introduce a new shared helper (the codebase has never had one for this idiom --
four independent inline implementations already exist, and centralizing now for
a single new caller would break that established pattern for no clear benefit).
str.startswith() is deliberately avoided in favor of commonpath(), since a
sibling directory whose name is a prefix of the hybrid root's name could defeat a
naive startswith() check.

t3/pdep/join.py's validate_networks_block (around line 406) was checked and does
NOT need a matching guard: network_id there is used only as a dict key and
interpolated into error-message text, never joined into a filesystem path.

Tests added to TestWritePdepHybridNetworkInputsPruning in test_main_wiring.py
cover both the '..'-relative and absolute-path escape vectors.

The confinement is applied to every network_id in the manifest UP FRONT, before
_prune_stale_pdep_hybrid_outputs() runs, rather than at the write site. Pruning is
destructive and is driven by these same ids -- a directory whose name is not among
them is removed -- so validating only at the write site let a manifest T3 was about
to refuse still delete a previous pass's legitimate outputs on its way to being
rejected. That failed closed (it raised, and no finalization marker was written), but
a manifest that fails validation should not get to touch the output tree at all.
Confirmed by test: with the check at the write site, the escaping-network_id test
raised the correct ValueError and yet network4_2/input.py was already gone.

The check itself is factored into _confine_to_pdep_hybrid_root(), local to main.py
for the same reason capture.py and discovery.py each carry their own: capture.py's
helper reports failures in terms of a capture directory and verification, which is a
non-sequitur on this write path.
…ponent

_confine_to_pdep_hybrid_root()'s realpath+commonpath check lets a
network_id containing a path separator (e.g. 'a/b') resolve under the
hybrid root and pass, but _prune_stale_pdep_hybrid_outputs() compares
raw accepted network_ids against TOP-LEVEL os.listdir() names, so 'a/b'
and a top-level 'a' entry collide there. Require network_id to be a
single, safe filename component (not empty, '.', '..', absolute, or
containing a path separator) before any path is ever built from it,
closing the collision at its source.
…output

_prune_stale_pdep_hybrid_outputs() previously validated and deleted entries
in the same os.listdir() pass: a stale directory could be rmtree'd before a
later entry was found to be unacceptable and raised, leaving a half-pruned
tree behind. It also never checked that an ACCEPTED network's own
destination under 'PDep hybrid' was still a plain directory (or absent)
before writing into it -- a plain file or symlink left there (e.g. by
external interference) would only be discovered after other, unrelated
stale directories had already been deleted.

Make pruning a genuine two-pass preflight-then-delete: first classify every
entry under the hybrid root (accepted-and-a-plain-directory-or-absent is
fine; anything else -- including a non-directory/symlink placed where an
accepted network's own output belongs -- raises immediately with nothing
yet removed), then delete only the entries confirmed stale in the first
pass.

Adds two tests under TestWritePdepHybridNetworkInputsPruning:
- test_an_accepted_networks_destination_that_is_a_plain_file_is_refused
- test_a_pre_existing_stale_directory_survives_when_a_later_entry_makes_pruning_raise

Verified: -k "TestWritePdepHybridNetworkInputsPruning" -> 9 passed, 53
deselected in 226.72s.
… status/path consistency

Two related defects in the capture re-verification boundary:

1. verify_capture() counted captured_artifact_count purely off "is
   captured_artifact_path non-None", never checking it against status. A
   manifest entry hand-edited (or corrupted) to status: usable with a null
   captured_artifact_path verified cleanly and then silently vanished from
   every downstream count, even though nothing had actually been captured
   for it. verify_capture() now fails closed: an unrecognized/missing
   status raises, a status in TS_ARTIFACT_STATUSES_REQUIRING_ARTIFACT_PATH
   (usable, unverified) with a null path raises, and a status in
   TS_ARTIFACT_STATUSES_REQUIRING_NO_ARTIFACT_PATH (missing, unusable,
   not_queued) with a non-null path raises. The two frozensets live in
   t3.pdep.discovery, read directly off discover_ts_artifacts()'s own
   branches, so verify_capture enforces exactly the pairing discovery
   itself would have produced -- not a second, independently-invented
   rule.

2. The hybrid-input writer (t3.main._write_pdep_hybrid_network_inputs)
   re-read the manifest itself after verify_capture() had already read and
   validated it. A second, untrusted read can observe a manifest mutated
   out from under the first read (e.g. a concurrent re-capture),
   reintroducing a check-then-use race that verify_capture()'s own
   re-verification exists to close. VerifyResult now carries a new
   ts_records: tuple field -- one TSArtifactRecord per transition-state
   manifest entry, built from exactly the fields verify_capture() itself
   just validated, with artifact_path already resolved to an absolute,
   confined path. The writer (fixed in a following commit) consumes this
   directly instead of re-reading the manifest.

Adds 5 tests to tests/test_pdep/test_capture.py:
- test_verify_capture_returns_the_verified_ts_records_for_the_writer_to_consume
- test_verify_capture_refuses_a_usable_status_with_no_captured_artifact_path
- test_verify_capture_refuses_a_non_null_captured_artifact_path_on_a_status_forbidding_one
- test_verify_capture_refuses_an_unrecognized_status
- test_verify_capture_refuses_a_missing_status_field

Verified: tests/test_pdep/test_capture.py -> 82 passed in 1.60s.
Comment thread t3/pdep/parser.py Fixed
…nguishable

Round-41 adversarial review of the T-grid clamp (5555c56, be177e1). Four findings.

A. The Tlist-drop equivalence claim was backed by an unrepresentative file. The
   commit verified the dropped Tlist against Arkane's 1/T-linspace branch, using
   the one network I happened to run -- which is interpolationModel
   'pdeparrhenius'. But 300/300 real RMG network files are ('Chebyshev', N, M),
   where generate_T_list() takes a completely different branch (Gauss-Chebyshev
   nodes). So the evidence covered ~0% of the real population.

   Re-probed: the claim does hold for Chebyshev too -- a real Chebyshev network
   (Tmin=300, Tmax=5100, Tcount=10) has a Tlist equal to Arkane's Gauss-Chebyshev
   nodes to 0.005 K, which is written-file rounding. The fix is branch-agnostic
   BY CONSTRUCTION: it preserves interpolationModel and lets Arkane pick the
   family itself, so it cannot pick the wrong one. That property, not my
   evidence, is what made it correct. Now pinned by a test that asserts both the
   Tlist drop AND the survival of the interpolationModel line, with the node
   formula in the docstring.

B. network_thermo_t_max() lied by omission: the docstring promised the ceiling at
   which EVERY species has valid thermo, while silently skipping species with
   missing, non-NASA, non-Kelvin or non-literal thermo. If a skipped species is
   the limiting one, it returns a too-high ceiling and the clamp under-protects --
   partial evidence presented as a network-wide guarantee. Now returns a
   NetworkThermoCeiling carrying what was skipped; both writers log a WARNING
   naming the count and labels. Deliberately NOT fail-closed: exotic thermo is
   common and harmless, and over-refusal is this branch's recurring failure mode.
   Visibility is the fix, not refusal.

C. The hybrid path fail-opened on any ValueError. A blanket `except ValueError`
   meant every error network_thermo_t_max could raise silently disabled BOTH the
   clamp and the Tlist drop. Only the "text does not parse" case should degrade
   (so the pipeline's own ast.parse self-check reports corruption); everything
   else is a defect signal and must propagate. Now raises and catches a
   purpose-named NetworkTextUnparseable rather than matching on message text.

   Worth recording HOW this was found: the surrounding comment already described
   the narrowed behaviour, and a previous report claimed it was implemented. It
   was not -- the comment described an intent the code never had. It surfaced
   only because a test written to pin already-"fixed" behaviour failed against
   the supposedly-correct code. A comment is not evidence that the code does what
   it says.

D. Tlist parsing assumed a single-line tuple of Kelvin numbers. Now validates the
   parsed shape before comparing and refuses with a message naming the file
   rather than an opaque IndexError/TypeError -- but only when a clamp is in
   play; an odd Tlist on a network needing no clamp is not this code's business.
   Robustness, not a live bug: 0/300 real files are multiline or non-K.

Mutation-tested throughout; the mutation for C is what exposed C.

tests/test_pdep + tests/test_utils: 1162 passed.
Comment thread t3/pdep/parser.py
Comment on lines +25 to +28
from t3.utils.network_thermo import (NetworkTextUnparseable, # noqa: F401
NetworkThermoCeiling, # noqa: F401
format_skipped_species, # noqa: F401
network_thermo_t_max) # noqa: F401
alongd added 5 commits August 1, 2026 00:42
…cord

Round-41 finding: the T-grid clamp was recorded only as a logger.warning. The
sidecar already carried the network hash, SA hash, method and perturbation, but
nothing durable said the sensitivity analysis had been computed over a NARROWED
temperature domain. A saved PDepNetworkSelection could not be told apart from
one resting on the network's original grid -- and a coefficient measured over
[700, 3000] K is not the same measurement as one over [700, 3200] K. A log line
is gone the moment the run ends, so this violated the rule that a decision's
evidence must be reconstructible from the persisted record.

New TGridClampRecord (t3/utils/network_thermo.py) carries the requested Tmax,
the thermo ceiling, the Tmax actually written, whether the explicit Tlist was
dropped and its original highest entry, and which species were skipped when
computing the ceiling. The writers emit it, write_sa_cache_metadata persists it
in the SA sidecar, and it rides on PDepNetworkSelection through the YAML round
trip (dict-shaped, no tuples, per the settled round-trip constraint).

The distinction that matters is THREE-way, not two:

  None                        -> provenance UNKNOWN (sidecar predates this, or
                                 the SA was produced outside T3)
  {'clamped': False, ...}     -> positively known NOT to have been clamped
  {'clamped': True, ...}      -> clamped, with both the requested and the
                                 written Tmax recoverable

Collapsing "unknown" into "not clamped" would silently reintroduce the exact
defect being fixed, so absence is never read as a negative. Unknown provenance is
a disclosure, not a disqualification: it does NOT make a decision not_evaluated
and does NOT refuse, since over-refusal is this branch's recurring failure mode.

SA_CACHE_CONTRACT_VERSION deliberately stays 1. The key is purely additive: a
sidecar written before this change is not MISREAD by the new reader, it simply
reads as unknown, which is a true statement about it. A bump would wrongly
invalidate every existing cache to record something no old cache ever claimed.

tests/test_pdep + tests/test_utils: 1185 passed.
tests/test_pdep/test_main_wiring.py: 66 passed (28 min).
Round-41 finding #1. A persisted PDepNetworkSelection decides whether T3 spends
expensive QM on a network. `_selection_from_dict` type/enum-validated each field
independently, so a hand-edited record with valid bools and enums, a matching
hash and method, and empty or fabricated evidence still passed
`explore_pdep_network`'s gate on `selection.qualified`.

5823c1c added a guard for the empty-evidence case in `reason()`, but that only
makes the human-readable prose honest -- the record still gated exploration.
Rejection belongs at load, where the record enters the system.

Two invariants, each derived by reading the constructors rather than assumed:

  qualified=True requires non-empty uncertain_path_reactions
      select_from_sa_dict sets `qualified = bool(uncertain_path_reactions)`, and
      combine() unions qualified over evaluated components -- whose own evidence
      is part of the unioned result. No path produces a qualified record with no
      evidence.

  every uncertain_path_reactions entry is marked uncertain AND appears in
  selected_ts
      select_from_sa_dict builds it as `[e for e in selected_ts if e.uncertain]`,
      a filtered subset by construction; combine() only unions matching lists.

Two candidate invariants were deliberately NOT enforced, because reading
combine() shows real records violate them:

  qualified == bool(uncertain_path_reactions) as full equality -- combine()
      legitimately yields qualified=False with non-empty evidence, since it
      unions uncertain_path_reactions across ALL components but unions qualified
      over EVALUATED ones only, so a not-evaluated component's evidence rides
      along on a negative decision.

  any evaluation_status cross-check -- `qualified=True` with `not_evaluated` is
      the PARTIAL YES, which combine() constructs when one component qualified
      and another could not be evaluated. The branch's asymmetry is that a
      partial yes is supported; refusing it would be over-refusal, this branch's
      recurring failure mode and as real a defect as the hole being closed.

Both over-refusal cases are pinned by tests built through the REAL constructors
(select_from_sa_dict against the network4_2 fixture, and combine()), not by
hand-written fixtures that would merely agree with the validator.

The third invariant's mutation initially killed no test -- the gap was closed
with a test rather than left standing.

tests/test_pdep + tests/test_utils: 1191 passed.
Queueing a P-dep network for QM refinement was an immediate, per-network
decision taken inside determine_species_from_pdep_network()'s loop: the
`if selection.qualified:` branch queued the network's uncertain transition
states on the spot. Whether a network is worth its QM cost is a question
about the whole field of candidates, so it cannot be answered while
candidates are still being discovered -- ranking networks against one
another is impossible from inside that branch.

The branch now records a frozen PDepQueueCandidate instead, carrying the
three loop-local values the queueing step needs and that do not otherwise
survive the loop (the parsed network, the Arkane structures block, and the
file the network was parsed from). A second pass after the loop does the
queueing, in the order the candidates were found. No budget or ranking is
applied yet; every qualified network is still queued, so which networks are
refined is unchanged.

This is NOT a pure no-op, and the difference is worth stating plainly.
queue_pdep_transition_states() calls add_reaction(), which cascades into
add_species() for any of the reaction's species T3 does not already know.
The sensitive-well branch of the same loop also calls add_species(). So
deferring the queueing reorders species-key assignment between those two
paths, and since add_species() assigns `key = len(self.species)` and
get_species_with_qm_label() derives the ARC-facing QM label from that key,
species keys and QM labels differ from what the old interleaving produced.
Consequently species.yml, reactions.yml and restart state are not
bit-for-bit comparable across this change. No work is lost: the SET of
species queued for QM is the same, and every one of them is still computed.

One consequence is a contract question rather than mere numbering. A
species reachable from both paths gets a key back from whichever path
reaches it first and None from the second, and only the well path appends
to the returned species_keys. With queueing deferred, the well analysis now
always reaches such a species first, so a species justified by a sensitive
well is always reported -- which is the intended reading of a return value
documented as "species determined to be calculated based on SA". The second
test below pins that deliberately.

Both new tests were mutation-verified against the same mutation, putting
the queue call back inside the loop:
  - test_queueing_is_deferred_until_every_network_has_been_evaluated
    explores two entries and asserts every queue call saw both selections
    already recorded ([2, 2]; the mutation yields [1, 2]).
  - test_a_species_justified_by_a_sensitive_well_is_reported_even_when_a_
    queued_ts_also_needs_it fails under the mutation because the queued
    TS1 reaction claims C4rad(5) first.

Green: 1191 (tests/test_pdep + tests/test_utils, ignoring test_flux.py and
test_main_wiring.py), 67 (test_main_wiring.py, 29m47s), and
tests/test_main.py -k pdep.
Whether a network is worth its quantum chemistry is a question about the
whole field of candidates, and the previous answer was a per-network gate:
every qualified network was refined, however many there were and however
much they cost. This adds the other half -- rank the qualified networks
against one another and refine as many as the configured budget allows,
most deserving first.

Two knobs, both defaulting to None (no limit), so the budget is opt-in and
setting nothing refines exactly the networks T3 refined before:
  t3.sensitivity.pdep_QM_max_transition_states -- the primary bound, since
    ARC jobs are per-species and per-transition-state;
  t3.sensitivity.pdep_QM_max_networks -- a secondary cap on whole networks.
Both are strict ints: bool is an int subclass, so without that a YAML
`pdep_QM_max_networks: true` would validate as 1 and silently cap the run
at a single network per iteration.

The ranking already existed in t3.pdep.api.rank_pdep_networks as a local
_rank_key. It is now t3.pdep.selector.selection_rank_key, so the in-run
queueing and the public ranking API cannot drift apart into two different
ideas of "most deserving".

t3/pdep/budget.py is pure: it reads decisions and returns indices. That is
not tidiness. Projecting a network's cost by actually queueing it would
mutate T3's species, reactions and QM state, which is precisely what a
budget has to be able to decide against before committing to it. Three
further properties are load-bearing and are argued in the module docstring:

  It never slices a network. uncertain_ts_labels() is sorted by LABEL, not
  by sensitivity, so taking "the first k" of them would choose quantum
  chemistry alphabetically. A network that does not fit is skipped whole
  and the walk continues, so a smaller, lower-ranked network can still be
  admitted rather than leaving budget unspent.

  Its cost is an upper bound. Some uncertain transition states will not
  become ARC jobs (unsafe label, missing structures, a shared transition
  state, a reaction T3 already knows), and they are charged anyway, so the
  budget can only over-estimate the spend it authorizes.

  A network offered several times in one iteration -- the normal case,
  since several sensitive reactions can belong to one network -- is charged
  once, for the UNION of what its offers name, and ranked by the best of
  them. Two offers are not one decision reached twice: each answers "which
  transition states is THIS observable reaction's rate sensitive to?", so
  they legitimately name different transition states, and queueing both
  produces their union.

A network no offer could evaluate is refused before it is ranked. It is not
a cheap candidate, it is an absent measurement: there is nothing to rank it
by and nothing to justify spending on it. A network whose cost exceeds the
entire budget is refused with a distinct reason naming the limit to raise,
because no remaining budget is ever larger than the whole of it and no
later iteration will change that on its own -- without that message it
would simply starve, quietly, forever.

Nothing is dropped silently. Every refusal is recorded in the returned
budget decision and logged as a WARNING naming the network, its cost and the bound that refused
it, because a run that quietly shrank its own work would be
indistinguishable in the logs from a run that had nothing left to do.

Note what this commit does NOT do. main.py still offers only QUALIFIED
networks to the budget, so the hard gate survives one level up, and
explore_pdep_network still refuses an evaluated-but-unqualified network
outright. Reframing that gate is a public-API change and is deliberately
left to its own commit. Also, a budget refusal is durable only in the log,
not on the persisted selection; recording it there bumps the selection
schema version and is likewise left separate.

Ten mutations were applied and all ten killed: charge the first offer
instead of the union; rank by the first offer instead of the best; group
unnamed selections together; delete the not-evaluated refusal; delete the
exceeds-the-whole-budget refusal; drop strict=True; hardcode the knobs to
None at the call site; queue every candidate ignoring admitted_indices;
drop the sort; turn skip-and-continue into stop-at-first-miss.

Green: 1247 (tests/test_pdep + tests/test_utils + tests/test_schema,
ignoring test_flux.py and test_main_wiring.py) and 70 (test_main_wiring.py,
30m23s).
…tion

Third and last part of the gate -> ranking reframe. 354476f gave T3's in-run
queueing the whole field to decide with; e43349d ranked that field against a
budget. Both stopped at T3's own wiring: explore_pdep_network(), the public
entry point, still treated selection.qualified as the sole admission
authority, so a caller that had ranked a network and chosen to spend on it had
no way to say so.

It now takes a keyword-only admission_policy. Under 'caller_admitted' BOTH
qualification checks stand aside -- the unqualified skip and the not-evaluated
raise -- because both are about using `qualified` AS A GATE, and a caller that
admitted the network elsewhere is not using it as one. Under the default,
'qualified_selection', nothing changes: with no external admission there is no
positive evidence and no spend decision, so a missing evaluation is still a
missing verdict rather than a negative one.

What does NOT stand aside is provenance. method, network_id and
network_source_hash stay unconditional, and evaluation_status is now checked
for validity unconditionally too -- admitting a network is a budget statement,
and no budget statement makes a stale or unreadable decision current. That is
the whole difference from selection=None, which drops the binding along with
the gate. Keyword-only because logger is the fourth positional parameter and
an argument inserted before it would land on callers' logger.

The result records what admitted it, since a 'succeeded' result carrying an
unqualified selection is otherwise indistinguishable from a bypassed gate. A
third value, 'ungated', covers selection=None: recording the argument default
there would have the record assert that a qualified selection admitted a run
for which no selection existed -- a false provenance claim, and the one an
auditor of an expensive QM run would lean on. It is derived rather than
requested, so a caller cannot claim 'ungated' while passing a selection, and
'skipped' is cross-checked against it because only the gate can decline a run.

A results file written before this field is DERIVED, not refused and not
blanket-defaulted: nothing predating the field could have been
caller-admitted, so a record with a selection was gate-admitted and one
without was ungated. That reconstructs the true value instead of guessing, and
keeps EXPLORATION_RESULT_SCHEMA_VERSION 1 meaning one loadable shape.

Public API change; PR #179's description needs it. T3's own path does not use
the new policy yet -- t3.main still offers only qualified selections to the
budget -- so in-run behaviour is unchanged.

Also de-drifts prose that still called this a budget gate, including two
claims in selector.py that stated flatly that explore_pdep_network refuses a
selection that did not qualify.

Verified: 1264 fast tests, and 70 in test_main_wiring.py against this exact
tree. Fourteen mutations applied, fourteen killed, all against the final tree:
the skip ignores the policy; the raise ignores it; the hash check becomes
policy-conditional; the evaluation_status check becomes policy-conditional;
the recorded value echoes the argument instead of being derived; 'ungated'
becomes requestable; the loader defaults, or blanket-defaults, instead of
deriving; the succeeded and failed sites drop the field; an unknown policy
falls back to the default; caller_admitted with no selection is allowed as a
no-op; the result skips policy validation; the skipped cross-check is removed;
as_dict drops the field.

Codex round 44 rejected the bare require_qualified bool this started as -- a
flag naming what is not enforced hides what did admit the run -- and round 45
caught the false 'qualified_selection' on the selection-less path.
@alongd

alongd commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Update: the per-network qualification GATE is now a budget-limited RANKING

Three commits implement the reframe you asked for. Still DO NOT MERGE.

commit what
354476f T3 decides the QM queue with the whole field of networks in view, instead of queueing each one as it is found
e43349d the budget itself: t3/pdep/budget.py, plus two opt-in knobs, pdep_QM_max_transition_states and pdep_QM_max_networks (both default None = no limit)
dcc8d97 the public explore_pdep_network() gate becomes an admission policy

Two things to look at specifically.

Behaviour is NOT preserved by 354476f, and my earlier note claiming otherwise was wrong.
Deferring the queue changes the ORDER in which species are added, add_reaction cascades into
add_species, and add_species assigns key = len(self.species) — from which the ARC-facing QM
label is derived. So species keys and QM labels churn. The SET of species queued is unchanged and no
work is lost, but species.yml / reactions.yml restart state is no longer bit-for-bit comparable
across that commit. Stated in the commit message rather than left to be discovered.

dcc8d97 is a public API change. explore_pdep_network() takes a keyword-only admission_policy.
The default is exactly today's behaviour. Under 'caller_admitted' the qualification checks stand
aside — for a caller that ranked the network and chose to spend on it — while method,
network_id, network_source_hash and evaluation_status stay enforced, because admitting a
network is a budget statement and no budget statement makes a stale decision current.
PDepExplorationResult gained an admission_policy field recording what admitted each run.

Design points worth your disagreement:

  1. A network the budget cannot afford is skipped whole, never sliced.
    uncertain_ts_labels() is sorted alphabetically, not by sensitivity, so taking "the first k"
    would mean choosing quantum chemistry by label. The cost charged is the network's uncertain-TS
    count, deliberately an over-estimate of the ARC jobs it will produce.
  2. A refused network is not recorded durably yet — only logged. That is the next commit.
  3. T3's own path does not use 'caller_admitted': t3.main still offers only qualified selections
    to the budget. Qualification (physics) and affordability (budget) stayed separate questions.

Verification: 1264 fast tests, 70 in test_main_wiring.py, and 24 mutations applied and killed
across the three commits.

Comment thread t3/pdep/api.py
Comment on lines +40 to +49
from t3.pdep.explorer.result import (ADMISSION_POLICY_CALLER_ADMITTED,
ADMISSION_POLICY_QUALIFIED_SELECTION,
ADMISSION_POLICY_UNGATED,
EXPLORATION_RESULT_SCHEMA_VERSION,
EXPLORATION_STATUS_FAILED,
EXPLORATION_STATUS_SKIPPED,
EXPLORATION_STATUS_SUCCEEDED,
PDepExplorationResult,
VALID_ADMISSION_POLICIES,
)
A network that qualified for QM refinement and then went unrefined because the
budget was spent left nothing behind but two log lines. That is the one outcome
a long unattended campaign most needs to be able to reconstruct afterwards, and
it was the only decision in this package that vanished with the process.

The record covers admitted and refused networks alike, not refusals alone.
Recording only refusals would collapse "no file", "empty file", "nothing was
refused", "nothing was a candidate" and "no budget was configured" into a single
ambiguous silence, and a reader could not tell a network that was admitted from
one that was never considered.

Refusal reasons gain stable codes alongside their prose. The prose is written to
be read by a human in a log and is free to be reworded; anything that has to
branch on why a network was refused needs a key that is not a sentence.

The decision now carries the walk it already made -- the ranked identities, their
offers, costs and remaining budget -- and the record is built from that alone.
Deriving them a second time from the selections would have meant two
independently maintained copies of the same grouping and ranking, and on drift
the failure is not a crash but a record that confidently attributes the wrong
refusal to the wrong network. Since the builder no longer takes the selections,
handing it a decision and a field that never met is now unconstructible rather
than merely unlikely.

A network the budget identifies positionally because it has no id is recorded
that way too, rather than as a repeated empty id the record then rejects; the
budget accepts such networks, so refusing to describe them would have made the
record fail on input the decision itself allows.

Both a schema version and an algorithm version, following the split already in
selector.py: this record's shape can stay fixed while what the budget means by
ranking and refusal changes, and a reader has to be able to tell those apart.
@alongd

alongd commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Increment 32 — a budget refusal is now a durable record (2 commits, pushed)

3d00fa7 feat(pdep): give the QM budget a record of what it admitted and refused
1404c47 feat(pdep): write the QM budget's decision to disk each iteration

Until now, a network that qualified for QM refinement and then went unrefined because the budget was
spent left nothing behind but two log lines. For a long unattended campaign that is the outcome most
worth being able to reconstruct afterwards, and it was the only decision in this package that
vanished with the process.

The plan I inherited for this was built on a false premise, and probing killed it. It said to
record the refusal as a field on PDepNetworkSelection. But t3/main.py never persists selections
at all — save_pdep_network_selections has no caller outside tests/, and
self.pdep_network_selections is in-memory only. That approach would have left the task's entire
purpose unmet while appearing met. It would also have conflated a physics judgement ("is this
network's rate sensitive to estimated kinetics?") with an affordability one, which is the separation
t3/pdep/budget.py exists to maintain — and budget outcomes are iteration-relative, so the same
byte-identical selection is admitted in one iteration and refused in the next.

Reusing the existing TS-join sidecar was considered and rejected on review, for a reason I had not
found myself: has_pending_ts_join() makes restart treat that sidecar's existence as evidence that
ARC finalization needs rescue, so writing refusals into it would have perturbed restart semantics
rather than merely reusing a convenient file.

What landed instead: a PDepBudgetRecord written per iteration into the iteration directory,
covering admitted and refused networks alike. Refusals-only would collapse "no file", "empty
file", "nothing refused", "nothing was a candidate" and "no budget configured" into one ambiguous
silence. Refusal reasons gained stable machine codes alongside their prose, since the prose is
written to be read by a human and is free to be reworded.

Two defects worth naming, both found before the tests were green:

  • The builder could construct a record its own validator rejected. Two networks with an empty
    network_id are explicitly supported by the budget and both get admitted, but the record refused a
    repeated network_id. Fixed by recording the budget's own identity rather than pretending
    network_id is always the key — not by refusing to describe such networks, which would have made
    the record fail on input the decision itself allows.
  • A decision claiming two admissions rendered as a record reporting that nothing was considered,
    silently. That is an authoritative-looking lie in the one artifact whose purpose is to be
    authoritative. The builder now refuses an incoherent decision, at the point where authority is
    claimed rather than where the value is made.

The builder originally re-derived the budget's grouping, ranking and remaining-budget bookkeeping.
On review that was replaced: apply_pdep_qm_budget now returns the walk it already made, and the
builder consumes only that. Two maintained copies of the same ranking would have failed by
attributing the wrong refusal to the wrong network — silent corruption rather than a crash. The
builder no longer takes the selections at all, so a mismatched pair is unconstructible.

Verification. Fast suite 1317 passed; tests/test_pdep/test_main_wiring.py 71 passed
(29:17)
run against the exact committed tree. Eight mutations applied, eight killed, tree restored
byte-identical each time — including three that silently drop a field from as_dict() or the loader,
which every round-trip test over default values would have missed.

One caveat worth flagging: the record has no in-repo consumer today. Its intended reader is human
or external audit. Using it to change the next iteration's behaviour would be a mistake — reusing a
prior refusal risks over-refusal, so the budget is still recomputed from the current field every
iteration.

Still DO NOT MERGE — this is for your review.

Comment thread t3/pdep/budget.py
network_id = offers[0].network_id
cost = len({ts_label for selection in offers for ts_label in selection.uncertain_ts_labels()})
remaining_before = remaining
unnamed_offer_index = indices[0] if not network_id else None
The record type existed but nothing wrote it, and a save/load pair with no
production caller is not durability -- it is the same silence in a different
place, which is precisely why writing the outcome onto the selection was
rejected: T3 never persists selections either.

So t3/main.py now writes the record itself, into the iteration directory,
alongside the other per-iteration sidecars. It is written whenever the budget
runs, including when nothing was refused and when there was nothing to consider
at all, so that the absence of the file carries a meaning that is actually true:
the feature was off this iteration, not that it had nothing to say. For that to
stay true across a re-run, a record left by an earlier run of the same iteration
is cleared on the path where the feature is off, the way a superseded ARC
finalization marker already is -- otherwise a stale file would go on describing
a decision this run never took.

It is written before the queueing loop rather than after. The record describes
what the budget decided, not what queueing later managed to do, so it should
outlive a crash in the step it authorizes. That only holds if the file is never
half-written, since a truncated record can still parse and still validate while
under-reporting what was refused, so it is staged beside its destination and
moved into place, as the capture manifest already is. Replacing the path rather
than writing through it also means a symlink sitting where the record belongs is
replaced instead of followed.

The loader is strict in the manner of the two loaders already here: an
unversioned file, a version it does not recognize, a malformed envelope and a
malformed record are all refused rather than guessed at. Versions are compared
as integers, since Python would otherwise read `true` as version 1. The record
carries its own schema and algorithm versions and is never nested inside another
persisted structure, so those fields serve as the file's version markers and no
second envelope-level copy is introduced to disagree with them.

One existing wiring test had hand-built a budget decision that reported a refusal
while carrying no account of it. That is exactly the incoherence the record
refuses to render, so the test now builds a decision that is consistent with
itself.
@alongd

alongd commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Follow-up on the comment above — adversarial review of the write path found six defects

1404c47 was amended to 38452ad (force-pushed with lease; no other branch or worktree was based
on it, so nothing was orphaned). The SHA in my previous comment is therefore dead — the review fixes
are squashed into the commit that owns the code rather than stacked on top of it, so what you review
is one coherent commit.

Two of the six are worth calling out, because they were real and neither was caught by a green suite:

The write followed a symlink. A symlink sitting at iteration_N/t3_pdep_qm_budget.yml redirected
the write outside the iteration directory. This was demonstrated before fixing it, not assumed: a test
that put a symlink at the target pointing at a sentinel file watched the sentinel get overwritten with
budget YAML. It is fixed by the same change that made the write atomic — staging beside the
destination and os.replace-ing into place replaces the link rather than writing through it.

The write was not atomic, and a truncated record still validates. A partial file can parse and
pass validation — an all-refused prefix, or an admitted prefix whose total_cost still matches — so a
crash mid-write produced silent authoritative under-reporting in the one artifact whose entire purpose
is to be authoritative. This repo had already hardened exactly this class twice (the ARC finalization
marker, the capture manifest); this now reuses the capture manifest's staging idiom rather than
inventing a third.

The other four: version gates compared with == accepted true and 1.0 as version 1 (Python's
True == 1); a record left by an earlier run of the same iteration survived a re-run with the feature
turned off, which silently falsified the "absence means the feature was off" invariant the commit
depends on (now cleared, the way a superseded ARC finalization marker already is); the loader accepted
a negative remaining_transition_states that apply_pdep_qm_budget cannot produce; and one test that
claimed to prove JSON-serializability could not fail, since json.dumps((1, 2)) succeeds and it read
back with the permissive loader rather than the strict one.

Verification. Fast suite 1324 passed; tests/test_pdep/test_main_wiring.py 72 passed
(30:18)
against the exact committed tree. Every fix is pinned by a test confirmed to fail against the
unfixed code first.

One disclosure: the save_/load_ pair was written before its tests (an implementation agent's
context compacted mid-task), so those are the weakest-tested lines in the increment. I compensated with
mutations — silently defaulting a loader field, and disabling each version gate — all killed. Eight
mutations total this increment, eight killed.

Still DO NOT MERGE — for your review.

…ysis

The SA sidecar has always had somewhere to put the provenance of the RMG-Py
that produced the sensitivity coefficients -- write_sa_cache_metadata accepts
rmg_py_path and stores it alongside get_git_commit()'s answer. Nothing ever
passed it. The only production caller, in determine_species_from_pdep_network,
omitted the argument, so every sidecar a real T3 run has ever written records
rmg_py_path: null and rmg_py_commit: null. The field was not unreliable; it was
dead.

That is not a cosmetic gap. A real PDep trial run was carried out against an
Arkane predating the fix that makes sensitivity meaningful for ILT-based path
reactions, so its transition-state coefficients were numerical noise nine orders
of magnitude below the selection floor. The run looked merely negative rather
than invalid, and it stayed that way for a week -- because the one field that
would have identified the Arkane it came from was empty. Provenance earns its
keep only when something fills it in.

Resolve the path from sys.modules['arkane'] rather than from configuration.
Arkane runs in-process (rmg_runner imports arc.statmech.arkane and calls it), so
after a successful job the interpreter already holds the exact module that ran;
its __file__ names the checkout with no guessing and no second source of truth
to drift. A dict lookup is not an import, so T3 still never imports arkane or
rmgpy. When Arkane has not run in this process there is nothing truthful to say
and the answer is None, which is what the sidecar recorded before and what it
will go on recording -- the difference is that null now means "no Arkane ran"
instead of "nobody asked".

The commit is deliberately recording-only. Rejecting a cache whose commit no
longer matches the checked-out RMG-Py is a tempting next step and the wrong one:
it would invalidate every cached network on any RMG-Py commit, including commits
touching nothing this code depends on, and mass re-running master-equation jobs
to satisfy a hash is the over-refusal failure this package keeps having to
resist. A human reading the sidecar is the consumer here, as with the budget
record.

The test that matters is the wiring one. The fast suite passes with the new
call-site argument deleted -- 1329 tests, all green, against code that had
silently resumed writing nulls -- because the existing coverage exercises
write_sa_cache_metadata directly and never the caller that was broken. The added
test drives determine_species_from_pdep_network down the Arkane-succeeded path
with a stub arkane module installed, and fails on that deletion.
…s process

The previous commit resolved the RMG-Py behind a sensitivity analysis by looking
up sys.modules['arkane'], on the stated premise that Arkane runs in-process.
That premise is wrong. ARC's run_arkane builds a bash script and invokes
micromamba run -n rmg_env python -m arkane input.py, so the work happens in a
subprocess in a different conda environment, and nothing of the RMG-Py that did
it is ever loaded here. T3 cannot import it either, by design and in practice
alike. The resolver therefore returned None on every real run, and the sidecar
went on recording the same null it recorded before -- the fix was ineffective
rather than harmful, but it was ineffective everywhere it mattered.

The mistake was not visible from the call site. rmg_runner imports run_arkane
from arc.statmech.arkane, which reads as an in-process call and is in fact a
wrapper around a shell-out. The tests did not catch it because they installed a
stub arkane module in sys.modules and then confirmed that the mechanism worked
against the stub. Mutation testing did not catch it either: deleting the
argument at the call site does fail the test, so the wiring was genuinely
covered. What went untested was the premise underneath the wiring, which no
amount of testing the caller can reach.

Arkane records its own provenance in the log it writes into the output directory
T3 already owns, which makes that log the only first-hand witness to what ran.
Read the commit from there. The value sits on the line after its label, and the
line after that is a commit date, so only the first following non-empty line is
considered and only if it actually looks like a hash; recording an arbitrary log
line would be worse than recording nothing, since it would read as real
provenance to whoever audits the sidecar later. write_sa_cache_metadata takes
the commit directly now, keeping the derive-from-a-checkout path as the fallback
for callers holding a checkout rather than a log.

Checked against the runs themselves rather than only against fixtures. The two
networks re-run today report e720866a, the fork tip carrying both ILT
sensitivity fixes. The trial whose transition-state coefficients were nine
orders of magnitude below the selection floor reports dfc4df86, which predates
both. That is the distinction the field existed to make and had never once made.
alongd added 5 commits August 1, 2026 18:53
The gate had never been tested against data it did not generate. Vendor two complete
unmodified chains from a real run and assert what each decision MEANS, not just whether
it qualified.

The pair is chosen because it disagrees. network21_1 (CSE) responds five times more
strongly than network799_1 (MSC) and still does not qualify, because all three of its
path reactions come from a reaction library and none of its kinetics are uncertain;
network799_1 qualifies with every one of its selected transition states uncertain.
Qualification is sensitivity TIMES uncertainty, and a gate that ranked on sensitivity
alone would invert both verdicts. A hand-built fixture would almost certainly make the
two agree and would pass against that broken gate.

Assertions are on the semantics -- the (transition state, path reaction, uncertainty)
triples, the counts, the thresholds, the cache status, the absence of warnings -- because
the boolean alone is nearly worthless here. Mutation testing, applied to production code
and reverted:

  qualified ignores uncertainty              killed by 2 tests
  absolute coefficient floor removed         killed by 5 tests, BOTH BOOLEANS UNCHANGED
  path-reaction join shifted by one          killed by 2 tests, BOTH BOOLEANS UNCHANGED
  reaction-library certainty marker removed  killed by 2 tests

The two mutations that leave `qualified` untouched are the argument for this shape: a
fixture asserting only the verdict would have shipped green against both.

The floor assertions are arithmetic rather than vibes: the raw SA files offer 36
transition-state rows each, of which 7 and 16 fall below the floor, and the selector
keeps exactly 29 and 20. What survived is what was offered minus what the floor
excluded, so a gate discarding rows for some other reason breaks the equality even if
the survivor count coincides.

Provenance is recorded rather than asserted into existence. Both arkane.logs name
RMG-Py e720866ae, which carries the two ILT sensitivity fixes; without them these
coefficients would be noise nine orders below the floor, which is exactly how an earlier
experiment looked merely negative for a week. The t3_sa_cache.yml sidecars still record
rmg_py_commit: null because they predate 2c91568 -- that null is now asserted by a test
so nobody "fixes" it by inventing a commit. Nothing binds a log to the SA file beside
it, and the test says so instead of overclaiming.

A method-level branch of the uncertainty predicate survives mutation. That is structural,
not a gap in the fixture: selector.py:1082 passes only kinetics_comment, and the network
parser records kinetics_type (the callee name), never a kinetics method -- so no
real-network fixture can reach it. Left to unit tests, where it belongs.

Test-only; no production code changed. Fast suite 1331 -> 1345.
… qualified

Until now the only in-run trace of a PDep network's fate was appended at one site deep
inside the success path, so every fail-closed exit before it left the network with no
record at all. On a real 12-network trial that meant 7 networks -- the majority -- vanished
silently while the log's "3 qualified" read like an unqualified success. Fail-closed was
and remains correct; the defect is that refusing to answer looked identical to never having
been asked.

This is the first half of that work: the record type and the diagnosis that populates it.
The main.py funnel that produces the records follows separately.

PDepNetworkAssessment is a new OUTER type rather than a reused PDepNetworkSelection.
A selection is rankable, and selection_rank_key sorts a not_evaluated one into tier 1,
ahead of evaluated negatives -- right for a selection, wrong for a placeholder, since it
would make "we never found out" outrank "we checked and it does not qualify" for every
future consumer. The selection is kept as an optional NESTED payload instead.

The record's load-bearing property is that it cannot disagree with itself. Each of the 21
reason codes implies exactly one status and one rule about the nested selection, and
construction refuses any combination that violates either. A durable file is believed, so
one claiming a selector verdict for a network whose SA never parsed would mislead with the
full authority of provenance.

The vocabulary lives in its own module with no t3 imports. It is shared by a producer (the
selector, reporting which refusal site fired) and a consumer (the assessment, persisting
it), and putting it in either would force a dependency between them -- with the codes in
assessment, selector had to import persistence, which in turn stopped assessment from
importing PDepNetworkSelection to type-check its own nested payload. That left `selection`
typed as `object` and duck-checked, so any value at all passed construction and only failed
later at serialization. The neutral module lets the dependency run selector -> reason_codes
<- assessment, and the nested payload is now type-checked.

Two malformed-payload codes exist because one could not serve both callers: T3's funnel
inspects `structures` before it can build a network reaction string, so a non-mapping SA
payload is caught there and never reaches the selector, while the selector's public entry
points can be handed one directly. A single code would have forced the funnel to either
fabricate a selection or violate the type.

An internal error gets its own status rather than sharing not_evaluated. It is a bug, not
an outcome, and counting it alongside legitimately unevaluable networks would make a T3
defect read as the ordinary cost of doing business. It is also the one category for which a
nested selection is optional -- a crash after the selector returned should keep the evidence
already obtained.

Where two selector refusals both apply they are now BOTH recorded. Discarded rows and an
unassessed transition-state provenance are independent defects in the evidence -- a
transition state can fail to join a path reaction whether or not other rows were unreadable
-- so reporting only the first would undercount the second wherever these are tallied.

The record is frozen and the nested selection is deep-copied. Freezing alone would not be
enough: a selection is mutable, so a caller still holding the original could flip
`qualified` afterwards and leave a validated record quietly self-contradictory.

Verified by mutation, each failing at least one test: dropping the status/reason cross-check,
the selection-presence check, the selection type check, the deep-copy snapshot, the frozen
decorator, the bare-string guard (tuple('MSC') is ('M','S','C')), the bool guards on
iteration and schema_version, the first-cause suppression, moving sa_output_malformed
between categories, collapsing internal_error into not_evaluated, and swapping or inverting
reason codes at four selector sites.

Fast suite: 1906 passed. The 4 failures in tests/test_main.py and tests/test_functional.py
are pre-existing at d251414 -- confirmed by running them in a detached worktree at that
commit -- and are stale set_paths expectations from the 'PDep QM budget' key plus a cantera
environment issue, not regressions from this change.

Freezing the record turned out to freeze the reference and not the object behind it, so
every invariant above could be undone the instant construction returned -- and it was the
undone state that got serialized. The deep copy shut the caller's alias and was mistakenly
believed to shut the record's own copy too, which it hands out freely through
record.selection. The rendered snapshot taken at construction is what makes the claim true:
as_dict() serializes it, so no later mutation reaches the file, and it refuses outright if
the live selection has drifted rather than quietly persisting one story while the record in
memory tells another.

The nested selection must also now be about the SAME network as the record. Six fields are
carried by both, and nothing checked any of them, so a record could nest another network's
evidence -- exactly the misattribution t3/pdep/reason_codes.py's own docstring says the
category rules exist to prevent. network_id must match exactly, since that is the identity
claim itself; the other five need only not conflict, because absence is not disagreement and
the funnel legitimately knows things the selector never recorded. That distinction is not
cosmetic: requiring both halves to be populated made 26 tests fail, since it refuses
ordinary records.

Adds ASSESSMENT_ENVELOPE_SCHEMA_VERSION, used by the persistence commit that follows.
…use to read one that lies

The record type from the previous commit had nowhere to live. This adds the two functions
that put it on disk and take it back off, so the next commit's funnel has somewhere to
write the fate of every network it looks at.

The write is atomic and durable -- staged in a private directory beside the destination,
flushed, renamed, and the destination directory flushed in turn -- which
save_pdep_network_selections is not. The difference is not stylistic. The funnel rewrites
this file once per network as an iteration progresses, so a crash or a full disk part-way
through a write is an ordinary event rather than a theoretical one, and this is precisely
the file whose job is to survive the failure that interrupted it. A truncated but still
parseable record would be read back as authoritative and would under-report exactly the
networks whose silent absence this increment exists to fix. The rename also closes the
symlink write-through vector, as the budget record's write already did. Atomic is not the
same as durable: os.replace orders the rename but says nothing about whether the bytes behind
it ever reached the disk, so without the two flushes a power loss can leave the rename applied
and the contents gone -- the file present, and empty or torn. Staging inside a fresh 0700
directory rather than beside the target (as the budget writer does) also closes the window
between creating the staged file and reopening it by path to write, during which anything
else with access to that directory could substitute a file at the staged path.

The list is wrapped in a versioned envelope for the reason the other list-savers give: an
iteration in which T3 finds no P-dep networks at all is an ordinary outcome, and an empty
list has no record of its own to carry a version. The envelope's version is its OWN constant
under its own key rather than a second copy of the per-record one. save_pdep_network_selections
does reuse a single number for both roles, but that makes each version a hostage of the
other: adding a field to a record would force the envelope to claim a change it never
underwent, and renaming the list key would force every record ever written to be re-stamped.
A version number that has to lie is worse than none. The loader checks the two separately, so
a file cannot claim one shape at the top level and hold another underneath.

The loader needs no companion cross-field validator, unlike _selection_from_dict. Every
invariant that makes an assessment trustworthy already lives in PDepNetworkAssessment's
__post_init__, so constructing one re-checks the whole record: a hand-edited file claiming
a selector verdict for a network whose SA never parsed is refused by the same rule that
refused the impossible combination at the site that would have written it. Construction is
wrapped only so the refusal names the file and the entry -- the type's own message names
the network, which suffices where it is written but not where it is read, with a dozen
iterations on disk. The per-field helpers already name both and are left unwrapped rather
than given a second prefix.

An absent 'selection' key is refused rather than read as null. as_dict() always writes the
key, rendering it null where the reason code forbids a nested selection, so absent and null
are different claims: guessing null would quietly convert a corrupt record into a plausible
"never evaluated" one, which is the misreading the file exists to prevent.

cache_status is read as a free optional string even though the selection loader
enum-restricts its own, because PDepNetworkAssessment accepts any string there. A loader
stricter than the constructor that feeds it would make a record T3 legitimately wrote
unreadable by T3 -- the one failure a durable record must never have. Strictness not shared
with the writer is not safety; it is a bug with a delay on it.

Nested selection versions are now bool-guarded like every other version on this branch:
True == 1, so `selection_schema_version: true` was being read as correctly versioned.

One gap is pinned by test rather than fixed, and deliberately. PDepNetworkSelection is mutable
by design -- the selector builds it across some twenty assignments -- so it validates almost
nothing, while _selection_from_dict is strict; a selection mutated into e.g. a bare-string
`warnings` therefore saves and then cannot be loaded back. It is not reachable from T3's own
paths, and validating at construction would not help since the mutation happens afterwards.
The real fix is one validation contract shared by writer and loader, which is its own
increment. The test exists so the gap stays visible and cannot silently widen.

Also drops an unused dataclasses.field import left in assessment.py.

78 new tests. Beyond the round trips, the ones that discriminate rather than merely describe:
a save whose underlying write raises leaves the previous record intact rather than truncated,
a save onto a symlink replaces the link instead of writing through it, both flushes happen in
the right order around the rename, and the staging directory is unreachable by anything else.

Verified by mutation, each failing at least one test: dropping either fsync, staging beside
the target instead of in a private directory, collapsing the envelope key back onto the record
key, dropping either bool guard on the nested selection versions, and reading an absent
'selection' key as null.

pdep suite: 1433 passed, up from 1355 at d251414.
Adds the 'PDep network assessments' iteration path key and clears a stale record when the
feature is off, so the funnel that follows has somewhere to write and cannot inherit another
run's answers. No funnel yet -- nothing writes this file at runtime.

It is a sibling of 'PDep QM budget' rather than part of it, because the two answer different
questions over different populations: the budget describes the networks that QUALIFIED --
which were admitted, which refused, and why -- while this describes every network that was
looked at at all. A network that failed before the selector ran never reaches the budget, and
those are precisely the ones that used to vanish without trace.

Stale-clearing mirrors _clear_pdep_budget_record and matters more here. This record's whole
purpose is to be believed about which networks were never evaluated, so a file left over from
an earlier run of the same iteration would not merely be stale, it would answer that question
with another run's networks. Clearing is the only thing standing between "no record" and "a
confident wrong record". It is narrow in the same way: the path is always the single
set_paths-derived key, never constructed or caller-supplied, and removal happens only after
os.path.isfile confirms a regular file.

Fixes the three long-standing failures in tests/test_main.py, which were failing before this
branch and are now green. Two of them turned out NOT to have the cause they had been recorded
as having. Only test_set_paths was stale on the path key ('PDep QM budget', added in
increment 32 and never added to the test's literal). test_as_dict and test_args_and_attributes
were stale on increment 32's SCHEMA additions instead -- pdep_QM_max_transition_states and
pdep_QM_max_networks were missing from the shared expected sensitivity dict. Same increment,
different omission; the diff says so plainly once read rather than assumed.

Fast suite: 1987 passed, 1 failed. The single remaining failure is
tests/test_functional.py::test_computing_thermo, a cantera environment issue unrelated to
this branch, down from 4 before this commit.
…ng one file end the run

On a real twelve-network run, seven networks -- the majority -- left no trace of what T3 decided
about them, because the only record was appended deep inside the success path. "Assessed and found
not worth refining" and "never assessed at all" were the same silence afterwards, and the second is
the one an operator has to act on. Four of the paths producing that silence were uncaught
exceptions that ended a multi-day RMG+ARC campaign over a single unreadable file.

Route every offer through one funnel, `_assess_pdep_network_candidate()`, which has no bare
`continue`: every path out of it produces a `PDepNetworkAssessment`, so a network cannot be dropped
by adding an exit site. The four crash paths become recorded outcomes -- writing the Arkane input,
reading the sensitivity YAML (missing and malformed), and mapping species labels -- and the version
arithmetic that used to raise on a stray `network4_backup.py` now ignores anything that is not a
versioned network file rather than either dying or discarding the real network beside it.

The catches are narrow and the line runs between DATA and CODE, not between convenient and
inconvenient. A missing or unreadable network file is a fact about this run's data at either site
that reads it, so the input writer's `OSError` is an outcome exactly as the parser's already was;
which of the two happens to touch the file first must not decide whether the campaign survives. A
`TypeError` is a fact about the code: it is recorded under its own status, never counted among the
networks that legitimately could not be evaluated, and re-raised. Recording it is guarded, because
this handler runs when something is already wrong enough that the record may be unbuildable, and a
complaint about writing the diagnosis down must not replace the diagnosis.

Two failures that were per-METHOD were being treated as per-network. The sensitivity artifact is now
opened inside the master-equation loop: a method that reports success and leaves nothing readable
behind says nothing about whether the next method would, and it no longer spends their turn.
Where methods break in different ways those are independent defects in different artifacts, so all
of them reach the record rather than whichever ran last.

The record is written after every network and marked incomplete until the pass ends. A file holding
four of twelve networks is not a smaller truth, it is a different claim, and it is the one
corruption that cannot be detected by inspecting the records, since each of them is individually
valid. The loader refuses an unfinished file unless asked for one. The pass also stakes its claim on
the file before doing any work, so a `complete: true` record from an earlier run of the same
iteration cannot survive a crash and answer "which networks were never evaluated?" with another
run's networks.

Both verdict lists now describe one iteration. Carrying them over was never a decision, and a
network re-examined in three iterations was reported three times, against a model that had changed
underneath each one.
Comment thread tests/test_pdep/test_assessment_persistence.py Fixed
Comment thread tests/test_pdep/test_assessment_persistence.py Fixed
Comment thread tests/test_pdep/test_assessment_persistence.py Fixed
Comment thread t3/pdep/assessment.py
Comment on lines +45 to +76
from t3.pdep.reason_codes import (INTERNAL_ERROR_REASON_CODES,
PRE_SELECTOR_REASON_CODES,
REASON_CODE_STATUS,
REASON_EVALUATED_NO_UNCERTAIN_TS,
REASON_INTERNAL_ERROR,
REASON_NETWORK_DISCOVERY_FAILED,
REASON_NETWORK_INPUT_WRITE_FAILED,
REASON_NETWORK_PARSE_FAILED,
REASON_QUALIFIED_UNCERTAIN_TS,
REASON_SA_ALL_METHODS_FAILED,
REASON_SA_OUTPUT_MALFORMED,
REASON_SA_OUTPUT_MISSING,
REASON_SA_OUTPUT_UNREADABLE,
REASON_SA_STRUCTURES_MISSING,
REASON_SELECTOR_DIRECTION_ENTRY_MALFORMED,
REASON_SELECTOR_DIRECTION_UNRESOLVED,
REASON_SELECTOR_MALFORMED_CONDITIONS_NO_TS_ROWS,
REASON_SELECTOR_NEGATIVE_INCOMPLETE_DATA,
REASON_SELECTOR_NO_TS_ROWS,
REASON_SELECTOR_NO_USABLE_TS_ROWS,
REASON_SELECTOR_SA_PAYLOAD_MALFORMED,
REASON_SELECTOR_TS_PROVENANCE_UNASSESSED,
REASON_SELECTOR_TS_RESPONSE_BELOW_FLOOR,
REASON_SPECIES_LABEL_MAPPING_FAILED,
SELECTION_BEARING_REASON_CODES,
STATUS_EVALUATED_NEGATIVE,
STATUS_INTERNAL_ERROR,
STATUS_NOT_EVALUATED,
STATUS_QUALIFIED,
VALID_ASSESSMENT_REASON_CODES,
VALID_ASSESSMENT_STATUSES,
)
alongd added 4 commits August 2, 2026 02:00
… back

`_read_persisted_yaml_file` already asserted the invariant -- "any file
containing [a Python object tag] is not a file T3 wrote" -- but that was an
assertion about the writers made on the read side, and nothing enforced it on
the write side. `arc.common.save_yaml_file` renders with a full representer, so
a single non-plain value in a record (a `pathlib.Path` handed to a
`str`-annotated field is the realistic way in) is written out as a
`!!python/object/apply:` tag, the write reports success, and every loader here
refuses that file from then on.

The failure being prevented is TOTAL LOSS, not a bad record: `yaml.safe_load`
fails before any per-record check runs, so one bad nested field costs the whole
iteration's assessments and reports a YAML tag rather than the field at fault.

`_refuse_content_that_would_not_parse_back` renders through `to_yaml` -- the
same function the write uses, custom string representer included -- and parses
that back. Checking with `yaml.safe_dump` instead would approve bytes other than
the ones written, which is the shape of the bug itself. Called by all four
writers, before staging in the two that stage, so a refusal leaves no droppings.

It does NOT make the loaders accept the file: a `str` where a list belongs is
good YAML, refused later per record with a message naming the field.
`PDepNetworkSelection` is the only record type written here that does not
type-check its own fields in `__post_init__`; closing that is its own work.

The `except yaml.YAMLError` catch stays narrow on purpose. A `RecursionError`
from a self-referential record is a defect in the code that built it, not a fact
about the data, and filing it as a refusal would hide a bug inside a provenance
record.
`PDepNetworkSelection` and `SensitiveTransitionState` were the only record
types in `t3/pdep` that validated nothing, while `_selection_from_dict` and
`_sensitive_transition_state_from_dict` have always been strict. The gap
between a permissive constructor and a strict loader is a record that can be
built and written but never read back -- and a selection is nested inside both
an exploration result and a network assessment, so one bad field cost whichever
file carried it.

Both now hold the contract their loader enforces. The selection's lives in
`validate()`, called from `__post_init__` AND from `as_dict()`, because a
constructor check alone would be a guarantee in name only: the record is
mutable and `select_from_sa_dict` builds it blank and fills it in across ~20
assignments, so construction sees an empty record. `as_dict()` is the one funnel
every persisted copy passes through.

Two things the suite proved wrong on the way:

`network_id` is NOT required. `rank_pdep_networks` records a decision for an
entry too malformed to name a network, and `t3.pdep.budget` counts two such
records as two distinct networks rather than collapsing them. The LOADER was
relaxed to match -- so a file written by `rank_pdep_networks` +
`save_pdep_network_selections`, two public API functions that pair, is no longer
refused outright by `load_pdep_network_selections`. The assessment record's
`network_id` stays required: it is a statement about a named network.

`combine()` then raised `TypeError` instead of its intended `ValueError` on a
set mixing `None` with a label, because `sorted` cannot order them. Sorted by
`repr` now -- the mixed case is exactly what that refusal is for.

Also: `VALID_CACHE_STATUSES` is now shared with the loader rather than respelled
there; the `network_id` annotation says `str | None` and its docstring records
that distinctness of unnamed records is positional, not identity-based, so a
consumer joining on it collapses them all.
…m a file

`PDepNetworkSelection.t_grid_clamp` is documented as a `TGridClampRecord.as_dict()`
rendering, and the only check anywhere was "a dict or None". It is also the one field
whose value is read off disk -- `t3.pdep.api` calls `read_t_grid_clamp_record(sa_path)`
unconditionally and copies whatever it returns into up to four live selections, which
then persist it as their own provenance.

Nothing in t3/ or tests/ reads a key back out of that dict. That makes a malformed value
worse rather than more tolerable: there is no failing consumer to reveal it, so the only
symptom is someone opening a saved decision much later and believing what it says about
the solve behind it.

`TGridClampRecord` now type-checks its own fields, and `t_grid_clamp_shape_error` asks the
same question of a plain dict, returning a reason rather than raising because its callers
need opposite outcomes: `read_t_grid_clamp_record` collapses a malformed sidecar dict to
None (unknown provenance -- its documented contract is to disclose, never to gate), while
`PDepNetworkSelection.validate()` and the selection loader refuse one. What makes that
leniency safe is the other end: `write_sa_cache_metadata` now refuses to record provenance
its own reader would discard, so a possibly-foreign file is read leniently and a file T3
writes is written strictly.

Only `clamped` is required and unrecognized keys are carried. Sidecars and selection files
outlive the version that wrote them in both directions, and refusing either an older
writer's missing key or a newer writer's extra one would discard honest provenance -- the
loss this record exists to prevent. Tolerating a key means saying nothing about its value,
which is what leaves the write-time plain-YAML backstop its remaining live reach.

Also from the round-61 review: `nan`/`inf` are refused as temperatures (a `nan` renders,
reloads, and then compares unequal to itself, so a record carrying one can never be shown
to have round-tripped); a `list` passed for `skipped_species` is normalized to a tuple,
since keeping it would let a caller append after construction and render entries the
contract never saw; and `combine()` no longer adopts the first component's provenance
without comparing, matching what `method` and `cache_status` beside it already do.

Cross-field invariants were considered and declined: both temperatures are read from the
same parsed line, so the real writers cannot produce an incoherent record, and a semantic
rule would fire only on a foreign one -- where, on the sidecar path, it would silently drop
provenance. The docstring states that boundary and a test pins it.
…nothing can use

`T3Sensitivity.check_me_methods` compared `entry.lower()` against the three method names and
then returned the value unchanged, so `ME_methods: ['cse']` in a user's YAML validated. Nothing
downstream of it is case-insensitive. `t3/main.py` reads the list straight out of
`InputBase(...).model_dump()` and hands each entry to `write_pdep_network_file`, which reaches
`METHOD_MAP[method]` in `rewrite_arkane_method_line` -- a bare `KeyError: 'cse'` that the
`(OSError, ValueError)` handler around that call does not catch, so a lowercase entry ended the
whole run with a traceback naming neither the input file nor the field. The same string is also
used verbatim as the per-method output directory name, as the `method` written into and compared
against the SA cache sidecar, and as `requested_me_methods` provenance.

The validator now returns the canonical `METHOD_MAP` key. Case-insensitive acceptance is kept
rather than tightened away: it is deliberate, it matches `global_observables` beside it whose
consumers genuinely do read case-insensitively, and no working configuration exists today with
lowercase -- it crashed. The repetition check moved after canonicalization, so `['CSE', 'cse']`
is still refused as one method spelled two ways.

`METHOD_MAP` moves from `t3/utils/writer.py` to `t3/common.py`. Validating input must not drag
in the writer's dependency stack (Mako, ARC species perception, the generator, the thermo
reader), and `t3/pdep/api.py` imports `T3Sensitivity` only for its defaults, so it would have
inherited all of it. `t3.utils.writer` re-exports the constant, leaving every existing
`from t3.utils.writer import METHOD_MAP` call site untouched.

`write_arkane_network_input_file` now refuses an unknown method before it touches the disk.
`rewrite_arkane_method_line` already failed on one, but only after the destination directory had
been created and the source copied into it -- leaving a plausible `<network>/<bad-method>/input.py`
still carrying the SOURCE file's method, a different solve from the one its own directory name
claims. The four other sites that render a method already check up front; this one was the
exception.
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.

3 participants