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.

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.

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.

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 OutputDocument, Quantity, Table, Column, 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("iter"), Column("energy"), Column("dE")])
t.add_row(1, Quantity(-76.02, "energy"), Quantity(-1.0e-3, "energy_delta"))
write(t.render() + "\n")

Quantity kinds today: energy, energy_delta, gradient, length, temperature, 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, double hybrid, periodic RHF). 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