Skip to content

Progress reporting overhaul (1.8.0)#32

Merged
andytimm merged 32 commits into
mainfrom
feat/progress-ui
Jun 13, 2026
Merged

Progress reporting overhaul (1.8.0)#32
andytimm merged 32 commits into
mainfrom
feat/progress-ui

Conversation

@andytimm

@andytimm andytimm commented Jun 13, 2026

Copy link
Copy Markdown
Owner

This PR improves the progress reporting considerably, aiming to provide an informative, opinionated experience similar to nutpie's, within the constraints of R (most notably limited environment support for cursor up, which rules out multi-line progress bars).

cli is used for the default mode, exposing:

  • a live updating progress bar with draws bar, eta, divergences, and grad/draw
  • opinionated, printed guidance for issues during sampling like divergences, high grad/draw, or chains diverging

Also offered are:

  • a mode to customize the status bar via chain_format if you prefer different monitoring tools like a sparkline, or have stricter space requirements. In addition to the default displays, there are opt-in tokens such as {spark}, {draws}, {lag}, {step}, and {tdepth}.
  • text mode (which exposes similar information in prints)
  • silent mode (of course)

Here's a rough idea of what these signals look like during sampling of a problematic model; the goal is to help people catch issues early in runs where possible:
image

I experimented with progressr for a while, but was ultimately dissatisfied with the flexibility and stability it offered, so that approach has been depricated. Some of the issues in this CmdStan PR may be helpful for intuition there. Similarly, I considered a multi-line UX closer to nutpie's for supported environments, but decided they're relatively rarely used in the R ecosystem, so decided that wasn't worth the squeeze.

This PR also bundles a fix for a minor windows build issue.

Testing

I tested this in most common R environments including positron, Rstudio, Quarto notebooks, and terminal.

andytimm and others added 30 commits June 6, 2026 12:11
R's SIGINT handler only sets a pending flag; the longjmp fires at the
next explicit R_CheckUserInterrupt() call. Without it, the longjmp can
fire asynchronously inside wait_timeout, skipping Rust destructors and
corrupting the session.

Now poll every 200ms unconditionally (was 600s when no progress
callback) and call R_CheckUserInterrupt() at each timeout wakeup —
a clean boundary where no Rust resources are mid-use.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous placement (after cb.call) was ineffective: R's evaluator
consumes the interrupt flag internally when it throws during callback
evaluation. By the time R_CheckUserInterrupt() ran, the flag was already
cleared and it was a no-op — sampling never stopped.

Moving the check before any R re-entry means the longjmp fires at a
clean Rust boundary before the interrupt can be swallowed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rather than calling R_CheckUserInterrupt() (which longjmp's past the
assignment, leaving the user with a confusing "object not found" on the
next line), peek at R_interrupts_pending directly, clear it, and return
a normal Err. extendr converts this to an R stop() at the nutpie_sample()
call site with just "Sampling interrupted." and nothing after.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace adaptive two-pass status assembly with a stable fixed layout:
always shows `div: N | X.X grad/draw`, positionally consistent across
the full run.

- ⚠/! prefix on div count when divergences > 0 (cli::symbol$warning
  with plain-text fallback)
- ~ prefix on grad/draw when avg leapfrog >= 5% of 2^max_treedepth-1,
  i.e. treedepth cap is being hit with meaningful frequency
- Color: red for divergences, yellow for elevated grad/draw
- Add | separator before status field in CLI format string for visual
  consistency with the bar's other | delimiters
- Remove chain-lag logic, adaptive-width two-pass, format_hard_chain_status,
  format_chain_progress_status, format_draw_range, format_chain_fraction_compact
  (all dead after the layout change)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add environment-aware progress routing: Positron and proper ANSI
