The output logger (vibeqc.output)¶
vibe-qc has one output surface. Every user-facing byte a calculation
emits – the banner, the SCF trace, energies, properties, timings – is
formatted in Python by vibeqc.output and written through a single
channel. Nothing in the codebase calls print() for .out content, opens
the .out file directly, or hand-formats a number. This page explains how
that works, both for users (log levels, where output goes) and for
contributors (how a module emits its own output).
Where output goes¶
A run writes a family of sibling files next to the output stem (see
Output files for the full list). The human-readable
one is {stem}.out. It is produced by an output channel: the runner
opens one at job start, and every module that wants to say something
writes into it.
from vibeqc.output import write, flush
write(" Total energy: -76.0261408 Ha\n")
flush()
write() targets the channel the runner installed. Outside a run (a
notebook, a unit test, a library import) no channel is active and
write() is a silent no-op, so any writer stays callable anywhere. If you
need to catch a “forgot to install a channel” bug, set
VIBEQC_STRICT_OUTPUT=1 and the no-op becomes a loud error instead.
For a titled block, use section() rather than hand-drawing an underline
rule, so widths stay consistent:
from vibeqc.output import section, write
with section("Energy components"): # title + a rule sized to it
write(" Nuclear repulsion 9.1671 Ha\n")
section(title, *, width=None, level=...) sizes the rule to the title by
default; pass width=N to fix it. section_header(title, ...) returns the
same header string for code that builds a string rather than writing live.
Log levels¶
Every message carries a level, and each channel has a threshold. A message appears only when its level is at or below the threshold. The four levels, from always-shown to diagnostic-only:
Level |
Shown when the channel is… |
Use for |
|---|---|---|
|
always (even |
essential results: final energy, converged/not-converged, fatal errors |
|
|
the normal |
|
|
opt-in detail: per-shell basis dumps, full orbital tables, per-stage timings |
|
|
developer diagnostics: intermediate norms, gauge checks |
An untagged write(text) is STANDARD. So a channel left at its default
standard threshold shows exactly the QUIET and STANDARD messages –
the same content the .out has always had. Raising the threshold to
verbose reveals the VERBOSE extras on top; lowering it to quiet
strips everything but the essentials.
Concretely, a VIBEQC_OUTPUT_LEVEL=quiet run of an SCF keeps the
converged in N iterations; E = ... Ha verdict line (it is QUIET) and any
warn() output, and drops the per-iteration table
above it (that is STANDARD). The default .out is unchanged – the verdict
sits welded to its table exactly as before.
If an engine exposes only a final iteration count, as current basis-free
semiempirical engines do, there is no per-iteration table at any level. The
same QUIET verdict remains, with the real count and final energy.
Correlated-solver diagnostics follow the same channel. Setting the solver’s
verbose option for CASSCF, selected-CI, DMRG, or v2RDM writes those messages
to the runner’s .out; it never prints around the channel. A direct library
caller that wants interactive console diagnostics can install stdout
explicitly:
from vibeqc.output import OutputChannel
with OutputChannel.to_stdout():
result = solver.solve(hamiltonian)
Setting the level¶
As a user, set the default with the environment variable (a name or
0-3):
VIBEQC_OUTPUT_LEVEL=verbose python run.py # standard + verbose
VIBEQC_OUTPUT_LEVEL=quiet python run.py # essentials only
VIBEQC_OUTPUT_LEVEL=debug python run.py # everything
In code, output_level(...) sets the default that newly-opened channels
adopt, and channel.set_level(...) changes a channel already open:
from vibeqc.output import output_level, Level
output_level("verbose") # or Level.VERBOSE, or 2
output_level() # read it back -> Level.VERBOSE
Tagging a message¶
A module decides which band its output belongs to by passing level=:
from vibeqc.output import write, Level
write(" Total energy: -76.0261408 Ha\n") # STANDARD (default)
write(" Converged in 12 iterations\n", Level.QUIET) # always shown
write(basis_shell_dump, Level.VERBOSE) # only in verbose+
write(f" ||[F,DS]|| = {res:.3e}\n", Level.DEBUG) # only in debug
That is the whole contract for making output level-aware: tag the write, and the channel filters it. There is no separate logger object to obtain and no per-module configuration.
Warnings and notes¶
A warning is not a raw write(). Use warn(), which emits a
WARNING: {message} line at QUIET – so it survives even
VIBEQC_OUTPUT_LEVEL=quiet – and simultaneously fires a machine-readable
warning event on the structured log:
from vibeqc.output import warn, note
warn("CIF write failed; skipping structure export", role="cif")
note("dispersion grid fell back to the default (no user grid set)")
note(message, ...) is the non-problem counterpart: a Note: {message}
line at STANDARD plus a note event, for something a user wants to know
(a fallback taken, a default filled in) that is not an error. Use warn()
for anything that should reach a user who asked for a quiet run; use
note() for advisory asides that a quiet run can drop.
For a guard that may be evaluated repeatedly inside one job, pass a stable
once_key rather than keeping suppression state in the calling module:
warn(
"lattice fold is under-converged; increase cutoff_bohr",
once_key="bipole.fold_truncation.rhf.warning",
drift=0.03,
)
The first call for that key emits both the human line and structured event; later calls in the same active output-channel or structured-log scope emit neither. Fresh scopes start empty. Use separate keys for note and warning severities so a later warning cannot be hidden by an earlier note.
Formatting numbers¶
Precision, field width, and units are not hand-written into f-strings.
A quantity is a Quantity, and a FormatPolicy decides how it renders –
which is what keeps the same physical value from being printed at five
widths under four labels. Tables compute their own column widths.
from vibeqc.output import (
Column,
CriteriaTable,
HeaderlessBlock,
HeaderlessColumn,
OutputDocument,
Quantity,
Table,
write,
)
doc = OutputDocument()
doc.section("Energy components", unit_for="energy")
doc.scalar("Nuclear repulsion", Quantity(9.1671, "energy"))
doc.scalar("Total energy", Quantity(-76.0261408, "energy"))
write(doc.render())
t = Table([Column("elem"), Column("Mulliken"), Column("Löwdin")])
t.add_row("O", "-0.42", "-0.42")
t.add_row("H", "0.21", "0.21")
t.footer("sum", "0.00", "0.00") # a total row, set apart by a rule
write(t.render() + "\n")
components = HeaderlessBlock(
"Energy components",
[HeaderlessColumn("<", min_width=24), HeaderlessColumn(">")],
unit_for="energy",
)
components.add_row("Nuclear repulsion", Quantity(9.1671, "energy"))
components.add_row("Total energy", Quantity(-76.0261408, "energy"))
components.divider()
write(components.render() + "\n")
criteria = CriteriaTable()
criteria.add(
"gmax",
Quantity(3.27e-4, "gradient"),
Quantity(4.50e-4, "gradient"),
True,
)
write(criteria.render() + "\n")
Use Table when columns have semantic headers. Use HeaderlessBlock for a
titled label/value list or matrix whose content, rather than its title, sets
the rule width. A row annotation can sit to the right without extending that
rule, which is useful for ragged HOMO/LUMO markers. Molecular SCF and
standalone double-hybrid energy-component blocks both use this primitive, so
their energy values and unit-bearing titles follow the same active policy.
The molecular and periodic timing summaries use it as well; their iteration
counts are annotations, while a time:ms or time:min policy moves both the
values and the title away from seconds. Molecular and periodic atom summaries
also size their rules from the coordinate rows rather than a guessed constant.
Periodic basis and primitive-cell metadata use the same layout, while smearing
rows additionally route energy, temperature, and entropy through their
quantity kinds so converted values keep truthful row labels. Periodic band
extrema and crystal-orbital summaries also use the primitive: k-point and
HOCO/LUCO annotations remain outside the rule, the band values retain their
deliberate Ha/eV pair, and the crystal-orbital title and values move together
when the active energy unit changes. The Gaussian-basis and full-k
semiempirical periodic optimization routes likewise share one final-geometry
block whose energy and rule follow the rendered content.
CriteriaTable is the value-versus-threshold variant: it keeps both sides in
the same semantic quantity kind, converts them together, and owns the ASCII
pass/fail verdict. Its render_inline() form fits the same diagnostic into
one cell of a streaming progress table.
For a single value inside an existing f-string, the render_* helpers route
one number through the active policy while keeping that site’s own field
width – byte-identical at the default unit, converting when the unit is
moved: render_energy / render_energy_labeled (energy, with/without the
unit token), render_duration (seconds), render_frequency (cm⁻¹),
render_temperature (K). Reach for these when migrating a legacy
f"{e:16.10f} Ha" rather than restructuring the whole block into a Table.
Quantity kinds today: energy, energy_delta, gradient, length,
temperature, duration (wall-clock seconds; time:ms/time:min),
frequency (vibrational cm⁻¹; wavenumber:THz/wavenumber:meV), and
dimensionless. Units belong to a physical dimension and
precision to a kind, so DEFAULT_POLICY.with_unit("energy", "eV") moves
total energies and SCF energy changes together – a table can never mix
Hartree and eV across its columns. To render a whole document in eV at 6
decimals, pass a policy to render() rather than editing the module that
produced the numbers.
For contributors: the rules¶
The full, authoritative version lives in
CLAUDE.md § 16
and AGENTS.md rule 11. In short:
Emit through the channel, never
print().print()for user-facing output is only acceptable in the console-script entry points (_cli.pyand friends), whose job is stdout.Never hand-format a number. Use
Quantity+ the document primitives. A total energy isQuantity(value, "energy"), notf"{e:16.10f} Ha".The C++ core emits nothing. It fills result structs and returns numbers across pybind11; Python renders them. This is enforced by
tests/test_cpp_emits_no_user_output.py.Do not change the shape of
vibeqc.outputyourself. The channel, the document primitives, the writer/manifest, andprogress.pyare owned by the IO dev chat. If you need a new quantity kind, a table variant, or a new lifecycle hook, raise it under Asks pending inhandovers/HANDOVER_OUTPUT_LOGGER.md. Using the module is every chat’s job; changing its shape is the IO chat’s.
The .out format is frozen¶
tests/test_out_format_snapshot.py locks the .out format against
committed golden files (molecular RHF/RKS/UHF, Hessian, TD-DFT, double
hybrid, periodic RHF, and periodic geometry optimization).
It freezes the scaffold – labels, widths, alignment, separators – not
the numbers, so a legitimate energy change passes but a format change
trips. If you deliberately change the format, regenerate with
VIBEQC_REGEN_GOLDEN=1 pytest tests/test_out_format_snapshot.py and review
the diff before committing.
Live progress vs the .out record¶
Two surfaces, on purpose:
The
.outfile is the complete batch record, written through the channel. It is columnar, with headers.The live stdout stream (
vibeqc.progress.ProgressLogger, theprogress=/verbose=knobs on the runners) is for watching a run happen. Its per-iteration lines are self-describing (iter 2 E = ... Ha dE = ... [0.3s]) so each stands alone in atail -fwhere a columnar header has scrolled off. These are different requirements, so the two formats differ by design; they are not expected to be identical.
The same distinction applies to direct experimental helpers. GAPW orbital
transformation iterations and opt-in density/Fock symmetry diagnostics use
ProgressLogger, so their normal direct-call stdout remains live and their
quiet modes remain silent. They do not call print() and they are not routed
to ambient write(), which would discard them when no calculation channel is
installed.
Standalone molecular workflows use the live surface too. Geometry optimizers,
relaxed scans, dimer saddle searches, and MECI optimization preserve their
opt-in stdout progress through ProgressLogger; molecular optimizers and
scans can instead receive a caller-owned logger when a notebook or application
wants that progress in another stream. Their persistent calculation results
and sidecars remain owned by the ambient channel and OutputWriter.
See also¶
Output files – the full sibling-file family.
docs/design_output_module.md– the output module’s design contract.