Skip to content

Add a first-class validated input schema for ARC (pydantic, mirroring T3) - #926

Open
alongd wants to merge 3 commits into
mainfrom
input_schema
Open

Add a first-class validated input schema for ARC (pydantic, mirroring T3)#926
alongd wants to merge 3 commits into
mainfrom
input_schema

Conversation

@alongd

@alongd alongd commented Jul 26, 2026

Copy link
Copy Markdown
Member

Motivation

ARC has no typed, validated input schema today. ARC.py reads the input YAML into a raw dict and
hands 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_adapter membership check,
StatmechEnum). T3 already solves this cleanly with a pydantic schema (t3/schema.py). This PR
brings the same kind of first-class, validated input schema to ARC.

What this adds

arc/schema.py — a pydantic v2 ARCInput model mirroring T3's structure (nested BaseModels,
Field constraints, Enum controlled vocabularies, field_validator / model_validator, a
per-model Config):

  • ARCInput — the top-level model; field names and defaults mirror the ARC.__init__ kwargs and
    the keys ARC.as_dict() emits into restart.yml. Only project is required.
  • Species and Reaction — the full ARCSpecies / ARCReaction input surfaces.
  • JobTypes — the 11 default_job_types keys, plus ARC's internal names.
  • BACTypeEnum, TemperatureUnitEnum, and the existing StatmechEnum (reused, not
    redefined); ts_adapters is validated against the live job-adapter registry.

Integration seam

Validate-then-construct at the single load seam in ARC.py main():

try:
    validated_input_dict = validate_input_dict(input_dict)
except ValidationError as e:
    print(f'Invalid ARC input file "{input_file}":\n{e}')
    sys.exit(1)
arc_object = ARC(**validated_input_dict)

ARC.__init__ is otherwise unchanged and still directly callable — the schema is a load-time gate
and normalizer, not a refactor of the constructor. validate_input_dict() is public so Python-API
and T3 callers can opt into the same validation without going through the CLI.

as_input_dict() uses model_dump(exclude_unset=True) rather than exclude_none=True, so only the
keys the user actually provided are passed through: ARCSpecies.from_dict gates 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 — the
Level object is deliberately not re-modelled); the full species and reaction input surfaces.

Deliberately deferred (kept loose and passed through): adaptive_levels, ess_settings, and the
restart runtime keys output, output_multi_spc, running_jobs, plus the internals of the runtime
blobs (rotors_dict, ts_guesses). These are written by ARC's own as_dict(), so only a
hand-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:

  • Top level — an unknown key already raised TypeError in ARC.__init__ (it has no **kwargs),
    so forbidding only improves the error message.
  • Species / Reaction — an unknown key was silently swallowed by ARCSpecies, so a
    mutliplicity: 1 typo yielded multiplicity 3 with no warning. This is where forbidding actually
    prevents 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 key as_dict() emits, and of default_job_types — so a field added
to 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:

  • A quoted "False" is truthy to raw ARC, so lax coercion would have silently inverted
    compute_thermo.
  • ARCSpecies raises on 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, since raw
    ARC accepts them; 14.7 and "14" are rejected.

Backward compatibility

The schema validates and normalizes; it does not break existing inputs.

  • All 8 examples/**/input.yml and all 4 arc/testing/restart/**/restart.yml files validate, and a
    test asserts they keep validating.
  • Real restart files legitimately carry explicit nulls (T_min, T_max, T_count, n_confs,
    bac_type, multiplicity, external_symmetry, optical_isomers, ...) — all still accepted.
  • T_min/T_max accept a bare scalar or a (value, unit) pair, and never serialize as a tuple, so
    save_yaml_file's yaml.dump cannot write !!python/tuple into restart.yml. The unit is
    restricted to Kelvin because it is never read downstream — [300, 'C'] was silently computed as
    300 K.
  • An end-to-end seam test asserts
    ARC(**raw).as_dict() == ARC(**validate_input_dict(raw)).as_dict() over the real example inputs
    and the 2_restart_rate / 5_TS1 restart files. It is mutation-tested to confirm it is not
    vacuous (sabotaging as_input_dict() to drop species makes it fail).

Schema versioning

as_dict() now records schema_version and arc_version in restart files. ARC.__init__ accepts
and ignores both, so a raw ARC(**restart_dict) keeps working. On read: absent is accepted, an
explicit 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_version is a
tripwire that makes a future incompatibility loud — it is not, by itself, a compatibility
guarantee.

Points for the reviewer

  • arc/plotter.py is touched in a separate commit (6001f419e) for an unrelated pre-existing
    bug: a bare scalar T_max was normalized to (T_min, 'K') instead of (T_max, 'K'), silently
    replacing T_max with T_min. It is fixed here because this PR newly blesses a bare scalar
    temperature 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_reverse is typed StrictBool | StrictInt | None rather than StrictBool | None,
    because real restart fixtures carry it as an int 0/1. Pragmatic, but a deviation worth a look.
  • class Config (not ConfigDict) is deliberate, mirroring T3's style. It emits a pydantic
    deprecation warning per model; migrating both tools together is a separate change.
  • pydantic >=2 is now declared in environment.yml, and a public get_registered_job_adapters()
    was added to arc/job/factory.py so neither arc/main.py nor arc/schema.py imports the private
    registry.

Comment thread arc/schema.py Fixed
Comment thread arc/job/factory_test.py Fixed
Comment thread arc/schema.py Fixed
@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 63.74%. Comparing base (9787770) to head (9841340).

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     
Flag Coverage Δ
functionaltests 63.74% <ø> (+0.30%) ⬆️
unittests 63.74% <ø> (+0.30%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.py with an ARCInput model (plus nested Species, Reaction, JobTypes, and enums) and a public validate_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 scalar T_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.

Comment thread arc/schema.py
alongd added 3 commits July 28, 2026 07:00
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants