Skip to content

Release 0.2.0: the analysis chain, and the browser workflow that feeds it - #17

Merged
cursor[bot] merged 53 commits into
mainfrom
release/0.2.0
Aug 13, 2026
Merged

cursor[bot] merged 53 commits into
mainfrom
release/0.2.0

Conversation

@its-janghoon

Copy link
Copy Markdown
Collaborator

Release merge, release/0.2.0 into main. Merge this with a merge commit, never squash or rebase (CONTRIBUTING is explicit: a squash into main destroys the shared ancestry that keeps later merges conflict-free). After merging, main gets the annotated tag v0.2.0 and this branch merges back into develop.

main is currently an ancestor of this branch, so the merge is clean with no conflicts.

What is in it

0.1.0 could compare models and settle quality by human preference. 0.2.0 is about what happens after a number appears: where the run is recorded, how it resumes, why a model was wrong, and whether a difference between two models is real. Full detail is in the new CHANGELOG.md.

PR What it added
#9 Preference ranking, elimination and re-voting; whole-host deploy measure/install; multi-turn evaluation; tool-routing set 100 to 324 tasks
#10 Experiment registry: one shared record, captured provenance, fs and sqlite drivers, yarn runs
#11 Resumable job queue: cells, concurrency budgets, retries, checkpoints
#12 Grids as one registry run, yarn tool-routing:matrix, resume by run id
#13 Failure analysis: shared taxonomy, run artifacts, yarn failures
#14 Cohorts, hand corrections, paired statistics (Wilson, exact McNemar, paired bootstrap, Holm)
#15 Analyze workspace, and Generate to Compare to Analyze as the primary browser workflow
#16 Release blockers: colliding optimize run ids, doubled CONTRIBUTING sections

The release commit itself

  • five version fields bumped to 0.2.0: root, apps/web, packages/harness, packages/tokenizers, and version: in CITATION.cff
  • CITATION.cff gains date-released
  • CHANGELOG.md added, and linked from both READMEs. 0.1.0 was never tagged, so v0.2.0 is the first release a reader can obtain by name rather than by pinning a commit

QA

Layers 1 and 2 (automated), run on this branch:

Layer 3, by hand, the things CI structurally cannot do:

  • Click-through of the production build (next start, not next dev): /analyze runs list, run detail, Failures tab with Expected/Asked/Got, Compare tab with rates and pair statistics, back navigation, plus /compare, /generate, /deploy, /evolve, /settings, /tool-routing. No JavaScript errors, no raw translation keys, no clipped controls. The only console output is Next.js CSS-preload timing warnings.
  • A real provider call: the browser end-to-end run on Primary browser workflow: Generate → Compare → Analyze #15 made 12 of 12 live OpenRouter calls through Generate to Compare to Analyze, on the code this release merges. The verify:* scripts are deliberately offline, so that run is the live evidence.
  • GPU host smoke test: not run, no GPU host available in this environment. Deploy changes in Preference ranking and re-voting, whole-host deploy actions, multi-turn evaluation #9 were verified against a real host when they landed.

Known limitations, stated rather than hidden

  • Image comparison is hidden behind COMPARE_IMAGE_UI_ENABLED = false. Nothing was deleted; suites, judges, adapters and tournament support remain in place.
  • Generated format-constraint tasks still route to Preference, because they have no single reference answer to score against.
  • Evolve is not yet a one-click action from an Analyze cohort.
Open in Web Open in Cursor 

cursoragent and others added 30 commits August 8, 2026 12:54
A template's verifier field now holds either one verifier or an array of
declarative verifiers, all of which must pass. Every element runs, and the
verdict carries a per-element report so a failure names the element rather
than collapsing to a boolean.

The array references the declarative union in the schema, so an executable
element is a load-time violation rather than a runtime refusal, and lists do
not nest, so the depth limit and the eager pre-walk both go away.

Spec version bumped to redrob-verifiable-task/v2.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Spec section 6.2 now describes the field shape, the per-element report and
the structural rejection of executable elements; section 11.1 records the
v2 migration. Decision record 0003 covers what was decided unreviewed.

Four negative controls added: the element report comparison catching a
short circuit that case verdicts do not, the same short circuit hiding an
executable element behind a failing one, the schema rejecting an executable
element, and a schema-rejection row proving its valid counterpart.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
main had been the only long-lived branch, which is how the Generate
module came to be squash merged straight into what is meant to be
deployable.

- develop is created from main and is now the base for all ordinary work
- CONTRIBUTING documents the model with a diagram, the branch prefixes,
  worked commands for cutting a release and making a hotfix, and the
  three QA layers -- noting that CI has no browser, no provider key and
  no GPU, so the UI, a live call, /deploy and upgrade-in-place are
  covered by nothing until someone tests them on a release branch
- the merge strategy is stated: squash into develop, real merge commits
  for release and hotfix in both directions, because a squashed hotfix
  reaches main and develop as two unrelated commits and the next release
  merge then conflicts over a fix that was applied deliberately
