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

QUIET

always (even quiet)

essential results: final energy, converged/not-converged, fatal errors

STANDARD

standard and above (the default)

the normal .out content

VERBOSE

verbose and above

opt-in detail: per-shell basis dumps, full orbital tables, per-stage timings

DEBUG

debug only

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.

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.

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,
    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")

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.

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.py and friends), whose job is stdout.

  • Never hand-format a number. Use Quantity + the document primitives. A total energy is Quantity(value, "energy"), not f"{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.output yourself. The channel, the document primitives, the writer/manifest, and progress.py are 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 in handovers/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 .out file is the complete batch record, written through the channel. It is columnar, with headers.

  • The live stdout stream (vibeqc.progress.ProgressLogger, the progress= / 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 a tail -f where a columnar header has scrolled off. These are different requirements, so the two formats differ by design; they are not expected to be identical.

See also