Skip to content

Live-log scroll fix (route C), linter pins, basis notation, monospace + test-isolation fixes - #32

Merged
NCCU-Schultz-Lab merged 11 commits into
mainfrom
smalls-logscroll-basis-linters
Jul 30, 2026
Merged

Live-log scroll fix (route C), linter pins, basis notation, monospace + test-isolation fixes#32
NCCU-Schultz-Lab merged 11 commits into
mainfrom
smalls-logscroll-basis-linters

Conversation

@NCCU-Schultz-Lab

@NCCU-Schultz-Lab NCCU-Schultz-Lab commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Four milestone items plus two bugs found along the way. One commit per package
per the M-CLEAN rule.

M-LOGSCROLL route C — scroll up during a run ✅ verified live

The reported bug: the Calculate-tab log snapped back to the bottom on every
printed line, so reading earlier output mid-run was impossible.

LOGSCROLL.0 (the "is route D enough?" re-check) was done empirically in a live
Voilà session, by disabling the existing guard without a code change — stripping
the class it re-queries each frame, then enabling native anchoring:

__el.classList.remove('quantui-run-output');
__el.style.overflowAnchor = 'auto';

The view jumped to the top on every line. So ipywidgets still performs the
per-line scrollTop = 0 reset, and overflow-anchor cannot defeat it — anchoring
protects against content insertion, not an explicit scrollTop write. Route D
ruled out. A second test wrapping the Output in an outer scroll container also
jumped to the top (its subtree teardown collapses the ancestor's scrollHeight),
ruling out the cheap variant of route C too.

So the re-render had to go rather than be out-raced. New quantui/live_log.py:
one <div> created once, text nodes appended to it, never re-rendered. With a
stable node, overflow-anchor: auto holds the user's position for free and a
single at-bottom check gives follow-the-tail. The per-frame pinning guard is gone.

LiveLog mimics the widgets.Output surface the app already used
(append_stdout, clear_output, assigning .outputs), so no write path
changed
_LogCapture.write, the atomic run-header write and Clear are
untouched. That kept a structural change reviewable.

The transport, and why it is not display()

Worth reading before touching this file. The first implementation pushed each
chunk as display(Javascript(...)) into a hidden Output, and silently dropped
every streaming line
— the header appeared and nothing followed.

display() inside an Output routes by parent message id: the frontend
captures iopub messages whose parent matches the one Output.__enter__ recorded.
The header survived only because it is written while the Run click's comm message
is being processed. Anything emitted from the calc thread — or from an io_loop
callback — has no message in flight, so there is no parent to route by and the
payload never leaves the kernel. Marshalling to the main thread did not help,
because the constraint is the message context, not the thread.

The old Output.append_stdout never hit this: it mutates the outputs
traitlet, which syncs over the widget comm and is parentage-independent.

So the shipped design uses the two mechanisms already proven in this app:

  1. a traitlet carries the data — thread-safe, no message context;
  2. a MutationObserver installed once at render carries the behaviour — the
    same way the vib camera hook installs (reflections/01 Rule 7).

The observer reads chunks from mutation records rather than re-reading DOM
state, so nothing is lost when several land in one frame; sequence numbers
discard replays and console.warn on gaps.

Known trade-off: the text lives in the DOM, not widget state, so a frontend
re-render (kernel reconnect) loses it. Python keeps the authoritative copy and
resync() repaints from it — M-RECONNECT must call resync() when restoring
a live view.

M-CLEAN CLEAN.2 — linter pins

The dev env had drifted ahead of the pins CI enforces (ruff 0.15.x / black 26.x
installed vs v0.4.7 / 24.4.2 pinned), so a bare ruff check reported 205
UP045 violations CI never saw
— noise that drowns real findings.

UP045 is the Optional[X]X | None half of UP007, which newer ruff split
into its own code. The project already ignores UP007 (requires-python is
>=3.9, target-version = "py39", mypy on 3.9, 3.9 in the CI matrix), so the
split silently un-ignored a decision already made. Added UP045 beside it rather
than converting 205 annotation sites.

  • pre-commit revs → ruff v0.16.0, black 26.5.1; hook id ruffruff-check
    (the bare id is now a deprecated alias).
  • Open floors in pyproject.toml's dev extra and local-setup/environment.yml
    replaced with bounded pins — that was the root cause; nothing prevented the
    drift. black is split on python_version because 26.x requires ≥3.10 while
    requires-python is >=3.9 (CI caught this).
  • Black 26 hugs a sole multiline-string argument → two test fixtures reformat;
    content and dedent behaviour unchanged.
  • UP031 newly fires (4 sites). Two plain labels → f-strings; two build
    JavaScript, where %-formatting is the right tool (an f-string would need every
    JS brace doubled) → noqa with the reason.

M-UX2 UXP2.1 — basis-set notation

6-31G* and 6-31G(d) are the same basis set written two ways, and nothing in
the UI said so — a student who learned the parenthesised form could reasonably
conclude the starred dropdown entry was a different, unavailable set.

  • Card gains one short line: "Also written 6-31G(d)." Deliberately one line —
    these cards exist because the old inline notes were "a lot of word clutter"
    (FR-DESCRIPTOR-CARDS); a test guards against it growing into a paragraph.
  • The basis help topic carries the full explanation: star↔parenthesis table, the
    +/++ diffuse markers and why they matter for anions, and the Dunning
    contrast — cc-pVDZ has no star because polarisation is built in, and its
    diffuse counterpart is aug-, not +.
  • The alias is derived from trailing stars, so a future 6-311G** entry gets
    it for free.
  • Both spellings verified against real PySCF construction (identical AO counts
    for the */(d) and **/(d,p) pairs), satisfying the roadmap's "don't show
    an alias the user might type unless it works" constraint with evidence.

Fix — the live log was rendering in a proportional font

User report: the run header's ASCII wordmark rendered as garbled letters and the
Label : value provenance rows had drifting colons.

Neither was a string-building bug — _row pads with {label:<16} and every colon
lands at column 19; the wordmark is faithful figlet. _APP_CSS's system-font rule
lists .jp-OutputArea-output, which is exactly what the log rendered into, so
it was forced into a proportional font with !important. ASCII art and padded
columns both need fixed-width cells, so they broke together. Re-asserted monospace
for the log only.

Fix — the test suite read real user settings

Two quantui gpu check tests began failing after a live GPU.8 verification left
compute.gpu_enabled = false in the developer's ~/.quantui/settings.json: the
probe short-circuited at the settings gate and never reached the import branch
those tests assert on. The tests were correct; their environment was not.

test_cli.py isolated QUANTUI_LOG_DIR but not QUANTUI_SETTINGS_PATH. Fixing
it there would have been too narrow — every test constructing QuantUIApp reads
settings, and viz.default_backend / vib_framerate_fps feed construction too.
So: a session-scoped autouse fixture, mirroring the existing _isolate_results_dir.
The suite was previously green only because that setting happened to be true.

Testing

  • tests/test_live_log.py (25) — the Output contract, buffering, concurrent
    appends from 8 threads, and the traitlet transport including HTML escaping and
    sequence ordering. It also caught a latent crash: the chunk sender was named
    _send, which overrides widgets.Widget._send — the comm transport — and
    broke add_class(), which app_builders calls on this very widget.
  • tests/test_basis_notation.py (24), tests/test_log_monospace.py (10).
  • Full suite: 1941 passed, 17 skipped (was 1882).
  • pre-commit run --all-files clean at the new pins.
  • Live-verified in Voilà: scrolling up during a run now holds position;
    returning to the bottom resumes following.

Also in this branch (planning vault, no code)

M-HISTORY-HARDENING closed. HIST.2's deferred latency phase closed without
optimizing: 34 real history_load_timing records give median 58 ms / p95
202 ms, and read_pyscf_log — the stage its own deferral note flagged with a

500 ms trigger — is 0.0 ms at the median. With HIST.1–.7 all resolved, the
milestone is closed and its roadmap archived.

Not in this PR

LOGSCROLL.2 — the optional "↓ Jump to latest" affordance. Native anchoring may
already be sufficient; worth deciding after living with the new behaviour.

🤖 Generated with Claude Code

NCCU-Schultz-Lab and others added 11 commits July 30, 2026 09:57
M-CLEAN CLEAN.2. The dev env had drifted ahead of the pins CI enforces
(ruff 0.15.x/black 26.x installed vs v0.4.7/24.4.2 pinned), so a bare
`ruff check` reported 205 UP045 violations CI never saw. UP045 is the
Optional[X] -> X | None half of UP007, which newer ruff split into its own
code — the project already ignores UP007 because requires-python is >=3.9
and 3.9 is in the CI matrix, so the split silently un-ignored a decision
already made. Added UP045 alongside it rather than converting annotations.

Also bumps the pre-commit revs to ruff v0.16.0 / black 26.5.1 and replaces
the open floors in pyproject's dev extra and local-setup/environment.yml
with compatible-release pins, so a fresh install can't drift ahead of CI
again. Black 26's style hugs a sole multiline-string argument, which
reformats two test fixtures; no content change.

UP031 also newly fires (4 sites). Two were plain labels, converted to
f-strings. Two build JavaScript payloads where %-formatting is the right
tool — an f-string would require doubling every JS brace — so those carry
a noqa with the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
M-UX2 UXP2.1. 6-31G* and 6-31G(d) are the same basis set written two ways,
and nothing in the UI said so — a student who learned the parenthesised
notation could reasonably conclude the starred entry in the dropdown was a
different, unavailable set.

The basis descriptor card now carries one short "Also written 6-31G(d)."
line, and the educational notes state the equivalence. Deliberately kept to
a single line: these cards exist because the previous inline notes were
"a lot of word clutter", so the full explanation lives in the basis-set
help topic instead, which now covers star/parenthesis, the +/++ diffuse
markers and why they matter for anions, and the Dunning contrast (cc-pVDZ
has no star because polarisation is built in, and its diffuse counterpart
is the aug- prefix rather than a +).

The alias is derived from the trailing stars rather than tabulated, so a
future dropdown entry like 6-311G** gets it for free. Both spellings were
verified against real PySCF basis construction — identical AO counts for
the */( d) and **/(d,p) pairs — so either is safe to show and to type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-on to the CLEAN.2 pin bump: current ruff-pre-commit reports the bare
`ruff` id as a legacy alias for `ruff-check`. pre-commit run --all-files is
clean at the new pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught this: black 26.5.1 requires Python >=3.10, but requires-python is
>=3.9 and 3.9 is in the test matrix, so the unconditional black~=26.5.1 in
the dev extra made `pip install -e .[dev]` unresolvable on the 3.9 job.
25.11.0 is the last black supporting 3.9.

The lint job runs on 3.11 and pre-commit builds its own hook env, so the
pinned rev was never affected — only the dev extra installed alongside the
tests. Documented the residual consequence: a developer on 3.9 formats with
25.11 and may differ slightly from CI, so run pre-commit before pushing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
User report: the run header's ASCII wordmark rendered as garbled letters and
the padded "Label : value" provenance rows had drifting colons.

Neither was a string-building bug — _row pads with {label:<16} and every colon
lands at column 19, and the wordmark is faithful figlet. Both symptoms came
from one CSS rule: _APP_CSS's system-font block lists .jp-OutputArea-output,
which is exactly the element the streaming log renders into, so the log was
forced into a proportional font with !important. ASCII art and padded columns
both need fixed-width cells, so they broke together.

Re-asserts monospace for the log only. Two classes out-specifies the
single-class rule, and !important is needed to beat its !important; the
override is also placed after it so source order is a backstop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two `quantui gpu check` tests began failing after a live GPU.8 verification
left compute.gpu_enabled=false in the developer's real ~/.quantui/settings.json:
the probe short-circuited at the settings gate and never reached the import
branch those tests assert on. The tests were correct; their environment was not.

test_cli.py isolated QUANTUI_LOG_DIR but not QUANTUI_SETTINGS_PATH, so it read
real user state. Fixing it there would have been too narrow — every test that
constructs QuantUIApp reads settings, and viz.default_backend and
vib_framerate_fps feed construction just as much. So this adds a session-scoped
autouse fixture pointing QUANTUI_SETTINGS_PATH at a temp file, mirroring the
existing _isolate_results_dir. Per-test monkeypatching still takes precedence.

The suite was previously green only because that setting happened to be true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ute C)