terminals (Ghostty, iTerm2, etc.) get one cli progress bar per chain;
RStudio falls back to the single aggregated bar; non-interactive R
gets plain text. The "multi" mode is also available as an explicit
progress = "multi" option (falls back to "cli" with a warning if the
terminal can't support it).

Per-chain bars show each chain's own phase, percent, draw count, ETA,
div count, and grad/draw — no aggregation needed, so slow chains are
immediately visible by position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace ~ with ▲ for treedepth warnings. Add format_status_tokens()
with {div}/{grad}/{draws}/{lag}/{step} placeholders so callers can
customize the progress bar status field via chain_format=. Add one-time
cli_alert_info hints above the bar when divergences or treedepth caps
first appear. Add make_text_progress_callback() for 1-line-per-chain
scrolling output (replacing the Rust-side text mode). Remove "multi"
progress mode and is_multi_bar_capable()/make_multi_chain_progress_callback()
entirely. "auto" now routes to "cli" or "text" only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
phase_label in mixed state now shows "sample N/M" (chains that have
graduated to sampling) instead of "warmup N/M" (chains still tuning) —
positive framing, shows progress rather than remaining work.

Treedepth hint now includes the actual numbers that triggered it:
"▲ grad/draw: avg X.X leapfrog steps/draw (≥N% of 2^D−1=M cap)" so
users immediately see how saturated the treedepth budget is.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Locks the design from review: teach-once-then-abbreviate hints (div,
grad/draw, spread), exact-data triggers with late-warmup baselines,
sticky spread token, opt-in gap-from-leader {spark}, progressr removal,
cli to Imports, message() stream unification, and the review bug list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cli becomes a hard dependency (Suggests -> Imports), so every
requireNamespace("cli") conditional and non-cli fallback branch is
removed. progressr mode is dropped entirely: the choice, the
resolve_progress_mode() branch, make_progressr_callback(), and
in_with_progress() all go, along with progressr from Suggests.

Routing now depends only on environment: auto -> cli when
interactive() and not rendering a knitr document, else text.

Step 1 of progress-ui-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With progress="text" now driven entirely by the R callback (a later
step), the Rust-side per-chain text log is dead: R always passes a
progress callback unless progress="none". Remove the rprintln chain
lines, start banner, completion/divergence summary, last_reported and
chain_announced bookkeeping, and announce_finished.

The 200ms poll loop and interrupt check stay — they're needed in every
mode, including "none" (no callback), so Ctrl-C still aborts.

The now-unused refresh parameter is dropped from sample_stan,
run_with_settings, and run_sampler; num_chains/num_draws/num_warmup
become dead in the sampler helpers and are dropped there too (still
used by configure_settings in sample_stan). extendr-wrappers.R updated
to match the new sample_stan arity.

Step 2 of progress-ui-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Text-mode per-chain lines switch from cat() (stdout) to message()
(stderr) so they interleave correctly with hints and the end summary,
and so suppressMessages() silences the whole progress stream at once.

Since the Rust start banner was removed in the previous step, the
text branch now prints "Sampling N chains, M draws each (W warmup)"
from R before sampling. The cli bar still carries its own header.

Update the text-callback test to capture_messages() instead of
capture.output().

Step 3 of progress-ui-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nuts-rs 0.18.2 already records divergences only outside tuning, so the
live {div} token, the hint, and red styling are post-warmup-only with no
extra filtering — the previously planned `>= num_warmup` filter is a
no-op. The remaining real fix: first_divergence in
summarize_progress_snapshot now reports a sample-relative, 1-indexed
draw (subtract num_warmup, +1) to match nutpieR's diagnostics
convention; the divergent_draws indices from nuts-rs are warmup-inclusive
and 0-based.

Introduce a small shared one-shot hint constructor (new_progress_hints)
used by both the cli and text callbacks, plus emit_progress_hint (cli
mode -> cli_alert_*, the !/i symbols come free; text mode -> message()).
The divergence hint is reworded to "div: divergent transitions
detected — try increasing `target_accept` or reparameterizing." and now
fires in text mode too, exactly once per run.

Step 4 of progress-ui-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a tunable constants block at the top of progress.R
(LATE_WARMUP_FRACTION, GRAD_HINT_THRESHOLD, CAP_SUMMARY_THRESHOLD, and
the spread constants used in the next step).

grad/draw now reports a late-warmup-baseline average: when a chain first
passes LATE_WARMUP_FRACTION of warmup, its (total_num_steps,
finished_draws) is snapshotted; the pooled average is
sum(total - base) / sum(finished - base) over baselined chains — exact,
unbiased, no cap detection. The cli {grad} token switches to this
average once baselines exist so the bar and the hint never disagree.

format_gradient_status accents the token (▲, or ^ when the console isn't
UTF-8) once the average reaches GRAD_HINT_THRESHOLD, an absolute
leapfrog-steps/draw count rather than a fraction of the treedepth cap.

New informational grad/draw hint (cli + text), fired once, with tree
depth round(log2(avg+1)). max_treedepth advice deliberately stays out of
the hint and lives only in the end summary, which now reports the exact
%-at-cap (depth >= max_treedepth, or n_steps >= 2^max_treedepth - 1) and
prints an advice line above CAP_SUMMARY_THRESHOLD.

Step 5 of progress-ui-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… rename

Split the two chain-spread surfaces that the working-tree experiment had
conflated:

- {spread} is now a percent-range token, "spread 23-78%" (ASCII hyphen,
  text-safe; percent not draw counts so it can't be confused with the
  bar's current/total). It is empty until the callback latches it on, and
  then renders for the rest of the run, collapsing honestly (e.g.
  "spread 98-100%") as chains converge. It joins the default cli
  chain_format: "{div} | {grad} | {spread}".
- {spark} is the opt-in gap-from-leader sparkline (the experiment's
  glyph encoding), renamed from format_chain_spread() to
  format_chain_spark(); never in the default format.

The trigger is computed over *started* chains only (so queued chains
when cores < num_chains don't fake a spread): max - min >=
SPREAD_TRIGGER_POINTS and the median chain past SPREAD_MEDIAN_FLOOR.
On latch, a one-shot cli-only spread hint fires. Revert the experiment's
always-on bar-format change: the bar shows pb_current/pb_total again and
spread/spark surface via the status field, not permanent bar real estate.

format_chain_draw_range switches its en dash to a hyphen; the empty-token
pipe cleanup in format_status_tokens becomes multi-pass so a run of three
empty tokens collapses cleanly.

Step 6 of progress-ui-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- as_progress_num() guards the is.na() check with x[[1]] so a length > 1
  input can't error ("condition has length > 1").
- Delete the dead print_progress_summary() (superseded by
  print_sampling_diagnostic_summary()).
- chain_format roxygen documents the new token set ({div} post-warmup,
  {grad} accent, {spread}, {spark}) and the new default.
- NEWS.md: user-facing 1.8.0 entry (modes, hints, tokens, cli Import,
  progressr removal). Bump DESCRIPTION to 1.8.0 — removing the
  progress="progressr" option is a breaking API change; adjust the number
  if your release cadence differs.
- CLAUDE.md: correct the stale nuts-rs 0.17.x note to 0.18.x, and replace
  the "load_all doesn't work" note with the actual loop (devtools::test
  works; full-suite debug panic in compile-cache is an extendr-0.9.0
  artifact; every install is a full recompile).

(Bug fixes 1/4/5 from the review — multi-pass pipe cleanup, knitr guard,
▲ UTF-8 gating — landed in earlier steps.)

Step 7 of progress-ui-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dogfooding feedback:

- Hints now emit via message() with cli::symbol$warning (⚠) / symbol$info
  (ℹ) instead of cli_alert_warning/info, so the divergence hint shows the
  same ⚠ as the {div} status token (cli_alert_warning is hardwired to "!",
  which mismatched the bar). One glyph vocabulary across bar and hints, and
  emission is now identical in cli and text modes — both on stderr. The
  now-unused `mode` field drops off new_progress_hints().
- grad/draw hint reworded to lead with the problem and save a line:
  "high (~129) gradient evaluations per draw (tree depth ~ 7) — often a
  sign of difficult posterior geometry; worth a sanity check if
  unexpected." The redundant "grad/draw:" label is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Keep the "high (~N)" wording and tightened sentence, but re-add the
"grad/draw:" label so all three hints (div:, grad/draw:, spread:) tie
back to their status-line token uniformly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fix the bar-trail regression: switching hints to plain message() lost
cli's coordination with the active progress bar, so each hint stranded
the current bar line. Hints now route through cli::cli_progress_output()
when a cli bar is active (clears the bar, prints the hint above it,
redraws one bar line), keeping the ⚠/ℹ glyphs. Falls back to message()
in text mode or if the bar id is unusable (also keeps the id="fake"
tests green and makes production robust).

Divergence hint now states the stakes in a few words: "these can bias
your results".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Drop the now-vestigial max_treedepth param from format_status_tokens
  and summarize_progress_snapshot — neither reads it anymore (the {grad}
  token switched to the absolute GRAD_HINT_THRESHOLD).
- fraction_at_treedepth_cap prefers nuts-rs's own maxdepth_reached flag
  (the same field print.nutpie_diagnostics reports), so the %-at-cap line
  and the diagnostics print can't disagree; depth / n_steps stay as
  fallbacks when the flag is absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two /simplify cleanups over the polish commits:

- sampling_summary_table no longer returns a `chains` element; its only
  caller stopped reading it once the per-chain divergence loop folded
  into the table builder.
- The treedepth-cap percentage was reported twice in the no-divergence
  path (headline plus a trailing info line). Fold the advice into the
  headline and gate the standalone cap line on `total_divs > 0L`, so it
  fires only for the divergence headlines that don't mention the cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mingw (x86_64-pc-windows-gnu) toolchain does not auto-emit -lcrypt32
the way MSVC does, so the Cert* symbols pulled in by
rustls-platform-verifier / ureq (via bridgestan's download-bridgestan-src
feature) failed to link on the r-universe Windows build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
andytimm and others added 2 commits June 13, 2026 15:05
- ASCII-escape the in-code glyph literals (triangle accent, sparkline
  blocks) and em-dashes in user-facing strings; comment em-dashes are
  left as-is (permitted by the check).
- Qualify tail() as utils::tail() to drop the undefined-global NOTE.
- Make resolve_progress_mode() take an injectable use_cli so the dispatch
  can be tested without mocking base::interactive(), which does not take
  effect under R CMD check's non-interactive test_check() run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@andytimm andytimm merged commit 8cad4a5 into main Jun 13, 2026
@andytimm andytimm mentioned this pull request Jun 13, 2026
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