diff --git a/HANDOFF-2.md b/HANDOFF-2.md index d91a911..db77186 100644 --- a/HANDOFF-2.md +++ b/HANDOFF-2.md @@ -231,9 +231,12 @@ money". ### UI -The header meter ([ui/widgets/quota_meter.py](ui/widgets/quota_meter.py)) gains a project -selector and shows three bars rather than one. The Quota tab gains a per-project breakdown by -stage. Both read the ledger; no new logic in the UI, per §10. +The header meter ([ui/models.py](ui/models.py) `quota_model`, rendered by +[ui/windows/quota.py](ui/windows/quota.py)) gains a project selector and shows three bars +rather than one. The Quota window gains a per-project breakdown by stage. Both read the +ledger; no new logic in the UI, per §10. (The tab strip and `ui/widgets/*` this section +originally named were replaced by the window system; the behaviour described here is +unchanged.) --- @@ -675,9 +678,17 @@ about exit 7 the hard way. python -m tools.evolve init --task-dir pipeline/evolve-lr --json python -m tools.evolve run --project proj-scaling-w2 --generations 20 --local --json python -m tools.evolve status --campaign camp-... --json +python -m tools.evolve halt --campaign camp-... --json # stops at the next generation boundary python -m tools.evolve promote --campaign camp-... --candidate 47 --json # into a normal run ``` +`halt` is a request written to the ledger, not a signal: `run` holds the loop in +whatever process started it, so the UI and a second terminal have no handle on +it. The loop reads the request between generations — the same boundary the +budget gate stops at, and for the same reason. Killing mid-generation would +abandon an in-flight candidate, which goes stale and blocks every future +submission (exit 7). + Default models per §16: Sonnet 5 primary plus Haiku 4.5 explorer. Sources: [ShinkaEvolve](https://github.com/SakanaAI/ShinkaEvolve) · diff --git a/README.md b/README.md index ba68f19..592cf4c 100644 --- a/README.md +++ b/README.md @@ -189,8 +189,15 @@ core/ the machinery the CLIs share, so no tool can forget a rule haiku.py funnel stages 0 and 3, via forced SDK tools http.py Semantic Scholar, rerank, embeddings, Context7 tools/ the CLIs -ui/ NiceGUI app and the widgets -config/jupyter/ the Lab server config: framing headers and overrides +ui/ the NiceGUI workspace: a tiling shell over eleven windows + tokens.py the design tokens; the stylesheet is generated from them + layout.py the pane tree and the moves over it -- pure, tested + models.py what each window shows, as plain data -- pure, tested + registry.py the one list of windows the shell is derived from + shell.py the chrome, and how a window survives a retile + windows/ eleven renderers, none of which read a ledger directly + jupyter_theme.py the same tokens, emitted as JupyterLab's custom.css +config/jupyter/ the Lab server config: framing headers, overrides, theme skills/ loaded on demand, not into the default context ledger/ expectations.jsonl, runs.jsonl, quota.jsonl, projects.jsonl, campaigns.jsonl, candidates.jsonl, preflight records diff --git a/config/jupyter/custom/custom.css b/config/jupyter/custom/custom.css new file mode 100644 index 0000000..22464f4 --- /dev/null +++ b/config/jupyter/custom/custom.css @@ -0,0 +1,204 @@ +/* generated by ui/jupyter_theme.py — edit the tokens, not this file */ + +/* ------------------------------------------------------------------ tokens */ +:root { + --jp-layout-color0: #F7F3E8; + --jp-layout-color1: #F7F3E8; + --jp-layout-color2: #EFE8D8; + --jp-layout-color3: #E8E2D4; + --jp-layout-color4: #E8E2D4; + + --jp-inverse-layout-color0: #14100C; + --jp-inverse-layout-color1: #14100C; + --jp-inverse-layout-color2: #14100C; + --jp-inverse-layout-color3: #8A8272; + + --jp-ui-font-color0: #14100C; + --jp-ui-font-color1: #14100C; + --jp-ui-font-color2: #8A8272; + --jp-ui-font-color3: #9C9484; + --jp-content-font-color0: #14100C; + --jp-content-font-color1: #14100C; + --jp-content-font-color2: #8A8272; + --jp-content-font-color3: #9C9484; + + --jp-border-color0: #14100C; + --jp-border-color1: #14100C; + --jp-border-color2: rgba(20,16,12,0.15); + --jp-border-color3: rgba(20,16,12,0.15); + --jp-border-width: 2px; + --jp-border-radius: 0px; + + --jp-brand-color0: #14100C; + --jp-brand-color1: #FFD400; + --jp-brand-color2: #FFD400; + --jp-brand-color3: #FFD400; + --jp-accent-color1: #12A594; + --jp-warn-color0: #FFD400; + --jp-warn-color1: #FFD400; + --jp-error-color0: #A3122F; + --jp-error-color1: #A3122F; + --jp-success-color1: #12A594; + --jp-info-color1: #B04A2C; + + --jp-cell-editor-background: #FFFDF8; + --jp-cell-editor-border-color: #14100C; + --jp-cell-editor-active-background: #FFFDF8; + --jp-cell-editor-active-border-color: #14100C; + --jp-cell-prompt-not-active-opacity: 0.55; + --jp-cell-inprompt-font-color: #14100C; + --jp-cell-outprompt-font-color: #B04A2C; + + --jp-code-font-family: 'JetBrains Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace; + --jp-code-font-size: 13.5px; + --jp-code-line-height: 1.7; + --jp-ui-font-family: 'Space Grotesk', Helvetica, Arial, sans-serif; + --jp-ui-font-size1: 13px; + --jp-content-font-family: 'Space Grotesk', Helvetica, Arial, sans-serif; + --jp-content-font-size1: 15px; + --jp-content-line-height: 1.58; + + --jp-toolbar-background: #EFE8D8; + --jp-toolbar-border-color: #14100C; + --jp-toolbar-box-shadow: none; + --jp-notebook-padding: 0px; + + --jp-rendermime-error-background: #FDEEF1; + --jp-rendermime-table-row-background: #F7F3E8; + --jp-rendermime-table-row-hover-background: #FDFAF2; + + /* Radius 0 everywhere, and no blur shadows anywhere. */ + --jp-elevation-z0: none; + --jp-elevation-z1: none; + --jp-elevation-z2: none; + --jp-elevation-z4: none; + --jp-elevation-z6: none; + --jp-elevation-z8: none; + --jp-elevation-z12: none; + --jp-elevation-z16: none; + --jp-elevation-z20: none; + --jp-elevation-z24: none; +} + +/* -------------------------------------------------------------- structure */ +body, .jp-Notebook, .jp-NotebookPanel { background: #F7F3E8; } +* { border-radius: 0 !important; box-shadow: none !important; } + +.jp-Toolbar { + background: #EFE8D8; + border-bottom: 2px solid #14100C; + min-height: 30px; +} + +/* The gutter: paper-sunk, a 2px right rule, right-aligned mono prompt. */ +.jp-InputArea-prompt, .jp-OutputArea-prompt { + background: #EFE8D8; + border-right: 2px solid #14100C; + font-family: 'JetBrains Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace; + font-size: 12px; + color: #14100C; + text-align: right; + padding-right: 9px; +} + +/* The 6px state stripe, driven by what Lab already knows about the cell. */ +.jp-Cell { + border-left: 6px solid transparent; + border-bottom: 1px solid rgba(20,16,12,0.15); + padding: 0; +} +.jp-Cell.jp-mod-selected { border-left-color: #14100C; background: #F7F3E8; } +.jp-Cell:has(.jp-OutputArea-output[data-mime-type="application/vnd.jupyter.stderr"]) { + border-left-color: #A3122F; +} +.jp-Cell:has(.jp-InputArea-prompt:empty) { border-left-color: #FFD400; } + +.jp-CodeCell .jp-Editor { + background: #FFFDF8; + border: 0; +} +.jp-InputArea-editor { border: 0; border-left: 1px solid rgba(20,16,12,0.15); } + +/* -------------------------------------------------------------- markdown */ +.jp-RenderedHTMLCommon h1, +.jp-RenderedHTMLCommon h2, +.jp-RenderedHTMLCommon h3, +.jp-RenderedHTMLCommon h4 { + font-family: 'Instrument Serif', Georgia, 'Times New Roman', serif; + font-weight: 400; + color: #14100C; + margin: 14px 0 6px; +} +.jp-RenderedHTMLCommon h1 { font-size: 30px; } +.jp-RenderedHTMLCommon h2 { font-size: 21px; } +.jp-RenderedHTMLCommon h3 { font-size: 19px; } +.jp-RenderedHTMLCommon p { max-width: 74ch; } +.jp-RenderedHTMLCommon a { color: #B04A2C; text-underline-offset: 2px; } +.jp-RenderedHTMLCommon code { background: #EFE8D8; padding: 1px 4px; } +.jp-RenderedHTMLCommon .MathJax_Display, +.jp-RenderedHTMLCommon mjx-container[display="true"] { + border: 2px solid #14100C; + background: #FFFDF8; + padding: 12px; +} + +/* ---------------------------------------------------------------- output */ +.jp-OutputArea-output { + background: #F7F3E8; + font-family: 'JetBrains Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace; + font-size: 12.5px; +} +.jp-RenderedText { + font-family: 'JetBrains Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace; + font-size: 12.5px; + line-height: 1.7; + color: #14100C; +} +.jp-RenderedText[data-mime-type="application/vnd.jupyter.stderr"] { + background: #FDEEF1; + border-left: 6px solid #A3122F; + color: #5A1020; +} + +.jp-RenderedHTMLCommon table { border-collapse: collapse; font-family: 'JetBrains Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace; } +.jp-RenderedHTMLCommon thead th { + background: #14100C; + color: #F7F3E8; + text-align: left; + padding: 7px 9px; + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.jp-RenderedHTMLCommon tbody td { border-bottom: 1px solid rgba(20,16,12,0.15); padding: 7px 9px; } +.jp-RenderedHTMLCommon tbody tr:nth-child(even) { background: #FDFAF2; } + +.jp-RenderedImage img { border: 2px solid #14100C; background: #FFFDF8; } + +/* ----------------------------------------------------------------- chrome */ +.jp-SideBar, .jp-FileBrowser, .jp-DirListing { background: #EFE8D8; } +.jp-DirListing-item.jp-mod-selected { background: #14100C; color: #F7F3E8; } +.jp-Toolbar-item .jp-ToolbarButtonComponent:hover { background: #EFE8D8; } +.lm-TabBar-tab.lm-mod-current { + background: #14100C !important; + color: #F7F3E8 !important; + border: 0; +} +.lm-TabBar-tab { background: #EFE8D8; border-right: 1px solid rgba(20,16,12,0.15); } +.jp-Statusbar, #jp-bottom-panel { + background: #14100C; + color: #F7F3E8; + font-family: 'JetBrains Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace; + font-size: 11px; +} + +/* CodeMirror token colours, matching the host's code blocks exactly. */ +.cm-editor .cm-content { font-family: 'JetBrains Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace; font-size: 13.5px; } +.cm-keyword, .cm-atom { color: #14100C; font-weight: 700; } +.cm-def, .cm-variable-2, .cm-property { color: #B04A2C; } +.cm-number, .cm-string { color: #0B7B6E; } +.cm-comment { color: #9C9484; font-style: italic; } +.cm-operator, .cm-punctuation { color: #8A8272; } +.cm-editor .cm-ruler { border-right: 1px dashed rgba(20,16,12,0.3); } +.cm-cursor { border-left: 2px solid #14100C; } +.cm-editor .cm-selectionBackground { background: #FFD400 !important; } diff --git a/config/jupyter/overrides.json b/config/jupyter/overrides.json index fdd4b62..76259c7 100644 --- a/config/jupyter/overrides.json +++ b/config/jupyter/overrides.json @@ -1,6 +1,6 @@ { "@jupyterlab/apputils-extension:themes": { - "theme": "JupyterLab Dark" + "theme": "JupyterLab Light" }, "@jupyterlab/notebook-extension:tracker": { "recordTiming": true diff --git a/core/campaign.py b/core/campaign.py index 01de1c3..2665d6a 100644 --- a/core/campaign.py +++ b/core/campaign.py @@ -34,15 +34,16 @@ from typing import Any from core import jsonl, paths -from core.errors import NotFound +from core.errors import EXIT_USAGE, GradError, NotFound T_CAMPAIGN = "campaign" T_GENERATION = "campaign_generation" T_CAMPAIGN_CLOSED = "campaign_closed" +T_HALT_REQUESTED = "campaign_halt_requested" T_CANDIDATE = "candidate" T_CANDIDATE_PROMOTED = "candidate_promoted" -STATUSES = ("open", "closed", "exhausted", "failed") +STATUSES = ("open", "closed", "exhausted", "failed", "halted") # Shinka's own markers, kept verbatim so a task directory works with the # upstream tool unmodified. @@ -152,11 +153,23 @@ def campaigns() -> dict[str, dict[str, Any]]: folded[cid] = {**{k: v for k, v in rec.items() if k != "type"}, "generations_run": 0} elif cid in folded and kind == T_GENERATION: node = folded[cid] - node["generations_run"] = max(node["generations_run"], int(rec.get("generation", 0)) + 1) + # A record carrying `halted` documents the generation the loop + # stopped *before*, not one it ran -- both the budget gate and a + # halt request write one at the boundary they break on. Counting it + # reported a campaign halted after generation 0 as having run two, + # which is the number the evolve window puts in its title bar. + if not rec.get("halted"): + node["generations_run"] = max( + node["generations_run"], int(rec.get("generation", 0)) + 1 + ) node["last_generation_at"] = rec.get("at") node.setdefault("generation_log", []).append( {k: v for k, v in rec.items() if k not in ("type", "id")} ) + elif cid in folded and kind == T_HALT_REQUESTED: + folded[cid]["halt_requested"] = True + folded[cid]["halt_requested_at"] = rec.get("at") + folded[cid]["halt_reason"] = rec.get("reason") elif cid in folded and kind == T_CAMPAIGN_CLOSED: folded[cid]["status"] = rec.get("status", "closed") folded[cid]["closed_at"] = rec.get("at") @@ -191,6 +204,56 @@ def close_campaign(campaign_id: str, *, status: str = "closed", reason: str = "" ) +def request_halt(campaign_id: str, *, reason: str = "") -> dict[str, Any]: + """Ask a running campaign to stop at the next generation boundary. + + A *request*, written to the ledger, rather than a signal: `evolve run` holds + the loop in whatever process started it -- usually the agent's -- and the UI + or a second terminal has no handle on it. An event both processes can see is + the only mechanism that works across all three callers, and it has the + side benefit of being diffable afterwards. + + The boundary matters as much as the request. Killing the loop mid-generation + abandons an in-flight candidate, which goes stale and blocks every future + submission (exit 7) -- so `_drive` checks this exactly where the budget gate + already stops cleanly, with every candidate collected. + + The campaign must exist and still be open, and that is checked *inside the + append lock* for the same reason `append_run_event` re-checks its binding + there: the loop closing a campaign and a human asking it to halt are two + processes racing over one file, and a check made before the lock is a check + the other process can win. `tools.evolve halt` still checks first, because + it produces the better message; this is the backstop, not the explanation. + """ + + def _still_open() -> None: + record = campaigns().get(campaign_id) + if record is None: + raise NotFound( + f"campaign {campaign_id!r} does not exist", + fix="python -m tools.evolve status --json # lists known campaigns", + ) + status = record.get("status") + if status != "open": + raise GradError( + "campaign_not_open", + f"campaign {campaign_id!r} is {status}, so there is nothing to halt", + exit_code=EXIT_USAGE, + fix=f"python -m tools.evolve status --campaign {campaign_id} --json", + ) + + return jsonl.append( + campaigns_path(), + {"type": T_HALT_REQUESTED, "id": campaign_id, "at": now_iso(), "reason": reason}, + precondition=_still_open, + ) + + +def halt_requested(campaign_id: str) -> bool: + record = campaigns().get(campaign_id) or {} + return bool(record.get("halt_requested")) + + # --------------------------------------------------------------------------- # candidates # --------------------------------------------------------------------------- diff --git a/design_handoff_grad_ui/README.md b/design_handoff_grad_ui/README.md new file mode 100644 index 0000000..354633a --- /dev/null +++ b/design_handoff_grad_ui/README.md @@ -0,0 +1,310 @@ +# Handoff: Grad — window system UI + +## Overview + +Grad is a personal autonomous ML-research agent (Python, Claude Agent SDK, NiceGUI desktop +app, embedded JupyterLab). This handoff covers the **visual system and the window layer**: +a tiling workspace shell plus eleven windows — notebook, agent chat, wiki, cited papers, +Evolve (ShinkaEvolve), LaTeX paper editor, ledger/expectations, preflight gates, quota & +budget, retrieval funnel, and run queue. + +The design goal was Claude-app calm carrying a neo-brutalist structure with a light retro +note: cream paper ground, 2px ink rules, hard offset shadows on floating shells, monospace +for everything the machine produced, and exactly one accent colour per state. + +## About the design files + +The three `.dc.html` files in `reference/` are **design references created in HTML** — +prototypes of the intended look, not production code to copy. They open directly in a +browser (each is a self-contained page; fonts load from Google Fonts). + +The implementation task is to **recreate these designs inside Grad's existing environment**: + +- App chrome, panes, chat, ledger, quota, evolve, queue, funnel, preflight, papers, wiki → + **NiceGUI** (Quasar under the hood) with a project stylesheet, using Grad's existing + `ui/app.py` structure and `ui/widgets/*`. +- Notebook cell rendering → **a pinned JupyterLab theme extension** (see "JupyterLab" below). + Do not restyle the iframe from the host page — cross-origin CSS will not apply. + +Do not lift the HTML verbatim. Take the tokens, the measurements and the component anatomy. + +## Fidelity + +**High fidelity.** Colours, type, spacing, borders and states are final. Recreate +pixel-accurately. Content strings in the mock are placeholder research data — the real +values come from Grad's ledger, budget tracker and Jupyter kernel. + +## Design tokens + +### Colour + +| Token | Hex | Role | +| --- | --- | --- | +| `ink` | `#14100C` | Text, all structural rules, dark bars, primary button fill | +| `paper` | `#F7F3E8` | Window surface | +| `paper-raised` | `#FFFDF8` | Cell bodies, input fields, message bubbles | +| `paper-sunk` | `#EFE8D8` | Title bars, gutters, side rails | +| `desk` | `#E8E2D4` | Page background behind window shells | +| `rule-soft` | `rgba(20,16,12,0.15)` | Hairline row/cell dividers | +| `rule-mid` | `rgba(20,16,12,0.2–0.3)` | Dashed secondary splits | +| `attention` | `#FFD400` | Needs the human: gates, unjudged, verify button, fix hints | +| `verified` | `#12A594` | Passing, in band, running-and-healthy | +| `verified-ink` | `#04302C` | Text on `verified` | +| `verified-tint` | `#DFF3EF` | Diff additions | +| `broken` | `#A3122F` | Errors, failed jobs, falsified expectations | +| `broken-tint` | `#FDEEF1` | Error output backgrounds, diff deletions | +| `broken-ink` | `#5A1020` / `#7A1024` | Text on `broken-tint` | +| `link` | `#B04A2C` | Links, function names in code | +| `muted` | `#8A8272` | Operators, secondary mono text | +| `muted-2` | `#9C9484` | Code comments | +| `literal` | `#0B7B6E` | Numbers and strings in code | +| `hatch-a` / `hatch-b` | `#F1EADA` / `#E7DFCC` | Figure placeholder hatching | + +Rules: one accent per state, never two in the same element. Accents are **fills with ink +borders**, not gradients. Never tint a whole pane. + +### Type + +- `'Space Grotesk', Helvetica, sans-serif` — 500/700 — all UI text and prose. +- `'JetBrains Mono', monospace` — 400/700 — anything the machine produced or that must + align: code, IDs, numbers, timings, labels, status chips, table bodies. +- `'Instrument Serif', Georgia, serif` — 400 + italic — markdown headings inside notebooks, + paper titles, LaTeX preview, math variables. + +Sizes in use: 9, 10, 11, 12, 12.5, 13, 13.5, 14, 14.5, 15, 19, 21, 28, 30, 34 px. +Uppercase mono labels carry `letter-spacing: 0.08–0.22em`. +Body line-height 1.55–1.62; code 1.65–1.75. + +### Structure + +- Structural border: `2px solid #14100C`. Hairline: `1px solid rgba(20,16,12,0.15)`. + Secondary: `1px dashed rgba(20,16,12,0.3)`. Empty/pending: `2px dashed #14100C`. +- **Radius: 0 everywhere.** +- Shadow: `8px 8px 0 #14100C` (workspace shell), `6px 6px 0 #14100C` (standalone window + cards). No blur shadows anywhere. +- Cell state stripe: `6px` solid left border — `#12A594` ok, `#A3122F` error, `#FFD400` + awaiting, `#14100C` focused/empty. +- Pane split handle: `8px` wide, `#14100C`, three centred `2px` `#F7F3E8` dots, + `cursor: col-resize`. +- Padding scale: 6/7 px (chips), 9–11 px (rows, buttons), 12–14 px (panels), + 16–22 px (cell bodies), 20 px (page gutter). +- Gutter column in notebooks: `72px` in panes, `76–84px` standalone. + +### States (all components) + +- Hover on ghost controls: background `#EFE8D8`. +- Active/selected tab or chip: background `#14100C`, text `#F7F3E8`. +- Primary action: `#FFD400` fill + 2px ink border. +- Destructive/blocked action: `#A3122F` fill, white text. +- Disabled: background `#EFE8D8`, `opacity: 0.5`, no pointer events. +- Focus-visible: `outline: 2px solid #14100C; outline-offset: 2px`. +- Blinking caret / live indicator: 7–8px ink or accent block, + `@keyframes gradblink { 0%,49% {opacity:1} 50%,100% {opacity:0} }`, `1.1s steps(1) infinite`. + +## Workspace shell + +File: `reference/Grad Workspace.dc.html`. Design width 1720px content, 900px pane height. + +1. **Title bar** (`#14100C`, `#F7F3E8` text, 2px bottom rule, ~42px): + `∇` mark in a `#FFD400` 22px square with a 2px paper border, `GRAD` at 15px mono + `letter-spacing: .22em`, then a `#F7F3E8` 2px divider; project selector; agent run state + chip (`#12A594` fill, ink square dot, "AGENT RUNNING · step 14") plus `■ PAUSE`; spacer; + session quota strip (150×12px, 1.5px paper border, segments `#FFD400` used-by-chat and + `#12A594` used-by-tools) with `$4.12 / $8.00` and reset countdown; `⌘K` and `LAYOUTS ▾`. +2. **Window opener strip** (`#EFE8D8`, 2px bottom rule, ~30px): one 11px mono uppercase cell + per window, separated by 1px hairlines. Open windows render inverted (ink fill, paper + text). Right side shows layout shortcuts. +3. **Tiling area**: horizontal flex of panes separated by 8px drag handles. Panes are + independently resizable; a pane may split vertically (the right pane stacks LEDGER over + QUOTA, each keeping its own title bar). +4. **Status bar** (`#14100C`, 11px mono, ~30px): cwd, kernel, queue/gpu counts, spacer, + `⌥drag to retile`, and an `#FFD400` chip with the open-window count. + +**Pane title bar** (every window, ~30px, `#EFE8D8`, 2px bottom rule): 11px mono uppercase +name at `letter-spacing: .14em`; a 55%-opacity mono subtitle; optional state chips; +spacer; `⇱ ⇲ ✕` at 50% opacity. + +## Windows + +### 1. Notebook (JupyterLab iframe) + +Standalone reference: `reference/Notebook Paper.dc.html`. + +- **Toolbar** (2px bottom rule): a joined button group — `▶ RUN` (active: ink fill), + `▶▶ ALL`, `■ STOP`, `↻ RESTART`, each 2px ink border with `border-left: 0` on the joins; + then `✓ VERIFY — FRESH KERNEL` on `#FFD400`; spacer; `ruler 88` in a dashed box; + `↗ OPEN IN LAB`. +- **Verify banner** (`#12A594`, `#04302C` text, 2px bottom rule): `NB VERIFY` label, the + result sentence ("clean — 12 cells ran top to bottom on a fresh kernel · 41.8s · date"), + and a `CITABLE` chip (`#04302C` fill, `#12A594` text). Failure variant: `#A3122F` fill, + white text, chip reads `NOT CITABLE`. Stale variant: `#FFD400`, `RE-VERIFY`. +- **Cells**: CSS grid `72–84px 1fr`. Gutter is `#EFE8D8` with a 2px right rule and holds + `In [n]:` (12px mono, right-aligned) and, under it, an execution-time chip + (10px mono, ink fill, paper text). Body has the 6px state stripe. + - Markdown cell: gutter reads `MD` at 50% opacity; body uses Instrument Serif 30px + headings and 15px Space Grotesk prose at `max-width: 74ch`; display math sits in a + 2px-bordered `#FFFDF8` box. + - Code cell: `pre`, 13.5px JetBrains Mono, line-height 1.7, token colours above. + - Output: a `56px` mono label column (`STDOUT`, `OUT[n]`, `ERR`) separated by a 1px + dashed rule. + - Table output: ink `thead` with paper text; 1px hairline rows; every other row + `#FDFAF2`; verdict chips in the last column (`IN BAND` teal, `HIGH · UNJUDGED` yellow, + `OUT OF BAND` outlined). + - Figure output: 2px ink frame over 45° hatching, a centred caption chip, and a + `640×400 · PNG` tag pinned bottom-left in ink. + - Error output: `#FDEEF1` background, `#A3122F` stripe and label, an + `ErrorType — cell n of m` chip, the traceback in `#5A1020`, and a `FIX` box — + 2px ink border on `#FFD400` — containing the exact shell command that repairs it. + - Empty trailing cell: dashed-ink stripe and a blinking caret. +- **Add-cell row**: `+ CODE` / `+ MARKDOWN` as 2px dashed ink buttons at 75% opacity. +- **Footer** (ink): `CMD`, cursor position, cell/output counts, spacer, `kernel owner: lab`, + and a `#12A594` project chip. + +### 2. Agent chat + +- User message: right-aligned, `max-width: 88%`, 2px ink border on `#FFFDF8`, 11px padding, + 14px text. Role line above in 10px mono at 50% opacity. +- Grad message: left, avatar = 16px `#FFD400` square with 1.5px ink border and `∇`; body + indented `23px`; inline code gets an `#EFE8D8` background. +- **Expectation card**: 2px ink border; `#FFD400` header bar (`EXPECTATION REGISTERED` + + id); body is a 12px mono key/value list — claim, band, falsifier, source. +- **Tool call**: ink header bar (`TOOL`, tool name, args, right-aligned result chip + `OK 8.4s` in teal); body `#FFFDF8` with output and a `▸ n more output lines` disclosure. +- **Streaming row**: 2px dashed ink box, blinking square, "running cell 4 of 12 …", + `esc to interrupt` at right. +- **Gate card**: 2px `#A3122F` border, solid `#A3122F` header (`GATE — YOUR CALL`), a + sentence naming the exact cost and resource, then `✓ APPROVE` (teal), `✎ EDIT PLAN`, + `✕ DENY` — all 2px ink borders. +- **Composer**: mode chips (`ASK` active ink / `PLAN` / `RUN`), `@notebook @paper @wiki` + mention hint, a 2px-bordered field on `#FFFDF8` with a blinking caret, and a `SEND ⏎` + button on `#FFD400`. + +### 3. Wiki + references + +Two panes split by an 8px handle: chat on the left (same message anatomy as above; answers +carry superscript reference markers in `#B04A2C`), references rail on the right (`#EFE8D8`, +440px). Each reference: a numbered ink chip, file path, line range, and the snippet in a +1.5px-bordered `#FFFDF8` `pre` — bordered `#A3122F` on `#FDEEF1` when the snippet is the +faulty one. Answer actions: `→ OPEN IN EDITOR` (yellow), `→ ASK GRAD TO PATCH`. + +### 4. Cited papers + +List + reader. Filter chips in the title bar (`CITED IN PAPER` active, `READ`, `QUEUED`). +Each row: 70×92px cover placeholder (1.5px border, horizontal stripe fill; dashed border +when unread), Instrument Serif 21px title, 11px mono authors/arXiv line, then status chips +— `3 CLAIMS DEPEND ON THIS` (yellow), figure/table references (outlined), +`CONTRADICTS exp-…` (teal), `QUEUED BY GRAD · not read` (dashed). Selected row gets a 6px +ink stripe and `#FFFDF8` fill. Reader rail (520px, `#EFE8D8`): page frame plus a `#FFD400` +`PULLED INTO exp-…` card holding the sentence that became an expectation. + +### 5. Evolve (ShinkaEvolve) + +Three panes: population stats (300px, `#EFE8D8`, mono key/value rows: islands, migrations, +novelty, spend; a dashed box restating the objective); lineage chart (bars per generation, +1.5px ink borders — `#EFE8D8` ordinary, `#FFD400` new best, `#12A594` current champion, +axis captions in 10px mono); champion diff (420px, `#FFFDF8`): a header with the fitness +delta chip (teal), a unified diff with `#FDEEF1`/`#7A1024` deletions and +`#DFF3EF`/`#04302C` additions, then `✓ ADOPT INTO MAIN` (yellow) and `→ SEND TO NOTEBOOK`. +Title bar carries an `EVOLVING` chip and `■ HALT`. + +### 6. Paper editor (LaTeX) + +Three panes: outline (190px, `#EFE8D8`; active section inverted ink; a `#A3122F`-bordered +`#FDEEF1` warning box — "2 claims uncited. Grad blocks the build until each is bound to a +run or a paper"); source (`pre`, 12.5px mono, `\gradcite{run-…}` / `\gradexp{exp-…}` macros +highlighted, uncited sentences flagged `#FDEEF1`); preview (`#FFFDF8`, Instrument Serif +headings, justified 14.5px prose, the uncited sentence underlined 2px `#A3122F`, matted +figure with caption). Title bar: `⌘S SAVE`, `BUILD PDF` (yellow). + +### 7. Ledger / expectations + +Filter chips (`OPEN n` active, `MET n`, `BROKEN n`). Each entry: 6px left stripe by state +(`#FFD400` open, `#12A594` met, `#A3122F` broken), id + state chip + timestamp, the claim +in 13.5px text, and — for open entries — a **band strip**: 30px tall, 1.5px ink border on +`#FFFDF8`, the predicted band as a 35%-opacity `#12A594` block, the observed value as a 2px +ink tick with its number above, falsifier bounds as `#A3122F` ticks, and min/band/max +labels in 10px mono underneath. + +### 8. Quota & budget + +Window-level 5-hour meter: 22px bar, 2px ink border, `#FFD400` chat segment (labelled +inside), `#12A594` tool segment, `#FFFDF8` remainder; legend in 10px mono. +Spend today: per-model horizontal bars (ink = sonnet, `#B04A2C` = opus, `#12A594` = gpu) +with dollar values. Always ends with the **honesty note** in a 2px dashed box: token counts +are Grad's own tally, not the provider's — an estimate within ±5%. + +### 9. Preflight + gates + +Checklist rows, 1px hairline separated. Each: an 18px status square with 2px ink border — +`#12A594` ✓, `#FFD400` ! (row background `#FFFBE8`), `#A3122F` ✕ with white glyph — then +the check sentence and a right-aligned detail or `FIX` button. Footer: `▶ PROCEED` +(disabled while anything blocks), the one-click remedy on `#FFD400`, and a blocking count. + +### 10. Funnel (retrieval) + +Stacked stage bars, each 34px with a 2px ink border, progressively indented and narrowed: +`CORPUS · n chunks` (`#EFE8D8`) → `BM25 + EMBED → n` → `RERANK → n` (`#FFD400`) → +`IN CONTEXT n` (`#12A594`). Below a dashed rule, the surviving chunks in rank order with +scores; dropped chunks at 45% opacity with the reason. + +### 11. Run queue / GPU jobs + +Full-width table: ink `thead`; columns job, what, device, progress, eta, cost, state. +Progress is a 12px bar with a 1.5px border — teal fill running, ink fill done, `#A3122F` +border+fill failed, dashed empty when queued. State chips: `RUNNING` teal, +`WAITING GATE` yellow, `DONE` outlined, `FAILED · KeyError` crimson. Running row is +`#FFFDF8`. + +## Interactions & behaviour + +- **Tiling**: panes resize by dragging the 8px handles; `⌥`+drag a title bar to retile; + `⌥1/⌥2/⌥3` switch tile/stack/full. Persist layout per project. Minimum pane width 320px. +- **Window opener**: clicking a name opens it into the focused pane (or splits if the pane + already holds one); clicking an open name closes it. +- **Notebook**: Run/Run-all/Stop/Restart map to Jupyter kernel commands. `VERIFY` runs the + notebook top-to-bottom on a fresh kernel and rewrites the verify banner; the banner is + the sole source of the citable/not-citable state, and it goes stale (yellow) on any edit. +- **Gates**: approving a gate resumes the agent loop; denying returns control to chat with + the reason attached. A gate always states the exact spend and resource before the ask. +- **Preflight**: `PROCEED` is disabled while any ✕ row exists; the yellow remedy button + performs the fix and re-runs the checklist in place. +- **Ledger**: a new run redraws the band strip's tick; crossing a falsifier bound flips the + entry to `BROKEN` (crimson) and posts a message into chat. +- **Evolve**: lineage bars append per generation; adopting a champion opens the diff as a + patch against main. +- **Motion**: only two — the 1.1s step blink for carets/live indicators, and instant state + swaps. No easing curves, no fades, no skeleton shimmer. Progress bars update in place. + +## State (host side) + +Per window: `open`, `pane_id`, `size_fraction`, `focused`. Global: `project`, +`agent_state` (idle | running | awaiting_gate | paused), `session_spend`, `quota_window`, +`queue`, `kernel_state`, `verify_state` per notebook, `ledger_entries`, `evolve_run`. +Layout persists to disk per project; everything else is live from the agent loop. + +## JupyterLab + +The notebook window is a real Lab iframe, so its interior cannot be styled from NiceGUI. +Implement it as a **pinned JupyterLab theme extension** applying the tokens above to Lab's +own CSS variables and cell classes — `--jp-layout-color0/1/2`, `--jp-border-color*`, +`--jp-cell-editor-background`, `--jp-code-font-family`, `--jp-content-font-family`, +`.jp-InputArea-prompt`, `.jp-OutputArea-output`, `.jp-RenderedText`. Set +`"theme": "Grad Paper"` in `config/jupyter/overrides.json` in place of JupyterLab Dark, and +keep `notebook-extension` defaults (ruler at 88) as they are. Chrome that Grad owns — the +toolbar, verify banner, and footer — stays in NiceGUI **above** the iframe, styled +identically, so the seam is invisible. + +## Assets + +None. Every mark is CSS: the `∇` wordmark glyph, `▶ ▶▶ ■ ↻ ✓ ✕ ! ⇱ ⇲ ↗ ⏎ ▾ ▸` from the +system font, hatched figure placeholders from `repeating-linear-gradient(135deg, …)`, and +paper covers from a horizontal stripe gradient. Fonts: Space Grotesk, JetBrains Mono, +Instrument Serif (Google Fonts — vendor them locally for an offline desktop app). + +## Files + +- `reference/Grad Workspace.dc.html` — the tiling shell with chat, notebook, ledger and + quota tiled live. +- `reference/Grad Windows.dc.html` — wiki, papers, evolve, editor, preflight, funnel, queue. +- `reference/Notebook Paper.dc.html` — the notebook window at full size; the reference for + the JupyterLab theme extension. diff --git a/design_handoff_grad_ui/reference/Grad Windows.dc.html b/design_handoff_grad_ui/reference/Grad Windows.dc.html new file mode 100644 index 0000000..73d1824 --- /dev/null +++ b/design_handoff_grad_ui/reference/Grad Windows.dc.html @@ -0,0 +1,392 @@ + + + + + + + + + + + + + + + +
+
+ +
+
Grad — the remaining windows
+
SAME CHROME, SAME 2PX RULE, ONE ACCENT PER STATE
+
+ +
+
+
WIKI
+
RepoWiki · grad/scaling · indexed 2h ago
+
+
↻ REINDEX
+
⇱ ⇲ ✕
+
+
+
+
+
YOU
+
Where does the sweep cache its tokenizer key?
+
+
+
+
+
WIKI
+
+
In grad/scaling.py the cache key is built from the tokenizer name at line 96[1], but val_loss reads it back with the caller's name, not the cached one[2]. That mismatch is exactly the KeyError in cell 4.
+
+
→ OPEN IN EDITOR
+
→ ASK GRAD TO PATCH
+
+
+
+
Ask the repo…
+
+
+
+
+
+
REFERENCES · 2
+
+
+ 1 + grad/scaling.py + L92–99 +
+
key = f"loss@{tok.name}"
+frame[key] = losses
+cache.put(cfg_hash, frame)
+
+
+
+ 2 + grad/scaling.py + L146–149 +
+
def val_loss(frame, tokenizer):
+    return frame[f"loss@{tokenizer}"]
+
+
+
+
+ +
+
+
PAPERS
+
cited by this project · 24
+
+
+
CITED IN PAPER
+
READ
+
QUEUED
+
+
⇱ ⇲ ✕
+
+
+
+
+
+
+
Scaling Laws for Neural Language Models
+
Kaplan et al. · 2020 · arXiv:2001.08361
+
+ 3 CLAIMS DEPEND ON THIS + Fig 3 · Fig 9 + PDF LOCAL +
+
+
+
+
+
+
Training Compute-Optimal Large Language Models
+
Hoffmann et al. · 2022 · arXiv:2203.15556
+
+ CONTRADICTS exp-79f4 + Tab 3 +
+
+
+
+
+
+
The Depth-to-Width Interplay in Self-Attention
+
Levine et al. · 2020 · arXiv:2006.12467
+
+ QUEUED BY GRAD · not read +
+
+
+
+
+
+
+
READER · Kaplan 2020
+
+
p. 8 / 30
+
+
+
+
PDF page — Fig 3 highlighted
+
+
+
PULLED INTO exp-7c41
+
Loss at 1.3B params, 1e9 tokens sits in 2.88–3.06 after tokenizer correction.
+
+
+
+
+
+ +
+
+
EVOLVE
+
ShinkaEvolve · run ev-014 · gen 12 / 40
+
+
EVOLVING
+
■ HALT
+
⇱ ⇲ ✕
+
+
+
+
POPULATION
+
+
island A16 alive · best 2.86
+
island B16 alive · best 2.91
+
migrations4
+
novelty0.41
+
spend$0.94 / $3.00
+
+
+
objective: minimise L_val at fixed 6NT, penalise param count above 1.4B
+
+
+
+
+
LINEAGE — BEST PER GENERATION
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
gen 1fitness = −L_valgen 12
+
+
+
+
+
DIFF — CHAMPION g12
+
+
2.86 ↓0.08
+
+
@@ grad/model.py L64 @@
+- attn = MHA(d, heads=d // 64)
++ attn = MHA(d, heads=d // 96,
++           qk_norm=True)
+@@ grad/train.py L21 @@
+- warmup = 1000
++ warmup = 2500
+
+
✓ ADOPT INTO MAIN
+
→ SEND TO NOTEBOOK
+
+
+
+
+ +
+
+
EDITOR
+
paper/main.tex · §4 Results
+
+
⌘S SAVE
+
BUILD PDF
+
⇱ ⇲ ✕
+
+
+
+
OUTLINE
+
1 Introduction
+
2 Setup
+
3 Method
+
4 Results
+
4.1 Width vs depth
+
4.2 Tokenizer
+
5 Discussion
+
2 claims uncited. Grad blocks the build until each is bound to a run or a paper.
+
+
+
+
SOURCE
+
\subsection{Width vs depth}
+
+At fixed compute the widest configuration wins:
+\gradcite{run-8f21} reaches $L_{val}=2.94$, inside the
+band registered as \gradexp{exp-7c41}.
+
+Depth beyond 12 layers does not help.  % uncited
+
+\begin{figure}
+  \includegraphics{figures/loss_band.png}
+  \caption{Nine configurations against the band.}
+\end{figure}
+
+
+
+
+
PREVIEW
+
+
built 14s ago
+
+
+
4.1  Width vs depth
+
At fixed compute the widest configuration wins: run-8f21 reaches Lval = 2.94, inside the band registered as exp-7c41. Depth beyond 12 layers does not help.
+
+
Figure 4: Nine configurations against the band.
+
+
+
+
+ +
+ +
+
+
PREFLIGHT
+
before any spend
+
+
⇱ ⇲ ✕
+
+
+
+
+
Expectation registered and falsifiable
+
exp-7c41
+
+
+
+
Notebook verifies on a fresh kernel
+
41.8s
+
+
+
!
+
Seed is fixed but the dataloader is not
+
FIX
+
+
+
+
Estimated spend exceeds the step cap
+
$1.80 > $1.00
+
+
+
+
▶ PROCEED
+
RAISE CAP TO $2.00
+
+
1 blocking
+
+
+ +
+
+
FUNNEL
+
retrieval for “tokenizer cache key”
+
+
⇱ ⇲ ✕
+
+
+
+
CORPUS · 1 284 chunks
+
+
+
BM25 + EMBED→ 96
+
+
+
RERANK→ 12
+
+
+
IN CONTEXT4
+
+
+
1.grad/scaling.py L92–990.88
+
2.grad/scaling.py L146–1490.84
+
3.tests/test_cache.py L300.61
+
4.docs/cache.md0.55 · dropped, stale
+
+
+
+
+ +
+
+
QUEUE
+
runs & GPU · 1 of 2 busy
+
+
+ ENQUEUE
+
⇱ ⇲ ✕
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
jobwhatdeviceprogressetacoststate
job-221sweep w1024 d12 · bpe-50kcuda:0 · 4090
6m 10s$0.61RUNNING
job-222sweep w512 d16 · bpe-50kcuda:1 · queued
~$0.58WAITING GATE
job-220evolve gen 12 evalcuda:0
done$0.34DONE
job-219sweep w256 d8 · bpe-32kcuda:1
$0.12FAILED · KeyError
+
+ +
+
+
+ + + diff --git a/design_handoff_grad_ui/reference/Grad Workspace.dc.html b/design_handoff_grad_ui/reference/Grad Workspace.dc.html new file mode 100644 index 0000000..3589e40 --- /dev/null +++ b/design_handoff_grad_ui/reference/Grad Workspace.dc.html @@ -0,0 +1,352 @@ + + + + + + + + + + + + + + + +
+
+ +
+
+
+
GRAD
+
+
+ projectscaling-w2 +
+
+
AGENT RUNNING · step 14
+
■ PAUSE
+
+
+
+ session +
+
+
+ $4.12 / $8.00 + · resets 3h 20m +
+
+
⌘K
+
LAYOUTS ▾
+
+
+ +
+
OPEN A WINDOW →
+
CHAT
+
NOTEBOOK
+
LEDGER
+
QUOTA
+
WIKI
+
PAPERS
+
EVOLVE
+
EDITOR
+
PREFLIGHT
+
FUNNEL
+
QUEUE
+
+
tile ⌥1 · stack ⌥2 · full ⌥3
+
+ +
+ +
+
+
CHAT
+
sonnet 4.5 · thread #31
+
+
⇱ ⇲ ✕
+
+ +
+
+
YOU · 09:04
+
Check whether width beats depth at 1e9 tokens. Register the prediction first.
+
+ +
+
+
+
GRAD · 09:04
+
+
Registering the expectation before any run, so the result can't be judged after the fact. From Kaplan Fig 3 the band at 1.3B params is 2.88–3.06.
+
+ +
+
+
EXPECTATION REGISTERED
+
+
exp-7c41
+
+
+
claimwidth 1024 beats depth 16 at fixed C
+
bandL_val ∈ [2.88, 3.06]
+
falsifierany run < 2.80 or > 3.20
+
sourcearXiv:2001.08361 Fig 3
+
+
+ +
+
+ TOOLnb.execscaling_w2.ipynb · cells 1–3 +
OK 8.4s +
+
9 rows · 9.00e+09 tokens
▸ 2 more output lines
+
+ +
+ + running cell 4 of 12 …
esc to interrupt +
+ +
+
GATE — YOUR CALL
+
+
Next step spends $1.80 and 42 GPU-min on a nine-config rerun with the corrected tokenizer.
+
+
✓ APPROVE
+
✎ EDIT PLAN
+
✕ DENY
+
+
+
+
+ +
+
+
ASK
+
PLAN
+
RUN
+
+
@notebook @paper @wiki
+
+
+
Rerun with bpe-50k and update the band
+
SEND ⏎
+
+
+
+ +
+
+
+ +
+
+
NOTEBOOK
+
notebooks/scaling_w2.ipynb
+
JUPYTERLAB IFRAME
+
+
idle
+
⇱ ⇲ ✕
+
+ +
+
+
▶ RUN
+
▶▶ ALL
+
■ STOP
+
↻ RESTART
+
+
✓ VERIFY — FRESH KERNEL
+
+
↗ OPEN IN LAB
+
+ +
+ NB VERIFYclean — 12 cells top to bottom on a fresh kernel · 41.8s +
CITABLE +
+ +
+
+
MD
+
+
Width vs depth at fixed compute
+
Sweep nine configurations at 1e9 tokens and compare Lval against the band predicted from arXiv:2001.08361, Fig 3 — registered before the run as exp-7c41.
+
+
+ +
+
+
In [1]:
+
0.42s
+
+
+
from grad.scaling import sweep, val_loss
+
+CFG = dict(widths=[256, 512, 1024], depths=[8, 12, 16], tokens=1e9)
+df = sweep(**CFG, cache=True)  # ledger-backed
+
+
STDOUT
+
9 rows · 9.00e+09 tokens
+
+
+
+ +
+
+
In [2]:
+
1.9s
+
+
+
df.sort_values("val_loss").head(3)
+
+
OUT[2]
+
+ + + + + + + + + +
runwidthdepthparamsval_lossband
run-8f211024121.31B2.94IN BAND
run-8f19512161.29B3.01IN BAND
run-8f241024161.74B3.28HIGH · UNJUDGED
+
+
+
+
+ +
+
+
In [3]:
+
6.1s
+
+
+
plot_band(df, expectation="exp-7c41")
+
+
OUT[3]
+
+
+
matplotlib figure → figures/loss_band.png
+
640×400
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
LEDGER
+
expectations & verdicts
+
+
⇱ ⇲ ✕
+
+ +
+
OPEN 3
+
MET 11
+
BROKEN 4
+
+ +
+
+
+
exp-7c41
+
AWAITING RERUN
+
09:04
+
+
Width 1024 beats depth 16 at fixed compute.
+
+
+
+
2.94
+
+
+
2.70band 2.88–3.063.40
+
+ +
+
+
exp-7b02
+
MET
+
yest.
+
+
Warmup ≥ 2k steps removes the loss spike at 512 width. 3 runs, all in band.
+
+ +
+
+
exp-79f4
+
BROKEN
+
2d
+
+
Depth scaling holds below 8 layers. Falsified by run-8f17 at 3.55.
+
+ +
+
QUOTA
+
tokens, spend, GPU
+
+
⇱ ⇲ ✕
+
+ +
+
+
5-HOUR WINDOW61% · resets 3h 20m
+
+
CHAT
+
+
+
+
chat 1.9M toktools 0.6Mfree 1.6M
+
+ +
+
SPEND TODAY$4.12 / $8.00
+
+
sonnet
$2.44
+
opus
$1.31
+
gpu
$0.37
+
+
+ +
+ HONESTY NOTE — token counts are Grad's own tally, not Anthropic's. Treat as an estimate within ±5%. +
+
+
+
+
+ +
+ CMD + ~/grad/projects/scaling-w2 + kernel: lab · py3.12 + queue 2 · gpu 1/2 +
+ ⌥drag to retile + 4 WINDOWS OPEN +
+
+
+
+ + + diff --git a/design_handoff_grad_ui/reference/Notebook Paper.dc.html b/design_handoff_grad_ui/reference/Notebook Paper.dc.html new file mode 100644 index 0000000..adfd6f5 --- /dev/null +++ b/design_handoff_grad_ui/reference/Notebook Paper.dc.html @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+
+
+
+
NOTEBOOK
+
notebooks/scaling_w2.ipynb
+
+
+
+
python 3.12 · idle +
+
autosaved 30s
+
+
+ +
+
+
▶ RUN
+
▶▶ ALL
+
■ STOP
+
↻ RESTART
+
+
+
✓ VERIFY — FRESH KERNEL
+
+
ruler 88
+
↗ OPEN IN LAB
+
+ +
+
NB VERIFY
+
clean — 12 cells ran top to bottom on a fresh kernel · 41.8s · 2026-08-14 09:12
+
+
CITABLE
+
+ +
+ +
+
+
MD
+
+
+
Width vs depth at fixed compute
+
Sweep nine configurations at 1e9 tokens and compare Lval against the band predicted from arXiv:2001.08361, Fig 3. The prediction is registered before this cell runs — see expectation exp-7c41.
+
minw,d  L(w, d)  s.t.  6·N(w,d)·T = C
+
+
+ +
+
+
In [1]:
+
0.42s
+
+
+
import torch, math
+from grad.scaling import sweep, val_loss
+
+CFG = dict(widths=[256, 512, 1024], depths=[8, 12, 16], tokens=1e9)
+df = sweep(**CFG, cache=True)  # 9 configs, ledger-backed
+print(f"{len(df)} rows · {df.tokens.sum():.2e} tokens")
+
+
STDOUT
+
9 rows · 9.00e+09 tokens
+
+
+
+ +
+
+
In [2]:
+
1.9s
+
+
+
df.sort_values("val_loss").head(4)
+
+
OUT[2]
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
runwidthdepthparamsval_lossband
run-8f211024121.31B2.94IN BAND
run-8f19512161.29B3.01IN BAND
run-8f241024161.74B3.28HIGH · UNJUDGED
run-8f1725680.41B3.55OUT OF BAND
+
+
+
+
+ +
+
+
In [3]:
+
6.1s
+
+
+
plot_band(df, expectation="exp-7c41", save="figures/loss_band.png")
+
+
OUT[3]
+
+
+
matplotlib figure → figures/loss_band.png
+
640×400 · PNG
+
+
+
+
+
+ +
+
+
In [4]:
+
+
+
val_loss(df, at_tokens=1e9, tokenizer="bpe-32k")
+
+
ERR
+
+
KeyError — cell 4 of 12
+
Traceback (most recent call last)
+  File "grad/scaling.py", line 148, in val_loss
+    return frame[f"loss@{tokenizer}"]
+KeyError: 'loss@bpe-32k'  — the sweep cached 'bpe-50k'
+
+
FIX
+
python -m tools.nb exec notebooks/scaling_w2.ipynb --cell 1 --set tokenizer=bpe-50k
+
+
+
+
+
+ +
+
+
In [ ]:
+
+
+ | + +
+
+ +
+
+ CODE
+
+ MARKDOWN
+
+
+ +
+
CMD
+
Ln 12, Col 4
+
12 cells · 4 outputs
+
+
kernel owner: LAB
+
GRAD ▪ SCALING-W2
+
+
+
+
+ + + diff --git a/pyproject.toml b/pyproject.toml index a9bc5de..b0fcef7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,11 @@ agent = ["claude-agent-sdk>=0.1.0"] notebook = ["jupyter-client>=8.6", "ipykernel>=6.29", "nbformat>=5.10", "nbconvert>=7.16"] retrieval = ["httpx>=0.27", "sqlite-vec>=0.1.6"] remote = ["keyring>=25.0", "huggingface-hub>=0.24"] -ui = ["nicegui>=2.0", "pywebview>=5.0", "nbformat>=5.10", "nbconvert>=7.16"] +# NiceGUI 3 is a floor, not a preference: the window system needs `Element.move` +# to reparent a window between panes without rebuilding it, and `shared=True` on +# `add_head_html`/`add_body_html`, which 3.0 requires when global-scope markup +# coexists with a `@ui.page` route. On 2.x the shell raises at page build. +ui = ["nicegui>=3.0", "pywebview>=5.0", "nbformat>=5.10", "nbconvert>=7.16"] math = ["sympy>=1.13", "mpmath>=1.3"] # HANDOFF-2 §19: the extension set is *declared*, not accumulated, and every pin # is exact rather than a floor. The JupyterLab 3->4 break is what killed the @@ -53,7 +57,13 @@ dev = ["pytest>=8.0", "pytest-asyncio>=0.23"] grad = "agent:main" [tool.setuptools] -packages = ["core", "tools", "ui", "ui.widgets"] +packages = ["core", "tools", "ui", "ui.windows"] + +# The stylesheet is generated from `ui/tokens.py`, but `tiling.js` and any +# vendored fonts are real files and have to travel with the wheel -- without +# them an installed Grad has no pane dragging and no typefaces. +[tool.setuptools.package-data] +ui = ["static/*.js", "static/fonts/*", "assets/katex/**/*"] # Top-level modules, not packages. Without these the installed `grad` command # cannot import `agent:main`, and `hooks` would be missing under it. py-modules = ["agent", "hooks"] diff --git a/tests/test_evolve.py b/tests/test_evolve.py index 5d20d16..e5df599 100644 --- a/tests/test_evolve.py +++ b/tests/test_evolve.py @@ -20,7 +20,7 @@ import pytest from core import budget, campaign as camp, config as config_mod, ledger_store as ls, paths -from core.errors import EXIT_PROJECT_BUDGET, GateRefusal, GradError, UsageError +from core.errors import EXIT_PROJECT_BUDGET, GateRefusal, GradError, NotFound, UsageError from tools import evolve @@ -470,3 +470,161 @@ def test_capabilities_names_the_granularity_it_found(workspace): assert isinstance(report["driver_viable"], bool) else: assert "shinka" in report["reason"] + + +# --------------------------------------------------------------------------- +# halting (the UI's ■ HALT, and the CLI behind it) +# --------------------------------------------------------------------------- +class HaltingMutator(FakeMutator): + """Requests a halt from inside generation 0, the way a human would from the + workspace while the loop is mid-generation.""" + + def __init__(self, campaign_getter, **kwargs): + super().__init__(**kwargs) + self._campaign = campaign_getter + + def propose(self, *, generation, population, best): + if generation == 0: + camp.request_halt(self._campaign(), reason="halted from the workspace") + return super().propose(generation=generation, population=population, best=best) + + +def test_a_halt_stops_the_loop_at_the_next_generation_boundary(workspace, monkeypatch): + """Not a kill. Stopping mid-generation would abandon an in-flight candidate, + which goes stale and blocks every future submission -- so the check sits at + the same boundary the budget gate stops at.""" + task_dir = scaffold(workspace) + monkeypatch.setattr( + evolve, "_make_mutator", + lambda *a, **k: HaltingMutator(lambda: next(iter(camp.campaigns()))), + ) + result = evolve.cmd_run(run_args(task_dir, make_expectation(), generations=5, population=2)) + + assert result["status"] == "halted" + # Generation 0 completed; generation 1 never started -- and the folded + # campaign has to agree with the returned count, because the evolve window + # reads the folded one for its title bar. + assert result["generations_run"] == 1 + assert camp.campaign(result["campaign"])["generations_run"] == 1 + assert len(camp.candidates(result["campaign"])) == 2 + # And nothing is left half-evaluated. + assert all(r.get("metrics") or r.get("error") for r in camp.candidates(result["campaign"])) + assert camp.campaign(result["campaign"])["status"] == "halted" + + +def test_halt_is_a_request_the_ledger_carries(workspace, monkeypatch): + task_dir = scaffold(workspace) + monkeypatch.setattr(evolve, "_make_mutator", lambda *a, **k: FakeMutator()) + result = evolve.cmd_run(run_args(task_dir, make_expectation())) + campaign_id = result["campaign"] + + # A closed campaign is told so rather than handed a request nothing reads. + payload = evolve.cmd_halt(argparse.Namespace(campaign=campaign_id, reason="", json=True)) + assert payload["halted"] is False + assert "already" in payload["message"] + assert camp.halt_requested(campaign_id) is False + + +def test_halt_on_an_open_campaign_records_the_request(workspace): + camp.append_campaign( + {"type": camp.T_CAMPAIGN, "id": "camp-1", "status": "open", "at": camp.now_iso()} + ) + payload = evolve.cmd_halt(argparse.Namespace(campaign="camp-1", reason="too slow", json=True)) + assert payload["halted"] is True + assert camp.halt_requested("camp-1") is True + assert camp.campaign("camp-1")["halt_reason"] == "too slow" + + +def test_halting_an_unknown_campaign_is_a_not_found(workspace): + with pytest.raises(NotFound): + evolve.cmd_halt(argparse.Namespace(campaign="nope", reason="", json=True)) + + +def test_a_halt_request_is_validated_inside_the_append_lock(workspace): + """The loop closing a campaign and a human halting it are two processes + racing over one file, so a check made before the lock is a check the other + process can win. Same backstop as `append_run_event`'s binding check.""" + with pytest.raises(NotFound): + camp.request_halt("camp-nope") + assert camp.campaign_events() == [] + + camp.append_campaign( + {"type": camp.T_CAMPAIGN, "id": "camp-1", "status": "open", "at": camp.now_iso()} + ) + camp.close_campaign("camp-1", status="closed") + before = len(camp.campaign_events()) + + with pytest.raises(GradError) as exc: + camp.request_halt("camp-1") + assert exc.value.code == "campaign_not_open" + # Rejected under the lock means nothing was written. + assert len(camp.campaign_events()) == before + assert camp.halt_requested("camp-1") is False + + +def test_losing_the_halt_race_reports_closed_rather_than_raising(workspace, monkeypatch): + """The campaign closed between the CLI's check and the append. That is the + halt getting what it wanted a moment early, not a failure.""" + camp.append_campaign( + {"type": camp.T_CAMPAIGN, "id": "camp-1", "status": "open", "at": camp.now_iso()} + ) + camp.close_campaign("camp-1", status="closed") + + real = camp.campaign + calls = {"n": 0} + + def stale_first(campaign_id): + # The pre-check sees a campaign that is still open; the precondition, + # reading under the lock, sees the truth. + calls["n"] += 1 + record = dict(real(campaign_id)) + if calls["n"] == 1: + record["status"] = "open" + return record + + monkeypatch.setattr(evolve.camp, "campaign", stale_first) + payload = evolve.cmd_halt(argparse.Namespace(campaign="camp-1", reason="", json=True)) + assert payload["halted"] is False + assert payload["status"] == "closed" + assert camp.halt_requested("camp-1") is False + + +def test_a_boundary_record_does_not_count_as_a_generation_that_ran(workspace): + """Both the budget gate and a halt write a generation record at the boundary + they stop *before*. Counting it reported a campaign halted after generation + 0 as having run two -- which is the number the evolve window puts in its + title bar.""" + camp.append_campaign( + {"type": camp.T_CAMPAIGN, "id": "camp-1", "status": "open", "at": camp.now_iso()} + ) + camp.record_generation("camp-1", 0) + assert camp.campaign("camp-1")["generations_run"] == 1 + + camp.record_generation("camp-1", 1, halted=True, reason="halt requested") + assert camp.campaign("camp-1")["generations_run"] == 1 + # The boundary is still on the record; it is just not counted as work done. + assert camp.campaign("camp-1")["generation_log"][-1]["halted"] is True + + +def test_the_budget_boundary_record_is_not_counted_either(workspace, monkeypatch): + """The same off-by-one existed on the pre-existing exhausted path.""" + budget.create("proj-1", title="t", budget={"gpu_usd": 10.0}) + task_dir = scaffold(workspace) + + class ConcurrentSpender(FakeMutator): + def propose(self, *, generation, population, best): + if generation == 1: + ls.append_run_event( + {"type": ls.T_RUN_SUBMITTED, "id": ls.new_id("run"), "status": "in_flight", + "submitted_at": ls.now_iso(), "project": "proj-1", "estimate_usd": 9.0} + ) + return super().propose(generation=generation, population=population, best=best) + + monkeypatch.setattr(evolve, "_make_mutator", lambda *a, **k: ConcurrentSpender()) + result = evolve.cmd_run( + run_args(task_dir, make_expectation(), project="proj-1", + generations=5, population=2, estimate_per_candidate_usd=0.5) + ) + assert result["status"] == "exhausted" + folded = camp.campaign(result["campaign"]) + assert folded["generations_run"] == result["generations_run"] diff --git a/tests/test_ui_layout.py b/tests/test_ui_layout.py new file mode 100644 index 0000000..e2eebf9 --- /dev/null +++ b/tests/test_ui_layout.py @@ -0,0 +1,335 @@ +"""The tiling layout (design handoff, "Interactions & behaviour"). + +This is the half of the window system with actual rules, and none of them need a +browser: where a new window lands, what happens to the fractions when one +closes, which layouts survive a version that renamed a window. Every test here +runs with NiceGUI uninstalled. +""" + +from __future__ import annotations + +import math + +import pytest + +from ui import layout as L +from ui import registry + + +def fractions(layout: L.Layout) -> list[float]: + return [c.fraction for c in layout.columns] + + +def sums_to_one(values: list[float]) -> bool: + return math.isclose(sum(values), 1.0, abs_tol=1e-6) + + +# --------------------------------------------------------------------------- +# opening and closing +# --------------------------------------------------------------------------- +def test_the_first_window_takes_the_whole_shell(): + layout = L.Layout().open("chat") + assert layout.windows == ["chat"] + assert layout.focused == "chat" + assert sums_to_one(fractions(layout)) + + +def test_a_second_window_splits_sideways(): + layout = L.Layout().open("chat").open("ledger") + assert len(layout.columns) == 2 + assert layout.columns[0].windows == ["chat"] + assert layout.columns[1].windows == ["ledger"] + + +def test_clicking_four_windows_reproduces_the_mock(): + """chat | notebook | ledger-over-quota, by clicking, in order. + + This is the arrangement the design shows, and it has to be reachable without + dragging anything: the fourth click stacks because the columns are full. + """ + layout = L.Layout() + for window in ("chat", "notebook", "ledger", "quota"): + layout.open(window) + assert [c.windows for c in layout.columns] == [["chat"], ["notebook"], ["ledger", "quota"]] + assert sums_to_one([s.fraction for s in layout.columns[2].slots]) + + +def test_opening_stops_growing_sideways_at_the_cap(): + layout = L.Layout() + for window in registry.ids(): + layout.open(window) + assert len(layout.columns) <= L.MAX_COLUMNS + assert len(layout.windows) == len(registry.ids()) + + +def test_opening_an_open_window_focuses_it_rather_than_duplicating(): + layout = L.Layout().open("chat").open("ledger") + layout.open("chat") + assert layout.windows.count("chat") == 1 + assert layout.focused == "chat" + + +def test_closing_the_last_window_in_a_column_drops_the_column(): + layout = L.Layout().open("chat").open("ledger") + layout.close("ledger") + assert len(layout.columns) == 1 + assert sums_to_one(fractions(layout)) + + +def test_closing_the_focused_window_moves_focus_to_a_live_one(): + layout = L.Layout().open("chat").open("ledger").open("quota") + layout.focus("quota").close("quota") + assert layout.focused in layout.windows + assert layout.focused is not None + + +def test_closing_the_only_window_leaves_no_focus(): + layout = L.Layout().open("chat") + layout.close("chat") + assert layout.windows == [] + assert layout.focused is None + + +def test_toggle_is_open_then_close(): + layout = L.Layout() + layout.toggle("funnel") + assert layout.is_open("funnel") + layout.toggle("funnel") + assert not layout.is_open("funnel") + + +# --------------------------------------------------------------------------- +# fractions +# --------------------------------------------------------------------------- +def test_opening_windows_produces_an_even_split(): + """A new column created at 1.0 sits beside neighbours that already + normalised down, so it ends up with half the shell. Opening four windows has + to give the even thirds the design shows, not 25/25/50.""" + layout = L.Layout() + for window in ("chat", "notebook", "ledger", "quota"): + layout.open(window) + for fraction in fractions(layout): + assert math.isclose(fraction, 1 / 3, abs_tol=1e-6) + assert all( + math.isclose(s.fraction, 0.5, abs_tol=1e-6) for s in layout.columns[2].slots + ) + + +def test_the_split_does_not_depend_on_the_order_windows_were_opened(): + a = L.Layout() + for window in ("chat", "ledger", "quota"): + a.open(window) + b = L.Layout() + for window in ("quota", "chat", "ledger"): + b.open(window) + assert sorted(fractions(a)) == pytest.approx(sorted(fractions(b))) + + +def test_fractions_always_sum_to_one_at_both_levels(): + layout = L.Layout() + for window in ("chat", "ledger", "quota", "funnel", "queue", "papers"): + layout.open(window) + assert sums_to_one(fractions(layout)) + for column in layout.columns: + assert sums_to_one([s.fraction for s in column.slots]) + + +def test_no_pane_is_ever_below_the_floor(): + """A 2% column is not a pane, it is a rounding error with a title bar.""" + layout = L.Layout().open("chat").open("ledger").open("wiki") + layout.resize_columns([0.98, 0.01, 0.01]) + assert all(c.fraction >= L.MIN_FRACTION - 1e-9 for c in layout.columns) + assert sums_to_one(fractions(layout)) + + +def test_the_floor_is_expressed_in_pixels_when_a_width_is_known(): + """320px of a 900px shell is 35%, which is a much stronger floor than 6%.""" + layout = L.Layout().open("chat").open("ledger") + layout.resize_columns([0.95, 0.05], total_px=900) + assert min(fractions(layout)) >= L.MIN_PANE_PX / 900 - 1e-6 + + +def test_a_resize_with_the_wrong_arity_is_ignored(): + """The browser and the server can disagree for one frame after a close.""" + layout = L.Layout().open("chat").open("ledger") + before = fractions(layout) + layout.resize_columns([0.5, 0.3, 0.2]) + assert fractions(layout) == before + + +def test_resize_slots_targets_one_column(): + layout = L.Layout() + for window in ("chat", "notebook", "ledger", "quota"): + layout.open(window) + layout.resize_slots(2, [0.8, 0.2]) + assert layout.columns[2].slots[0].fraction > layout.columns[2].slots[1].fraction + assert sums_to_one([s.fraction for s in layout.columns[2].slots]) + + +def test_normalise_leaves_a_valid_layout_alone(): + """Idempotence, which is what stops a saved layout drifting toward uniform + over a week of opening and closing windows.""" + layout = L.Layout().open("chat").open("ledger").open("quota") + layout.resize_columns([0.5, 0.3, 0.2]) + before = fractions(layout) + for _ in range(20): + layout.normalise() + for a, b in zip(before, fractions(layout)): + assert math.isclose(a, b, abs_tol=1e-9) + + +def test_a_resize_is_preserved_across_a_save_and_load_cycle(): + layout = L.Layout().open("chat").open("ledger").open("quota") + layout.resize_columns([0.5, 0.3, 0.2]) + before = fractions(layout) + for _ in range(10): + layout = L.Layout.from_dict(layout.to_dict(), known=registry.ids()) + for a, b in zip(before, fractions(layout)): + assert math.isclose(a, b, abs_tol=1e-4) + + +def test_resize_slots_on_a_column_that_does_not_exist_is_a_no_op(): + layout = L.Layout().open("chat") + layout.resize_slots(7, [1.0]) + assert layout.windows == ["chat"] + + +def test_negative_and_zero_fractions_do_not_produce_nan(): + layout = L.Layout().open("chat").open("ledger") + layout.resize_columns([0.0, -3.0]) + assert all(f == f for f in fractions(layout)) # not NaN + assert sums_to_one(fractions(layout)) + + +# --------------------------------------------------------------------------- +# retiling +# --------------------------------------------------------------------------- +def test_move_pulls_a_window_into_another_column(): + layout = L.Layout().open("chat").open("ledger").open("quota") + layout.move("quota", 0) + assert "quota" in layout.columns[0].windows + + +def test_move_past_the_last_column_creates_one(): + """A drag past the right edge means "make a new column".""" + layout = L.Layout().open("chat").open("ledger") + layout.move("ledger", 5) + assert len(layout.columns) == 2 + assert layout.columns[-1].windows == ["ledger"] + + +def test_move_of_an_unopened_window_is_a_no_op(): + layout = L.Layout().open("chat") + layout.move("evolve", 0) + assert layout.windows == ["chat"] + + +# --------------------------------------------------------------------------- +# presets +# --------------------------------------------------------------------------- +def test_tile_gives_each_window_its_own_column_up_to_the_cap(): + layout = L.Layout() + for window in ("chat", "ledger", "quota"): + layout.open(window) + layout.apply_preset("tile") + assert [c.windows for c in layout.columns] == [["chat"], ["ledger"], ["quota"]] + + +def test_stack_collapses_everything_into_one_column(): + layout = L.Layout().open("chat").open("ledger").open("quota") + layout.apply_preset("stack") + assert len(layout.columns) == 1 + assert set(layout.columns[0].windows) == {"chat", "ledger", "quota"} + + +def test_full_keeps_every_window_open(): + """`full` is a view, not a close: `⌥1` has to restore without reopening.""" + layout = L.Layout().open("chat").open("ledger").open("quota") + layout.focus("ledger").apply_preset("full") + assert set(layout.windows) == {"chat", "ledger", "quota"} + assert layout.columns[0].windows == ["ledger"] + assert layout.columns[0].fraction > layout.columns[1].fraction + + +def test_presets_round_trip(): + layout = L.Layout() + for window in ("chat", "ledger", "quota"): + layout.open(window) + before = set(layout.windows) + for preset in L.PRESETS: + layout.apply_preset(preset) + assert set(layout.windows) == before + + +def test_an_unknown_preset_is_an_error_not_a_silent_no_op(): + layout = L.Layout().open("chat") + with pytest.raises(ValueError): + layout.apply_preset("cascade") + + +def test_a_preset_on_an_empty_layout_does_nothing(): + assert L.Layout().apply_preset("tile").windows == [] + + +# --------------------------------------------------------------------------- +# persistence +# --------------------------------------------------------------------------- +def test_a_layout_round_trips_through_json(): + layout = L.Layout().open("chat").open("ledger") + layout.resize_columns([0.6, 0.4]) + restored = L.Layout.from_dict(layout.to_dict(), known=registry.ids()) + assert [c.windows for c in restored.columns] == [c.windows for c in layout.columns] + assert restored.focused == layout.focused + for a, b in zip(restored.columns, layout.columns): + assert math.isclose(a.fraction, b.fraction, abs_tol=1e-5) + + +def test_a_layout_naming_a_window_this_version_does_not_have_still_opens(): + """A layout file outlives the window set that wrote it. The failure mode for + "we renamed the funnel" must be a missing pane, not an app that will not + start.""" + stored = { + "columns": [ + {"fraction": 0.5, "slots": [{"window": "chat", "fraction": 1.0}]}, + {"fraction": 0.5, "slots": [{"window": "holodeck", "fraction": 1.0}]}, + ], + "focused": "holodeck", + } + restored = L.Layout.from_dict(stored, known=registry.ids()) + assert restored.windows == ["chat"] + assert restored.focused == "chat" + + +def test_a_duplicated_window_in_a_stored_layout_is_deduplicated(): + stored = { + "columns": [ + {"slots": [{"window": "chat"}]}, + {"slots": [{"window": "chat"}]}, + ] + } + assert L.Layout.from_dict(stored, known=registry.ids()).windows == ["chat"] + + +@pytest.mark.parametrize( + "garbage", + [None, [], "chat", 7, {"columns": "chat"}, {"columns": [{"slots": "chat"}]}, {"columns": [7]}], +) +def test_hand_edited_garbage_yields_an_empty_layout_not_a_traceback(garbage): + assert L.Layout.from_dict(garbage, known=registry.ids()).windows == [] + + +def test_fractions_that_are_not_numbers_fall_back_to_even(): + stored = { + "columns": [ + {"fraction": "wide", "slots": [{"window": "chat", "fraction": None}]}, + {"fraction": float("inf"), "slots": [{"window": "ledger"}]}, + ] + } + restored = L.Layout.from_dict(stored, known=registry.ids()) + assert sums_to_one(fractions(restored)) + + +def test_the_default_layout_is_the_registrys_defaults(): + layout = L.Layout.default(registry.defaults()) + assert set(layout.windows) == set(registry.defaults()) + assert sums_to_one(fractions(layout)) diff --git a/tests/test_ui_models.py b/tests/test_ui_models.py new file mode 100644 index 0000000..11e92f6 --- /dev/null +++ b/tests/test_ui_models.py @@ -0,0 +1,770 @@ +"""What each window shows, as plain data. + +Everything interesting about a window is a decision -- which expectation counts +as broken, what the band strip does when the band is a single point, whether an +uncollected run is "running" or "waiting" -- and none of those decisions need a +browser. These tests run with the `ui` extra uninstalled, which is also what +keeps `ui/models.py` honest about never importing NiceGUI. +""" + +from __future__ import annotations + +import datetime as dt +import json + +import pytest + +from core import ledger_store as ls, paths +from ui import models + + +# --------------------------------------------------------------------------- +# the rule that makes the rest of this file possible +# --------------------------------------------------------------------------- +def test_the_model_layer_does_not_drag_in_nicegui(): + """`ui/models.py` and `ui/layout.py` are the tested half of the UI. If either + grows a NiceGUI import the tests stop being runnable without the extra, and + the layering claim in `ui/__init__.py` stops being true.""" + for module in ("ui.models", "ui.layout", "ui.registry", "ui.tokens", "ui.fonts"): + source = __import__(module, fromlist=["__file__"]).__file__ + assert source + with open(source, encoding="utf-8") as fh: + text = fh.read() + assert "nicegui" not in text, f"{module} must not reference nicegui" + + +# --------------------------------------------------------------------------- +# the band strip +# --------------------------------------------------------------------------- +def test_a_closed_band_puts_the_observed_value_where_it_belongs(): + geometry = models.band_geometry(low=2.9, high=3.2, actual=3.05) + assert geometry is not None + assert geometry["in_band"] is True + assert 0.0 < geometry["band_start"] < geometry["actual"] < geometry["band_end"] < 1.0 + + +def test_an_observed_value_outside_the_band_is_not_in_band(): + geometry = models.band_geometry(low=2.9, high=3.2, actual=4.1) + assert geometry["in_band"] is False + assert geometry["actual"] > geometry["band_end"] + + +def test_a_half_open_band_reaches_the_axis_edge(): + """`compute_deviations` treats a missing bound as infinity, so "below 3.2" + has to draw as a block reaching the wall -- not as nothing at all.""" + geometry = models.band_geometry(low=None, high=3.2, actual=3.0) + assert geometry["band_start"] == 0.0 + assert geometry["open_low"] is True + assert geometry["in_band"] is True + + other = models.band_geometry(low=2.9, high=None, actual=9.0) + assert other["band_end"] == 1.0 + assert other["in_band"] is True + + +def test_a_point_prediction_still_renders(): + geometry = models.band_geometry(low=3.0, high=3.0, actual=3.0) + assert geometry is not None + assert 0.0 < geometry["actual"] < 1.0 + assert geometry["axis_min"] < 3.0 < geometry["axis_max"] + + +def test_an_inverted_band_is_read_the_right_way_round(): + assert models.band_geometry(low=3.2, high=2.9, actual=3.05)["in_band"] is True + + +def test_a_relational_prediction_has_no_band_to_draw(): + """§7 prefers relational expectations. A degenerate strip for them would be + worse than none: it would assert a comparison the ledger never made.""" + assert models.band_geometry(low=None, high=None, actual=3.0) is None + + +def test_a_non_numeric_result_has_no_band_either(): + assert models.band_geometry(low=1, high=2, actual="diverged") is None + assert models.band_geometry(low=1, high=2, actual=None) is None + + +def test_every_position_stays_inside_the_strip(): + geometry = models.band_geometry(low=0.0, high=1.0, actual=1e6) + for key in ("band_start", "band_end", "actual"): + assert 0.0 <= geometry[key] <= 1.0 + + +# --------------------------------------------------------------------------- +# ledger +# --------------------------------------------------------------------------- +def _expect(workspace, **overrides): + record = { + "id": ls.new_id("exp"), + "task": "t", + "created_at": ls.now_iso(), + "quantity": "val_loss", + "claim": "val loss lands between 2.9 and 3.2", + "predicted": {"low": 2.9, "high": 3.2, "direction": None}, + "basis": [{"paper": "arXiv:2001.08361", "locator": "Table 3", "value": 3.05, "conditions": "1.3B"}], + "comparability": "our tokenizer differs", + "confidence": "medium", + } + record.update(overrides) + ls.append_expectation(record) + return record + + +def _run(run_id: str, expectation_id: str | None, actual, in_range, **overrides): + ls.append_run_event( + { + "type": ls.T_RUN_SUBMITTED, + "id": run_id, + "task": "t", + "status": "in_flight", + "submitted_at": ls.now_iso(), + "estimate_usd": 4.0, + "estimated_duration_s": 60, + **overrides, + } + ) + if actual is not None or in_range is not None: + ls.append_run_event( + { + "type": ls.T_RUN_COLLECTED, + "id": run_id, + "status": "completed", + "collected_at": ls.now_iso(), + "cost_usd_actual": 3.5, + "results": {"val_loss": actual}, + "deviations": [ + { + "expectation_id": expectation_id, + "quantity": "val_loss", + "expected": {"low": 2.9, "high": 3.2, "direction": None}, + "actual": actual, + "in_range": in_range, + } + ], + } + ) + + +def test_an_expectation_with_no_run_is_open(workspace): + _expect(workspace) + model = models.ledger_model() + assert model["counts"]["open"] == 1 + assert model["entries"][0]["band"] is None + + +def test_an_expectation_whose_run_landed_in_band_is_met(workspace): + record = _expect(workspace) + _run("run-1", record["id"], 3.05, True) + model = models.ledger_model() + assert model["counts"]["met"] == 1 + assert model["entries"][0]["accent"] == "ok" + assert model["entries"][0]["unjudged"] is False + + +def test_an_expectation_whose_run_missed_is_broken(workspace): + record = _expect(workspace) + _run("run-1", record["id"], 4.4, False) + entry = models.ledger_model()["entries"][0] + assert entry["state"] == "broken" + assert entry["band"]["in_band"] is False + + +def test_an_unsettleable_deviation_is_flagged_unjudged(workspace): + """`in_range` is None for the cases no program can settle. Those need a + human, and §7's argument is that they otherwise accumulate quietly.""" + record = _expect(workspace) + _run("run-1", record["id"], 3.05, None) + entry = models.ledger_model()["entries"][0] + assert entry["unjudged"] is True + assert entry["state"] == "open" + + +def test_an_explicit_falsification_outranks_the_arithmetic(workspace): + """A human's judgement beats the comparison, so it is checked first.""" + record = _expect(workspace) + _run("run-1", record["id"], 3.05, True) + ls.append_expectation_event( + {"type": ls.T_EXPECTATION_FALSIFIED, "id": record["id"], "at": ls.now_iso(), "reason": "bad eval"} + ) + assert models.ledger_model()["entries"][0]["state"] == "broken" + + +def test_an_empty_ledger_carries_the_command_that_ends_it(workspace): + model = models.ledger_model() + assert model["entries"] == [] + assert "tools.ledger expect" in model["empty_fix"] + + +# --------------------------------------------------------------------------- +# queue +# --------------------------------------------------------------------------- +def test_an_uncollected_run_counts_at_its_estimate(workspace): + """A job that has not been collected yet is not free.""" + _run("run-1", None, None, None) + row = models.queue_model()["rows"][0] + assert row["cost"] == "$4.00" + + +def test_the_status_the_ledger_actually_writes_reads_as_running(workspace): + """`core/submit.py` writes exactly `in_flight`. An unrecognised status falls + through to "waiting gate", so getting this wrong renders a fleet of running + jobs as a queue waiting on a human who has nothing to approve.""" + _run("run-1", None, None, None) # submitted with status "in_flight" + row = models.queue_model()["rows"][0] + assert row["state"] == "RUNNING" + assert row["tone"] == "running" + assert models.queue_model()["running"] == 1 + + +def test_a_submit_failure_reads_as_failed(workspace): + ls.append_run_event( + {"type": ls.T_RUN_SUBMITTED, "id": "run-1", "task": "t", "status": "submit_failed", + "submitted_at": ls.now_iso(), "estimate_usd": 0.0, "estimated_duration_s": 1} + ) + assert models.queue_model()["rows"][0]["tone"] == "failed" + + +def test_the_status_bar_and_the_queue_agree_about_what_is_outstanding(workspace): + """Two counters that disagree about the same runs are worse than either.""" + _run("run-1", None, None, None) + _run("run-2", None, None, None) + rows = models.queue_model()["rows"] + outstanding = len([r for r in rows if r["state"] != "DONE"]) + assert models.status_model()["queued"] == outstanding == 2 + + +def test_a_collected_run_reads_done_and_costs_its_actual(workspace): + record = _expect(workspace) + _run("run-1", record["id"], 3.05, True) + row = models.queue_model()["rows"][0] + assert row["state"] == "DONE" + assert row["cost"] == "$3.50" + assert row["tone"] == "done" + + +def test_a_failed_run_names_the_error_in_its_chip(workspace): + ls.append_run_event( + {"type": ls.T_RUN_SUBMITTED, "id": "run-1", "task": "t", "status": "in_flight", + "submitted_at": ls.now_iso(), "estimate_usd": 1.0, "estimated_duration_s": 10} + ) + ls.append_run_event( + {"type": ls.T_RUN_COLLECTED, "id": "run-1", "status": "failed", "collected_at": ls.now_iso(), + "results": {}, "deviations": [], "error": {"type": "KeyError"}} + ) + row = models.queue_model()["rows"][0] + assert row["state"] == "FAILED · KeyError" + assert row["accent"] == "broken" + + +def test_an_open_campaign_appears_in_the_queue(workspace): + """Candidates spend the same GPU dollars against the same ceiling; a queue + that showed only runs.jsonl would render a campaign as idle.""" + from core import campaign as campaign_mod + + campaign_mod.append_campaign( + {"type": campaign_mod.T_CAMPAIGN, "id": "camp-1", "status": "open", + "task_dir": "tasks/x", "at": ls.now_iso(), "generations_run": 3} + ) + rows = models.queue_model()["rows"] + assert any(r["kind"] == "campaign" and r["job"] == "camp-1" for r in rows) + + +# --------------------------------------------------------------------------- +# preflight +# --------------------------------------------------------------------------- +def _preflight(workspace, checks, **extra): + from core import jsonl + + record = {"submission_hash": "abc123", "spec": "specs/x.json", + "verified_at": ls.now_iso(), "checks": checks, **extra} + paths.preflight_dir().mkdir(parents=True, exist_ok=True) + jsonl.write_json(paths.preflight_record("abc123"), record) + return record + + +def test_proceed_is_disabled_while_anything_is_failing(workspace): + _preflight(workspace, { + "tests": {"ok": True, "duration_s": 3.0}, + "dry_run": {"ok": False, "duration_s": 1.0, "reason": "shape mismatch", + "fix": "python -m tools.preflight run --spec specs/x.json --json"}, + }) + model = models.preflight_model() + assert model["blocking"] == 1 + assert model["can_proceed"] is False + assert "preflight run" in model["remedy"] + + +def test_a_clean_checklist_can_proceed(workspace): + _preflight(workspace, {"tests": {"ok": True, "duration_s": 3.0}}) + model = models.preflight_model() + assert model["can_proceed"] is True + assert model["blocking"] == 0 + + +def test_a_check_that_never_ran_is_neither_passing_nor_blocking(workspace): + _preflight(workspace, {"smoke": {"ok": None}}) + row = models.preflight_model()["current"]["rows"][0] + assert row["state"] == "attention" + assert models.preflight_model()["blocking"] == 0 + + +def test_an_unreadable_record_is_reported_not_swallowed(workspace): + """`jsonl.read_json` returns None for missing and malformed, but lets + `UnicodeDecodeError` through -- it is a sibling of `JSONDecodeError` under + `ValueError`, not a subclass. Left to escape, `Workspace.rebuild` catches it + upstream and the window renders "No preflight records yet.", which is the + one wrong answer: it says nothing is there exactly when something is there + and cannot be read.""" + paths.preflight_dir().mkdir(parents=True, exist_ok=True) + (paths.preflight_dir() / "bad.json").write_bytes(b"\xff\xfe{}") + model = models.preflight_model() + assert model["current"] is None + assert "bad.json" in model["error"] + assert "UnicodeDecodeError" in model["error"] + + +def test_one_unreadable_record_does_not_hide_the_readable_ones(workspace): + _preflight(workspace, {"tests": {"ok": True, "duration_s": 1.0}}) + (paths.preflight_dir() / "bad.json").write_bytes(b"\xff\xfe{}") + model = models.preflight_model() + assert model["can_proceed"] is True + assert model["error"] + + +def test_a_vanished_record_does_not_break_the_listing(workspace, monkeypatch): + """`preflight run` writes atomically, so a path returned by `glob` can be + gone by the time it is stat'd.""" + _preflight(workspace, {"tests": {"ok": True}}) + monkeypatch.setattr( + models.Path, "stat", lambda self, **k: (_ for _ in ()).throw(FileNotFoundError(self)) + ) + assert isinstance(models.preflight_model(), dict) + + +def test_an_unreadable_wiki_manifest_is_not_reported_as_never_generated(workspace): + from tools import wiki as wiki_tool + + wiki_tool.output_dir().mkdir(parents=True, exist_ok=True) + (wiki_tool.output_dir() / "manifest.json").write_bytes(b"\xff\xfe{}") + model = models.wiki_model() + assert model["built"] is False + assert "UnicodeDecodeError" in model["error"] + + +def test_hash_warnings_survive_into_the_window(workspace): + """The known gaps in the submission hash: dynamic imports and runtime-loaded + files. Shown, not swallowed.""" + _preflight(workspace, {"tests": {"ok": True}}, warnings=["dynamic import in train.py"]) + assert models.preflight_model()["current"]["warnings"] == ["dynamic import in train.py"] + + +# --------------------------------------------------------------------------- +# funnel +# --------------------------------------------------------------------------- +def _trace(workspace, name="q", **extra): + directory = paths.notes_dir() / "funnel" + directory.mkdir(parents=True, exist_ok=True) + payload = { + "question": "does depth help at fixed compute?", + "stages": { + "0_expand": {"queries": ["scaling laws depth"], "hyde_words": 84}, + "1_retrieve": {"candidates": 400, "corpus_chunks": 12000}, + "2_rerank": {"out": 50}, + "3_triage": {"returned": 15}, + }, + "survivors": [{"id": "a", "title": "A", "rerank_score": 0.9, "reason": "states the ratio"}], + "dropped": [{"id": "b", "title": "B", "reason": "different tokenizer"}], + **extra, + } + (directory / f"{name}.json").write_text(json.dumps(payload), encoding="utf-8") + + +def test_the_funnel_bars_narrow_stage_by_stage(workspace): + _trace(workspace) + bars = models.funnel_model()["trace"]["bars"] + assert [b["label"] for b in bars][0].startswith("CORPUS · 12000") + assert [b["width"] for b in bars] == sorted((b["width"] for b in bars), reverse=True) + assert bars[-1]["tone"] == "context" + + +def test_dropped_chunks_survive_with_their_reason(workspace): + """A funnel that shows only survivors cannot answer the question you have + when retrieval goes wrong.""" + _trace(workspace) + dropped = models.funnel_model()["trace"]["dropped"] + assert dropped[0]["reason"] == "different tokenizer" + + +def test_an_unknown_trace_name_falls_back_to_the_newest(workspace): + _trace(workspace, name="a") + _trace(workspace, name="b") + assert models.funnel_model("nope")["trace"]["name"] == "b" + + +def test_a_corrupt_trace_reports_rather_than_raises(workspace): + directory = paths.notes_dir() / "funnel" + directory.mkdir(parents=True, exist_ok=True) + (directory / "bad.json").write_text("{not json", encoding="utf-8") + model = models.funnel_model("bad") + assert model["error"] + assert model["trace"]["survivors"] == [] + + +# --------------------------------------------------------------------------- +# papers +# --------------------------------------------------------------------------- +def test_a_papers_basis_matches_its_directory_despite_the_naming(workspace): + """A basis cites "arXiv:2001.08361", the directory is `2001.08361`, the + corpus stores `arxiv_2001.08361`. Comparing them raw yields a claim count of + zero, which looks like working software.""" + (paths.papers_dir() / "2001.08361").mkdir(parents=True, exist_ok=True) + _expect(workspace) + row = models.papers_model(filter_name="all")["all"][0] + assert len(row["claims"]) == 1 + assert any("CLAIMS DEPEND" in c["text"] for c in row["chips"]) + + +def test_an_unread_paper_says_so_rather_than_being_hidden(workspace): + (paths.papers_dir() / "2401.00001").mkdir(parents=True, exist_ok=True) + model = models.papers_model(filter_name="queued") + assert model["rows"][0]["read"] is False + assert any("NOT READ" in c["text"] for c in model["rows"][0]["chips"]) + + +@pytest.mark.parametrize( + "raw,expected", + [("arXiv:2001.08361", "2001.08361"), ("arxiv_2001.08361", "2001.08361"), + ("2001.08361", "2001.08361"), ("", "")], +) +def test_paper_keys_normalise(raw, expected): + assert models._paper_key(raw) == expected + + +def test_a_workspace_with_no_corpus_is_not_an_error(workspace): + """A fresh install has no local index. Reporting that in the error strip + would put a red card on the papers window of every new workspace, which + teaches people to ignore the strip.""" + (paths.papers_dir() / "2001.08361").mkdir(parents=True, exist_ok=True) + assert models.papers_model()["error"] is None + + +# --------------------------------------------------------------------------- +# evolve +# --------------------------------------------------------------------------- +def _campaign(workspace, scores, status="open"): + from core import campaign as campaign_mod + + campaign_mod.append_campaign( + {"type": campaign_mod.T_CAMPAIGN, "id": "camp-1", "status": status, + "task_dir": "tasks/x", "at": ls.now_iso(), "objective": "maximise acc"} + ) + for index, score in enumerate(scores): + campaign_mod.append_candidate( + {"type": campaign_mod.T_CANDIDATE, "id": f"cand-{index}", "campaign": "camp-1", + "generation": index, "metrics": {"combined_score": score}, + "cost_usd": 0.02, "at": ls.now_iso()} + ) + + +def test_the_score_is_read_where_the_campaign_ledger_writes_it(workspace): + """`metrics.combined_score`, which is Shinka's contract and what `top_k` + sorts on. Reading a top-level key does not raise -- it silently produces an + empty lineage and no champion, which looks exactly like a campaign that has + not evaluated anything yet.""" + _campaign(workspace, [0.41, 0.52, 0.71, 0.58]) + campaign = models.evolve_model()["campaign"] + assert len(campaign["bars"]) == 4 + assert campaign["champion"]["id"] == "cand-2" + assert campaign["champion_score"] == 0.71 + assert campaign["delta"] == pytest.approx(0.30) + + +def test_the_lineage_marks_new_bests_and_one_champion(workspace): + _campaign(workspace, [0.41, 0.52, 0.71, 0.58]) + bars = models.evolve_model()["campaign"]["bars"] + assert [b["tone"] for b in bars] == ["best", "best", "champion", "ordinary"] + assert all(0 < b["height"] <= 1 for b in bars) + + +def test_the_worst_candidate_still_gets_a_visible_bar(workspace): + """A zero-height rectangle reads as a missing generation, not a bad one.""" + _campaign(workspace, [0.1, 0.9]) + assert min(b["height"] for b in models.evolve_model()["campaign"]["bars"]) >= 0.08 + + +def test_top_is_flattened_so_the_window_never_touches_metrics(workspace): + _campaign(workspace, [0.41, 0.71]) + top = models.evolve_model()["campaign"]["top"] + assert top[0]["score"] == 0.71 + assert set(top[0]) == {"id", "generation", "score"} + + +def test_a_requested_halt_is_visible_before_the_loop_reaches_a_boundary(workspace): + """The request is in the ledger but the generation is still running. The + window has to show that, or the button looks unpressed and gets pressed + again.""" + from core import campaign as campaign_mod + + _campaign(workspace, [0.4]) + assert models.evolve_model()["campaign"]["halt_requested"] is False + campaign_mod.request_halt("camp-1", reason="too slow") + campaign = models.evolve_model()["campaign"] + assert campaign["halt_requested"] is True + assert campaign["running"] is True + + +def test_an_unevaluated_campaign_has_no_champion_rather_than_a_fake_one(workspace): + from core import campaign as campaign_mod + + campaign_mod.append_campaign( + {"type": campaign_mod.T_CAMPAIGN, "id": "camp-1", "status": "open", + "task_dir": "tasks/x", "at": ls.now_iso()} + ) + campaign = models.evolve_model()["campaign"] + assert campaign["bars"] == [] + assert campaign["champion"] is None + + +# --------------------------------------------------------------------------- +# quota +# --------------------------------------------------------------------------- +def _quota(workspace, stage, credits, **extra): + from core import jsonl, quota_log + + jsonl.append( + paths.quota_path(), + {"at": ls.now_iso(), "stage": stage, "role": extra.pop("role", "sonnet"), + "input_tokens": 1000, "output_tokens": 500, "credits_usd": credits, **extra}, + ) + + +def test_the_session_meter_splits_chat_from_tools(workspace): + """"you are near the cap" and "your retrieval is what put you there" are + different pieces of information.""" + from core import quota_log + + _quota(workspace, quota_log.STAGE_MAIN, 2.0) + _quota(workspace, quota_log.STAGE_RERANK, 1.0) + session = models.quota_model()["session"] + assert session["chat_usd"] == 2.0 + assert session["tool_usd"] == 1.0 + assert abs(session["chat_fraction"] - 2 / 3) < 1e-9 + + +def test_spend_outside_the_five_hour_window_does_not_count(workspace): + from core import jsonl, quota_log + + old = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=9)).isoformat() + jsonl.append(paths.quota_path(), {"at": old, "stage": quota_log.STAGE_MAIN, + "input_tokens": 1, "output_tokens": 1, "credits_usd": 99.0}) + assert models.quota_model()["session"]["credits_usd"] == 0.0 + + +def test_the_honesty_note_is_in_the_window_not_in_a_docstring(workspace): + honesty = models.quota_model()["honesty"] + assert "not the provider's" in honesty + assert "fuel gauge" in honesty + + +def test_gpu_spend_separates_collected_from_in_flight(workspace): + _run("run-1", None, None, None) + gpu = models.quota_model()["gpu"] + assert gpu["in_flight_usd"] == 4.0 + assert gpu["actual_usd"] == 0.0 + + +# --------------------------------------------------------------------------- +# notebook verify state +# --------------------------------------------------------------------------- +def _notebook(workspace, name="x.ipynb"): + paths.notebooks_dir().mkdir(parents=True, exist_ok=True) + path = paths.notebooks_dir() / name + path.write_text('{"cells": [], "nbformat": 4, "nbformat_minor": 5, "metadata": {}}', encoding="utf-8") + return path + + +def test_a_never_verified_notebook_is_not_citable(workspace): + _notebook(workspace) + state = models.verify_state("x.ipynb") + assert state["citable"] is False + assert state["chip"] == "NOT CITABLE" + + +def test_a_clean_verification_is_citable(workspace): + _notebook(workspace) + models.write_verify_record("x.ipynb", {"ok": True, "at": ls.now_iso(), + "cells_executed": 12, "duration_s": 41.8}) + state = models.verify_state("x.ipynb") + assert state["citable"] is True + assert state["chip"] == "CITABLE" + assert "12 cells" in state["sentence"] + + +def test_an_edit_after_the_verification_makes_it_stale(workspace): + """Including an edit made inside Lab that the host never saw -- which is the + whole point: Lab and tools/nb.py are two kernel owners over one notebook.""" + path = _notebook(workspace) + past = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=1)).isoformat() + models.write_verify_record("x.ipynb", {"ok": True, "at": past, "cells_executed": 3}) + path.write_text('{"cells": [1], "nbformat": 4, "nbformat_minor": 5, "metadata": {}}', encoding="utf-8") + state = models.verify_state("x.ipynb") + assert state["state"] == "stale" + assert state["citable"] is False + assert state["chip"] == "RE-VERIFY" + + +def test_a_failed_verification_carries_its_fix(workspace): + _notebook(workspace) + models.write_verify_record("x.ipynb", {"ok": False, "at": ls.now_iso(), "message": "NameError", + "cell_index": 4, "fix": "pip install torch"}) + state = models.verify_state("x.ipynb") + assert state["state"] == "failed" + assert state["cell_index"] == 4 + assert state["fix"] == "pip install torch" + + +# --------------------------------------------------------------------------- +# editor +# --------------------------------------------------------------------------- +def _report(workspace, tex: str, claims: dict | None = None): + from core import budget as budget_mod, report as report_mod + + budget_mod.create("proj", title="t", payer="me", budget={"gpu_usd": 10.0}) + budget_mod.set_current("proj") + targets = report_mod.paths_for("proj") + targets["dir"].mkdir(parents=True, exist_ok=True) + targets["tex"].write_text(tex, encoding="utf-8") + targets["claims"].write_text(json.dumps(claims or {}), encoding="utf-8") + return targets + + +def test_an_unbound_number_blocks_the_build(workspace): + """`\\gradnum{}` resolving through claims.json to a run *and its value* is + strictly stronger than the mock's `\\gradcite{}`: it catches a citation that + points at the right run and prints the wrong number.""" + _report(workspace, "\\section{Results}\nWe reach \\gradnum{loss}.\n") + model = models.editor_model("proj") + assert model["exists"] is True + assert model["blocking"] >= 1 + assert any(f["rule"] == "claims" for f in model["findings"]) + + +def test_the_outline_reads_the_sections(workspace): + _report(workspace, "\\section{Setup}\ntext\n\\section{Results}\nmore\n") + titles = [s["title"] for s in models.editor_model("proj")["outline"]] + assert titles == ["Setup", "Results"] + + +def test_no_draft_yields_the_command_that_writes_one(workspace): + from core import budget as budget_mod + + budget_mod.create("proj", title="t", payer="me", budget={"gpu_usd": 1.0}) + budget_mod.set_current("proj") + model = models.editor_model("proj") + assert model["exists"] is False + assert "tools.report draft" in model["empty_fix"] + + +def test_gradnum_macros_are_their_own_token_class(): + spans = models.highlight_tex("We reach \\gradnum{loss} on \\textbf{eval}. % note") + kinds = [s["kind"] for s in spans] + assert "gradnum" in kinds + assert "command" in kinds + assert "comment" in kinds + assert "".join(s["text"] for s in spans) == "We reach \\gradnum{loss} on \\textbf{eval}. % note" + + +# --------------------------------------------------------------------------- +# message anatomy +# --------------------------------------------------------------------------- +def test_a_fenced_shell_block_becomes_a_tool_card(): + blocks = models.parse_message("before\n```bash\npython -m tools.nb verify x\n```\nafter") + kinds = [b["kind"] for b in blocks] + assert kinds == ["text", "tool", "text"] + assert blocks[1]["title"] == "python -m tools.nb verify x" + + +def test_a_fenced_python_block_stays_code_not_a_tool_call(): + blocks = models.parse_message("```python\nimport torch\n```") + assert blocks[0]["kind"] == "code" + assert blocks[0]["language"] == "python" + + +def test_an_expectation_header_becomes_a_card_with_its_rows(): + text = "EXPECTATION REGISTERED exp-7\nclaim: loss lands in band\nband: 2.9 – 3.2\n\ntrailing prose" + blocks = models.parse_message(text) + card = next(b for b in blocks if b["kind"] == "expectation") + assert card["id"] == "exp-7" + assert ("claim", "loss lands in band") in card["rows"] + assert any(b["kind"] == "text" and "trailing prose" in b["text"] for b in blocks) + + +def test_a_gate_header_becomes_a_gate_card(): + blocks = models.parse_message("GATE — YOUR CALL\ncost: $18.40\nresource: 4x A100\n") + assert blocks[0]["kind"] == "gate" + assert ("cost", "$18.40") in blocks[0]["rows"] + + +def test_prose_with_no_structure_stays_one_block(): + blocks = models.parse_message("just a sentence about gates and expectations") + assert [b["kind"] for b in blocks] == ["text"] + + +def test_only_figures_that_exist_are_offered(workspace): + paths.figures_dir().mkdir(parents=True, exist_ok=True) + (paths.figures_dir() / "loss.png").write_bytes(b"") + found = models.figures_in("see figures/loss.png and figures/missing.png") + assert len(found) == 1 + assert found[0].endswith("loss.png") + + +# --------------------------------------------------------------------------- +# chrome +# --------------------------------------------------------------------------- +def test_the_header_never_invents_an_agent_state(workspace): + assert models.header_model(agent_state="dancing")["agent_state"] == "idle" + assert models.header_model(agent_state="running")["accent"] == "ok" + assert models.header_model(agent_state="awaiting_gate")["accent"] == "attention" + + +def test_every_agent_state_has_exactly_one_accent(): + """"one accent per state, never two in the same element" is only enforceable + if the mapping is total.""" + assert set(models.AGENT_ACCENT) == set(models.AGENT_STATES) + + +def test_the_status_bar_reads_the_workspace(workspace): + status = models.status_model() + assert str(paths.root()) == status["cwd"] + assert status["queued"] == 0 + + +# --------------------------------------------------------------------------- +# failure containment +# --------------------------------------------------------------------------- +def test_a_damaged_ledger_line_degrades_to_an_error_not_a_crash(workspace): + paths.expectations_path().parent.mkdir(parents=True, exist_ok=True) + paths.expectations_path().write_text("{not json at all\n", encoding="utf-8") + model = models.ledger_model() + assert isinstance(model, dict) + assert model["entries"] == [] + + +def test_every_model_survives_a_completely_empty_workspace(workspace): + """Eleven windows over eight ledgers is eight chances per refresh for one + bad file to take the workspace down. None of them may raise.""" + for builder in ( + models.ledger_model, + models.quota_model, + models.preflight_model, + models.funnel_model, + models.queue_model, + models.evolve_model, + models.papers_model, + models.wiki_model, + models.notebook_model, + models.editor_model, + models.status_model, + models.header_model, + ): + assert isinstance(builder(), dict) diff --git a/tests/test_ui_registry.py b/tests/test_ui_registry.py new file mode 100644 index 0000000..e06543e --- /dev/null +++ b/tests/test_ui_registry.py @@ -0,0 +1,106 @@ +"""The window registry, and the contract every window module signs. + +The registry is the one list the opener strip, the layout presets, the command +palette, the persisted layout's validation and the status bar's count are all +derived from. If it is ever more than one list, two of those will drift. + +These tests import the window modules, which imports `ui/kit.py` -- but not +NiceGUI, because `kit` imports it inside its functions. That is deliberate: it +means a window with a typo in its module scope fails here rather than at page +build time on someone's laptop. +""" + +from __future__ import annotations + +import importlib +import inspect + +import pytest + +from ui import layout as layout_mod, registry, state as state_mod + + +def test_ids_are_unique(): + assert len(registry.ids()) == len(set(registry.ids())) + + +def test_the_eleven_windows_the_handoff_lists_are_all_here(): + assert set(registry.ids()) == { + "chat", "notebook", "wiki", "papers", "evolve", "editor", + "ledger", "preflight", "quota", "funnel", "queue", + } + + +@pytest.mark.parametrize("window", registry.WINDOWS, ids=lambda w: w.id) +def test_every_window_module_imports_and_defines_render(window): + module = importlib.import_module(window.module) + assert callable(getattr(module, "render", None)), f"{window.module}.render" + signature = inspect.signature(module.render) + assert len(signature.parameters) == 1, "render takes the workspace and nothing else" + + +@pytest.mark.parametrize("window", registry.WINDOWS, ids=lambda w: w.id) +def test_optional_title_bar_hooks_have_the_right_shape(window): + module = importlib.import_module(window.module) + for name in ("subtitle", "chips"): + fn = getattr(module, name, None) + if fn is None: + continue + assert len(inspect.signature(fn).parameters) == 1 + + +@pytest.mark.parametrize("window", registry.WINDOWS, ids=lambda w: w.id) +def test_no_window_reads_a_ledger_directly(window): + """A window renders a model; it does not read `runs.jsonl`. Keeping that + true is what lets `tests/test_ui_models.py` be the whole specification for + what the windows say.""" + module = importlib.import_module(window.module) + source = inspect.getsource(module) + for forbidden in ("ledger_store", "quota_log", "jsonl.read", "core.corpus"): + assert forbidden not in source, f"{window.module} reaches past ui/models.py for {forbidden}" + + +def test_every_window_but_chat_has_a_model_builder(): + """`chat` is the exception on purpose: its state is the live SDK session, + not a file, so the poll must not redraw it and take the transcript's scroll + position with it.""" + assert set(state_mod.MODEL_BUILDERS) == set(registry.ids()) - {"chat"} + + +def test_the_defaults_reproduce_the_mocks_opening_arrangement(): + """chat | notebook | ledger-over-quota.""" + assert registry.defaults() == ("chat", "notebook", "ledger", "quota") + layout = layout_mod.Layout.default(registry.defaults()) + assert [c.windows for c in layout.columns] == [["chat"], ["notebook"], ["ledger", "quota"]] + + +def test_the_default_layout_fits_the_minimum_pane_width(): + columns = len(layout_mod.Layout.default(registry.defaults()).columns) + assert columns * layout_mod.MIN_PANE_PX <= 1600, "the default window would open below minimum" + + +def test_an_unknown_window_id_is_an_error_with_the_known_ones_in_it(): + with pytest.raises(KeyError) as excinfo: + registry.spec("holodeck") + assert "chat" in str(excinfo.value) + + +def test_a_subtitle_that_raises_falls_back_to_the_hint(monkeypatch): + """A title bar must never be the thing that takes a window down.""" + import ui.windows.ledger as ledger_window + + monkeypatch.setattr(ledger_window, "subtitle", lambda _: (_ for _ in ()).throw(RuntimeError("boom"))) + assert registry.subtitle("ledger", object()) == registry.spec("ledger").hint + + +def test_chips_that_raise_degrade_to_none(monkeypatch): + import ui.windows.queue as queue_window + + monkeypatch.setattr(queue_window, "chips", lambda _: (_ for _ in ()).throw(RuntimeError("boom"))) + assert registry.chips("queue", object()) == [] + + +def test_persistent_windows_are_the_ones_that_own_a_document(): + """A persistent window is one whose root must survive a retile. Chat owns a + transcript; notebook owns the Lab iframe's anchor.""" + assert {w.id for w in registry.WINDOWS if w.persistent} == {"chat", "notebook"} diff --git a/tests/test_ui_shell.py b/tests/test_ui_shell.py new file mode 100644 index 0000000..dd1fcd4 --- /dev/null +++ b/tests/test_ui_shell.py @@ -0,0 +1,471 @@ +"""The shell, rendered for real. + +Everything else in `tests/test_ui_*.py` runs without NiceGUI, which is the point +of the layering. This file is the other half: it builds an actual element tree +for all eleven windows inside a real `Client`, so a typo in a window's render +path fails here rather than on someone's laptop at page-build time. + +Two properties are worth holding still, and both are about `Element.move()`: + + * a window's root **survives** a retile -- otherwise every drag would wipe the + chat transcript and the notebook's iframe anchor; + * a closed window's root is **destroyed** -- otherwise the attic accumulates + one detached subtree per window per session. + +Skipped rather than failed when the `ui` extra is not installed: `core/` is +meant to run without it, and a test suite that cannot be run at all on a machine +without pywebview is a test suite that stops being run. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("nicegui", reason="the ui extra is not installed") + +from nicegui.client import Client # noqa: E402 +from nicegui.page import page # noqa: E402 + +from ui import layout as layout_mod, registry, shell, state as state_mod # noqa: E402 + + +class FakeSession: + """Enough of `ui.app.Session` to render. The SDK client is never started -- + `Session.start` only runs on the first `ask`, so a render touches nothing.""" + + busy = False + buffer = "" + + def __init__(self) -> None: + self.settled: list[dict[str, str]] = [] + + def interrupt(self) -> None: + pass + + +@pytest.fixture +def rendered(workspace): + """A built shell inside a real client, torn down afterwards.""" + clients: list[Client] = [] + + def build(windows=None, project="proj"): + client = Client(page("/")) + clients.append(client) + with client: + space = state_mod.Workspace(FakeSession(), project) + space.layout = layout_mod.Layout() + for window in windows if windows is not None else registry.ids(): + space.layout.open(window) + shell.build(space) + return client, space + + yield build + + for client in clients: + client.delete() + + +def html_of(client: Client) -> str: + return " ".join( + f"{element.tag} {' '.join(element.classes)} {getattr(element, 'content', '')}" + for element in client.elements.values() + ) + + +# --------------------------------------------------------------------------- +# every window renders +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("window", registry.ids()) +def test_every_window_renders_on_an_empty_workspace(rendered, window): + """The empty state is the state a new user sees, so it is the one most + worth proving renders at all.""" + client, _ = rendered([window]) + assert len(client.elements) > 10 + + +def test_all_eleven_render_together(rendered): + client, space = rendered() + assert len(space.layout.windows) == len(registry.ids()) + markup = html_of(client) + assert "grad-shell" in markup + assert "grad-tiles" in markup + assert "grad-statusbar" in markup + + +def test_a_window_whose_render_raises_does_not_take_the_shell_down(rendered, monkeypatch): + """Ten working windows and one broken one is a usable workspace; a traceback + at page build time is not.""" + import ui.windows.funnel as funnel_window + + monkeypatch.setattr( + funnel_window, "render", lambda _: (_ for _ in ()).throw(RuntimeError("boom")) + ) + client, _ = rendered(["funnel", "ledger"]) + assert "failed to render" in html_of(client) + assert "grad-statusbar" in html_of(client) + + +# --------------------------------------------------------------------------- +# the chrome reflects the layout +# --------------------------------------------------------------------------- +def test_the_opener_marks_open_windows(rendered): + client, space = rendered(["chat"]) + opener_cells = [ + e for e in client.elements.values() if "grad-opener-cell" in getattr(e, "classes", []) + ] + assert len(opener_cells) == len(registry.ids()) + assert len([c for c in opener_cells if "open" in c.classes]) == 1 + + +def test_a_handle_sits_between_every_pair_of_columns(rendered): + client, space = rendered(["chat", "ledger", "quota"]) + handles = [e for e in client.elements.values() if "grad-handle" in getattr(e, "classes", [])] + columns = [e for e in client.elements.values() if "grad-column" in getattr(e, "classes", [])] + assert len(columns) == 3 + assert len([h for h in handles if "row" not in h.classes]) == 2 + + +def test_a_stacked_column_gets_a_row_handle(rendered): + client, space = rendered(["chat", "notebook", "ledger", "quota"]) + handles = [e for e in client.elements.values() if "grad-handle" in getattr(e, "classes", [])] + assert len([h for h in handles if "row" in h.classes]) == 1 + + +def test_the_title_bar_tracks_the_model_not_just_the_layout(rendered): + """The subtitle and the state chips are read from the model: `EVOLVING` + becomes `HALTING`, a verify turns `NOT CITABLE` into `CITABLE`. Drawing them + only when the panes are rebuilt leaves them stale until the next retile, and + a chip that lags what it reports is worse than no chip.""" + from core import campaign as campaign_mod, ledger_store as ls + + campaign_mod.append_campaign( + {"type": campaign_mod.T_CAMPAIGN, "id": "camp-1", "status": "open", + "at": ls.now_iso(), "task_dir": "tasks/x"} + ) + campaign_mod.append_candidate( + {"type": campaign_mod.T_CANDIDATE, "id": "c0", "campaign": "camp-1", "generation": 0, + "metrics": {"combined_score": 0.4}, "at": ls.now_iso()} + ) + client, space = rendered(["evolve"]) + assert "EVOLVING" in html_of(client) + + campaign_mod.request_halt("camp-1", reason="from the workspace") + space.tick() # a poll, with no retile + markup = html_of(client) + assert "HALTING" in markup + assert "EVOLVING" not in markup + + +def test_a_verify_flips_the_notebook_chip_without_a_retile(rendered): + from core import ledger_store as ls + from ui import models + + seed_everything() + client, space = rendered(["notebook"]) + space.tick() + assert "NOT CITABLE" in html_of(client) + + models.write_verify_record( + "x.ipynb", {"ok": True, "at": ls.now_iso(), "cells_executed": 12, "duration_s": 4.0} + ) + space.tick() + markup = html_of(client) + # "CITABLE" is a substring of "NOT CITABLE", so the negative is the + # assertion that actually carries the test. + assert "NOT CITABLE" not in markup + assert "CITABLE" in markup + + +def click(element) -> None: + """Invoke an element's click handlers, the way the browser would.""" + for listener in element._event_listeners.values(): # noqa: SLF001 - no public hook + if listener.type == "click" and listener.handler is not None: + listener.handler() + + +def find_button(client: Client, label: str): + """`kit.button` renders through `ui.html(tag="button")`, so the real tag + lives in the props rather than on the element.""" + for element in client.elements.values(): + if element._props.get("tag") != "button": # noqa: SLF001 - no public accessor + continue + if label in str(getattr(element, "content", "")): + return element + raise AssertionError(f"no button matching {label!r}") + + +def test_answering_a_gate_with_no_session_does_not_claim_the_agent_is_running(rendered): + """The guard has to come before the state change: leaving `running` set + would paint the title bar with a live agent and a PAUSE button while + nothing is running and nothing will start.""" + client, space = rendered(["chat"]) + space.chat_send = None + with client: + from ui.windows.chat import _gate_card + + _gate_card({"kind": "gate", "id": "gate-1", "rows": [("cost", "$18.40")]}, space) + + click(find_button(client, "APPROVE")) + assert space.agent_state == "idle" + assert "no chat session" in (space.notice or "") + + +def test_answering_a_gate_sends_the_decision_into_the_session(rendered): + client, space = rendered(["chat"]) + sent: list[str] = [] + space.chat_send = sent.append + with client: + from ui.windows.chat import _gate_card + + _gate_card({"kind": "gate", "id": "gate-1", "rows": []}, space) + + click(find_button(client, "DENY")) + assert space.agent_state == "running" + assert sent and "denied" in sent[0] + + +def test_the_focused_window_is_marked(rendered): + client, space = rendered(["chat", "ledger"]) + space.focus("ledger") + focused = [ + e for e in client.elements.values() + if "grad-window" in getattr(e, "classes", []) and "focused" in e.classes + ] + assert len(focused) == 1 + + +# --------------------------------------------------------------------------- +# roots survive retiling +# --------------------------------------------------------------------------- +def _root_ids(client: Client) -> dict[str, int]: + """Window roots, by the id of the element carrying them.""" + out = {} + for element in client.elements.values(): + if "grad-titlebar" in getattr(element, "classes", []): + window = element.props.get("data-window") + if window: + out[window] = element.id + return out + + +def test_a_window_root_is_reparented_not_rebuilt_on_retile(rendered): + """`Element.move()` is what makes the window system practical: without it a + drag would wipe the chat transcript and reload the Lab iframe.""" + client, space = rendered(["chat", "ledger"]) + body_before = { + e.id for e in client.elements.values() if "grad-body" in getattr(e, "classes", []) + } + space.preset("stack") + body_after = { + e.id for e in client.elements.values() if "grad-body" in getattr(e, "classes", []) + } + assert body_before == body_after, "a retile rebuilt a window root" + + +def test_the_chat_transcript_survives_a_retile(rendered): + client, space = rendered(["chat", "ledger"]) + transcripts = [ + e for e in client.elements.values() if "grad-transcript" in getattr(e, "classes", []) + ] + assert len(transcripts) == 1 + identity = transcripts[0].id + space.preset("stack") + space.preset("tile") + space.retile("chat", 1) + still = [e for e in client.elements.values() if "grad-transcript" in getattr(e, "classes", [])] + assert [e.id for e in still] == [identity] + + +def test_closing_a_window_destroys_its_root(rendered): + """Otherwise the attic accumulates a detached subtree per window per + session, each one still bound to the poll.""" + client, space = rendered(["chat", "ledger"]) + before = len(client.elements) + space.close("ledger") + assert len(client.elements) < before + assert "ledger" not in _root_ids(client) + + +def test_reopening_a_closed_window_builds_a_fresh_root(rendered): + client, space = rendered(["chat", "ledger"]) + space.close("ledger") + space.open("ledger") + assert "ledger" in _root_ids(client) + + +# --------------------------------------------------------------------------- +# the whole lifecycle, in one pass +# --------------------------------------------------------------------------- +def test_the_full_gesture_sequence_leaves_a_consistent_tree(rendered): + client, space = rendered() + for action in ( + lambda: space.preset("stack"), + lambda: space.preset("full"), + lambda: space.preset("tile"), + lambda: space.close("chat"), + lambda: space.open("chat"), + lambda: space.tick(), + lambda: space.select("papers.filter", "queued"), + lambda: space.select("funnel.trace", "nope"), + lambda: space.retile("ledger", 0), + lambda: space.resize("columns", [0.5] * len(space.layout.columns), total_px=1600), + lambda: space.set_agent_state("running", step=14), + lambda: space.say("verifying …"), + ): + action() + + windows = [e for e in client.elements.values() if "grad-window" in getattr(e, "classes", [])] + assert len(windows) == len(space.layout.windows) + assert "AGENT RUNNING · step 14" in html_of(client) + assert "verifying" in html_of(client) + + +def test_a_tick_with_real_data_redraws_the_window(rendered): + from core import ledger_store as ls + + client, space = rendered(["ledger"]) + ls.append_expectation( + {"id": "exp-1", "task": "t", "created_at": ls.now_iso(), "quantity": "val_loss", + "claim": "the loss lands between 2.9 and 3.2", + "predicted": {"low": 2.9, "high": 3.2, "direction": None}, + "basis": [{"paper": "arXiv:1", "locator": "T3", "value": 3.0, "conditions": "1B"}], + "comparability": "same eval", "confidence": "medium"} + ) + space.tick() + assert "the loss lands between 2.9 and 3.2" in html_of(client) + + +# --------------------------------------------------------------------------- +# populated: the paths an empty workspace never reaches +# --------------------------------------------------------------------------- +def seed_everything() -> None: + """One of each thing the eleven windows read. + + The empty states are easy; the render paths that actually break are the ones + behind a non-empty list -- a band strip, a lineage bar, a traceback, a + reader rail. This seeds all of them. + """ + import json + + from core import ( + budget as budget_mod, campaign as campaign_mod, jsonl, + ledger_store as ls, paths, report as report_mod, + ) + from tools import wiki as wiki_tool + from ui import models + + ls.append_expectation( + {"id": "exp-1", "task": "t", "created_at": ls.now_iso(), "quantity": "val_loss", + "claim": "the loss lands in band", "predicted": {"low": 2.9, "high": 3.2, "direction": None}, + "basis": [{"paper": "arXiv:2001.08361", "locator": "T3", "value": 3.0, "conditions": "1B"}], + "comparability": "same eval", "confidence": "medium"} + ) + ls.append_run_event( + {"type": ls.T_RUN_SUBMITTED, "id": "run-1", "task": "t", "status": "in_flight", + "submitted_at": ls.now_iso(), "estimate_usd": 4.0, "estimated_duration_s": 60} + ) + ls.append_run_event( + {"type": ls.T_RUN_COLLECTED, "id": "run-1", "status": "completed", + "collected_at": ls.now_iso(), "cost_usd_actual": 3.5, "results": {"val_loss": 4.4}, + "deviations": [{"expectation_id": "exp-1", "quantity": "val_loss", + "expected": {"low": 2.9, "high": 3.2}, "actual": 4.4, "in_range": False}]} + ) + + budget_mod.create("proj", title="Scaling", payer="me", budget={"gpu_usd": 100.0}) + budget_mod.set_current("proj") + targets = report_mod.paths_for("proj") + targets["dir"].mkdir(parents=True, exist_ok=True) + targets["tex"].write_text( + "\\section{Setup}\nWe reach \\gradnum{loss} on eval. % note\n\\section{Results}\n", + encoding="utf-8", + ) + targets["claims"].write_text("{}", encoding="utf-8") + + for stage, role, credits in (("main", "opus", 2.0), ("funnel.rerank", "sonnet", 0.5)): + jsonl.append(paths.quota_path(), {"at": ls.now_iso(), "stage": stage, "role": role, + "input_tokens": 900, "output_tokens": 300, + "credits_usd": credits, "project": "proj"}) + + jsonl.write_json(paths.preflight_record("abc"), { + "submission_hash": "abc", "spec": "specs/x.json", "verified_at": ls.now_iso(), + "checks": {"tests": {"ok": True, "duration_s": 2.0}, + "dry_run": {"ok": False, "duration_s": 1.0, "reason": "shape mismatch", + "output": "boom", "fix": "python -m tools.preflight run --spec specs/x.json"}}, + "warnings": ["dynamic import in train.py"]}) + + funnel_dir = paths.notes_dir() / "funnel" + funnel_dir.mkdir(parents=True, exist_ok=True) + (funnel_dir / "q.json").write_text(json.dumps({ + "question": "does depth help at fixed compute?", + "stages": {"0_expand": {"queries": ["depth scaling"], "hyde_words": 80}, + "1_retrieve": {"candidates": 400, "corpus_chunks": 12000}, + "2_rerank": {"out": 50}, "3_triage": {"returned": 1}}, + "survivors": [{"id": "a", "title": "A", "rerank_score": 0.9, "reason": "states the ratio"}], + "dropped": [{"id": "b", "title": "B", "reason": "different tokenizer"}], + "warnings": ["rerank fell back to lexical"]}), encoding="utf-8") + + paper = paths.papers_dir() / "2001.08361" + paper.mkdir(parents=True, exist_ok=True) + (paper / "meta.json").write_text( + json.dumps({"title": "Scaling Laws", "authors": ["Kaplan"], "year": 2020}), encoding="utf-8" + ) + + campaign_mod.append_campaign({"type": campaign_mod.T_CAMPAIGN, "id": "camp-1", "status": "open", + "task_dir": "tasks/x", "at": ls.now_iso(), "generations_run": 3, + "project": "proj", "objective": "maximise accuracy", "islands": 4}) + for index, score in enumerate((0.1, 0.4, 0.3, 0.9)): + campaign_mod.append_candidate({"type": campaign_mod.T_CANDIDATE, "id": f"cand-{index}", + "campaign": "camp-1", "generation": index, + "combined_score": score, "cost_usd": 0.02, "at": ls.now_iso()}) + + paths.notebooks_dir().mkdir(parents=True, exist_ok=True) + (paths.notebooks_dir() / "x.ipynb").write_text( + '{"cells": [], "nbformat": 4, "nbformat_minor": 5, "metadata": {}}', encoding="utf-8" + ) + models.write_verify_record("x.ipynb", {"ok": False, "at": ls.now_iso(), + "message": "NameError: torch is not defined", + "cell_index": 4, "traceback": "Traceback ...", + "fix": "pip install torch"}) + + wiki_tool.output_dir().mkdir(parents=True, exist_ok=True) + jsonl.write_json(wiki_tool.output_dir() / "manifest.json", + {"generated_at": ls.now_iso(), "output_dir": str(wiki_tool.output_dir()), + "source": {"hash": "stale00", "files": {"core/x.py": "aaa"}}, + "scopes": {"core": 12, "tools": 9}}) + + +def test_every_window_renders_with_real_data(rendered): + seed_everything() + client, space = rendered() + space.select("papers.selected", "2001.08361", window="papers") + space.select("ledger.filter", "broken") + space.tick() + markup = html_of(client) + + for expected in ( + "the loss lands in band", # ledger, with a band strip + "Scaling Laws", # papers, list and reader rail + "does depth help at fixed compute?", # funnel + "different tokenizer", # funnel, dropped + "dynamic import in train.py", # preflight warnings + "shape mismatch", # preflight failing row + "NameError: torch is not defined", # notebook failure detail + "pip install torch", # notebook FIX box + "camp-1", # evolve + "gradnum", # editor source highlighting + "different source tree", # wiki staleness + "run-1", # queue + ): + assert expected in markup, expected + + +def test_a_populated_workspace_survives_the_full_gesture_sequence(rendered): + seed_everything() + client, space = rendered() + space.tick() + for preset in ("stack", "full", "tile"): + space.preset(preset) + space.retile("evolve", 0) + space.tick() + windows = [e for e in client.elements.values() if "grad-window" in getattr(e, "classes", [])] + assert len(windows) == len(space.layout.windows) diff --git a/tests/test_ui_state.py b/tests/test_ui_state.py new file mode 100644 index 0000000..2ce2c27 --- /dev/null +++ b/tests/test_ui_state.py @@ -0,0 +1,256 @@ +"""The workspace: one poll, one snapshot, layout that persists per project. + +The old app gave every panel its own refresh button and its own read of the +ledger. Eleven windows on that pattern is eleven pollers doing eleven full +subtree rebuilds. What replaced it has two properties worth holding still: a +tick only redraws the windows whose data actually changed, and one window's +failure cannot stop the other ten redrawing. +""" + +from __future__ import annotations + +import asyncio +import json + +import pytest + +from core import ledger_store as ls +from ui import layout as layout_mod, registry, state as state_mod + + +class FakeSession: + busy = False + buffer = "" + settled: list = [] + + def interrupt(self) -> None: + pass + + +def workspace_for(project: str | None = "proj") -> state_mod.Workspace: + return state_mod.Workspace(FakeSession(), project) + + +# --------------------------------------------------------------------------- +# layout persistence +# --------------------------------------------------------------------------- +def test_a_fresh_workspace_opens_the_default_arrangement(workspace): + space = workspace_for() + assert set(space.layout.windows) == set(registry.defaults()) + + +def test_the_layout_persists_per_project(workspace): + a = workspace_for("alpha") + a.open("funnel") + b = workspace_for("beta") + assert not b.layout.is_open("funnel") + assert workspace_for("alpha").layout.is_open("funnel") + + +def test_a_project_id_with_path_separators_cannot_escape_the_layout_directory(workspace): + """The project id reaches this from a config file, so it is untrusted input + to a filename.""" + path = state_mod.layout_path("../../etc/passwd") + assert path.parent == state_mod.layout_dir() + assert ".." not in path.name + + +def test_an_unreadable_layout_file_falls_back_to_the_default(workspace): + state_mod.layout_dir().mkdir(parents=True, exist_ok=True) + state_mod.layout_path("proj").write_text("{ not json", encoding="utf-8") + assert set(workspace_for("proj").layout.windows) == set(registry.defaults()) + + +def test_a_saved_layout_survives_a_reconnect(workspace): + space = workspace_for() + space.preset("stack") + space.close("quota") + reopened = workspace_for() + assert not reopened.layout.is_open("quota") + assert len(reopened.layout.columns) == 1 + + +def test_a_resize_is_saved_without_a_redraw(workspace): + """The browser already moved the panes; rebuilding the tree here would throw + away the gesture's own result mid-drag.""" + space = workspace_for() + redraws: list[str] = [] + space.bind_retile(lambda: redraws.append("retile")) + space.resize("columns", [0.5] * len(space.layout.columns), total_px=1600) + assert redraws == [] + assert state_mod.layout_path("proj").exists() + + +# --------------------------------------------------------------------------- +# the poll +# --------------------------------------------------------------------------- +def test_a_tick_redraws_only_what_changed(workspace): + space = workspace_for() + space.layout = layout_mod.Layout().open("ledger").open("queue") + drawn: list[str] = [] + for window in ("ledger", "queue"): + space.bind_window(window, lambda w=window: drawn.append(w)) + + space.tick() # first pass: both models are new + drawn.clear() + space.tick() # nothing on disk moved + assert drawn == [] + + ls.append_expectation( + {"id": ls.new_id("exp"), "task": "t", "created_at": ls.now_iso(), "quantity": "q", + "claim": "c", "predicted": {"low": None, "high": None, "direction": "decrease"}, + "basis": [], "comparability": "", "confidence": "low"} + ) + space.tick() + assert drawn == ["ledger"] + + +def test_a_closed_window_is_not_polled(workspace): + space = workspace_for() + space.layout = layout_mod.Layout().open("ledger") + space.tick() + assert "ledger" in space.models + assert "queue" not in space.models + + +def test_a_window_whose_redraw_raises_does_not_stop_the_others(workspace): + """A traceback in one window must not leave the other ten frozen.""" + space = workspace_for() + space.layout = layout_mod.Layout().open("ledger").open("queue") + drawn: list[str] = [] + space.bind_window("ledger", lambda: (_ for _ in ()).throw(RuntimeError("boom"))) + space.bind_window("queue", lambda: drawn.append("queue")) + space.tick() + assert drawn == ["queue"] + + +def test_a_model_builder_that_raises_becomes_an_error_in_the_model(workspace, monkeypatch): + space = workspace_for() + space.layout = layout_mod.Layout().open("ledger") + monkeypatch.setitem( + state_mod.MODEL_BUILDERS, "ledger", lambda w: (_ for _ in ()).throw(ValueError("nope")) + ) + space.rebuild("ledger") + assert "nope" in space.models["ledger"]["error"] + + +def test_selecting_a_filter_forces_that_window_to_recompute(workspace): + space = workspace_for() + space.layout = layout_mod.Layout().open("papers") + space.tick() + drawn: list[str] = [] + space.bind_window("papers", lambda: drawn.append("papers")) + space.select("papers.filter", "queued") + assert drawn == ["papers"] + assert space.models["papers"]["filter"] == "queued" + + +# --------------------------------------------------------------------------- +# chrome +# --------------------------------------------------------------------------- +def test_opening_a_window_redraws_the_chrome(workspace): + space = workspace_for() + drawn: list[str] = [] + space.bind_chrome(lambda: drawn.append("chrome")) + space.open("funnel") + assert drawn + + +def test_focusing_the_already_focused_window_does_not_rewrite_the_layout(workspace): + space = workspace_for() + space.layout.focus("chat") + calls: list[str] = [] + space.bind_retile(lambda: calls.append("retile")) + space.focus("chat") + assert calls == [] + + +def test_an_unknown_preset_is_ignored_rather_than_raising(workspace): + space = workspace_for() + before = [c.windows for c in space.layout.columns] + space.preset("cascade") + assert [c.windows for c in space.layout.columns] == before + + +# --------------------------------------------------------------------------- +# the CLI bridge +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_run_tool_parses_the_json_envelope(workspace): + payload = await state_mod.run_tool("json.tool", "--help") + assert isinstance(payload, dict) + assert "ok" in payload + + +@pytest.mark.asyncio +async def test_run_tool_reports_a_command_that_produced_nothing_usable(workspace): + payload = await state_mod.run_tool("this_module_does_not_exist_at_all") + assert payload["ok"] is False + assert payload["error"]["message"] + + +@pytest.mark.asyncio +async def test_spawn_holds_a_reference_until_the_task_settles(workspace): + """asyncio keeps only a *weak* reference to a running task, so a bare + `create_task` whose result nobody holds can vanish part-way through.""" + space = workspace_for() + started = asyncio.Event() + release = asyncio.Event() + + async def work() -> None: + started.set() + await release.wait() + + space.spawn(work(), "unit work") + await started.wait() + assert len(space._tasks) == 1 + + release.set() + await asyncio.sleep(0) + await asyncio.sleep(0) + assert space._tasks == set() + + +@pytest.mark.asyncio +async def test_a_failed_task_reaches_the_log_and_the_status_bar(workspace, caplog): + """`t.exception()` in a done-callback silences Python's warning by + *discarding* the error, which is worse than the warning it suppresses: a + gate approval that failed inside the SDK becomes invisible.""" + + async def boom() -> None: + raise RuntimeError("the SDK said no") + + space = workspace_for() + with caplog.at_level("ERROR", logger="grad.ui"): + space.spawn(boom(), "gate answer") + await asyncio.sleep(0) + await asyncio.sleep(0) + + assert "gate answer" in (space.notice or "") + assert "RuntimeError" in space.notice + # The message itself stays out of the status bar; an SDK message can carry + # a URL with a token in it. + assert "the SDK said no" not in space.notice + assert any("gate answer failed" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_a_cancelled_task_is_not_reported_as_a_failure(workspace): + async def forever() -> None: + await asyncio.Event().wait() + + space = workspace_for() + task = space.spawn(forever(), "unit work") + task.cancel() + await asyncio.sleep(0) + await asyncio.sleep(0) + assert space.notice is None + + +def test_envelope_message_prefers_the_fix(): + assert state_mod.envelope_message({"ok": True}) == "done" + message = state_mod.envelope_message( + {"ok": False, "error": {"message": "gate refused", "fix": "run preflight"}} + ) + assert "gate refused" in message + assert "run preflight" in message diff --git a/tests/test_ui_theme.py b/tests/test_ui_theme.py new file mode 100644 index 0000000..bbb5773 --- /dev/null +++ b/tests/test_ui_theme.py @@ -0,0 +1,109 @@ +"""The JupyterLab half of the design. + +The notebook window is a real Lab iframe on its own port, so its interior cannot +be styled from the host page. `ui/jupyter_theme.py` emits the same tokens as +JupyterLab's `custom.css`, and `tools/lab.py` starts Lab with the flag that +loads it. Three things have to hold or the seam becomes visible: + + 1. the file on disk still matches the tokens it was generated from, + 2. Lab is actually started with `--custom-css`, + 3. the selected theme is a light one, since the sheet re-tokens a light base. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +from ui import jupyter_theme, tokens + +REPO = Path(jupyter_theme.__file__).resolve().parents[1] + + +def config_dir() -> Path: + return REPO / "config" / "jupyter" + + +def test_the_generated_sheet_is_checked_in_and_current(): + """Generated rather than hand-written so the notebook interior cannot drift + from the chrome above it. Regenerate with: + + python -m ui.jupyter_theme --write + """ + path = jupyter_theme.repo_path() + assert path.exists(), "run `python -m ui.jupyter_theme --write`" + assert path.read_text(encoding="utf-8") == jupyter_theme.stylesheet() + + +def test_it_lands_where_jupyterlab_looks(): + """JupyterLab 4 loads `{JUPYTER_CONFIG_DIR}/custom/custom.css`, and + `tools/lab.py` sets `JUPYTER_CONFIG_DIR` to `config/jupyter`.""" + assert jupyter_theme.repo_path() == config_dir() / "custom" / "custom.css" + + +def test_lab_is_started_with_the_flag_that_loads_it(): + """Without `--custom-css` JupyterLab ignores the file entirely, and the + iframe renders as stock Lab inside Grad's own chrome.""" + source = (REPO / "tools" / "lab.py").read_text(encoding="utf-8") + assert '"--custom-css"' in source + assert 'JUPYTER_CONFIG_DIR' in source + + +def test_the_selected_theme_is_a_light_one(): + """`custom.css` cannot register a named theme, so the sheet re-tokens a base + -- and it re-tokens the light one. Leaving JupyterLab Dark selected would + put dark defaults under cream overrides.""" + overrides = json.loads((config_dir() / "overrides.json").read_text(encoding="utf-8")) + theme = overrides["@jupyterlab/apputils-extension:themes"]["theme"] + assert "Dark" not in theme + + +def test_the_ruler_the_handoff_asks_to_leave_alone_is_left_alone(): + overrides = json.loads((config_dir() / "overrides.json").read_text(encoding="utf-8")) + editor = overrides["@jupyterlab/fileeditor-extension:plugin"]["editorConfig"] + assert editor["rulers"] == [88] + + +@pytest.mark.parametrize( + "variable", + [ + "--jp-layout-color0", "--jp-layout-color1", "--jp-layout-color2", + "--jp-border-color0", "--jp-border-color1", "--jp-border-color2", + "--jp-cell-editor-background", "--jp-code-font-family", "--jp-content-font-family", + ], +) +def test_every_variable_the_handoff_names_is_set(variable): + assert f"{variable}:" in jupyter_theme.stylesheet() + + +@pytest.mark.parametrize( + "selector", + [".jp-InputArea-prompt", ".jp-OutputArea-output", ".jp-RenderedText"], +) +def test_every_class_the_handoff_names_is_styled(selector): + assert selector in jupyter_theme.stylesheet() + + +def test_the_sheet_uses_the_same_palette_as_the_host(): + sheet = jupyter_theme.stylesheet() + for name in ("ink", "paper", "attention", "verified", "broken"): + assert tokens.COLOUR[name] in sheet + + +def test_no_colour_in_the_sheet_is_outside_the_palette(): + """Same single-source rule as the host stylesheet: a hex here that is not a + token is a colour that will not follow the next palette change.""" + allowed = {v.lower() for v in tokens.COLOUR.values()} | {"#fff", "#ffffff"} + found = {m.group(0).lower() for m in re.finditer(r"#[0-9A-Fa-f]{3,8}\b", jupyter_theme.stylesheet())} + assert found <= allowed, found - allowed + + +def test_radius_and_blur_are_gone_inside_lab_too(): + sheet = jupyter_theme.stylesheet() + assert "--jp-border-radius: 0px;" in sheet + assert "border-radius: 0 !important" in sheet + for match in re.finditer(r"--jp-elevation-z\d+:\s*([^;]+)", sheet): + assert match.group(1).strip() == "none" diff --git a/tests/test_ui_tokens.py b/tests/test_ui_tokens.py new file mode 100644 index 0000000..2f1433a --- /dev/null +++ b/tests/test_ui_tokens.py @@ -0,0 +1,186 @@ +"""The design system, as assertions. + +The handoff states its rules in prose -- "radius: 0 everywhere", "no blur +shadows anywhere", "one accent per state, never two in the same element" -- and +a rule you can only check by eye is a rule that drifts on the third window +somebody adds. These tests make the checkable half checkable. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from ui import fonts, tokens + +UI_DIR = Path(tokens.__file__).resolve().parent +HEX = re.compile(r"#[0-9A-Fa-f]{3,8}\b") + + +def ui_sources() -> list[Path]: + return sorted(p for p in UI_DIR.rglob("*.py") if "__pycache__" not in p.parts) + + +# --------------------------------------------------------------------------- +# the table +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + "token", + [ + "ink", "paper", "paper-raised", "paper-sunk", "desk", "rule-soft", "rule-mid", + "attention", "verified", "verified-ink", "verified-tint", "broken", "broken-tint", + "broken-ink", "link", "muted", "muted-2", "literal", "hatch-a", "hatch-b", + ], +) +def test_every_token_in_the_handoff_table_exists(token): + assert token in tokens.COLOUR + assert f"--grad-{token}:" in tokens.css_variables() + + +@pytest.mark.parametrize( + "name,value", + [("ink", "#14100C"), ("paper", "#F7F3E8"), ("attention", "#FFD400"), + ("verified", "#12A594"), ("broken", "#A3122F"), ("link", "#B04A2C")], +) +def test_the_colours_are_the_ones_the_handoff_specifies(name, value): + """Fidelity is stated as high and the colours as final, so a typo in a hex + digit is a bug rather than a preference.""" + assert tokens.COLOUR[name] == value + + +def test_every_state_accent_is_a_real_token(): + assert set(tokens.STATE_ACCENT) == {"ok", "attention", "broken", "neutral"} + assert set(tokens.STATE_ACCENT.values()) <= set(tokens.COLOUR.values()) + + +# --------------------------------------------------------------------------- +# the structural rules +# --------------------------------------------------------------------------- +def test_radius_is_zero_everywhere(): + sheet = tokens.stylesheet() + for match in re.finditer(r"border-radius:\s*([^;!]+)", sheet): + assert match.group(1).strip() in ("0", "0px"), match.group(0) + + +def test_no_shadow_in_the_system_has_a_blur(): + """`8px 8px 0` and `6px 6px 0`. A third length that is not zero is a blur, + and there are none.""" + for shadow in (tokens.SHADOW_SHELL, tokens.SHADOW_CARD): + parts = shadow.split() + assert parts[2] == "0", shadow + # The two custom properties are resolved rather than skipped, so a shadow + # reached through `var()` is held to the same rule as a literal one. + resolved = ( + tokens.stylesheet() + .replace("var(--grad-shadow-shell)", tokens.SHADOW_SHELL) + .replace("var(--grad-shadow-card)", tokens.SHADOW_CARD) + ) + for match in re.finditer(r"box-shadow:\s*([^;]+)", resolved): + value = match.group(1).replace("!important", "").strip() + if value == "none": + continue + assert not value.startswith("var("), f"unresolved shadow variable: {value}" + assert value.split()[2] == "0", value + + +def test_the_structural_border_is_two_pixels_of_ink(): + assert tokens.BORDER_STRUCTURAL == f"2px solid {tokens.COLOUR['ink']}" + + +def test_the_minimum_pane_matches_the_layout_model(): + """Two modules enforce this number; they must agree or a drag settles to a + width the server immediately rewrites.""" + from ui import layout + + assert tokens.MIN_PANE_PX == layout.MIN_PANE_PX + + +def test_the_handle_is_eight_pixels(): + assert tokens.HANDLE_WIDTH == 8 + assert "--grad-handle: 8px" in tokens.css_variables() + + +def test_the_blink_is_the_only_animation(): + """"only two [motions] -- the 1.1s step blink for carets/live indicators, and + instant state swaps. No easing curves, no fades, no skeleton shimmer.\"""" + sheet = tokens.stylesheet() + assert sheet.count("@keyframes") == 1 + assert "gradblink" in sheet + for match in re.finditer(r"animation:\s*([^;]+)", sheet): + assert "gradblink" in match.group(1) + assert "transition:" not in sheet + + +# --------------------------------------------------------------------------- +# single source +# --------------------------------------------------------------------------- +def test_no_module_outside_tokens_spells_a_hex_colour(): + """The single-source rule. A window that hardcodes `#12A594` is a window + that will not follow the next change to the palette.""" + offenders = {} + for path in ui_sources(): + if path.name in ("tokens.py", "jupyter_theme.py"): + continue + found = HEX.findall(path.read_text(encoding="utf-8")) + if found: + offenders[path.name] = sorted(set(found)) + # `#fff` on a crimson fill is the one exception the design itself names + # ("`#A3122F` fill, white text"), and it is not a palette entry. + offenders = {k: [v for v in vals if v.lower() not in ("#fff", "#ffffff")] + for k, vals in offenders.items()} + offenders = {k: v for k, v in offenders.items() if v} + assert offenders == {} + + +def test_the_generated_stylesheet_is_a_pure_function_of_the_tokens(): + assert tokens.stylesheet() == tokens.stylesheet() + + +def test_the_stylesheet_covers_every_component_class_the_kit_emits(): + """A primitive whose class has no rule renders as unstyled text, which is + the failure mode hardest to notice in review.""" + sheet = tokens.stylesheet() + for name in ( + "grad-shell", "grad-appbar", "grad-opener", "grad-statusbar", "grad-tiles", + "grad-column", "grad-slot", "grad-handle", "grad-window", "grad-titlebar", + "grad-body", "grad-btn", "grad-chip", "grad-kv", "grad-bar", "grad-progress", + "grad-status-square", "grad-band", "grad-stage", "grad-lineage", "grad-diff", + "grad-cover", "grad-figure", "grad-table", "grad-row", "grad-card", + "grad-composer", "grad-msg", "grad-pre", "grad-note", "grad-empty", + "grad-iframe-host", "grad-iframe-anchor", "grad-caret", "grad-label", + ): + assert f".{name}" in sheet, name + + +# --------------------------------------------------------------------------- +# fonts +# --------------------------------------------------------------------------- +def test_the_three_families_are_the_ones_the_design_names(): + assert set(fonts.FAMILIES) == {"Space Grotesk", "JetBrains Mono", "Instrument Serif"} + + +def test_nothing_vendored_means_a_google_fonts_link(tmp_path): + html = fonts.head_html(tmp_path) + assert "fonts.googleapis.com" in html + assert "@font-face" not in html + + +def test_a_vendored_family_gets_a_font_face_rule(tmp_path): + (tmp_path / "jetbrains-mono-400.woff2").write_bytes(b"") + html = fonts.head_html(tmp_path) + assert "@font-face" in html + assert "jetbrains-mono-400.woff2" in html + # The other two are still missing, so the link stays -- partial vendoring + # cuts the dependency down rather than all-or-nothing. + assert "fonts.googleapis.com" in html + + +def test_everything_vendored_means_no_network(tmp_path): + for stem, weights in fonts.FAMILIES.values(): + for weight in weights: + (tmp_path / f"{stem}-{weight}.woff2").write_bytes(b"") + html = fonts.head_html(tmp_path) + assert "fonts.googleapis.com" not in html + assert fonts.vendored(tmp_path) == set(fonts.FAMILIES) diff --git a/tools/evolve.py b/tools/evolve.py index de66c6a..fe4b5ec 100644 --- a/tools/evolve.py +++ b/tools/evolve.py @@ -487,6 +487,15 @@ def _drive( reason = "" for generation in range(generations): + # Checked at the generation boundary, alongside the gate and for the + # same reason: this is the point where the campaign can end with every + # candidate collected rather than one abandoned in flight. + if camp.halt_requested(campaign_id): + status = "halted" + reason = "halt requested" + camp.record_generation(campaign_id, generation, halted=True, reason=reason) + break + remaining = (generations - generation) * population try: _campaign_gate(project_id, per_candidate * remaining, remaining, per_candidate) @@ -802,6 +811,54 @@ def cmd_status(args: argparse.Namespace) -> dict[str, Any]: } +def _halt_args(p: argparse.ArgumentParser) -> None: + p.add_argument("--campaign", required=True) + p.add_argument("--reason", default="", help="recorded on the halt event") + + +@cli.command("halt", "stop a campaign at the next generation boundary", setup=_halt_args) +def cmd_halt(args: argparse.Namespace) -> dict[str, Any]: + """Ask a running campaign to stop cleanly. + + Not a kill. `evolve run` holds the loop in whatever process started it, so + this writes a request the loop reads between generations -- which is also + the only boundary at which stopping is safe, because every candidate is + collected there. A campaign that is already closed is reported as such + rather than being handed a request nothing will ever read. + """ + record = camp.campaign(args.campaign) + if record.get("status") != "open": + return _already_closed(args.campaign, record.get("status")) + try: + camp.request_halt(args.campaign, reason=args.reason) + except GradError as exc: + # The loop closed the campaign between the check above and the append. + # That is the halt getting what it wanted a moment early, not a failure, + # so it reports the same way as finding it closed in the first place. + if exc.code != "campaign_not_open": + raise + return _already_closed(args.campaign, camp.campaign(args.campaign).get("status")) + return { + "campaign": args.campaign, + "halted": True, + "status": "open", + "message": ( + "halt requested; the campaign stops before the next generation, with every " + "candidate collected" + ), + "next": f"python -m tools.evolve status --campaign {args.campaign} --json", + } + + +def _already_closed(campaign_id: str, status: Any) -> dict[str, Any]: + return { + "campaign": campaign_id, + "halted": False, + "status": status, + "message": f"campaign {campaign_id} is already {status}", + } + + def _promote_args(p: argparse.ArgumentParser) -> None: p.add_argument("--campaign", required=True) p.add_argument("--candidate", required=True, help="candidate id, or its index within the campaign") diff --git a/tools/lab.py b/tools/lab.py index b3e05e4..c2d08a2 100644 --- a/tools/lab.py +++ b/tools/lab.py @@ -174,6 +174,11 @@ def cmd_start(args: argparse.Namespace) -> dict[str, Any]: argv = [ executable, "lab", "--no-browser", + # Loads config/jupyter/custom/custom.css, which is the Grad Paper theme + # (see ui/jupyter_theme.py). Without this flag JupyterLab ignores the + # file entirely and the iframe renders as stock Lab inside Grad's own + # chrome -- a visible seam exactly where the design says there is none. + "--custom-css", f"--port={port}", "--ip=127.0.0.1", f"--IdentityProvider.token={token}", diff --git a/ui/__init__.py b/ui/__init__.py index 81099c6..59ab290 100644 --- a/ui/__init__.py +++ b/ui/__init__.py @@ -4,4 +4,24 @@ CLIs from §8. It holds no logic of its own. Anything the UI can do, the CLIs can already do, which keeps the terminal path alive and keeps the portability claim honest. + +Since the window-system redesign the package has a shape worth stating, because +the layering is what keeps that promise checkable: + + tokens.py design tokens; the stylesheet is generated from them + fonts.py @font-face for whatever is vendored, Google Fonts for the rest + layout.py the pane tree and the moves over it -- pure, tested + models.py what each window shows, as plain data -- pure, tested + registry.py the one list of windows the shell derives from + state.py one poll, one snapshot, per-client workspace state + kit.py the primitives; Quasar is bypassed, not overridden + shell.py the chrome, and how a window survives a retile + windows/ eleven renderers, none of which read a ledger directly + jupyter_theme.py the same tokens, emitted as JupyterLab's custom.css + katex.py math in the transcript + +`layout.py` and `models.py` import nothing from NiceGUI, at module scope or +inside a function. That is the rule that makes the interesting half of this +package testable with the `ui` extra uninstalled, and `tests/test_ui_*.py` +enforces it. """ diff --git a/ui/app.py b/ui/app.py index 2b11489..4279bb2 100644 --- a/ui/app.py +++ b/ui/app.py @@ -1,19 +1,30 @@ -"""The NiceGUI desktop app (HANDOFF §10). +"""The NiceGUI desktop app: a tiling workspace over eleven windows. "The things that make it pleasant -- being able to see a funnel's reasoning, a preflight's failing check, a prediction against its outcome -- are the same things that make it trustworthy." +The app used to be a row of tabs. It is now a workspace shell: a title bar, a +window opener, a tiling area of resizable panes, and a status bar. What moved +where: + +* `ui/tokens.py` -- the design tokens, and the stylesheet generated from them +* `ui/layout.py` -- the pane tree and the moves over it, pure and tested +* `ui/models.py` -- what each window shows, as plain data, pure and tested +* `ui/registry.py` -- the list of windows the whole shell is derived from +* `ui/shell.py` -- the chrome, and how a window survives a retile +* `ui/windows/` -- eleven renderers, none of which read a ledger directly + +This module keeps only what is genuinely the application's: the SDK session, the +per-client keying, and `run()`. + Two implementation details are the difference between this feeling like a tool -and feeling like a demo, and both are cheap: +and feeling like a demo, and both survive from the first version: - * **Buffered flush.** Updating a `ui.markdown` per token re-renders and - reflows the whole element on every token. Tokens go into a buffer and a - `ui.timer` flushes at ~15 Hz. + * **Buffered flush.** Tokens go into a buffer and a `ui.timer` flushes at + ~15 Hz, rather than re-rendering a markdown element per token. * **Split tail.** The streaming message lives in its own element, separate - from the settled transcript above it, so only the tail re-renders. It is - promoted into the transcript (and KaTeX runs over it) once the message - completes. + from the settled transcript above it, so only the tail re-renders. Notebooks render, they do not rebuild: JupyterLab already exists and is better at editing. Building a notebook editor is the single easiest way to burn a month @@ -23,47 +34,27 @@ from __future__ import annotations import asyncio -import html import json import logging import re import secrets -import sys from pathlib import Path from typing import Any from core import config as config_mod, paths -from ui import katex -from ui.widgets import expectation_panel, funnel_view, preflight_panel, quota_meter, quota_panel +from ui import katex, kit, shell, state as state_mod -FLUSH_HZ = 15 SESSION_PREFIX = "ui_session" ROLES = ("user", "assistant") +STATIC_URL = "/grad-static" # Where anything the transcript must not carry goes instead: this handler is the # app's own log, not user-visible text and not the persisted session file. log = logging.getLogger("grad.ui") -# Quasar's defaults, overridden rather than accepted -- untouched spacing and -# typography is the giveaway that something is a stock NiceGUI app. -THEME = """ - -""" + +def static_dir() -> Path: + return Path(__file__).resolve().parent / "static" class Session: @@ -176,8 +167,9 @@ def restore(self) -> None: """Read the transcript back, keeping only records that render. The file is on disk between runs, so a record is not necessarily one we - wrote: `_bubble` subscripts `role` and `text`, and a line that is a bare - string or is missing `text` would take the whole page down at build time. + wrote: the chat window subscripts `role` and `text`, and a line that is a + bare string or is missing `text` would take the whole page down at build + time. """ path = self.path() if not path.exists(): @@ -198,28 +190,68 @@ def restore(self) -> None: def build() -> None: """Register the page. Everything with per-client state is built inside it. - Constructing the `Session` out here would build the layout once, at import, - and hand every connected client the same `ClaudeSDKClient`, the same token - buffer and the same transcript file -- a second window would see the first - one's stream and race it on the way to disk. + Constructing the `Session` or the `Workspace` out here would build the + layout once, at import, and hand every connected client the same + `ClaudeSDKClient`, the same token buffer, the same transcript file and the + same focused pane -- a second window would see the first one's stream, race + it on the way to disk, and fight it over the layout file. """ from nicegui import app as nicegui_app, ui - ui.add_head_html(THEME) + _serve_static(nicegui_app) + # `shared=True` because these are registered at global scope alongside a + # `@ui.page` route; without it NiceGUI refuses, since it cannot tell whether + # the markup was meant for one page or all of them. Here it is genuinely all + # of them: one stylesheet, one tiling module, one KaTeX. + ui.add_head_html(kit.stylesheet_head(url_prefix=f"{STATIC_URL}/fonts"), shared=True) + ui.add_body_html(f'', shared=True) katex.install(nicegui_app) @ui.page("/") def index() -> None: from nicegui import context # noqa: PLC0415 - page scope, not import scope - ui.dark_mode(True) session = Session(_client_key()) session.restore() + workspace = state_mod.Workspace(session, _current_project()) # Per client, not `app.on_shutdown`: that would accumulate one handler # per connection and hold every session's subprocess open until the app # itself exits. context.client.on_disconnect(session.close) - _layout(ui, session) + shell.build(workspace) + + +def _serve_static(nicegui_app: Any) -> None: + """Fonts and `tiling.js`, plus the generated wiki if one exists. + + Both are mounted read-only under fixed prefixes rather than served from the + workspace root: this app binds an unauthenticated port and accepts prompts + for an agent with Bash access, and exposing `paths.root()` over HTTP would + hand anything that reached that port the whole workspace, credentials + directory included. + """ + directory = static_dir() + directory.mkdir(parents=True, exist_ok=True) + (directory / "fonts").mkdir(parents=True, exist_ok=True) + nicegui_app.add_static_files(STATIC_URL, str(directory)) + + try: + from tools import wiki as wiki_tool # noqa: PLC0415 - optional + + wiki_dir = wiki_tool.output_dir() + if wiki_dir.exists(): + nicegui_app.add_static_files("/grad-wiki", str(wiki_dir)) + except Exception: # noqa: BLE001 - a missing wiki is not a startup failure + log.debug("no wiki directory to serve") + + +def _current_project() -> str | None: + from core import budget as budget_mod + + try: + return budget_mod.current_project() + except Exception: # noqa: BLE001 - an unreadable project file is not fatal + return None def _client_key() -> str: @@ -256,301 +288,6 @@ def _storage_secret() -> str: return path.read_text(encoding="utf-8").strip() -def _layout(ui: Any, session: Session) -> None: - with ui.header().classes("items-center justify-between px-4 py-2 grad-panel"): - with ui.row().classes("items-center gap-2"): - ui.label("Grad").classes("text-lg font-semibold") - ui.label("research instrument").classes("text-xs opacity-50") - quota_meter() - - with ui.tabs().classes("w-full") as tabs: - tab_chat = ui.tab("Session") - tab_preflight = ui.tab("Preflight") - tab_expect = ui.tab("Expectations") - tab_funnel = ui.tab("Funnel") - tab_quota = ui.tab("Quota") - tab_nb = ui.tab("Notebooks") - tab_lab = ui.tab("Lab") - - with ui.tab_panels(tabs, value=tab_chat).classes("w-full"): - with ui.tab_panel(tab_chat): - _chat_panel(ui, session) - with ui.tab_panel(tab_preflight): - _refreshable(ui, preflight_panel) - with ui.tab_panel(tab_expect): - _refreshable(ui, expectation_panel) - with ui.tab_panel(tab_funnel): - _refreshable(ui, funnel_view) - with ui.tab_panel(tab_quota): - _refreshable(ui, quota_panel) - with ui.tab_panel(tab_nb): - _notebook_panel(ui) - with ui.tab_panel(tab_lab): - _lab_panel(ui) - - -def _refreshable(ui: Any, render: Any) -> None: - container = ui.column().classes("w-full") - - def draw() -> None: - container.clear() - with container: - render() - - ui.button(icon="refresh", on_click=draw).props("flat dense").classes("self-end") - draw() - - -def _chat_panel(ui: Any, session: Session) -> None: - transcript = ui.column().classes("w-full gap-3 grad-transcript").props('id="grad-transcript"') - with transcript: - for message in session.settled: - _bubble(ui, message["role"], message["text"]) - - tail = ui.markdown("").classes("w-full grad-transcript opacity-90") - - def flush() -> None: - # ~15 Hz, not per token: only the tail element re-renders. - if session.buffer and tail.content != session.buffer: - tail.content = session.buffer - - ui.timer(1 / FLUSH_HZ, flush) - - async def settle(text: str) -> None: - tail.content = "" - if text: - with transcript: - _bubble(ui, "assistant", text) - await katex.render("#grad-transcript") - - async def send() -> None: - prompt = entry.value.strip() - if not prompt or session.busy: - return - entry.value = "" - with transcript: - _bubble(ui, "user", prompt) - await session.ask(prompt, settle) - - with ui.row().classes("w-full items-end gap-2 mt-2"): - entry = ui.textarea(placeholder="ask, or paste a result to interrogate").classes("flex-grow").props( - "autogrow outlined dense" - ) - entry.on("keydown.enter.prevent", send) - ui.button("Send", on_click=send).props("unelevated") - ui.button(icon="stop", on_click=session.interrupt).props("flat dense").tooltip("interrupt (Esc)") - - # Keyboard-first: submit, interrupt, jump to the latest tool call. - ui.keyboard( - on_key=lambda e: session.interrupt() if (e.key == "Escape" and e.action.keydown) else None - ) - - -def _bubble(ui: Any, role: str, text: str) -> None: - if role == "user": - ui.markdown(text).classes("grad-user w-full") - return - with ui.column().classes("w-full gap-1"): - for block in _split_tool_calls(text): - if block["kind"] == "tool": - # Tool calls render as collapsible cards, not raw text. - with ui.expansion(block["title"], icon="terminal").classes("w-full grad-panel"): - ui.code(block["text"], language="bash").classes("w-full") - else: - ui.markdown(block["text"], extras=["fenced-code-blocks", "tables"]).classes("w-full") - for figure in _figures_in(text): - ui.image(figure).classes("w-full max-w-2xl rounded") - - -def _split_tool_calls(text: str) -> list[dict[str, str]]: - """Very small parser: fenced bash blocks become cards, prose stays prose.""" - out: list[dict[str, str]] = [] - parts = text.split("```") - for index, part in enumerate(parts): - if index % 2 == 0: - if part.strip(): - out.append({"kind": "text", "text": part}) - continue - lang, _, body = part.partition("\n") - if lang.strip() in ("bash", "sh", "console"): - first = body.strip().splitlines()[0] if body.strip() else "command" - out.append({"kind": "tool", "title": first[:80], "text": body.strip()}) - else: - out.append({"kind": "text", "text": f"```{part}```"}) - return out - - -def _figures_in(text: str) -> list[str]: - """Figures are referenced by path; the UI renders them from that path, so - the two-call workaround in §8 costs the human nothing.""" - found = [] - for token in text.replace("(", " ").replace(")", " ").split(): - if token.endswith(".png") and "figures" in token.replace("\\", "/"): - path = Path(token) - if path.exists(): - found.append(str(path)) - return found - - -async def _verify_notebook(ui: Any, name: str, target: Any) -> None: - """Shell out to `nb verify` and render the failing cell index and traceback. - - HANDOFF-2 §19 calls this the highest-value part of the whole item, and it is - why it was built before the embed: Lab and `tools/nb.py` are two kernel - owners over one notebook, which reproduces exactly the "works in the kernel - that grew it" failure `nb verify` exists to catch. The discipline is - unchanged -- anything edited in Lab passes this before it is cited in - `notes/` or referenced from a ledger entry -- and a button is what makes a - discipline actually get followed. - """ - target.clear() - with target: - ui.spinner(size="sm") - ui.label(f"running {name} top to bottom on a fresh kernel…").classes("text-sm opacity-70") - - proc = await asyncio.create_subprocess_exec( - sys.executable, "-m", "tools.nb", "verify", f"notebooks/{name}", "--json", - cwd=str(paths.root()), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - out, err = await proc.communicate() - try: - payload = json.loads((out or b"").decode("utf-8", "replace").strip().splitlines()[-1]) - except (json.JSONDecodeError, IndexError): - payload = {"ok": False, "error": {"message": (err or b"").decode("utf-8", "replace")[-2000:]}} - - target.clear() - with target: - if payload.get("ok"): - data = payload.get("data") or {} - ui.label( - f"verified — {data.get('cells_executed', '?')} cells ran clean on a fresh kernel" - ).classes("text-sm text-green-400") - return - error = payload.get("error") or {} - detail = error.get("detail") or {} - ui.label(error.get("message") or "verification failed").classes("text-sm text-red-400") - index = detail.get("cell_index") - if index is not None: - ui.label( - f"first failing cell: index {index} " - f"({detail.get('cells_executed', '?')} cells ran before it)" - ).classes("text-xs opacity-80") - # `nb verify` nests the kernel's own error under `error`, with the - # traceback already stripped of ANSI escapes. - traceback_text = ((detail.get("error") or {}).get("traceback")) or detail.get("stdout") - if traceback_text: - if isinstance(traceback_text, list): - traceback_text = "\n".join(str(t) for t in traceback_text) - ui.code(str(traceback_text)[-4000:], language="python").classes("w-full") - if error.get("fix"): - ui.code(str(error["fix"]), language="bash").classes("w-full") - - -def _lab_panel(ui: Any) -> None: - """JupyterLab, embedded (HANDOFF-2 §19). - - This iframe is **deliberately unsandboxed**, and that is a considered - difference from the notebook-output iframe below it. Notebook output is - untrusted HTML from files that may have come from a downloaded repository, - so it is `sandbox=""`. Lab is a server we started ourselves, on its own - port, with a token we minted -- and it cannot function sandboxed. The two - are separate iframes on purpose; do not merge them. - """ - from tools import lab as lab_tool # noqa: PLC0415 - optional dependency - - container = ui.column().classes("w-full") - - def draw() -> None: - container.clear() - state = lab_tool.lab_state() - with container: - if not state.get("running"): - ui.label("JupyterLab is not running.").classes("text-sm opacity-70") - ui.code("python -m tools.lab start --json", language="bash") - ui.label( - "Anything edited in Lab must pass `nb verify` before it is cited in " - "notes/ or referenced from a ledger entry — Lab and tools/nb.py are " - "two kernel owners over one notebook." - ).classes("text-xs opacity-60 max-w-2xl") - return - with ui.row().classes("items-center gap-3"): - ui.label(f"127.0.0.1:{state['port']}").classes("text-xs font-mono opacity-70") - ui.button("Stop", on_click=lambda: (lab_tool.cmd_stop(None), draw())).props("flat dense") - ui.element("iframe").props( - f'src="http://127.0.0.1:{state["port"]}/lab?token={state["token"]}" ' - 'allow="clipboard-read; clipboard-write"' - ).classes("w-full h-[80vh] rounded bg-white") - - ui.button(icon="refresh", on_click=draw).props("flat dense").classes("self-end") - draw() - - -def _notebook_panel(ui: Any) -> None: - """Render notebook *outputs*, read-only, with Verify and a link into Lab.""" - notebooks = sorted(paths.notebooks_dir().glob("*.ipynb")) if paths.notebooks_dir().exists() else [] - if not notebooks: - ui.label("No notebooks yet.").classes("text-sm opacity-60") - return - - container = ui.column().classes("w-full") - - def show(name: str) -> None: - container.clear() - path = paths.notebooks_dir() / name - with container: - verify_out = ui.column().classes("w-full gap-1") - with ui.row().classes("items-center gap-3"): - ui.button( - "Verify", - icon="fact_check", - on_click=lambda: _verify_notebook(ui, name, verify_out), - ).props("unelevated dense").tooltip( - "restart the kernel and run every cell top to bottom" - ) - ui.link("open in JupyterLab", _lab_link(name)).classes("text-sm") - ui.code(f"python -m tools.nb verify notebooks/{name} --json", language="bash") - try: - import nbformat # noqa: PLC0415 - from nbconvert import HTMLExporter # noqa: PLC0415 - - nb = nbformat.read(path, as_version=4) - body, _ = HTMLExporter(template_name="basic").from_notebook_node(nb) - # Sandboxed iframe, not ui.html: notebook outputs are untrusted - # HTML and can carry