The Calculate-tab log was a widgets.Output. ipywidgets rebuilds that widget's
subtree on every appended line and resets scrollTop to 0, so scrolling up
mid-run was impossible. The existing requestAnimationFrame guard "fixed" that
by re-pinning to the bottom every frame — trading jumps-to-top for
stuck-at-bottom, which is the reported bug.

Two cheaper routes were tried in a live Voilà session and ruled out:

  - overflow-anchor:auto alone still jumped to the top. Anchoring protects
    against content insertion; it cannot undo an explicit scrollTop write.
  - An outer scroll container wrapping a non-scrolling Output also jumped to
    the top — the subtree teardown collapses the ancestor's scrollHeight, so
    the browser clamps the ancestor's scrollTop too.

So this removes the re-render rather than racing it. LiveLog owns one <div>
created once and appends text nodes to it; with a stable node, native
overflow-anchor holds the user's position and a single at-bottom check gives
follow-the-tail. No per-frame pinning.

LiveLog mimics the widgets.Output surface the app used (append_stdout,
clear_output, .outputs), so _LogCapture, the atomic run-header write and the
Clear button are unchanged. The atomic header assignment is preserved
deliberately — it is the pre-step-1 blank-window fix. Background-thread writes
are buffered under a lock and flushed on a short timer.