- both workflows build develop on push; master is dropped from the
  triggers because no such branch exists

Decision record 0004 covers the naming, why the Generate merge on main
is not reverted, and the repository settings that still have to be set
by hand for any of this to be enforced rather than requested.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Part C of the Generate work: all_of is replaced by a verifier field that
accepts either a single verifier object or an array, with a normative
per-element report and executable elements refused by the schema rather
than at runtime. Spec bumped to v2.

Resolves a README conflict where both branches extended the decision
record list, and drops a duplicate Contributing bullet the branching
change had introduced.

Known to still need fixes; it lands on develop rather than main so the
follow-ups are small branches off an integrated base instead of a
long-lived branch drifting from it.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
The href was updated to verifiable-task-v2.md but the link text still
read v1, so the README announced the wrong spec version on the line that
introduces it. Found while resolving the merge; the file it points at
was already correct, so no link checker would have caught it.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Generate can now run one study end to end: redrob-generate study takes a
config and writes a validated results artifact plus an aggregate table.

- Verdict records carry the producing implementation, its version and its
  Unicode table version. Python is authoritative, TypeScript is display
  only. The two runtimes really do read different Unicode tables here
  (15.0.0 against 16.0), which is why the distinction is provenance rather
  than a correctness claim.
- Locale layers carry translation_status, and hi, hi-Latn and ko stubs
  render the English text verbatim so the pipeline can be exercised without
  fabricating translations.
- Two new fields: fertility_tokenizer on a study config, code_mix_ratio on
  an instance. The latter is always null; defining the measurement is a
  human decision.
- Publication refuses an untranslated locale and refuses a verdict this
  project did not produce.
- The locale pattern gained a script subtag so Hinglish is expressible as
  hi-Latn.

Model invocation goes through the harness callModel via a thin bridge, so
there is no second model client. The mock provider needs no network.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
- spec: two sentences on Python being normative for publication, plus the
  translation_status and code_mix_ratio fields and the script subtag.
- STUDY.md: config format, worked example, and a plain statement that the
  stub locales cannot be published.
- Decision record 0005 records the five choices taken without review, the
  largest being that the bundled tokenizers are offline proxies rather than
  model tokenizers.
- Six new negative controls, one per new invariance claim. All 17 detect
  their break.
- Three new DoD steps: reproducible study, artifact shape, publication
  gates. All 16 steps pass.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
SUMMARY-4 records what was built, the five decisions taken without review,
the deviations and their cost, and the command output behind each
definition-of-done item, plus the list of things left alone for being out of
scope.

