Add a first-class validated input schema for ARC (pydantic, mirroring T3) - #926
Add a first-class validated input schema for ARC (pydantic, mirroring T3)#926alongd wants to merge 3 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #926 +/- ##
==========================================
+ Coverage 63.43% 63.74% +0.30%
==========================================
Files 114 115 +1
Lines 38325 38585 +260
Branches 10030 10046 +16
==========================================
+ Hits 24312 24595 +283
+ Misses 11096 11076 -20
+ Partials 2917 2914 -3
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR introduces a first-class, Pydantic v2–based input/restart schema for ARC to validate and normalize YAML input at load time (mirroring T3’s approach), improving early error reporting and preventing silent misconfiguration.
Changes:
- Added
arc/schema.pywith anARCInputmodel (plus nestedSpecies,Reaction,JobTypes, and enums) and a publicvalidate_input_dict()helper for validate-then-construct. - Wired the CLI entry point (
ARC.py) to validate inputs and exit cleanly with formatted validation errors on invalid YAML content. - Added schema drift-guard tests and restart/versioning support (
schema_version,arc_version), plus a small plotter regression fix for scalarT_max.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| environment.yml | Declares pydantic>=2 for the conda environment. |
| arc/schema.py | Adds the Pydantic input/restart schema and validate_input_dict() API. |
| arc/schema_test.py | Adds comprehensive schema validation + drift-guard + seam equivalence tests. |
| arc/plotter.py | Fixes scalar T_max normalization bug in kinetics plotting. |
| arc/plotter_test.py | Adds regression coverage for scalar T_max in draw_kinetics_plots(). |
| arc/main.py | Adds restart version keys to as_dict(), ignores them in __init__, and uses public adapter-registry getter. |
| arc/main_test.py | Updates as_dict() expectations and adds CLI validation seam test. |
| arc/job/factory.py | Exposes get_registered_job_adapters() for public registry access. |
| arc/job/factory_test.py | Adds a unit test asserting the registry accessor returns the live adapter registry. |
| arc/common.py | Introduces ARC_INPUT_SCHEMA_VERSION constant for restart schema versioning. |
| ARC.py | Validates input dict via schema before constructing ARC; prints validation errors and exits with code 1. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Introduce arc/schema.py: a pydantic v2 ARCInput model (mirroring T3's t3/schema.py) that validates and normalizes the ARC input/restart dict before an ARC object is constructed. Wire it into ARC.py's main() as a validate-then-construct gate; ARC.__init__ is otherwise unchanged. - Top-level ARCInput mirrors the ARC.__init__ kwargs and as_dict() keys - Full Species and Reaction sub-models, a JobTypes sub-model, BACTypeEnum, TemperatureUnitEnum, the reused StatmechEnum, and ts_adapters validation - extra="forbid" at every level: an unknown top-level key already raised a TypeError in ARC.__init__, but an unknown species key was silently swallowed by ARCSpecies (a "mutliplicity" typo yielded multiplicity 3). Guard tests assert the schema fields stay a superset of what ARC.__init__ accepts and of what as_dict() emits, so the strictness cannot drift out of sync. - Strict bools and ints: a quoted "False" is truthy to raw ARC and would have silently inverted compute_thermo; ARCSpecies rejects a non-int multiplicity, so the schema must not launder "3" into 3. Integral floats (14.0) are still accepted for job_memory, n_confs and T_count, which raw ARC accepts. - T_min/T_max accept either a bare scalar or a (value, unit) pair, are serialized as plain lists (yaml.dump writes !!python/tuple for a tuple, corrupting restart.yml) and are restricted to Kelvin, since the unit string is never read downstream and [300, 'C'] was silently computed as 300 K. - as_input_dict() uses exclude_unset rather than exclude_none: an explicit bac_type: None disables BAC, while an absent bac_type defaults to 'p'. - Public validate_input_dict() so Python-API and T3 callers can opt into the same validation without touching ARC.__init__. - as_dict() now records schema_version and arc_version in restart files; ARC.__init__ accepts and ignores them so a raw ARC(**restart_dict) keeps working. A newer schema version is an error, an older one warns. arc/schema_test.py mirrors t3/tests/test_schema.py and adds an end-to-end check that ARC(**raw).as_dict() equals ARC(**validated).as_dict() over the real example and restart fixtures. Declare pydantic in environment.yml, and add a public get_registered_job_adapters() to arc/job/factory.py so neither arc/main.py nor arc/schema.py imports the private registry.
draw_kinetics_plots() normalizes a bare scalar T_max to a (value, 'K') tuple, but used T_min as the value, so a scalar T_max was silently replaced by T_min. Pre-existing and unrelated to the input schema, but the schema newly blesses a bare scalar temperature as a valid input form, which makes this path reachable from a plain input file.
initialize_job_types() aliased the module-level default_job_types dict when job_types was None and then deleted 'fine_grid' from it (and added 'fine'/ 'onedmin'), corrupting ARC's defaults for every subsequent reader in the process. Copy the dict before mutating it. Surfaced by the input-schema work, whose JobTypes model reads default_job_types at class-body time and would KeyError on 'fine_grid' once the global had been corrupted.
Motivation
ARC has no typed, validated input schema today.
ARC.pyreads the input YAML into a raw dict andhands it straight to
ARC(**input_dict), so malformed input fails deep inside object construction —or, worse, silently misbehaves — instead of failing at load with a clear message. The only
validation that exists is ad-hoc (project name required, a
ts_adaptermembership check,StatmechEnum). T3 already solves this cleanly with a pydantic schema (t3/schema.py). This PRbrings the same kind of first-class, validated input schema to ARC.
What this adds
arc/schema.py— a pydantic v2ARCInputmodel mirroring T3's structure (nestedBaseModels,Fieldconstraints,Enumcontrolled vocabularies,field_validator/model_validator, aper-model
Config):ARCInput— the top-level model; field names and defaults mirror theARC.__init__kwargs andthe keys
ARC.as_dict()emits intorestart.yml. Onlyprojectis required.SpeciesandReaction— the fullARCSpecies/ARCReactioninput surfaces.JobTypes— the 11default_job_typeskeys, plus ARC's internal names.BACTypeEnum,TemperatureUnitEnum, and the existingStatmechEnum(reused, notredefined);
ts_adaptersis validated against the live job-adapter registry.Integration seam
Validate-then-construct at the single load seam in
ARC.pymain():ARC.__init__is otherwise unchanged and still directly callable — the schema is a load-time gateand normalizer, not a refactor of the constructor.
validate_input_dict()is public so Python-APIand T3 callers can opt into the same validation without going through the CLI.
as_input_dict()usesmodel_dump(exclude_unset=True)rather thanexclude_none=True, so only thekeys the user actually provided are passed through:
ARCSpecies.from_dictgates on key presence,and an explicit
bac_type: null(which disables BAC) must not be silently dropped back to'p'.Covered vs deferred
Covered (validated): top-level ARC options; job types; levels of theory (as
str | dict— theLevelobject is deliberately not re-modelled); the full species and reaction input surfaces.Deliberately deferred (kept loose and passed through):
adaptive_levels,ess_settings, and therestart runtime keys
output,output_multi_spc,running_jobs, plus the internals of the runtimeblobs (
rotors_dict,ts_guesses). These are written by ARC's ownas_dict(), so only ahand-edited restart file can make them malformed; typing them would turn an input schema into a
serialization schema.
Strictness
Every model uses
extra="forbid". The two levels are not symmetric, and that asymmetry is the point:TypeErrorinARC.__init__(it has no**kwargs),so forbidding only improves the error message.
ARCSpecies, so amutliplicity: 1typo yielded multiplicity 3 with no warning. This is where forbidding actuallyprevents silent corruption, and it required modelling every runtime key ARC's own
as_dict()writes.
To keep that maintainable, guard tests assert that the schema's fields stay a superset of what
ARC.__init__accepts, of every keyas_dict()emits, and ofdefault_job_types— so a field addedto ARC without a matching schema field fails CI in the PR that caused it, rather than silently
breaking a user's restart months later.
Bools and ints are strict, because lax coercion changed the science:
"False"is truthy to raw ARC, so lax coercion would have silently invertedcompute_thermo.ARCSpeciesraises on a non-int multiplicity, so the schema must not launder"3"into3.14.0) are still accepted forjob_memory,n_confsandT_count, since rawARC accepts them;
14.7and"14"are rejected.Backward compatibility
The schema validates and normalizes; it does not break existing inputs.
examples/**/input.ymland all 4arc/testing/restart/**/restart.ymlfiles validate, and atest asserts they keep validating.
T_min,T_max,T_count,n_confs,bac_type,multiplicity,external_symmetry,optical_isomers, ...) — all still accepted.T_min/T_maxaccept a bare scalar or a(value, unit)pair, and never serialize as a tuple, sosave_yaml_file'syaml.dumpcannot write!!python/tupleintorestart.yml. The unit isrestricted to Kelvin because it is never read downstream —
[300, 'C']was silently computed as300 K.
ARC(**raw).as_dict() == ARC(**validate_input_dict(raw)).as_dict()over the real example inputsand the
2_restart_rate/5_TS1restart files. It is mutation-tested to confirm it is notvacuous (sabotaging
as_input_dict()to dropspeciesmakes it fail).Schema versioning
as_dict()now recordsschema_versionandarc_versionin restart files.ARC.__init__acceptsand ignores both, so a raw
ARC(**restart_dict)keeps working. On read: absent is accepted, anexplicit null is an error, a newer version is an error, an older version warns and proceeds.
To be honest about what this is: with no migration machinery behind it,
schema_versionis atripwire that makes a future incompatibility loud — it is not, by itself, a compatibility
guarantee.
Points for the reviewer
arc/plotter.pyis touched in a separate commit (6001f419e) for an unrelated pre-existingbug: a bare scalar
T_maxwas normalized to(T_min, 'K')instead of(T_max, 'K'), silentlyreplacing
T_maxwithT_min. It is fixed here because this PR newly blesses a bare scalartemperature as a valid input form, which makes the path reachable from a plain input file. Drop
that commit if you would rather it went separately.
family_own_reverseis typedStrictBool | StrictInt | Nonerather thanStrictBool | None,because real restart fixtures carry it as an int
0/1. Pragmatic, but a deviation worth a look.class Config(notConfigDict) is deliberate, mirroring T3's style. It emits a pydanticdeprecation warning per model; migrating both tools together is a separate change.
pydantic >=2is now declared inenvironment.yml, and a publicget_registered_job_adapters()was added to
arc/job/factory.pyso neitherarc/main.pynorarc/schema.pyimports the privateregistry.