Known trade-off: the text lives in the DOM, not widget state, so a frontend
re-render loses it. Python keeps the authoritative copy and resync() repaints
from it; M-RECONNECT should call resync() when restoring a live view.

Browser-side behaviour still needs a live pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Regression from route C, caught in a live run: the header printed and then
nothing. widgets.Output.__enter__ captures output by recording the current
parent message id, and a background thread has no parent-message context — so
the JS payload emitted from the calc thread never reached the frontend. The
header was unaffected only because it is written from the click handler, which
is already on the main thread.

LiveLog now takes a `schedule` marshaller (app._queue_main_thread_callback);
emission is routed through it, and callbacks queued on the io_loop preserve
order so appends stay sequenced.

Two things that let this ship broken, both fixed:

  - _emit_js swallowed every exception silently, which is what hid the
    failure. It now logs a warning so `quantui log tail` reveals it.
  - The kernel check sat in _run_js and returned before scheduling, making the
    marshalling path invisible to tests. It moved into _emit_js, so the
    scheduling contract is now exercised off-frontend and guarded by
    TestBackgroundThreadEmission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Streaming output is still missing after the marshalling fix, so this stops
guessing and does two things.

Fix candidate: use the exact shape already proven to work for repeated
post-render JS pushes in this app (app_visualization._vib_bridge_set_mode) —
the Output widget's own clear_output() method called OUTSIDE the context,
then a bare display() inside it. LiveLog was calling the free
IPython.display.clear_output() INSIDE the context, which publishes a clear
message through the display pipeline rather than clearing the widget's
outputs. Reflections rule 8 applies: when a working pattern exists in the
codebase, match it instead of inventing a variant.

