Live-log scroll fix (route C), linter pins, basis notation, monospace + test-isolation fixes - #32
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 liveVoilà session, by disabling the existing guard without a code change — stripping
the class it re-queries each frame, then enabling native anchoring:
The view jumped to the top on every line. So ipywidgets still performs the
per-line
scrollTop = 0reset, andoverflow-anchorcannot defeat it — anchoringprotects against content insertion, not an explicit
scrollTopwrite. Route Druled 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 astable node,
overflow-anchor: autoholds the user's position for free and asingle at-bottom check gives follow-the-tail. The per-frame pinning guard is gone.
LiveLogmimics thewidgets.Outputsurface the app already used(
append_stdout,clear_output, assigning.outputs), so no write pathchanged —
_LogCapture.write, the atomic run-header write and Clear areuntouched. 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 droppedevery streaming line — the header appeared and nothing followed.
display()inside an Output routes by parent message id: the frontendcaptures 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_stdoutnever hit this: it mutates theoutputstraitlet, which syncs over the widget comm and is parentage-independent.
So the shipped design uses the two mechanisms already proven in this app:
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.warnon 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 callresync()when restoringa 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.2pinned), so a bareruff checkreported 205UP045violations CI never saw — noise that drowns real findings.UP045is theOptional[X]→X | Nonehalf ofUP007, which newer ruff splitinto its own code. The project already ignores
UP007(requires-pythonis>=3.9,target-version = "py39", mypy on 3.9, 3.9 in the CI matrix), so thesplit silently un-ignored a decision already made. Added
UP045beside it ratherthan converting 205 annotation sites.
v0.16.0, black26.5.1; hook idruff→ruff-check(the bare id is now a deprecated alias).
pyproject.toml'sdevextra andlocal-setup/environment.ymlreplaced with bounded pins — that was the root cause; nothing prevented the
drift. black is split on
python_versionbecause 26.x requires ≥3.10 whilerequires-pythonis>=3.9(CI caught this).content and
dedentbehaviour unchanged.UP031newly fires (4 sites). Two plain labels → f-strings; two buildJavaScript, where
%-formatting is the right tool (an f-string would need everyJS brace doubled) →
noqawith the reason.M-UX2 UXP2.1 — basis-set notation
6-31G*and6-31G(d)are the same basis set written two ways, and nothing inthe UI said so — a student who learned the parenthesised form could reasonably
conclude the starred dropdown entry was a different, unavailable set.
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.
+/++diffuse markers and why they matter for anions, and the Dunningcontrast —
cc-pVDZhas no star because polarisation is built in, and itsdiffuse counterpart is
aug-, not+.6-311G**entry getsit for free.
for the
*/(d)and**/(d,p)pairs), satisfying the roadmap's "don't showan 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 : valueprovenance rows had drifting colons.Neither was a string-building bug —
_rowpads with{label:<16}and every colonlands at column 19; the wordmark is faithful figlet.
_APP_CSS's system-font rulelists
.jp-OutputArea-output, which is exactly what the log rendered into, soit was forced into a proportional font with
!important. ASCII art and paddedcolumns 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 checktests began failing after a live GPU.8 verification leftcompute.gpu_enabled = falsein the developer's~/.quantui/settings.json: theprobe 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.pyisolatedQUANTUI_LOG_DIRbut notQUANTUI_SETTINGS_PATH. Fixingit there would have been too narrow — every test constructing
QuantUIAppreadssettings, and
viz.default_backend/vib_framerate_fpsfeed 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, concurrentappends 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 overrideswidgets.Widget._send— the comm transport — andbroke
add_class(), whichapp_builderscalls on this very widget.tests/test_basis_notation.py(24),tests/test_log_monospace.py(10).pre-commit run --all-filesclean at the new pins.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_timingrecords give median 58 ms / p95202 ms, and
read_pyscf_log— the stage its own deferral note flagged with aNot 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