The logs/ ignore rule exists because a build transcript is reproducible by
re-running the build. A written report is neither reproducible nor a
transcript, so the rule was catching it by accident, and three passes of
reasoning have been sitting untracked as a result. The pattern becomes
logs/* with the reports negated -- git will not re-include a file whose
parent directory is excluded -- and the earlier three are backfilled in the
same commit so the record is not left half present.

Per-step .log files stay ignored; CI uploads those as workflow artifacts.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
…lowed

There is no prettier config at the root, so running the binary bare formats to
its own defaults -- double quotes and an 80 column wrap -- which disagrees with
every file in the repository. A previous step in this pass ran it over six
files and committed the result, which churned reader.ts and index.ts far beyond
the lines actually being changed and made the real edit hard to find in a diff.

reader.ts and index.ts go back to their committed formatting with the semantic
edits kept. The four files written during this pass are converted to the
repository's single-quote style so they match their neighbours.

No behaviour changes: tsc is clean and all 665 TypeScript tests pass.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Three additions behind a Generate UI, all in the generate subpath so nothing
reaches the main harness barrel or a client bundle.

catalog.ts reads the template tree and the shipped study configs from disk:
which families exist, which locales each has, and what review status each
locale claims. It deliberately needs no Python. Sampling does -- this
implementation reads and verifies but does not generate -- so a catalog that
also needed the interpreter would leave a workbench without one showing an
empty page instead of a list of what exists and a note about what is missing.

A locale layer that declares no translation status reads as untranslated rather
than as anything friendlier. Absence of a claim is not a review, and the
failure mode of defaulting upward is silent: a green chip, a plausible table,
and a reader believing a number describes Korean.

python-client gains emitWithPython and studyWithPython alongside the existing
verify, reusing the same spawn and the same treatment of absence as an expected
state. studyWithPython reads exit 3 as a result rather than a failure: the CLI
writes the artifact before the publication gate runs, so a refusal still has an
artifact to show and a reason worth reading.

13 new tests, one of which asserts every stub locale still reports the same
prompt length as its English source.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Generate has had a spec, a generator, two verifier implementations and a study
runner, and no way to reach any of it without a terminal. This adds /generate
to the module nav next to Compare, Evolve and Deploy.

Two stages. Templates lists every family, the locales it has and how reviewed
each one is, and samples instances on demand so you can read the rendered
prompt, the parameters behind it and the verifier that will score it. Study
runs a shipped config and renders the aggregate tables together with the
provenance block and the publication verdict.

Three deliberate choices in the routes:

The publication gate always runs. The CLI writes the artifact before checking
it, so asking costs nothing, and reporting publishable without having checked
would be the page asserting something it never established. The refusal shown
is Python's own rather than a second copy of the rule living in the browser.

A study config that reaches a provider is refused by the route, not merely
hidden in the UI. Real model calls stay on the command line where the spend is
a decision someone typed.

Paths from the browser are confined rather than trusted: a preview path must
resolve inside templates/, and a study config must be one the catalog offered.
Otherwise these would be arbitrary-read and arbitrary-execute primitives with a
JSON body.

Absent Python is reported once, up front, with the install command, and the
catalog still renders because it is read from disk. The alternative is
discovering it one dead button at a time.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
The README said Generate had no page, no route and no navigation entry, which
is no longer true. It now appears in the module table and the paragraph about
what exists says so, including that the page still works without Python.

The locale note was also understating things: it said templates are
English-only and the locale structure merely exists, which reads as though
hi, hi-Latn and ko are absent rather than present and holding English.

STUDY.md gains a section on the page, covering the three things it does
deliberately -- always asking the gate, refusing to spend, and saying when it
cannot help.

Negative control 18 defaults an undeclared translation status to
native-reviewed and confirms the catalog tests catch it. This is the failure
mode worth a control because it is silent and it is upward: nothing errors, the
chip turns green, and a reader believes a number is about a language nobody
translated.

The definition-of-done build check now expects generate.html alongside the
other three module pages.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Manual testing turned up four things worth fixing.

The right-hand panel was an empty white void until you clicked something. The
page is reached to look at templates, so the first family is now selected and
sampled on load and there is nothing to click before seeing a prompt.

The locale chips in the template list read 'single reviewer, untranslated,
untranslated, untranslated' -- which says a family has stubs but not which
ones, and which one is which is the only part a reader can act on. They now
carry the locale tag and keep the colour.

A chip inside a column flex container stretched to the full width of its card,
so 'mock only, no spend' was drawn as a long pill across the study config.

Three labels were terse enough to be ambiguous: 'instances' next to a number
field, '#0' on an instance header, and 'Expected' over the verifier config.
Provenance now also says in one line why two runtimes are listed at all.

The loading flag is gone rather than fixed. Results are keyed by the request
that produced them, so switching template or locale shows nothing instead of
briefly showing the previous template's instances, and 'loading' follows from
having neither a result nor an error for the current key. One less piece of
state to keep in sync, and it satisfies the lint rule against setting state
synchronously in an effect without an escape hatch.

Verified in a browser: switching templates never shows stale instances, the
count field does not re-sample per keystroke, and switching locale re-samples
to identical seeds -- seeds carry no locale term, which is what makes the
paired deltas paired.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
The stylesheet named 123 colours directly and the charts named 18 more. A literal in
a rule is a colour that cannot follow a theme, so a second palette was impossible
before this: nothing to switch.

Every literal below the new token layer is now a token, and the layer defines two
palettes under one set of names. The rules that needed judgement rather than a table:

- White is three different roles. A panel background, a label on a saturated fill,
  and titlebar hover text all wrote #fff, and they diverge in dark mode -- the panel
  darkens, the label does not. They are --panel, --on-solid and --titlebar-ink-max.
- Status tints stay explicit per theme. Mixing them from the base colour is tidier and
  was tried, but a mix of --warn into white lands on beige where the light theme has
  always used cream: the elegant mechanism quietly restyles the shipped product.
- The titlebar and the terminal are dark in both themes, so their tokens are named for
  the surface they sit on rather than for the theme.
- Charts get var() in their SVG attributes, which resolve as CSS and so re-colour on a
  theme change with no re-render. xterm paints to a canvas and cannot, so it reads the
  same two tokens through getComputedStyle -- which is also what stops the canvas and
  the frame around it from drifting apart.

No visible change yet: the light palette holds the previous values.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Three states rather than two: light, dark, and follow the OS. The preference and the
resolved theme are kept apart so the switch still reads System after the OS flips,
instead of silently rewriting the choice to whatever the OS happened to be.

Applied by an inline script in <head>, which is the only place it can be applied. A
dark-mode user whose page paints white and corrects itself is the most visible defect
a theme can have, and no effect can prevent it, because effects run after paint.

Because that script always resolves to a concrete light or dark on <html>, the
stylesheet needs one [data-theme='dark'] block rather than that block plus a duplicate
inside a prefers-color-scheme query, and the two cannot drift apart.

The choice lives in localStorage and in the OS setting, neither of which React owns,
so it is read with useSyncExternalStore rather than mirrored into state. Mirroring
would mean writing state from an effect on mount -- a cascading render, and a second
copy of the truth that goes stale when another tab changes it.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Settings gets the three-way control, in its own card above the saved settings and
outside the save flow -- this preference lives in the browser rather than in .env and
applies on click, so a Save button next to it would be a lie.

Three radios rather than a toggle: follow the OS is a distinct answer from either
fixed value, and a checkbox cannot express it. The titlebar gets a single button that
cycles the same three states, because that bar is 48px tall and already shared with
every module's own controls; it is the shortcut for someone who has just walked into
a dark room, not the place to explain the options.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
A dark palette is easy to get subtly wrong in a way that looks fine to whoever picked
the colours and is unreadable to everyone else. A review of the first draft reported
no defects; these numbers found three, one of them severe:

- The dark titlebar sat at 1.02:1 against the page behind it. Not subtle -- invisible.
  In light mode the bar is the dark element and needs no help separating itself; in
  dark mode nothing is, so it now separates by hue and by sitting slightly lighter.
- Seven controls filled with --brand under a white label. --brand is lifted in dark
  mode so it stays readable as text on a dark panel, which makes it far too light to
  sit under white. Fills are now their own tokens, kept saturated.
- The infeasible dots on the Pareto chart were at 1.78:1 against the panel.

The bar for dark is the bar light already clears. An absolute WCAG floor would fail
pairings light has always failed -- hairline borders, muted grey on the page -- and
the only way to pass would be to restyle the light theme, which is not what adding a
dark mode is for. Surface joins are judged on being perceptible rather than on 3:1,
which governs controls and meaningful graphics; the titlebar's own contents are
checked as text and clear 4.5:1.

The tokens are read out of globals.css rather than restated, because a copy of the
palette in a test is a copy that goes stale and then passes while the app is broken.
A companion assertion fails the build if any colour below the token layer stops being
a token, which is how the palette stays the only place colour is decided.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Resolving every declaration in the old stylesheet and the new one and diffing the
results turned up two rules where an opaque token had replaced a translucent value:
the browse badge's border and the completed-step marker. Both sit on surfaces that
are not the panel, so flattening them paints over what is behind. They keep their
alpha now.

Eleven differences remain and are deliberate: near-duplicate values collapsed onto one
token, none of them more than 3/255 in a channel or 5% alpha on an already-faint tint.

The theme toggle also needed a focus ring of its own. The titlebar is dark in both
themes but color-scheme follows the page, so in light mode the browser was drawing its
default ring dark-on-dark, on a control that had just been added.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
What matters about that script is which theme it decides on, and a regex over its
source cannot tell whether the fallback chain is right. It is executed against a stub
document instead, once per case: an explicit choice beating the OS, no choice or
'system' following it, a nonsense stored value, storage that throws the way private
browsing does, and color-scheme being set so native controls follow.

Guarding where it lives is a separate assertion, because the flash it prevents is a
property of running in <head>. Measured: with the script, every frame of a dark reload
is at brightness 33/255; with it removed, every frame is at 234.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
The contrast check is worth pointing at from the README because it is not obvious that
'yarn test' fails on a colour.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Every colour in the workbench becomes a theme token, and the app follows the
operating system's light or dark setting unless told otherwise from the title bar
or the Appearance card in Settings. Lands here rather than on develop because it
themes the Generate page added on this branch as well as everything else.

The theme resolves in an inline script before first paint, which is the only place
it can resolve without showing a dark-mode reader a white page that corrects itself.

Contrast is asserted rather than eyeballed: a review of the first draft reported no
defects and measurement found three, including a title bar sitting at 1.02:1 against
the page behind it. yarn test now fails if any pairing is worse in dark than in
light, or if any colour below the token layer stops being a token.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Two pieces of work that share a stylesheet, so they integrate together.

Generate becomes usable end to end. Python is normative for verdicts and TypeScript
verdicts are marked non-authoritative, because the two read different Unicode tables
and only one of them may be published from. Templates gain a locale layer with a
declared translation_status, a study runner turns a config into a validated results
artifact, and a /generate page makes all of it reachable without a terminal. The
publication gate always runs, a config that would reach a provider is refused by the
route rather than merely hidden, and paths arriving from the browser are confined.

The theme turns every colour in the app into a token and adds a second palette.
The workbench follows the operating system unless told otherwise from the title bar
or Settings, resolved before first paint so a dark-mode reader is never shown a white
page that corrects itself. Contrast is asserted rather than eyeballed, and the light
theme was diffed declaration by declaration against its previous self to confirm the
refactor did not restyle it.

Both PRs were green on all seven checks before merging, and the sixteen checks of
verify-generate-dod.sh pass on the merged tree.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Three primary buttons existed. `app-run-btn` was teal, 28px and square;
`deploy-op-btn.is-primary` was brand blue, 32px and square; `settings-save`
was brand blue, 36px and rounded. Teal appears nowhere else in the product
except the progress bar, so the one control the eye should land on first was
the only thing wearing a colour the design does not otherwise use, and a Run
button four pixels shorter than the Discard beside it reads as a mistake.

One geometry for all three roles, brand blue for primary, and a titlebar
override so the compact bar keeps its 26px buttons. Compare's Setup action
gets a panel to sit on rather than floating loose under the two cards.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
The list read `extraction.quarterly_ledger`, `format.release_note`,
`math.linear_equation` — three ids in a column, scanned by their least
distinctive end. The title is derived rather than authored: the spec has no
title field, and adding one would put a second name on every template free to
drift from the id the CLI is called with. The id stays on screen beneath the
title, because it is what you type.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Sampling instances and then having no way to use them made the module a
viewer. Templates now offers the set as a download, the prompts on the
clipboard, the CLI command that reproduces the same sampling on disk, and a
hand-off that opens Compare with the prompts already loaded as a custom set.
Study offers the result artifact, which is the deliverable the tables below it
are only a reading of, and says plainly that the download is the only copy.

The hand-off travels through sessionStorage rather than the URL — a dozen
prompts do not fit in a query string — and is read once and removed, so a
later visit to Compare does not silently re-apply a set the reader has
forgotten about. It carries no gold answers, because a verifier is not a
reference string; the page says so and points at the preference tournament.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
It was four cards on one long scroll: the GPU host fields sat below the fold
behind six provider keys, and there was no way to see what the page contained
without scrolling all of it. Sections on the left, the chosen one on the
right, matching Deploy. The nav carries how many values each section has set,
and a dot where edits are pending, because Save applies every pane at once and
that should not be something you discover by pressing it.

Appearance is a section like the others even though it saves nothing. Keeping
it out of the nav to reflect that implementation detail would only make it
harder to find. Deploy's /settings#HF_TOKEN link now opens the pane holding
the field before scrolling to it, and answers a hash change as well as a load.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
`--run-ink` existed only for text on the teal Run button, which is now brand
blue; nothing sits on `--run` any more, so the token and its contrast pairing
go with it. Teal stays as the progress accent, which is what it now solely is.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
A solid fill reads tighter than an outline at the same padding: the ghost's
border supplies an edge the eye takes as breathing room, and blue to the edge
does not. Two more pixels each side, on all three roles so the row still
agrees.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Found with a live key. On GSM8K the task cap is 512 tokens; qwen3.7-flash spent
all 512 thinking, returned no content, and the harness raised "Empty model
response". The eval scored that 0 and the ranking reported one of the better
models on the board as the worst: 10% against llama-3.1-8b's 70%. The same run
now reports 100%.

That is the one failure an evaluation tool may not have — a limit of our own
making, published as a fact about the model. When a reply is cut off and the
tokens went on reasoning the caller never asked for and cannot see, the budget
is raised once and the question asked again. Ordinary models hitting the cap
are left alone: that is the cap doing its job.

Empty and truncated replies are also no longer retried three times. These calls
go out at temperature 0, so the retry bought an identical answer — and after
the escalation it would have bought it at four times the budget.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Found with a live key. Seed model was set to Llama 3.1 8B and the run manifest
recorded it — `openrouter:meta-llama/llama-3.1-8b-instruct` — while every
rollout went to openai/gpt-4o, because the seed candidate took modelCatalog[0]
and the page sends its model-picker selection as the catalog. The report then
attributed the result to a model that had not been run, and the run billed at
roughly fifty times the rate asked for.

The seed candidate now takes the seed model. Re-run live, the manifest and the
candidate name the same model.

Worth saying plainly: both halves of this were wrong in the same direction.
The artifact asserted a model it had not measured, which is the failure an
evaluation tool exists to prevent.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
cursoragent and others added 23 commits August 9, 2026 05:50
Found on a live custom-goal run. The reflector is asked for
{ lesson, instruction, ... }; the instruction is the longest field, so the
900-token cap landed inside it and the object never closed. The fallback then
used the whole raw reply as the evolved instruction, so the prompt driving
every later rollout — and the evolved prompt the reader copies out — began with
a ```json fence and a "lesson" key.

Quality still rose 46% to 62%, which is the uncomfortable part: the artifact
was visibly malformed and nothing downstream objected.

A cut-off reply now gives up its instruction and leaves its syntax behind. When
nothing can be recovered the parent instruction is kept, because raw JSON is
never a better prompt than the one we already had. The cap goes to 1800, since
a lesson plus a full replacement instruction does not fit in 900.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
A rubric that lists Readability (1-10), Accuracy (1-10) and three more came
back as one number between 0 and 1. It could tell you the prompt scored 0.54;
it could not tell you accuracy was fine and calibration was what was dragging,
which is the only part anyone can act on.

The dimensions are read out of the rubric the user already wrote — a bulleted
or numbered line with a range in brackets — rather than asking them to restate
it in a second structured field. A rubric that names none behaves exactly as
before: one score, one paragraph.

They travel with the batch, so reflection sees them named in its traces and can
aim at the weak one, and the report shows baseline against evolved per
dimension.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
The id was date_HHMMSS_dataset, unique while runs are started by one person
clicking one button. Racing three models starts them in the same tick, so all
three took the same id: one directory, one entry in the jobs map, three jobs
writing over each other, and three event streams reporting the same run.

The comparison table showed three identical rows — 60.0% to 82.0% for every
model, down to the token count. That reads as suspicious rather than broken,
which is the kind of result that gets believed.

Four random characters after the dataset; the leading timestamp still sorts,
and ids already on disk still validate.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
A single run says whether the prompt improved on one model. The question people
actually have is which model to ship the evolved prompt on, and neither the
strongest model nor the one that improved most is reliably the answer.

Run live on a compatibility-report goal: Llama 3.1 8B gained the most, +16
points, and finished last on test at 58%. Ling-3.0-flash gained nothing at all
and won at 90%, because it started at 86%. Ranking by improvement would have
recommended the worst model, so the table ranks by held-out test — not by the
validation split each run was fitted to, and not by the delta.

Picking challengers starts the same search on each, at the same time, against
the same goal, rubric and budget. A row that has not finished sorts last rather
than first: an unfinished run has not earned a position. Each row carries the
prompt it won with, because the answer is a pair, and showing the model without
the prompt invites someone to take the name and leave the reason.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
The table needed a dozen columns — baseline, evolved, delta, test, tokens and
one per rubric dimension — and the results pane is a third of the window, so
everything past the model name was scrolled off. Rows read at that width and do
not get wider when the pane does.

The validation pair is now labelled. Beside a large test percentage, two
unexplained figures that disagree read as a bug rather than as the difference
between the split each run was fitted to and the one held out.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Buttons, Generate's export and hand-off, readable template names, Settings in
two panes. Then four correctness bugs found by running the workbench against a
real provider — a token cap scored as the model's answer, Evolve optimising a
model nobody chose, GEPA shipping a JSON blob as the evolved prompt, and run
ids colliding when runs start together — and the two Evolve features that came
out of using it: per-dimension rubric scoring, and racing several models to see
which one to ship the prompt on.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Bring PR #8 (tool-routing Compare, multi-slot Deploy, i18n) back onto
develop per the release merge-back rule, keeping develop's Evolve and
Generate UX under the shared i18n keys.
…rn evaluation (#9)

* Rank, eliminate and re-vote on the preference ballot

A group ballot could only name a winner, which says nothing about how the
answers passed over compare, and the flow only ever moved forward. Ranking
records every pair; eliminating records the survivors beating what went out;
re-voting rebuilds one prompt from its answers and retracts its votes with an
append-only undo entry, so the log still holds the change of mind.

Identities now stay hidden until every prompt is decided: naming the winner of
one prompt would name the model on the next.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>

* Measure and check every deploy slot from one action

Install now lays down the unit and serve wrapper for every slot, so adding a
slot later is a measurement rather than another install. Measure gains a
whole-host run: weights for every slot download in parallel, which is where the
wall time goes, then each slot is sized alone because a slot is measured against
the VRAM actually free and two probes would each claim what the other is about
to take. A slot that fails is reported and the rest still run.

Health is genuinely parallel - independent HTTP calls to ports already serving -
with output buffered per slot so it stays readable. Benchmark stays sequential:
concurrent generation on one card measures contention, not throughput.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>

* Add multi-turn evaluation for text and tool conversations

Single-turn scoring cannot see what conversations break on: a constraint given
once and owed three turns later, a correction that has to stick, a tool result
that should be read rather than fetched again. Scenarios script the user side so
every model hears the same words, and each turn declares which capability it is
there to test, because a bare pass rate cannot say whether the failures were
forgotten instructions or bungled calls.

Tools ride the same JSON contract as the single-turn routing harness rather than
provider-native function calling, so a small vLLM model and a hosted API can sit
the same exam; the docs say plainly what that measures and what it does not.
Providers now take conversation history, which is what made any of this possible.

A turn expecting a result for a call that never happened is marked desynced: the
result is still delivered so the rest runs, but the turn cannot pass.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>

* Keep a half-eliminated ballot honest

Knocking one answer out and then naming a winner recorded the winner as beating
the answer already out, and let a model judge crown it. Both now see only what
is still standing, a tie called after an elimination ties only the survivors,
and the per-prompt order places the knocked-out below them, last one out
highest.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>

* Report a failed weight prefetch once, with the reason

Every parallel job wrote to one log and then claimed success regardless, so a
failed prefetch printed both the warning and "weights ready" for the same repo.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>

* Expand the tool-routing dataset and gate its integrity

The set was 100 tasks, 25 per language, split further by toolset and by
call/absence until the interesting cells were single digits - too small to put
a confidence interval on, which is the whole problem for a paper. It is now 324
tasks, 81 balanced per language, with absence up from 20 to 52 so BLOCK and
DEFER are each measurable.

A bigger set is only worth having if every task is answerable, so a validator
enforces the property that matters: any argument the schema marks copied
verbatim must appear in the request, while normalized values (English city,
ISO date, language code) are not required to. It runs in verify:tool-routing
and as a unit test, and it found a real bug on its first run - a Korean task
that expected "Gangnam Station" where the request said 강남역.

The registry gains more sub-10B tool callers: Qwen2.5 (Apache sizes), SmolLM2,
and the xLAM function-calling specialists (eval_only, non-commercial).

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>

* Add the current sub-10B field, and a live runner to actually test it

Searched the model landscape as of today rather than trusting the registry:
Granite 4.1 (April 2026, dense 3B/8B, Apache-2.0, and IBM's own numbers put the
8B at or above the 4.0 32B MoE on tool calling), Qwen3.5-9B, and Gemma 4
E2B/E4B. Every repo id and licence was verified against the Hugging Face API
before being written down; Ministral 2512 is gated and was left out.

yarn tool-routing:live runs the set against real models and prints an overall
table plus a per-language one, so the numbers in a paper can be regenerated.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>

* Sample subsets by stride, and print every denominator

The live runner's --limit took a prefix. The fixture file is ordered by
scenario, so the first N tasks of a language are its first few scenarios rather
than a sample of it, and an early run computed an absence rate over five items -
the exact small-denominator problem the enlarged set exists to escape. Subsets
now take an even stride, and every rate is printed with the number of examples
it was taken over.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>

* Record the sub-10B run these changes were made to support

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Every module already produced runs and each stored them its own way, which is
fine until someone asks what they ran yesterday, which cells failed, whether a
configuration has already been run, and what commit produced a number. Those are
questions about runs in general, so the registry holds one small record every
kind shares and treats what the module did as opaque JSON. A new kind of run
plugs in without the registry changing.

Storage is configuration: an fs driver by default, one directory per run in the
layout eval/tournaments already uses, and a sqlite driver for when there are
thousands. node:sqlite ships with the runtime but is experimental, so it is
imported only when configured. Filtering lives above both drivers and the test
suite runs against each, because two stores that disagree about what a filter
means would answer the same question differently depending on a setting.

Provenance is captured rather than asked for - commit, dirty flag, params hash,
runtime - and records machine facts only: the vote log already refuses to record
who voted, and reintroducing identity beside it would undo that.

Tool-routing sweeps from Compare are the first producer, through a helper whose
failures are swallowed. Recording a run must never be able to break the run.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
A researcher's unit of work is a grid - these models over these datasets in
these languages - long enough that something interrupts it, and re-running the
finished cells wastes time and risks inconsistent numbers. The queue expands the
grid into cells with stable keys, runs them under a global and per-group
concurrency budget so one provider's rate limit is never tripped, retries the
transient failure without retrying the broken cell forever, isolates a bad cell
from the rest, and checkpoints each as it lands.

Resume is a two-method Checkpoint interface: a Map in a test, the registry's
append-only event log in production. Because it reads through the RunStore,
resume works across processes - kill the job, restart it against the same run id,
and only the unfinished cells run. That is what the event log was for.

The scheduler is a fixed worker pool over a shared cursor; the worker holding
the last slot of a saturated group drains it, so a single pass suffices and no
cell is orphaned. The queue never reads params or summaries - a new kind of
matrix needs no change to it, the same way a new run needs none to the registry.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
…on top (#12)

The registry stores runs and the queue runs cells; a researcher wants one verb:
run this grid, and if it dies, run it again and pick up where it stopped.
runRegistryMatrix is that verb - it opens or reopens a run, checkpoints every
cell to the run's event log, drives the queue, and finalises the status. A
resume reopens the same run and reruns only the unfinished cells; a resume whose
parameters hash differently is refused so it cannot become a different
experiment under one id.

The cell summaries now ride along in the checkpoint, so a results table is
rebuilt from the registry even after a resume rather than living only in the
process that produced it.

First producer: yarn tool-routing:matrix, one cell per (model, language),
resumable with --resume. Verified live through OpenRouter - interrupted a grid
at 2/3 cells and resumed to run only the remainder under the same run id.

Also fixed two usability bugs in yarn runs that this surfaced: it truncated run
ids so you could not copy them, and it crashed with EPIPE when piped to head.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
An accuracy column cannot distinguish a model that routes correctly and formats
badly from one that picks the wrong tool from one that acts when it should have
declined - and those three need a parser fix, better tool descriptions, and
something else again. This classifies them.

Runs could not be triaged at all before, because only slice metrics survived:
a run could say 6% failed to parse but never which items or why. So the registry
gains artifacts - a run's full output, beside the summary rather than in it - and
the tool-routing matrix stores each cell's report. Tool-routing examples now also
record the expected outcome, because 'wrong arguments' is not a finding until the
wanted ones are visible.

The taxonomy is shared across harnesses on purpose, and ordered: the right tool
inside the wrong key is an envelope failure, not a bare format one, since calling
it format hides the most actionable result in the set. format, envelope and
desynced are marked recoverable - a prompt or parser change rather than a better
model.

yarn failures triages a run from its artifacts without re-calling anything. On a
live two-model run it found 10 of 13 failures were wrapper problems, and showed
Ministral-3B inventing a cat joke and calling translate_text on it where BLOCK
was owed. The request is printed without the tool catalogue, which is identical
on every task and buries the line that differs.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Two accuracy numbers side by side invite one question that a point estimate
cannot answer. Comparisons are now paired the way the evals are - every model
answers the same items - with Wilson intervals rather than the normal
approximation, which breaks at exactly the denominators these slices have,
McNemar's exact test on the disagreements, a seeded paired bootstrap so an
interval does not move on re-run, and Holm adjustment across pairs. Power
warnings print next to the result, because the failure mode is not a wrong
number but a true one that cannot carry the weight put on it.

That earned itself immediately: a matrix reported llama-3.2-3b at 100% tool
select against 90% for two others, which is an artefact of a denominator of 3 -
the other 21 items never reached the metric because they failed to parse. On
parse compliance the same run separates at 25% against 100%, p<0.001.

Triage now produces something too. A cohort saves the current selection as a
registry run - so it is listable, has provenance, and is a cell list the queue
can consume - and re-running one calls only those items. Annotations let a
reader overrule a derived classification while keeping what the classifier said,
because one corrected the same way repeatedly is telling you to fix it.

Two bugs the end-to-end run exposed, both mine: cohort cell keys kept a
character artifact names forbid, so every re-run cell failed loudly; and the
run listing's unit table was off by one, printing a two-minute-old run as
'2s ago'.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Everything the CLI tools surface - the registry, failure triage, the paired
comparison - was reachable only from a terminal. This puts it in the browser:
browse runs, open one, see why it failed and whether a difference between models
is real. A new top-level module, thin API routes over the harness logic that
already existed, and a client app that is read-mostly - the only writes are a
saved cohort and a hand correction, both of which the CLI can also make.

The failures view is the side-by-side that ends an argument: the request, the
expected outcome and the raw reply next to each other, kind chips that filter
and separate the recoverable wrapper failures from the ones needing a better
model, save-as-cohort, and reclassify. The compare view is the paired stats with
Wilson intervals and Holm-adjusted p, and it reproduces the CLI finding exactly -
llama at 100% tool-select over 2 items, 20% parsed with p=0.000.

The run list polls, so a matrix started from the CLI visibly finishes on screen.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
The detail panel rendered after a long failure table, so opening one put the
evidence off-screen - you had to scroll to find what you just clicked. It now
renders above the table and scrolls itself into view on open, so a click shows
the request, expectation and reply straight away.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Image comparison leaves the product surface for now behind one dormant switch;
its suites, judge, APIs and adapters stay implemented for later wiring. With text
as the only modality, Compare no longer asks the reader to choose one. The module
nav now teaches the workflow: Generate, Compare, Analyze, Evolve, Deploy.

Generate now hands Compare deterministic references, the bound verifier, metric,
template revision, locale and seeds. Exact/numeric verifiers carry one reference;
format-only verifiers remain unscored rather than inventing a gold. In a source
checkout Generate also falls back to importing packages/generate directly via
python3, so the browser's first step no longer requires pip install -e.

Compare scores with the bound verifier - making 13.8000 a valid answer to numeric
reference 13.8 at tolerance 1e-4 - while retaining flat gold for Expected/Asked/Got.
Every completed text run becomes a compare-text registry run with a full
redrob-text-eval/v1 artifact. The primary completion CTA deep-links to Analyze,
which classifies wrong/partial/empty answers and compares models on paired pass
rates with the same statistical safeguards as tool routing.

Desktop gutters and card padding were increased; the old 16px edge read as no
padding around dense tables.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Four random base36 characters collide about 1% of the time at 200 ids in a
burst, which failed CI at random and, when it passed, still let two runs share
a directory. A process-local counter from a random start makes a burst unique
outright.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Merging main into develop after the tool-routing release kept both sides of the
rewritten sections, so the file has carried two copies of the branching model,
the squash rule and the release QA layers since. The remaining copy is byte for
byte the one on main.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
…ING (#16)

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
Bump the five version fields, and start a changelog: 0.1.0 was never tagged, so
v0.2.0 is the first release a reader can obtain by name.

Co-authored-by: Janghoon Lee <savagemanage@users.noreply.github.com>
@cursor
cursor Bot merged commit d05d620 into main Aug 13, 2026
3 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants