Run logs

Every ferric CLI run writes a machine-readable JSON Lines log, without being asked.

The format is ferric's own. It is not QCSchema, and there is no QCSchema exporter. Each record's schema field (currently 1, on run_start) versions this format. It is not a QCSchema version.

$ ferric water-rhf.toml
[ferric] JSON run log: water-rhf.ferric.jsonl
...

Why it is on by default

A result whose run left no artifact can't be checked. Stdout captures get truncated, and a claim such as "173 iterations, converged" that survives only in a truncated capture can be repeated but never checked. The run log keeps the record on disk whether or not anyone captured stdout, which is why it is on by default.

Opt out explicitly if you need to:

[output]
json = false          # no log
# json = "runs/a.jsonl"  # or put it somewhere specific

or from the command line:

ferric --no-json input.toml
ferric --json runs/benzene.jsonl input.toml

The default path is the input file's name with .ferric.jsonl in place of its extension, written beside the input.

A log never fails a calculation. If the path cannot be opened or the disk fills, ferric warns once on stderr and the run continues.

Format

One JSON object per line, written and flushed as it happens — not buffered to the end of the run. A job that is OOM-killed, hits a wall-clock limit, or is Ctrl-C'd at iteration 90 of 100 leaves those 90 iterations on disk. Only the final line may be truncated; everything before it parses.

jq -c 'select(.record=="scf_iter") | {iter, energy, dp_rms}' water-rhf.ferric.jsonl

Every record carries record (its type), seq (a gap-free counter, so a missing record is detectable) and t (seconds since the run started).

run_start

Written before any expensive work, so it survives a job killed in the first iteration. Carries the ferric version, the git SHA the binary was built from (null if the build had no checkout; a -dirty suffix means uncommitted changes were compiled in), a UTC timestamp, the resolved config — including the memory budget actually used, not the key as written — and the molecule.

scf_iter

One per SCF iteration: iter, energy, de, dp_rms, dp_max, err_max, and grad_rms where the solver forms one. rung says which ladder rung the iteration belongs to. method is rhf/uhf/rohf or their KS counterparts rks/uks/roks.

These are the quantities the solver already computes for its convergence decision (see scf_converged) — the log reports them, it does not create them.

dp_rms and dp_max are "inf" on the first iteration of each SCF, before there is a previous density to compare against. JSON has no infinity, so non-finite values are written as the strings "inf", "-inf" and "NaN" rather than as null, which would make a NaN indistinguishable from a field that was simply not reported.

guess_scf_iter

Identical shape, but for the free-atom SCFs the SAD/MINAO initial guess runs per element. They are real work and can fail, so they are logged — under a different record type so that free oxygen's energy is never mistaken for the molecule's.

ladder_rung

One per convergence-ladder rung: rung, tricks (which accelerators it enables), iterations, exit, energy, converged — and max_iter.

That last field matters more than it looks. In the CLI, kind = "rhf" and kind = "ksdft" run through a convergence ladder, and unless you define your own [[scf.ladder]] rungs, the default ladder sets its own per-rung iteration caps:

kindrung 0rungs 1–4
rhf6060 / 60 / 80 / 100
ksdft[scf] max_iter60 / 60 / 80 / 100

So for rhf, [scf] max_iter does not set the per-rung cap. A run that reports "iterations = 100" is usually rung 4 using up its own budget, not a single hundred-iteration SCF. The cap and the count are recorded together so the log can't be misread that way.

run_end

The terminal record: energy, converged, exit, wall_s, cpu_s and peak_rss_bytes (the high-water mark, not the RSS at exit).

For a post-SCF method the energy is the SCF reference, not the method's total. extra.energy_is says which. Per-iteration records for MP2/RPA/CC/GW are not implemented yet.

Only task = "energy" writes run_end. task = "optimize" and task = "frequencies" currently write run_start and the SCF iteration records, but no run_end, ladder_rung or result. Read the final geometry and energy from stdout for those tasks. A missing run_end in such a log doesn't mean the run failed (MEASURED on examples/h2-lda-opt.toml, 2026-09-23).

result

The number a correlated run was launched to produce. It's written once, after the method finishes. Plain SCF runs (rhf, uhf, rohf, ksdft) don't write one, because for them run_end.energy is the answer.

fieldmeaning
kindthe method that produced it, e.g. "rimp2", "ccsd", "gw"
totalthe headline number a user would quote
componentsthe decomposition that makes total checkable — e_corr, e_os/e_ss, the reference energy, and whatever else the method defines

total and run_end.energy are different numbers for every post-SCF method. A kind="rimp2" run's run_end.energy is the RHF energy it was built on; its result.total is the RI-MP2 total. Reading the wrong one silently gives an uncorrelated answer.

components exists because a total alone cannot be reconciled against a reference implementation — you need the pieces to see where a disagreement comes from.

result_unlogged

A post-SCF method that isn't wired up to result yet writes this instead, naming the kind. The methods that do write result are rimp2, oo-rimp2, att-rimp2, laplace-mp2, laplace-sos-mp2, scs-mp2, scs-mp2-2terfc, mp3, ccsd, ccd, ccsd(t), linlccd, drpa, mp2-v and rs-mp2-rpa. For rimp2, drpa and linlccd the record's components carry "local": null for the exact method, else {"scheme", "eps", "keep_fraction", "integral_direct"}.

It exists because silence is ambiguous: a log with no result record could mean the method does not record one yet, or that the run died before producing one. Those demand opposite responses from whatever reads the log. This makes the first case explicit and leaves "the run failed" as the only remaining reading of a genuinely missing record.

Example

A water/STO-3G RHF run, abridged:

{"record":"run_start","schema":1,"seq":0,"t":0.002,"ferric_version":"0.1.0","git_sha":"396e0d61eded-dirty","timestamp":"2026-09-18T16:04:12Z","config":{"method":"rhf","basis":"sto-3g","max_iter":100,"density_conv":1e-7,"memory_budget_bytes":13018454425,"rayon_num_threads":12,"mpi_ranks":1},"molecule":{"formula":"H2O","n_atoms":3,"n_basis":7,"n_electrons":10,"charge":0,"multiplicity":1}}
{"record":"guess_scf_iter","seq":1,"t":0.014,"method":"uhf","iter":1,"energy":-0.4665818503784864,"dp_rms":"inf","dp_max":"inf","de":0.4665818503784864,"err_max":0.0,"grad_rms":null,"rung":0}
{"record":"scf_iter","seq":5,"t":0.017,"method":"rhf","iter":1,"energy":-74.68273456362847,"de":74.68273456362847,"dp_rms":"inf","dp_max":"inf","err_max":1.6088693243641559,"grad_rms":0.5469406324593924,"rung":0}
{"record":"scf_iter","seq":13,"t":0.019,"method":"rhf","iter":9,"energy":-74.9631468000391,"de":5.684341886080802e-14,"dp_rms":1.356320515468342e-10,"dp_max":4.726057323267696e-10,"err_max":3.5214886562329184e-14,"grad_rms":1.2777284834157483e-14,"rung":0}
{"record":"ladder_rung","seq":14,"t":0.019,"rung":0,"tricks":["diis"],"max_iter":60,"iterations":9,"exit":"Converged","energy":-74.9631468000391,"converged":true}
{"record":"run_end","seq":15,"t":0.019,"energy":-74.9631468000391,"converged":true,"exit":"Converged","wall_s":0.0189,"cpu_s":0.05,"peak_rss_bytes":128815104,"extra":{"method":"rhf","task":"energy","scf_iterations":9,"energy_is":"total"}}

Does it change the answer?

No, and that is a regression test, not a claim: SCF energies are bit-identical (f64::to_bits()) with the log on and off, asserted across RHF, RKS, UHF and ROHF in crates/ferric-scf/tests/runlog_bit_identity.rs. Logging is observation, never participation — every value written is one the solver had already computed for its own convergence decision, and nothing is ever read back.

Does it cost anything?

The per-record cost has not yet been measured on a quiet machine. Run scripts/bench-runlog-overhead.sh to measure it; the script explains why the emit path must be timed directly rather than by diffing whole-process wall times, and what to compare the result against.