From ebb2898f872583cb1f50e96592edbabc9922a60e Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:01:50 +0700 Subject: [PATCH 1/2] =?UTF-8?q?release:=202.0.0=20=E2=80=94=20reconverge?= =?UTF-8?q?=200.4.0,=20and=20the=20first=20ten=20minutes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A major, and both reasons are on this project's own breaking list: a changed CLI flag, and a change to what the gate admits. The gate pins reconverge 0.4.0, which reads a shared-memory length written as a named const. 0.3.0 could only read a literal — given `SharedArray` its analyzer refused the unevaluated const and dropped the static from the RC004 budget with no finding and no diagnostic. That is not an abstract gap; it is the shape every tunable kernel has. `corpus/matmul-tiled` declares `SharedArray` and this tool rewrites TM and TK per candidate, so every configuration it tries took the path RC004 could not see. Verified both ways: the six corpus kernels' verdicts are unchanged, and raising matmul-tiled to TM = TK = 128 now reports 73728 bytes over the cap where 0.3.0 said nothing. `--cc` is required by `tune` now, as it already was by `prune` and `model`. It defaulted to 8.6 — so the one command whose answer you act on quietly picked a device while the two inspection commands made you choose, and `prune --cc`'s own help says a verdict at one capability does not transfer to another. The rest is the first ten minutes of using this tool. A mistyped `--cc` used to spawn eleven subprocesses and print ninety lines in which the problem appeared nowhere; it is one line in ~100ms with nothing spawned, and `--cc 86` and `sm_86` now work rather than failing. A reconverge failure reports the lines reconverge marked `error:` rather than the last six, which were reliably its exit-code legend. `model` says it is not gated — its top five on reduce-flip are all configurations the gate refuses, under a header that carefully labelled the cheap mistake and left the expensive one unmarked. `tune --backend model` no longer leaves an empty directory in `runs/`. `launchbound-tui --help` prints help instead of looking for a directory called `--help`. The chosen configuration's interval is dropped whole at eighty columns rather than cut mid-number. And the TUI goldens wait for a finished frame instead of a 150ms quiet period. That had already cost this suite once — the comment in `ranking_scrolls_a_long_candidate_list` records a golden blessed from a too-early capture, which then verified nothing while passing — and the same shape failed reconverge's main on macOS this week. The binary already brackets its repaints, so only the tests changed. The 100-iteration gate went from 15.8s to 0.7s, and thirty local runs at 2 and 16 threads found no flake. Docs: the CLI table listed a `launchbound tui` subcommand that does not exist, omitted `model`, and showed `tune` without the `--cc` it now requires; the Action's input table gave reconverge-version as 0.1.11, two releases stale. Closes #18, closes #19, closes #20, closes #21, closes #22, closes #23, closes #24, closes #26. Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- .github/workflows/prune.yml | 2 +- CHANGELOG.md | 103 ++++++++++++++++++ Cargo.lock | 22 ++-- Cargo.toml | 18 +-- README.md | 16 ++- action/README.md | 2 +- action/action.yml | 4 +- crates/launchbound-cli/src/main.rs | 87 ++++++++++++++- crates/launchbound-prune/src/runner.rs | 87 +++++++++++++-- crates/launchbound-tui/src/app.rs | 72 +++++++++++- crates/launchbound-tui/src/main.rs | 35 +++++- .../tests/golden/overview-80x24.txt | 2 +- crates/launchbound-tui/tests/tui.rs | 92 ++++++++++------ docs/LIMITATIONS.md | 2 +- 14 files changed, 455 insertions(+), 89 deletions(-) diff --git a/.github/workflows/prune.yml b/.github/workflows/prune.yml index 64be1a0..31695ca 100644 --- a/.github/workflows/prune.yml +++ b/.github/workflows/prune.yml @@ -17,7 +17,7 @@ permissions: # pins.yml issue body lists every site. env: PINNED_TOOLCHAIN: nightly-2026-04-03 - RECONVERGE_VERSION: "0.3.0" + RECONVERGE_VERSION: "0.4.0" jobs: gate: diff --git a/CHANGELOG.md b/CHANGELOG.md index 92b3947..3f9837a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,109 @@ change measured timings are marked `bench:`. ## [Unreleased] +## [2.0.0] - 2026-08-26 + +A major, and both reasons are in the "breaking" list this project keeps in +[docs/RELEASING.md](docs/RELEASING.md): a changed CLI flag, and a change to +what the gate admits. + +### Changed — breaking + +- **The safety gate pins `reconverge` 0.4.0**, up from 0.3.0, and 0.4.0 + reads a shared-memory length written as a **named const**. 0.3.0 could + only read a literal: `SharedArray` arrived as an unevaluated + const, the analyzer's `eval_target_usize` refused it, and the static was + dropped from the RC004 budget with no finding and no diagnostic. + + This is not an abstract gap — it is the shape every tunable kernel has. + `corpus/matmul-tiled` declares `SharedArray`, and + launchbound rewrites `TM`/`TK` per candidate, so **every configuration + this tool tries took the path RC004 could not see**. A space with an + over-cap tile pruned as all-clean. + + Verified against the corpus: the six kernels' verdicts are unchanged + (their tiles are well under the cap), and raising `matmul-tiled` to + `TM = TK = 128` now produces + `error[RC004]: kernel matmul declares 73728 bytes`, where 0.3.0 said + nothing. Any kernel whose shared memory is sized by a const may now be + refused where it previously passed — which is the gate working, and is + why this is a major. + +- **`--cc` is required by `tune`**, as it already was by `prune` and + `model`. It defaulted to `8.6`, which meant the one command whose answer + you act on quietly picked a device, while the two inspection commands + made you choose. `prune --cc`'s own help says a verdict at one capability + does not transfer to another; RC004 is a capacity check, and 8.6 offers + 164 KB per SM against 7.5's 64 KB. + + `launchbound tune --backend model` now asks for `--cc`. + +### Added + +- **`--cc` is validated at the command line, and the CUDA spellings work.** + A mistyped `--cc` used to be handed to `cargo reconverge` once per + candidate: eleven subprocesses for `reduce-flip`, 101 over the corpus, and + ninety lines of output in which the actual problem appeared nowhere. It is + now one line in ~100ms with nothing spawned. `--cc 86` and `--cc sm_86` + are normalized to `8.6` rather than rejected — for two digits the mapping + is unambiguous, and it is the spelling a CUDA user already has. + +### Fixed + +- **A reconverge failure reports what reconverge said.** The tool error + showed the *last* six lines of its stderr — a reasonable-looking default, + since a failing tool usually fails last, and reliably the wrong six: + reconverge prints its diagnosis first and its usage reference after it, so + the tail was the exit-code legend. reconverge 0.4.0 stopped printing usage + after a bad value, which fixes that case at the source; this reads the + lines marked `error:` regardless, because no caller controls what its + analyzer prints, and falls back to the head rather than the tail. + +- **`model` says that it is not gated.** It ranks the whole space, and on + `reduce-flip` its top five are all configurations the gate refuses — the + fastest row was a kernel that hangs, under a header that carefully said + "estimated, not a measurement" and nothing about safety. It still runs no + gate and needs no `reconverge`; it now says so, and names `tune --backend + model` as the gated form. + +- **`tune --backend model` no longer leaves an empty run directory.** The + directory was created before the backend match, for every backend, and the + model path writes nothing — so every run littered `runs/`, which is + checked in, and `launchbound report` on it failed with `verdicts.json: No + such file`. An `--out` given to this backend is now answered rather than + silently ignored. + +- **`launchbound-tui --help` prints help.** It read `args()` directly, so + every flag was taken as a run-directory path: `--help` came back as + `run dir: --help/verdicts.json: No such file or directory`, which reads as + a broken tool. `-h`, `--help`, `-V` and `--version` answer; any other + leading dash is reported as an unknown option, which is what stops the + next flag landing here as a path. This is a published binary. + +- **The chosen configuration's interval is dropped, not cut.** At eighty + columns — the default terminal size, and the width this suite mandates — + the line ended `0.0400 ms [0.0398, `: a number with no upper bound and a + dangling comma, on the one line carrying the result. The interval now goes + whole when it does not fit; at 110 columns it is unchanged. + +- **The TUI goldens wait for a finished frame.** They synced on a 150ms + quiet period, which is a guess at how long a repaint takes; on a loaded + runner the app pauses mid-repaint and the screen read is half-painted. + This had already cost the suite once — `ranking_scrolls_a_long_candidate_list` + carries a comment about a golden blessed from a too-early capture, which + then verified nothing while passing — and the same shape failed + reconverge's `main` on macOS. The binary already brackets every repaint in + DEC 2026 synchronized updates, so `wait_frame` observes only whole frames. + The 100-iteration stress gate went from **15.8s to 0.7s**. + +### Documentation + +- The CLI table listed `launchbound tui `, which is not a subcommand — + the binary is `launchbound-tui`. It also omitted `model`, and showed + `tune` without the `--cc` it now requires. +- The Action's input table still gave `reconverge-version` as `0.1.11`, two + releases stale. + ## [1.2.0] - 2026-08-22 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 16de33f..d765f17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1121,7 +1121,7 @@ checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" [[package]] name = "launchbound-bench" -version = "1.2.0" +version = "2.0.0" dependencies = [ "anyhow", "launchbound-space", @@ -1135,7 +1135,7 @@ dependencies = [ [[package]] name = "launchbound-build" -version = "1.2.0" +version = "2.0.0" dependencies = [ "launchbound-space", "serde", @@ -1146,7 +1146,7 @@ dependencies = [ [[package]] name = "launchbound-cli" -version = "1.2.0" +version = "2.0.0" dependencies = [ "anyhow", "clap", @@ -1164,7 +1164,7 @@ dependencies = [ [[package]] name = "launchbound-metal" -version = "1.2.0" +version = "2.0.0" dependencies = [ "launchbound-bench", "launchbound-space", @@ -1175,7 +1175,7 @@ dependencies = [ [[package]] name = "launchbound-model" -version = "1.2.0" +version = "2.0.0" dependencies = [ "launchbound-space", "serde", @@ -1185,7 +1185,7 @@ dependencies = [ [[package]] name = "launchbound-prune" -version = "1.2.0" +version = "2.0.0" dependencies = [ "launchbound-build", "launchbound-space", @@ -1198,7 +1198,7 @@ dependencies = [ [[package]] name = "launchbound-report" -version = "1.2.0" +version = "2.0.0" dependencies = [ "insta", "jsonschema", @@ -1210,7 +1210,7 @@ dependencies = [ [[package]] name = "launchbound-runner" -version = "1.2.0" +version = "2.0.0" dependencies = [ "launchbound-bench", "launchbound-search", @@ -1218,7 +1218,7 @@ dependencies = [ [[package]] name = "launchbound-search" -version = "1.2.0" +version = "2.0.0" dependencies = [ "launchbound-bench", "proptest", @@ -1226,7 +1226,7 @@ dependencies = [ [[package]] name = "launchbound-space" -version = "1.2.0" +version = "2.0.0" dependencies = [ "proptest", "serde", @@ -1237,7 +1237,7 @@ dependencies = [ [[package]] name = "launchbound-tui" -version = "1.2.0" +version = "2.0.0" dependencies = [ "anyhow", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 4345ddf..11cb5f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ members = [ exclude = ["corpus"] [workspace.package] -version = "1.2.0" +version = "2.0.0" edition = "2024" # MSRV for crates that do not require the pinned nightly (CONTRIBUTING.md); # set by ratatui 0.30. The analysis and compile paths require @@ -33,14 +33,14 @@ keywords = ["cuda", "gpu", "autotuner", "convergence", "kernel"] categories = ["development-tools", "development-tools::profiling"] [workspace.dependencies] -launchbound-space = { path = "crates/launchbound-space", version = "1.2.0" } -launchbound-prune = { path = "crates/launchbound-prune", version = "1.2.0" } -launchbound-build = { path = "crates/launchbound-build", version = "1.2.0" } -launchbound-bench = { path = "crates/launchbound-bench", version = "1.2.0" } -launchbound-report = { path = "crates/launchbound-report", version = "1.2.0" } -launchbound-search = { path = "crates/launchbound-search", version = "1.2.0" } -launchbound-model = { path = "crates/launchbound-model", version = "1.2.0" } -launchbound-metal = { path = "crates/launchbound-metal", version = "1.2.0" } +launchbound-space = { path = "crates/launchbound-space", version = "2.0.0" } +launchbound-prune = { path = "crates/launchbound-prune", version = "2.0.0" } +launchbound-build = { path = "crates/launchbound-build", version = "2.0.0" } +launchbound-bench = { path = "crates/launchbound-bench", version = "2.0.0" } +launchbound-report = { path = "crates/launchbound-report", version = "2.0.0" } +launchbound-search = { path = "crates/launchbound-search", version = "2.0.0" } +launchbound-model = { path = "crates/launchbound-model", version = "2.0.0" } +launchbound-metal = { path = "crates/launchbound-metal", version = "2.0.0" } anyhow = "1" clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index 01fc0cf..ab6f3a1 100644 --- a/README.md +++ b/README.md @@ -130,15 +130,25 @@ Two asymmetries, published rather than buried: ``` launchbound space [--json] # enumerate the space, print its size -launchbound prune [--json] # reconverge pass only — NO GPU NEEDED -launchbound tune [--budget 30m] [--backend cuda|metal|model] +launchbound prune --cc 8.6 [--json] # reconverge pass only — NO GPU NEEDED +launchbound model --cc 8.6 # analytical ranking — NOT GATED +launchbound tune --cc 8.6 --backend cuda|metal|model [--budget 30m] launchbound report [--json] [--rejected] # includes refused-but-faster configs launchbound apply # emit the cuda-oxide policy specialization -launchbound tui # the run in four views: the chosen +launchbound-tui # the run in four views: the chosen # configuration and the field it beat, # the ranking, the refusals, the progress ``` +`--cc` is required by `prune`, `model` and `tune` alike — a verdict at one +compute capability does not transfer to another, and `tune` is the command +whose answer you act on. The CUDA spellings work: `--cc 86` and `--cc sm_86` +mean `8.6`. + +`model` ranks the **whole** space and says so: it runs no gate and needs no +`reconverge`, so its fastest row may be a configuration that hangs. `tune +--backend model` is the gated form of the same ranking. + Exit codes: `0` a safe configuration was found; `1` the fastest candidates were refused and the chosen one is slower than a rejected candidate — notable, not an error; `2` tool error. `--allow-unsafe` exists, requires an explicit diff --git a/action/README.md b/action/README.md index ea87ffa..ee0007c 100644 --- a/action/README.md +++ b/action/README.md @@ -63,7 +63,7 @@ the gate specializes per candidate. | `cc` | — | target compute capability, e.g. `"8.6"` (required; verdicts do not transfer across parts) | | `fail-on` | `tool-error` | `never`, `refused`, or `tool-error` | | `version` | `latest` | launchbound-cli release to install | -| `reconverge-version` | `0.1.11` | reconverge release from crates.io — moves in lockstep with `toolchain` | +| `reconverge-version` | `0.4.0` | reconverge release from crates.io — moves in lockstep with `toolchain` | | `toolchain` | `nightly-2026-04-03` | the nightly that built that reconverge | | `summary` | `"true"` | write the verdict table to the job summary | diff --git a/action/action.yml b/action/action.yml index b650ed3..c80b1d7 100644 --- a/action/action.yml +++ b/action/action.yml @@ -40,7 +40,7 @@ inputs: default: tool-error version: description: >- - Which launchbound release runs the gate, e.g. "1.0.1". The default, + Which launchbound release runs the gate, e.g. "2.0.0". The default, `latest`, installs the newest release on crates.io each run. Pin a number if you want the gate to change only when you say so. required: false @@ -51,7 +51,7 @@ inputs: reconverge-driver). Must be built by the toolchain below — the pins move together. required: false - default: "0.3.0" + default: "0.4.0" toolchain: description: >- The pinned nightly that matches reconverge-version; the pair moves diff --git a/crates/launchbound-cli/src/main.rs b/crates/launchbound-cli/src/main.rs index dd6d9ce..3dd3b7e 100644 --- a/crates/launchbound-cli/src/main.rs +++ b/crates/launchbound-cli/src/main.rs @@ -44,7 +44,7 @@ enum Command { /// Target compute capability for RC004 shared-memory context /// (docs/SAFETY.md): 8.6 for A10G, 7.5 for T4. A verdict at one /// --cc does not transfer to another. - #[arg(long)] + #[arg(long, value_parser = parse_cc)] cc: String, /// Directory containing the cargo-reconverge binary (else /// LAUNCHBOUND_RECONVERGE or PATH). @@ -64,7 +64,7 @@ enum Command { #[arg(long, default_value = "corpus")] corpus: PathBuf, /// Target compute capability (gate context and provenance). - #[arg(long)] + #[arg(long, value_parser = parse_cc)] cc: String, /// Output directory for plan.json + PTX artifacts. #[arg(long)] @@ -105,7 +105,7 @@ enum Command { #[arg(long, default_value = "corpus")] corpus: PathBuf, /// Target compute capability (device table lookup). - #[arg(long)] + #[arg(long, value_parser = parse_cc)] cc: String, /// A results.v1 file to correlate the model's ranking against. #[arg(long)] @@ -123,8 +123,10 @@ enum Command { corpus: PathBuf, #[arg(long, value_parser = ["cuda", "metal", "model"])] backend: String, - /// Target compute capability (cuda/model backends). - #[arg(long, default_value = "8.6")] + /// Target compute capability (cuda/model backends). Required, like + /// `prune`'s and `model`'s: a verdict at one --cc does not transfer + /// to another, and this is the command whose answer you act on. + #[arg(long, value_parser = parse_cc)] cc: String, /// Wall-clock budget, e.g. 30m, 90s, 1h. Honoured, resumably. #[arg(long)] @@ -173,6 +175,47 @@ fn parse_budget(text: &str) -> anyhow::Result { } } +/// A compute capability, as typed at the command line. +/// +/// Checked here so a mistake costs nothing. `prune` used to hand whatever +/// was typed straight to `cargo reconverge`, once per candidate: a mistyped +/// `--cc 80` spawned eleven subprocesses and printed ninety lines in which +/// the actual problem appeared nowhere. Over the whole corpus, 101 spawns. +/// +/// The format is all that is checked. Which capabilities are *known* differs +/// by command — `prune` passes it to reconverge, whose table is larger than +/// the model's — so membership stays where the table is consulted, and says +/// so with the list. +/// +/// `sm_NN` is the spelling a CUDA person already has in their fingers, and +/// for two digits it is unambiguous: the first is the major, the second the +/// minor. It is normalized rather than rejected, so `--cc 86` works. +fn parse_cc(raw: &str) -> Result { + let normalized = match raw.strip_prefix("sm_").unwrap_or(raw) { + digits if digits.len() == 2 && digits.chars().all(|c| c.is_ascii_digit()) => { + format!("{}.{}", &digits[..1], &digits[1..]) + } + other => other.to_string(), + }; + let mut parts = normalized.split('.'); + let well_formed = match (parts.next(), parts.next(), parts.next()) { + (Some(major), Some(minor), None) => { + !major.is_empty() + && !minor.is_empty() + && major.chars().all(|c| c.is_ascii_digit()) + && minor.chars().all(|c| c.is_ascii_digit()) + } + _ => false, + }; + if !well_formed { + return Err(format!( + "`{raw}` is not a compute capability — expected MAJOR.MINOR, e.g. `8.6` \ + (the `sm_86` and `86` spellings are accepted too)" + )); + } + Ok(normalized) +} + fn main() -> ExitCode { match run() { Ok(code) => code, @@ -369,11 +412,18 @@ fn cmd_tune( let budget_secs = budget.map(parse_budget).transpose()?; let dir = resolve_kernel_dirs(Some(kernel), corpus)?.remove(0); let spec = KernelSpec::load(&dir)?; + let explicit_out = out.is_some(); let out = out.unwrap_or_else(|| PathBuf::from("runs").join(format!("{}-{backend}", spec.name))); - std::fs::create_dir_all(&out)?; + // Created by the backends that write into it, not before the match. The + // model backend prints to stdout and writes nothing, so creating it + // unconditionally left an empty directory behind on every run — in + // `runs/`, which is checked in — and `launchbound report` on it failed + // with `verdicts.json: No such file`. Following the two commands in the + // order `--help` lists them did not work. match backend { "metal" => { + std::fs::create_dir_all(&out)?; // NO GATE on this path (docs/SAFETY.md §3.4): candidates are // `ungated`, and the report renderer prints the notice // unconditionally. @@ -408,6 +458,15 @@ fn cmd_tune( cmd_report(&out, false, false) } "model" => { + // Nothing is written on this path, so an --out the caller took + // the trouble to type is worth answering rather than ignoring. + if explicit_out { + eprintln!( + "note: --out is unused with --backend model — it prints the ranking and \ + writes no run directory. `launchbound stage` writes one that \ + `launchbound report` can read." + ); + } use launchbound_model::{device, estimate}; use launchbound_prune::{PruneOptions, Verdict, prune_kernel}; let verdicts = prune_kernel( @@ -450,6 +509,7 @@ fn cmd_tune( Ok(ExitCode::SUCCESS) } "cuda" => { + std::fs::create_dir_all(&out)?; let code = cmd_stage(kernel, corpus, cc, &out, reconverge_dir, false, None)?; if code != ExitCode::SUCCESS { return Ok(code); @@ -576,6 +636,21 @@ fn cmd_model( "{} — ESTIMATED ranking (analytical model, cc {cc}; not a measurement):", spec.name ); + // The ranking is over the *whole* space. On `reduce-flip` the model's + // top five are all configurations the gate refuses — `warp_id()` splits + // a multi-warp block at a block-wide barrier — so the fastest thing + // here is a kernel that hangs. Saying "estimated, not measured" and + // nothing about safety puts the caveat on the cheap mistake and leaves + // the expensive one unmarked. + // + // This command deliberately runs no gate and needs no reconverge, which + // is worth keeping; so it says what it did not do, and names the command + // that does. + println!( + " NOT GATED — every configuration, including the ones the convergence\n \ + gate refuses. `launchbound tune --backend model --cc {cc}` ranks only\n \ + the admitted ones; `launchbound prune --cc {cc}` says which those are." + ); println!(" {}", calibration_line(corpus, &spec.name)); for est in ranked { println!( diff --git a/crates/launchbound-prune/src/runner.rs b/crates/launchbound-prune/src/runner.rs index c5c36bd..f157b3f 100644 --- a/crates/launchbound-prune/src/runner.rs +++ b/crates/launchbound-prune/src/runner.rs @@ -111,17 +111,84 @@ fn run_reconverge(dir: &Path, options: &PruneOptions) -> AnalyzerOutcome { } } else { let stderr = String::from_utf8_lossy(&output.stderr); - let tail: String = stderr - .lines() - .rev() - .take(6) - .collect::>() - .into_iter() - .rev() - .collect::>() - .join("\n"); AnalyzerOutcome::ToolError { - detail: format!("cargo reconverge exited {exit_code}:\n{tail}"), + detail: format!( + "cargo reconverge exited {exit_code}:\n{}", + diagnosis(&stderr) + ), } } } + +/// The lines of `stderr` that say what went wrong. +/// +/// reconverge marks them: they begin `error:`. Taking the *last* six lines +/// instead — a reasonable-looking default, since a failing tool usually +/// fails last — reliably picked the wrong six, because reconverge prints +/// its diagnosis first and its usage reference after it. A mistyped `--cc` +/// came back as the exit-code legend, once per candidate, with the sentence +/// that would have solved it forty-odd lines out of view. +/// +/// reconverge 0.4.0 stopped printing usage after a bad *value*, which fixes +/// that case at the source. This is still the right way to read it: no +/// caller can control what its analyzer prints, and an older reconverge on +/// someone's PATH is exactly when a clear message matters most. +/// +/// Falls back to the *head* rather than the tail when nothing is marked — +/// a tool that prints a reference puts the reason before it. +fn diagnosis(stderr: &str) -> String { + let marked: Vec<&str> = stderr + .lines() + .filter(|line| line.trim_start().starts_with("error:")) + .collect(); + if !marked.is_empty() { + return marked.join("\n"); + } + let head: Vec<&str> = stderr + .lines() + .filter(|l| !l.trim().is_empty()) + .take(6) + .collect(); + if head.is_empty() { + "(no output on stderr)".to_string() + } else { + head.join("\n") + } +} + +#[cfg(test)] +mod diagnosis_tests { + use super::diagnosis; + + /// The shape that caused this: the reason first, the reference after. + #[test] + fn the_marked_line_is_taken_however_far_from_the_end_it_is() { + let stderr = format!( + "error: `80` is not a compute capability; expected e.g. `8.6`\n\n{}", + "usage line\n".repeat(44) + ); + assert_eq!( + diagnosis(&stderr), + "error: `80` is not a compute capability; expected e.g. `8.6`" + ); + } + + #[test] + fn every_marked_line_is_kept() { + let stderr = "error: first\nnoise\nerror: second\n"; + assert_eq!(diagnosis(stderr), "error: first\nerror: second"); + } + + #[test] + fn unmarked_output_falls_back_to_the_head_not_the_tail() { + let stderr = "the reason\n\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\n"; + assert!(diagnosis(stderr).starts_with("the reason")); + assert!(!diagnosis(stderr).contains("line 8")); + } + + #[test] + fn empty_stderr_says_so_rather_than_nothing() { + assert_eq!(diagnosis(""), "(no output on stderr)"); + assert_eq!(diagnosis("\n \n"), "(no output on stderr)"); + } +} diff --git a/crates/launchbound-tui/src/app.rs b/crates/launchbound-tui/src/app.rs index 387c303..611b002 100644 --- a/crates/launchbound-tui/src/app.rs +++ b/crates/launchbound-tui/src/app.rs @@ -108,6 +108,41 @@ fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) { frame.render_widget(Paragraph::new(lines), area); } +const CHOSEN_LABEL: &str = "CHOSEN "; + +/// The chosen configuration, with its interval only if the interval fits. +/// +/// At eighty columns — the default terminal size, and the width this suite +/// mandates — the line used to be cut mid-value: +/// +/// ```text +/// │CHOSEN c1-0000000000000009 block_x=32 tile=512 unroll=4 0.0400 ms [0.0398, │ +/// ``` +/// +/// That is not a shortened interval, it is a number with no upper bound and +/// a dangling comma, on the one line carrying the result the reader came +/// for. Dropping the interval whole is the honest shortening: the +/// configuration and its time outrank the interval, and a reader who needs +/// the interval has view 2 and a wider terminal. +fn chosen_tail( + id: &str, + config: &str, + median_ms: f64, + lo_ms: f64, + hi_ms: f64, + panel_width: u16, +) -> String { + let without = format!("{id} {config} {median_ms:.4} ms"); + let with = format!("{without} [{lo_ms:.4}, {hi_ms:.4}]"); + // The panel's two border columns are not text. + let usable = usize::from(panel_width).saturating_sub(2 + CHOSEN_LABEL.len()); + if with.chars().count() <= usable { + with + } else { + without + } +} + /// The banner above the field when refused configurations measured faster. /// /// A function rather than an inline `format!` so both arities can be tested. @@ -131,10 +166,14 @@ fn draw_overview(frame: &mut Frame<'_>, app: &App, area: Rect) { Some(chosen) => { let s = &chosen.summary; lines.push(Line::from(vec![ - Span::styled("CHOSEN ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format!( - "{} {} {:.4} ms [{:.4}, {:.4}]", - chosen.id, chosen.config, s.median_ms, s.ci95_lo_ms, s.ci95_hi_ms + Span::styled(CHOSEN_LABEL, Style::default().add_modifier(Modifier::BOLD)), + Span::raw(chosen_tail( + &chosen.id, + &chosen.config, + s.median_ms, + s.ci95_lo_ms, + s.ci95_hi_ms, + area.width, )), ])); if !r.indistinguishable_from_chosen.is_empty() { @@ -381,6 +420,31 @@ fn bar(done: usize, total: usize, width: usize) -> String { mod tests { use super::refused_faster_banner; + #[test] + fn the_chosen_line_drops_its_interval_rather_than_cutting_it() { + let tail = |w| { + super::chosen_tail( + "c1-0000000000000009", + "block_x=32 tile=512 unroll=4", + 0.0400, + 0.0398, + 0.0402, + w, + ) + }; + // 110 columns: room for all of it. + assert!(tail(110).ends_with("[0.0398, 0.0402]"), "{}", tail(110)); + // 80 columns: the interval goes, whole. + let narrow = tail(80); + assert!(narrow.ends_with("0.0400 ms"), "{narrow}"); + assert!(!narrow.contains('['), "no half-interval: {narrow}"); + // And what is left fits the panel. + assert!( + narrow.chars().count() + 2 + super::CHOSEN_LABEL.len() <= 80, + "{narrow}" + ); + } + #[test] fn the_refused_faster_banner_agrees_with_its_own_count() { assert_eq!( diff --git a/crates/launchbound-tui/src/main.rs b/crates/launchbound-tui/src/main.rs index 93c8f6b..ba517ec 100644 --- a/crates/launchbound-tui/src/main.rs +++ b/crates/launchbound-tui/src/main.rs @@ -15,11 +15,38 @@ use launchbound_report::{RunDir, build_report}; use std::io::Write; use std::path::PathBuf; +const USAGE: &str = "\ +usage: launchbound-tui + + a directory written by `launchbound stage` or `launchbound tune`, + holding verdicts.json (and results.json once measured) + + 1 overview \u{b7} 2 ranking \u{b7} 3 rejections \u{b7} 4 progress \u{b7} j/k scroll \u{b7} q quit +"; + fn main() -> anyhow::Result<()> { - let run_dir = std::env::args() - .nth(1) - .map(PathBuf::from) - .ok_or_else(|| anyhow::anyhow!("usage: launchbound-tui "))?; + // One positional, read straight from `args()` — but a leading dash is + // answered rather than opened as a path. `--help` used to come back as + // `run dir: --help/verdicts.json: No such file or directory`, which + // reads as a broken tool rather than as an unknown flag, and this is a + // published binary. + let run_dir = match std::env::args().nth(1).as_deref() { + Some("-h" | "--help") => { + print!("{USAGE}"); + return Ok(()); + } + Some("-V" | "--version") => { + println!("launchbound-tui {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + // Rejecting every other leading dash is what stops this recurring: + // otherwise `--ascii` or `--no-color` lands here next, as a path. + Some(flag) if flag.starts_with('-') => { + return Err(anyhow::anyhow!("unknown option `{flag}`\n\n{USAGE}")); + } + Some(path) => PathBuf::from(path), + None => return Err(anyhow::anyhow!("{USAGE}")), + }; let run = RunDir::load(&run_dir)?; let planned = run.plan.as_ref().map(|p| p.candidates.len()).unwrap_or(0); let report = build_report(&run)?; diff --git a/crates/launchbound-tui/tests/golden/overview-80x24.txt b/crates/launchbound-tui/tests/golden/overview-80x24.txt index 9d2c89e..1f8bf64 100644 --- a/crates/launchbound-tui/tests/golden/overview-80x24.txt +++ b/crates/launchbound-tui/tests/golden/overview-80x24.txt @@ -2,7 +2,7 @@ size: 80x24 cursor: hidden launchbound — reduce-flip · gate cc 8.6 · measured · NVIDIA A10G 27 candidates · 9 admitted · 18 refused · 10 measured ok ┌overview──────────────────────────────────────────────────────────────────────┐ -│CHOSEN c1-0000000000000009 block_x=32 tile=512 unroll=4 0.0400 ms [0.0398, │ +│CHOSEN c1-0000000000000009 block_x=32 tile=512 unroll=4 0.0400 ms │ │ │ │1 REFUSED configuration measured FASTER than the chosen one — view 3 │ │ │ diff --git a/crates/launchbound-tui/tests/tui.rs b/crates/launchbound-tui/tests/tui.rs index 05baf1b..f38b9be 100644 --- a/crates/launchbound-tui/tests/tui.rs +++ b/crates/launchbound-tui/tests/tui.rs @@ -1,8 +1,22 @@ //! S8 gate tests: golden frames through a real PTY on hermetic fixtures — //! initial layout, live resize, a long candidate list scrolled, the live //! search progress view, and the rejection view — plus the 100-iteration -//! stress. Sync policy: wait_idle / wait_until only, never sleep. No frame -//! contains a clock or an animation. +//! stress. No frame contains a clock or an animation. +//! +//! Sync policy: `wait_frame`, and the frame it returns is the one asserted +//! on — never `wait_idle`, never sleep. +//! +//! These used to sync on a 150ms quiet period, which is a guess at how long +//! a repaint takes. On a loaded runner it is the wrong guess: the app pauses +//! mid-repaint, the period elapses, and the screen read is half-painted. It +//! had already cost this suite once — see the comment in +//! `ranking_scrolls_a_long_candidate_list`, where a golden was blessed from +//! a too-early capture and the test then verified nothing while passing. +//! The same shape failed reconverge's `main` on macOS at 2 and 16 threads. +//! +//! The binary brackets every repaint in DEC 2026 synchronized updates, so +//! `wait_frame` observes only whole frames. No duration is involved, so +//! there is no duration to get wrong. //! //! Regenerate goldens after an intentional UI change with //! `LAUNCHBOUND_BLESS=1 cargo test -p launchbound-tui --test tui`. @@ -13,7 +27,6 @@ use std::{env, fs}; use termlens::{Key, Terminal}; -const QUIET: Duration = Duration::from_millis(150); const TIMEOUT: Duration = Duration::from_secs(10); fn fixture_run() -> PathBuf { @@ -71,11 +84,17 @@ fn quit(mut t: Terminal, context: &str) { assert!(status.success(), "{context}: exited with {status:?}"); } +/// The overview has painted once its footer is on screen: it is drawn last, +/// so a frame carrying it carries everything above it. +fn ready(screen: &termlens::Screen) -> bool { + screen.to_string().contains("q quit") +} + #[test] fn overview_at_80x24() { let mut t = spawn((80, 24)); - t.wait_idle(QUIET).expect("wait_idle"); - assert_golden("overview-80x24.txt", &t.screen().to_string(), "overview"); + let frame = t.wait_frame(ready).expect("the first complete frame"); + assert_golden("overview-80x24.txt", &frame.to_string(), "overview"); quit(t, "overview"); } @@ -83,10 +102,15 @@ fn overview_at_80x24() { #[test] fn resize_relayouts_the_frame() { let mut t = spawn((80, 24)); - t.wait_idle(QUIET).expect("initial idle"); + t.wait_frame(ready).expect("the first complete frame"); t.resize(110, 32).expect("resize"); - t.wait_idle(QUIET).expect("post-resize idle"); - assert_golden("overview-110x32.txt", &t.screen().to_string(), "resized"); + // The wider panel is what the frame is waited on, not a duration: the + // chosen line only has room for its interval at this geometry, so the + // interval's presence *is* the relayout having happened. + let frame = t + .wait_frame(|s| s.to_string().contains("[0.0398, 0.0402]")) + .expect("the relaid-out frame"); + assert_golden("overview-110x32.txt", &frame.to_string(), "resized"); quit(t, "resized"); } @@ -94,11 +118,10 @@ fn resize_relayouts_the_frame() { #[test] fn ranking_scrolls_a_long_candidate_list() { let mut t = spawn((80, 24)); - t.wait_idle(QUIET).expect("idle"); + t.wait_frame(ready).expect("the first complete frame"); t.send(Key::Char('2')).expect("send 2"); - t.wait_until(|s| s.to_string().contains("ranking (")) + t.wait_frame(|s| s.to_string().contains("ranking (")) .expect("ranking view"); - t.wait_idle(QUIET).expect("view settled"); for _ in 0..5 { t.send(Key::Char('j')).expect("send j"); } @@ -106,17 +129,13 @@ fn ranking_scrolls_a_long_candidate_list() { // pre-scroll top rows (…0a, …09, …01, …02, …03) are gone and …04 leads. // The original golden was blessed from a too-early capture and never // verified scrolling at all — caught by ubuntu delivering all five keys. - t.wait_until(|s| { - let frame = s.to_string(); - frame.contains("c1-0000000000000004") && !frame.contains("c1-0000000000000003") - }) - .expect("scroll applied"); - t.wait_idle(QUIET).expect("scrolled idle"); - assert_golden( - "ranking-scrolled-80x24.txt", - &t.screen().to_string(), - "ranking", - ); + let frame = t + .wait_frame(|s| { + let frame = s.to_string(); + frame.contains("c1-0000000000000004") && !frame.contains("c1-0000000000000003") + }) + .expect("scroll applied"); + assert_golden("ranking-scrolled-80x24.txt", &frame.to_string(), "ranking"); quit(t, "ranking"); } @@ -124,14 +143,14 @@ fn ranking_scrolls_a_long_candidate_list() { #[test] fn rejection_view_names_rules_and_spans() { let mut t = spawn((80, 24)); - t.wait_idle(QUIET).expect("idle"); + t.wait_frame(ready).expect("the first complete frame"); t.send(Key::Char('3')).expect("send 3"); // The help line always contains the word "rejections"; sync on // view-body content instead. - t.wait_until(|s| s.to_string().contains("all refused configurations:")) - .expect("rejections view"); - t.wait_idle(QUIET).expect("settled"); - let screen = t.screen().to_string(); + let screen = t + .wait_frame(|s| s.to_string().contains("all refused configurations:")) + .expect("rejections view") + .to_string(); assert!(screen.contains("RC001"), "rule id visible"); assert!(screen.contains("src/lib.rs:33:13"), "span visible"); assert_golden("rejections-80x24.txt", &screen, "rejections"); @@ -142,27 +161,28 @@ fn rejection_view_names_rules_and_spans() { #[test] fn progress_view_shows_measured_of_planned() { let mut t = spawn((80, 24)); - t.wait_idle(QUIET).expect("idle"); + t.wait_frame(ready).expect("the first complete frame"); t.send(Key::Char('4')).expect("send 4"); - t.wait_until(|s| s.to_string().contains("measured 11 of")) - .expect("progress view"); - t.wait_idle(QUIET).expect("settled"); - let screen = t.screen().to_string(); + let screen = t + .wait_frame(|s| s.to_string().contains("measured 11 of")) + .expect("progress view") + .to_string(); assert!(screen.contains("measured 11 of"), "progress counter"); assert_golden("progress-80x24.txt", &screen, "progress"); quit(t, "progress"); } -/// The S8 stress gate: 100 consecutive spawn → idle → golden → quit cycles. +/// The S8 stress gate: 100 consecutive spawn → frame → golden → quit cycles. #[test] fn stress_100_runs_at_80x24() { for run in 0..100 { let mut t = spawn((80, 24)); - t.wait_idle(QUIET) - .unwrap_or_else(|e| panic!("run {run}: wait_idle: {e}")); + let frame = t + .wait_frame(ready) + .unwrap_or_else(|e| panic!("run {run}: waiting for the first frame: {e}")); assert_golden( "overview-80x24.txt", - &t.screen().to_string(), + &frame.to_string(), &format!("run {run}"), ); quit(t, &format!("run {run}")); diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index f0bf692..7d54b18 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -6,7 +6,7 @@ launchbound's, with numbers where we have them. Everything here was true on ## The gate inherits reconverge's limits, wholesale -A clean gate is **not a proof of correctness**. `reconverge` (v0.3.0) is +A clean gate is **not a proof of correctness**. `reconverge` (v0.4.0) is summary-based and interprocedural, handles reducible control flow only, cannot evaluate non-literal masks, and puts data races entirely out of scope. Its own documentation is the authority; launchbound adds no analysis From 93ce412986439709e62c25be35f07ae11433466a Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:11:05 +0700 Subject: [PATCH 2/2] docs: point the Action at @v2, and say what @v1 keeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A floating major tag is a promise to the people who followed the documentation, and this release breaks that promise if the documentation still says @v1: they would get a 1.x action — pinned to reconverge 0.3.0, the analyzer that cannot see a named-const shared-memory size — for a 2.0 tool whose entire subject is that it now can. `release.yml` moves `vN` automatically from the pushed tag, so `v2` appears on its own; only the two places a reader copies from needed changing. `@v1` stays on 1.2.0, which is what a floating major tag is for. The CHANGELOG now says so, and says what it costs. Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ README.md | 2 +- action/README.md | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9837a..308937f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,15 @@ what the gate admits. DEC 2026 synchronized updates, so `wait_frame` observes only whole frames. The 100-iteration stress gate went from **15.8s to 0.7s**. +### Migrating from 1.x + +- **The Action's floating tag is now `@v2`.** `uses: + vyncint/launchbound/action@v1` keeps working and stays on 1.2.0, which is + the point of a floating major tag — but it stays on reconverge 0.3.0 with + it, and 0.3.0 is the analyzer that cannot see a named-const shared-memory + size. Move to `@v2` to get the gate this release is about. +- **Add `--cc` to any `launchbound tune` invocation.** It has no default now. + ### Documentation - The CLI table listed `launchbound tui `, which is not a subcommand — diff --git a/README.md b/README.md index ab6f3a1..4fb9d15 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ reason string recorded in the report, and is never the default. `prune` needing no GPU is the reason it exists as its own verb: it is the only part of the pipeline a developer on a laptop can run, and it is the part that finds the bugs. It also ships as a [GitHub Action](action/) — -`uses: vyncint/launchbound/action@v1` puts the gate in your CI. +`uses: vyncint/launchbound/action@v2` puts the gate in your CI. ## Compared to the neighbours diff --git a/action/README.md b/action/README.md index ee0007c..53a3c45 100644 --- a/action/README.md +++ b/action/README.md @@ -34,7 +34,7 @@ jobs: - name: Safety gate id: gate - uses: vyncint/launchbound/action@v1 + uses: vyncint/launchbound/action@v2 with: kernel: myrepo/kernels/my-reduction cc: "8.6" # the part you will actually run on