Xtalate is a pure-Python library with a thin CLI presenter on top. This document is the reference for both surfaces. For the design and principles behind them, see the Architecture Overview; for building and extending Xtalate, the Developer Guide.
The in-process library and
xtalateCLI are documented first (§1–§4); the REST Service (v0.5) is §5. The Service exposes the same report models over HTTP without re-implementing the core — a thin presenter that embeds the pydantic reports verbatim (no parallel DTOs).
Install the package (pip install xtalate, or pip install -e ".[dev]" from a checkout) and the
xtalate command becomes available. It has four subcommands.
xtalate inspect Run the Information Discovery Engine (✓/✗ inventory).
xtalate convert Full pipeline: parse → pre-flight → recovery → export → validate.
xtalate validate Offline re-parse re-validation, or re-threshold a stored report.
xtalate capabilities Print the Capability Matrix.
Any command accepts --json to emit the underlying report schema verbatim (for piping); without
it, output is a human-readable terminal rendering. Global: xtalate --version.
xtalate inspect FILE [--format FORMAT_ID] [--report PATH] [--json]
Reports which canonical fields a file actually contains, each annotated with the format's read
capability — without converting anything. --format overrides format sniffing; --report PATH
also writes the DiscoveryReport JSON to a file.
xtalate convert FILE --to FORMAT_ID [-o PATH]
[--format FORMAT_ID]
[--mode permissive|strict]
[--recover SCENARIO=CHOICE[,param=value…]] (repeatable)
[--acknowledge-loss] [--acknowledge-parse-warnings]
[--tolerance-profile NAME|FILE]
[--report PATH] [--validation-report PATH]
[--json]
Runs the whole pipeline and prints a Conversion Report followed by a Validation Report. --to is
required; -o writes the converted artifact (without it, the bytes are dumped to stdout in human
mode). Key options:
--recover SCENARIO=CHOICE[,param=value…]— supply an explicit recovery preset (repeatable). Examples:--recover frame_selection=last,--recover missing_lattice=bounding_box,padding_ang=5.0,--recover missing_velocities=maxwell_boltzmann,--recover missing_masses=standard_masses. With no preset for a decision the target requires, the conversion refuses rather than guessing.--mode strict— reductive loss and parse warnings must be acknowledged (--acknowledge-loss/--acknowledge-parse-warnings) or the conversion refuses.--tolerance-profile— one of the named profilesdefault/strict/loose, or a path to a custom per-quantity tolerance table (.jsonparsed as JSON, any other extension as YAML).--report/--validation-report— also write each report's JSON to a file.
Eligible conversions (an -o target, permissive mode, and either no recovery presets or a simple
first/last/index frame selection) are routed through the frame-chunked streaming engine
automatically, so the CLI inherits sub-linear memory on large trajectories. Which path ran is not
observable: the artifact and the report are byte-identical either way.
# Full offline re-parse re-validation:
xtalate validate --source FILE --output FILE --conversion-report PATH
[--tolerance-profile NAME|FILE] [--validation-report PATH] [--json]
# Re-threshold a stored Validation Report under a new profile (no re-parse):
xtalate validate --validation-report REPORT.json --tolerance-profile NAME|FILE [--json]
Full re-parse mode reconstructs the expected object from the source file plus the Conversion Report's write plan, re-parses the output, and diffs. Re-threshold mode re-applies a new tolerance profile to an already-stored report without re-reading any files. (Offline full re-parse is unavailable for conversions with recovery-supplied fields, since the fabricated values cannot be reconstructed from the source; re-threshold the original report instead.)
xtalate capabilities [FORMAT_ID] [--json]
Prints the Capability Matrix — what each format can and cannot express, per direction (read/write). Limit to one format by naming it.
The CLI is CI-native: it signals outcome through the exit code, so you never parse stdout.
| Code | Meaning |
|---|---|
0 |
OK |
1 |
usage / internal error |
2 |
refused (a first-class outcome, not a crash) |
3 |
validation failed |
4 |
parse error |
5 |
passed with warnings under --mode strict |
The library is the CLI without the argument parsing. The entry point is default_registry(), which
assembles the built-in parsers/exporters plus any third-party plugins discovered from entry points.
from xtalate.registry import default_registry
from xtalate.conversion import ConversionEngine
registry = default_registry()
# Parse a source file into a Canonical Object.
with open("in.extxyz", "rb") as fh:
source = registry.get_parser("extxyz").parse(fh, filename="in.extxyz").canonical
# Convert (parse-time recovery, pre-flight, export, and automatic validation all run here).
result = ConversionEngine(registry).convert(
source,
source_format_id="extxyz",
target_format_id="poscar",
)
print(result.report.model_dump_json(indent=2)) # the ConversionReport
print(result.validation.status) # "passed" | "passed_with_warnings" | "failed"
with open("POSCAR", "wb") as fh:
fh.write(result.output) # None iff the conversion refusedConversionEngine.convert(...) returns a ConversionResult with:
report: ConversionReport— always present (a refusal is a completed report withstatus == "refused").output: bytes | None— the converted bytes;Noneif refused (or ifoutputscarries a per-frame set from asplit_allrecovery).canonical_out: CanonicalObject | None— the write-plan-filtered object handed to the exporter (the Validation Engine's expected object);Noneif refused.validation: ValidationReport | None— exactly one per completed conversion;Noneif refused.outputs: list[bytes] | None— one file per frame, set only whenframe_selection=split_all.
Recovery presets are passed as recovery_choices, e.g.
recovery_choices={"missing_lattice": {"choice": "bounding_box", "parameters": {"padding_ang": 5.0}}}.
Other keyword options mirror the CLI flags: mode, acknowledge_loss,
acknowledge_parse_warnings, and tolerance_profile (a named profile — default/strict/loose
— or a full custom ToleranceProfile / tolerance table; an unknown name or a malformed table is
rejected, never silently ignored).
from xtalate.registry import default_registry
from xtalate.discovery import DiscoveryEngine
registry = default_registry()
with open("water.xyz", "rb") as fh:
report = DiscoveryEngine(registry).discover(fh.read(), filename="water.xyz")
print(report.model_dump_json(indent=2)) # the DiscoveryReportFor trajectories that should not be materialized in memory, the Conversion Engine exposes streaming variants that hold one frame resident and write the target incrementally:
ConversionEngine.convert_stream(source, *, source_format_id, target_format_id, output, …)— a frame-chunked conversion writing into an open binaryoutputstream.ConversionEngine.convert_stream_select(source, *, frame_selection, output, …)— the same, for afirst/last/indexframe selection.ConversionEngine.streaming_eligible(source_format_id, target_format_id)andframe_selection_streaming_eligible(...)— predicate checks for whether a case can stream.
Streaming changes memory, never truth: the streamed report is proven identical to the materialized one.
from xtalate.validation import ValidationEngine, ToleranceProfile, rethresholdValidationEngine(registry).validate(expected=…, output=…, target_format_id=…, conversion_report=…, tolerance=…)— re-parse and diff.ToleranceProfile.named("default"|"strict"|"loose")andToleranceProfile.from_mapping(name, mapping)— build a tolerance profile (the latter from a custom per-quantity table).rethreshold(stored_report, profile)— re-apply a new tolerance profile to a storedValidationReportwithout re-parsing.
All three reports are pydantic models. Serialize any of them with .model_dump(mode="json") or
.model_dump_json(indent=2); the Service layer embeds these same models
verbatim in its HTTP responses (no parallel DTOs).
| Report | What it records |
|---|---|
DiscoveryReport |
The ✓/✗ inventory of which canonical fields a file contains, each with the format's read capability, plus any namespaced format-specific extras carried through. |
ConversionReport |
status (completed / refused), and the accounting of every source field: preserved, removed, supplied (with the assumptions that produced each fabricated value), and warnings. The completeness invariant guarantees every source field appears in exactly one of these. |
ValidationReport |
status (passed / passed_with_warnings / failed), the tolerance profile used, and the per-check results (atom count, species preservation, positions RMSD, lattice consistency, frame count, numeric field fidelity, metadata preservation, absence conformance, report consistency). |
Read and write: xyz, extxyz, poscar, contcar, xdatcar, ase_traj, cif,
lammps_dump, lammps_data, qe_pw_in, ase_db, and deepmd_npy (a directory format —
see the Developer Guide for its -o DIR write surface). Read-only /
parser-only sources: vasprun (VASP vasprun.xml), outcar (VASP OUTCAR), and qe_pw_out
(pw.x output); they are valid conversion sources but never targets. A dataset is aggregation,
not a new model: a multi-row ase_db or a multi-frame directory fans out under
convert --batch, and assemble builds one container (extXYZ, .db, DeePMD systems) from N
sources — every per-file report is embedded verbatim and tallies are counts, never
restatements. Third-party formats registered via entry points (see the Developer
Guide) appear here on equal footing — xtalate capabilities always reflects
the live set.
The same engine is exposed over HTTP under /v1. The API is a thin presenter over the library — it
contains no scientific logic, and every response embeds the pydantic report models verbatim (the
same schemas as §3, no parallel DTOs). Two rules run through the whole surface:
- A refused conversion is not an error. A conversion the engine declines is a completed job
whose
ConversionReport.status == "refused", returned as HTTP 200 — never a 4xx. - Long operations are async jobs.
inspect/convert/validatereturn a job; you pollGET /v1/jobs/{job_id}until it reachescompleted(orawaiting_recovery, if you opted into interactive recovery). The machine-readable contract is the committedopenapi.jsonartifact.
One command brings up the Tier 1 stack (API + worker + PostgreSQL + MinIO + Redis):
docker compose up --build --wait
# readiness — green only once migrations ran and the DB + object store answer:
curl -s "http://localhost:8000/v1/health?ready=true"For a dependency-free Tier 0 run (SQLite + local filesystem, jobs executed in-process), install the service extra and run the app directly — no database or object store to stand up:
pip install "xtalate[service]"
python -m backend # serves on http://localhost:8000Upload a file, convert it interactively (the two-frame molecular input needs both a frame picked and a lattice supplied for a periodic POSCAR target — the worked example of the recovery workflow), resume with your choices, then download the output.
BASE=http://localhost:8000/v1
# 1. Upload — returns a file_id.
FILE_ID=$(curl -s -F "file=@traj.xyz" "$BASE/upload" | jq -r .file_id)
# 2. Inspect — the Discovery Report (✓/✗ per canonical field). Poll the job to completed.
JOB=$(curl -s "$BASE/inspect" -H 'content-type: application/json' \
-d "{\"file_id\":\"$FILE_ID\"}" | jq -r .job_id)
curl -s "$BASE/jobs/$JOB" | jq .result.discovery_report
# 3. Convert to POSCAR asking for interactive recovery — the job PAUSES at awaiting_recovery
# with the computed options for each unresolved scenario.
JOB=$(curl -s "$BASE/convert" -H 'content-type: application/json' -d "{
\"file_id\": \"$FILE_ID\",
\"target_format_id\": \"poscar\",
\"options\": { \"allow_recovery\": true }
}" | jq -r .job_id)
curl -s "$BASE/jobs/$JOB" | jq '.state, .awaiting_recovery.unresolved_scenarios[].scenario'
# 4. Resume with your choices — every choice is recorded as an Assumption in the report.
curl -s "$BASE/jobs/$JOB/recovery" -H 'content-type: application/json' -d '{
"choices": {
"frame_selection": { "choice": "last" },
"missing_lattice": { "choice": "bounding_box", "parameters": { "padding_ang": 5.0 } }
}
}' > /dev/null
CID=$(curl -s "$BASE/jobs/$JOB" | jq -r .result.conversion_id)
# 5. Download the converted POSCAR (streamed through the API, never a presigned URL).
curl -s "$BASE/download/$CID" -o out.POSCAR
# The durable record serves BOTH reports back verbatim — even after the bytes expire.
curl -s "$BASE/conversions/$CID" | jq '.conversion_report.status, .validation_report.status'Before you resume, you can preview the exact Assumptions your choices would record — without
advancing the job — by POSTing the same { choices } body to
/v1/jobs/{job_id}/recovery/preview. It returns { previews: [{ scenario, choice, parameters, description }…], unresolved: [<scenario>…] }, where each description is byte-identical to the
Assumption the resume will write, because the preview runs the engine's real apply path and returns
its sentence verbatim (the browser cannot reproduce it — that is the point). Recovery is
all-or-nothing: an incomplete choice set returns no previews and names the scenarios still
unresolved instead. The preview writes nothing, enqueues nothing, and leaves the job paused and
answerable; it shares the resume's guards (404, 409 JOB_NOT_AWAITING_RECOVERY,
422 INVALID_RECOVERY_CHOICE). This is how the Web UI shows the record you are about to create
before you confirm it — consent and provenance are the same artifact.
The options object also accepts tolerance_profile — a named profile (default/strict/loose)
or a full custom tolerance table; an unknown name or a malformed table is refused at submit as
400 MALFORMED_REQUEST carrying the library's own reason, before a job exists (D93).
Supplying the same recovery_choices in the initial convert request (instead of
allow_recovery) skips the pause and completes in one call — the preset path and the interactive
path produce byte-equivalent reports. Read the advertised limits (GET /v1/limits) before you hit
them: an oversized upload is 413, a rate burst is 429 with Retry-After, and — on an instance
configured with a static API key — a keyless mutating request is 401.
As of the v1.0 contract freeze, the /v1 surface is frozen for the 1.x series. Three things are
the contract, and they are the same three the service ships as machine-readable artifacts:
- the endpoint set — the paths and methods enumerated above (
upload,inspect,convert,validate,batch/convert(v1.5),jobs/{id}and itsrecovery/recovery/preview/cancelsub-resources,conversions/{id},download/{id},history,capabilities[/{format_id}],limits,health, plus the v1.6 additive read-only geometry routesfiles/{id}/geometryandconversions/{id}/geometry(below)); - the response envelopes — the pydantic report models of §3, embedded verbatim, and the single
error envelope
{ error: { code, message, details, request_id, documentation_url } }; and - the error-code set — the stable machine strings each non-2xx response carries, cataloged in
error_codes.jsonwith a human reference inerrors.md.
docs/openapi.json is the versioned, machine-readable form of this contract. It
is generated deterministically from the assembled app (python -m backend.openapi), source-pinned so
two checkouts produce byte-identical output, and diff-guarded in CI — a route added, a field renamed,
or a status code changed fails the build until the artifact is regenerated on purpose. It is
published as a release artifact, so each release carries the exact /v1 schema it shipped.
The surface evolves under an additive-only policy (Part 6 §7), so a client written against 1.x keeps working across the series:
- Path-prefix versioning. The version lives in the path (
/v1). A change that would break the frozen endpoints, envelopes, or codes waits for a new prefix (/v2); it is never slipped into/v1. - New capability arrives as values, not new endpoints. A new format or a new recovery scenario
is a new value in an existing request or response field (a
format_id, a scenario name), so it needs no new route —GET /v1/capabilitiesand the report bodies simply carry more. Third-party plugin formats appear on this surface with no API change at all. - Additive fields are non-breaking. New optional request fields and new response fields may be added within 1.x; existing fields are not removed, renamed, or repurposed, and an existing error code is never given a new meaning (new codes may be added to the set).
The batch endpoint is the HTTP form of the library's run_batch contract — the API reproduces it,
never re-implements it (the same BatchTallies/LabelPresence and verbatim-embedding rules). It is
an additive job kind: the /v1 endpoints, envelopes, and codes above are unchanged, and a
client that never calls it keeps working.
# Upload the files first (each is an ordinary upload):
F1=$(curl -s -F "file=@run1/vasprun.xml" "$BASE/upload" | jq -r .file_id)
F2=$(curl -s -F "file=@run2/pw.out" "$BASE/upload" | jq -r .file_id)
# Submit the batch: ordered file_ids + one target + shared options.
JOB=$(curl -s "$BASE/batch/convert" -H 'content-type: application/json' -d "{
\"file_ids\": [\"$F1\", \"$F2\"],
\"target_format_id\": \"extxyz\"
}" | jq -r .job_id)
# Poll the parent; its result is the aggregate (tallies + per-file entries embedding each
# child's reports verbatim) and its `children` projection names each child job:
curl -s "$BASE/jobs/$JOB" | jq '.state, .result.tallies, .children'
# Each child is an ordinary job — GET /v1/jobs/{child_id} is its own full record.Semantics worth knowing before you call it:
- One target, shared options. The request is
{ file_ids: [...], target_format_id, options }with an optional per-fileoverrideslist whose fields replace the shared value (an unset field inherits;recovery_choicesreplace, never merge — the librarySourceOverridesemantics). At least onefile_idis required (422 EMPTY_BATCH); an empty list is the only batch-specific refusal. - Fan-out to ordinary child jobs. The parent creates N ordinary
kind="convert"child jobs, each a navigable record with its own pause, refusal, expiry, and cancellation — exactly as if submitted alone. A rejected submit leaves no orphans: children are created by the parent's dispatch, so a request that fails validation creates none. - Per-file consent stays per-file. A child that pauses at
awaiting_recoveryleaves the parent honestly non-terminal at the same state with no recovery block of its own — the batch never answers a recovery question wholesale, and resuming the parent with choices is422 INVALID_RECOVERY_CHOICE. Answer the child on its own record; once every child is terminal, the parent re-drives itself lazily on the nextGET /v1/jobs/{id}poll and completes. - The aggregate is a container, never a digest.
result.entries[]embeds each child'sConversionReport/ValidationReportverbatim (byte-identical to the child's own record), andresult.talliesare counts (total/converted/refused/failed +label_presence) — no merged assumptions, no "top losses" summary. A cancelled child reportsfailedwith codeJOB_CANCELLED; a child whose pause expired resolves as aRECOVERY_REQUIREDrefusal, never a silent default. - Navigable in every state. The envelope carries an additive
children: [{ job_id, file_id, state }…]projection so a client (or the Web UI) can reach each child's record whether the batch is running, paused, or complete.
The committed openapi.json is regenerated (python -m backend.openapi) and
contains the exact request/response schema; EMPTY_BATCH and JOB_CANCELLED are cataloged in
error_codes.json.
5.5 Read-only geometry endpoints (GET /v1/files/{id}/geometry, GET /v1/conversions/{id}/geometry) — an additive read surface (v1.6 M59)
Two additive GET routes expose the canonical geometry the viewer renders, as a wire projection
of the Canonical Object — never a second canonical artifact and never a hidden export:
GET /v1/files/{file_id}/geometry— an uploaded file's own geometry;GET /v1/conversions/{conversion_id}/geometry?side=source|output— a conversion's source or re-parsed output geometry (the two objects the Validation Engine diffed).
Both take a half-open, 0-based ?frames=start:end window (default 0:1, a single structure) and
return species (one symbol per atom), an optional per-frame cell (a (3, 3) lattice; null
when the source carried none — absence renders as absence, P3), per-frame positions as nested
lists, frame_index_base (the window's absolute first index) and frame_count (the whole object's
total). The endpoints ride the existing streaming engine behind a byte-bounded server-side cache —
never materializing a whole trajectory. Geometry expires with the bytes: once the underlying
bytes are gone the route answers 410 FILE_EXPIRED / OUTPUT_EXPIRED, while the conversion record
and its reports remain readable (reports outlive bytes). A malformed or reversed range is
400 INVALID_FRAME_RANGE. No bonds: the Canonical Model holds no bonds, so the projection
carries none — a coordination bond is a display heuristic (D234), never file content and never served
here. This surface changes no library/CLI surface; it is the Web UI viewer's read path only.