Diagnosis: every payload now carries a console.debug('[quantui-live-log]',
len, target) marker, and LiveLog.diagnostics() reports emit/error counts plus
whether a scheduler and kernel were found. Between them, "Python never
emitted", "emitted and raised", and "emitted but the browser dropped it or
the selector missed" become distinguishable in one observation instead of a
round trip each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Streaming output never reached the browser. The console marker added last
round proved why: exactly one payload arrived (the run header, 1562 chars,
target true) and nothing after it.

display() inside an Output routes by parent message id — the frontend
captures iopub messages whose parent matches the one Output.__enter__
recorded. The header worked because it is written while the Run click's comm
message is being processed. Anything emitted from the calc thread, or from an
io_loop callback, has no message being processed, so there is no parent to
route by and the payload is dropped before it leaves the kernel. Marshalling
to the main thread could never have fixed it: the constraint is the message
context, not the thread.

The old Output.append_stdout never hit this because it mutates the outputs
traitlet, which syncs over the widget comm and is parentage-independent. So
this uses the two mechanisms already proven in this app: a traitlet carries
the data (thread-safe, no message context), and a MutationObserver installed
once at render — the same way the vib camera hook installs — copies each
chunk into the log container. No display() on the streaming path.

The observer reads chunks from mutation records rather than re-reading DOM
state, so nothing is lost when several land in one frame; sequence numbers
discard replays and warn on gaps.

Also fixes a latent crash the new tests caught: the chunk sender was named
_send, which overrides widgets.Widget._send — the comm transport. That broke
add_class(), which app_builders calls on this very widget. Renamed to _post.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes 3 UP031 errors I pushed in the previous commit — I chained the commit
on a piped pre-commit, so the shell saw tail's exit status instead of the
hook's and the lint failure went unnoticed.

Token replacement rather than another noqa: the JS is brace-dense, so
%-formatting was the lesser evil for the small payloads in
app_visualization, but here the payload is a whole script and swapping two
placeholders is both lint-clean and easier to read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@NCCU-Schultz-Lab NCCU-Schultz-Lab changed the title Bump linter pins (CLEAN.2) and clarify basis-set notation (UXP2.1) Live-log scroll fix (route C), linter pins, basis notation, monospace + test-isolation fixes Jul 30, 2026
@NCCU-Schultz-Lab
NCCU-Schultz-Lab merged commit a1259ea into main Jul 30, 2026
5 checks passed
@NCCU-Schultz-Lab
NCCU-Schultz-Lab deleted the smalls-logscroll-basis-linters branch July 30, 2026 19:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant