Skip to content

Rank tiers by capability, verify every rung is callable, route scout and vision - #37

Merged
atyrode merged 5 commits into
mainfrom
model-selection-redesign
Jul 25, 2026
Merged

Rank tiers by capability, verify every rung is callable, route scout and vision#37
atyrode merged 5 commits into
mainfrom
model-selection-redesign

Conversation

@atyrode

@atyrode atyrode commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Driven by Opus 5 shipping. The id swap was the smallest part.

1. Price stopped predicting capability

pickLadder ranked by input cost. Against today's live catalog that produced:

plan/slow/designer/reviewer  claude-opus-4-1:high   ($15, 200k, xhigh)
                  fallback   claude-opus-5:high     ($5,  1M,   max)

The smart rung led by a fossil its own fallback dominates on every axis, capped a thinking rung below what the model behind it offers. Providers now reprice successors below predecessors they never delist, so cost inverts capability.

Now: supersession first (newest per family, version components compared numerically so a future 5.10 beats 5.9), then ranked by thinking ceiling → context → price. Price is the last word, not the first. loadCatalog asserts the result: a dearer rung offering less context or less thinking headroom is rejected by name.

2. A model your account cannot call could lead the smart rung

This is the part worth reviewing closely.

omp models --json lists claude-mythos-5. It 404s on this account. It reports claude-fable-5's exact price, context window, thinking range, modalities and maxTokens — I diffed every field; only id/name/selector differ. Nothing in the metadata says which one you can call.

The only thing keeping it off the ladder was a shorter-id tiebreak. Rename it claude-myth-5 and it wins the smart rung, and every profile leading with it fails at launch.

So reachability is no longer a flag. init probes every candidate; only a model that answers may become a rung. The file is stamped probed: true and generate refuses one that is not — that marker is the gate, so --from-json can still scaffold offline (stamped false) without an unverified scaffold quietly becoming live routing. --bench is gone: measurement is now a side effect of a probe that always runs, which also retires the placeholder 50/2.0 pair that made the speed meter identical for every model.

The probe found a false positive that mattered. claude-fable-5 failed — not unreachable, but refusing omp's bundled bench prompt:

Refusal (cyber): This request triggered restrictions on violative cyber
content and was blocked under Anthropic's Usage Policy.

A rule of "any failure means unreachable" would have silently deleted a perfectly callable elite from the ladder. omp bench is not a reachability oracle on its default prompt. So the probe asks something innocuous (--prompt), and sorts three ways:

outcome evidence action
reachable answered, zero failures, non-nil average eligible, measured
not found provider says it does not exist dropped
unresolved refusal, rate limit, missing row nothing inferred

Unresolved is a refusal to certify, not a quiet drop: init names each model and its reason and writes nothing. A probed: true file built from a partial probe is worse than no file.

3. The elite was crowned as the everyday smart rung

Tier 0/4 came only from omp's tier-scoped quota buckets. That report turns out to depend on the ambient auth environment — the anthropic:fable bucket is visible from one environment on this machine and absent from another, minutes apart, deterministically (3/3 each way). Root cause: OMP_AUTH_BROKER_TOKEN/_URL present or not.

When absent, the elite didn't merely go undetected — it fell onto the ordinary ladder and became tier 3, so every routine "smart" request would drain the scarce $10 bucket that exists to be spent deliberately. Observed live:

A: haiku(1)  opus-5(2)  fable-5(3)     <- no tier 4, sonnet-5 gone

Price now confirms what the bucket used to assert alone: a pool's top model priced at twice the next is its elite, bucket or no bucket. Price says nothing about entitlement — that is the probe's job — but a model in its own price class is not the everyday workhorse.

4. scout and vision

omp bundles six agents; the grid routed five. scout — the one omp's own prompt marks "MUST be used for exploratory codebase research" — silently inherited @smol, invisible in the preview and unselectable. Now agent-backed, so genConfigYAML mirrors it into task.agentModelOverrides. vision is one of omp's ten real role ids (confirmed against omp's docs) and backs image description when the active model is text-only; it always leads on an image-capable model, since the codex spark variants are not.

Both needed roleWeight entries — weightedModels silently skips a role it cannot weigh, dropping it from both meters with no trace. A test asserts every emitted role is weighted.

Routing scout also desynced the catalog header, which carried a hand-typed copy of the agent list beside genAgentRoles: the grid marked 414 ● scout rows while the legend named five agents and denied scout existed. The legend is now derived from genAgentRoles, and a test asserts it names exactly the roles the rows mark — nothing covered that line before, which is how it went stale unnoticed.

Enter no longer launches a combo the catalog lacks. It walked a nil block and handed omp an empty modelRoles: map — reproducible on a stock scaffold, where shipped spark: on pointed at a combo no tier-0-less catalog contained. Dials are now derived from the catalog: a toggle with no combos is forced off and hidden.

Result

O: spark(0)  mini(1)  terra(2)  sol(3)
A: haiku(1)  sonnet-5(2)  opus-5(3)  fable-5(4)

The hand-curated dotfiles ladder, derived automatically. 414 combos (was 91, none with spark or fable).

Verification

  • go build, go vet, gofmt, go test ./... clean — 9.4s, down from 564s: the suite had been making real API calls through obScan. Now fully hermetic.
  • Live code generate init: 21 models probed in ~47s → mythos dropped, fable-5 retained and correctly tier 4, probed: true written.
  • New binary against the dotfiles curated catalog: 415 combos, 8 models. Delta vs the deployed catalog is exactly 414 x 2 lines — the new scout and vision rows, nothing else.
  • The gate rejects an unattested copy of that same file with an actionable message.
  • Regression tests for each finding, including TestScaffoldRefusalSurfacesNotDropped — a refusal must surface as unresolved naming the model, while a genuine 404 is dropped and must not appear in that list. It fails under the previous two-state rule.
  • Rebased onto main after code ls/session reap (feat(session): add code ls and code session reap #36) and the v0.4.8 pin (chore(nix): pin code v0.4.8 #38) landed — clean, no conflicts. Re-verified on the combined tree: build/vet/fmt/tests green, code ls works, and the generator still renders the dotfiles catalog to 415 combos.

Not in this PR

No release tag — per AGENTS.md a v* tag is a deployment, so that waits for review. Companion: atyrode/dotfiles#341.

One loose end, evidenced but unfixed: retry.fallbackChains keyed by agent name never fire. Verified by forcing a retry — a sonic: chain specifying haiku was ignored and the agent landed on sonnet-5 instead, because reviewer/librarian/sonic/scout are agent names, not role ids. Role-keyed chains do work (a default: chain fired exactly as written). That affects defaults.yml, not this repo, and keying by model selector shadows the working designer: chain, so it needs a decision rather than a patch.

@atyrode atyrode changed the title Rank model tiers by capability, route scout and vision, never launch a missing profile Rank tiers by capability, verify every rung is callable, route scout and vision Jul 25, 2026
atyrode added 5 commits July 25, 2026 13:01
Input cost stopped being a proxy for capability once providers began
repricing new models below predecessors they never delisted. Run against
today's real catalog, the old scaffolder produced:

    plan/slow/designer/reviewer -> claude-opus-4-1:high  ($15, 200k, xhigh)
                       fallback -> claude-opus-5:high    ($5,  1M,   max)

the smart rung led by a fossil its own fallback dominates on every axis.
At the top dial position it also capped thinking, emitting
`claude-opus-4-1:xhigh` while the model behind it could do `max`.

The ladder is now derived rather than guessed from price:

  - supersession first: only the newest member of each model family
    survives, so claude-opus-4-1 never competes with claude-opus-5.
    Version components compare numerically, so a future 5.10 beats 5.9.
  - ranking by thinking ceiling, then context, then price. Price is the
    last word, not the first: among models with identical published
    specs it is the only remaining signal of size, but across
    generations it lies.
  - loadCatalog now asserts the property the ranking should produce. A
    dearer rung offering less context or less thinking headroom is
    rejected with both models named, rather than silently shipped.

Tiers 0 and 4 were previously unreachable — pickLadder only ever emitted
tiers 1..3, so the spark and fable dials were dead on every scaffolded
config and the shipped default (spark on) opened the TUI on a combo that
was never generated. They are now read from the tier-scoped quota buckets
omp already reports, capped at one per pool and sanity-checked against
price.

Also:

  - --bench fills speed/ttft from `omp bench --json` instead of writing
    50/2.0 for every model, which made the speed meter model-invariant.
    It doubles as a reachability probe: omp lists claude-mythos-5 at
    claude-fable-5's exact price but it 404s here, and no metadata
    distinguishes them. A failed probe drops the model.
  - --refresh re-derives tiers over an existing file. init used to refuse
    outright, which is how a catalog goes three model generations stale
    without anyone noticing.
  - thinking is a level set, not a range. claude-opus-4-6 offers
    low/medium/high/max but not xhigh; a range claimed a level the API
    rejects.
  - same-family short keys disambiguate by version (opus5, opus48)
    rather than by ladder index, which named the newer model `opus` and
    the older one `opus3`.

The models file gains bucket, context and image fields. The init fixture
now models the catalog's real shape — four same-priced Opus variants, an
undelisted $15 legacy, two identically priced elites, a text-only spark —
so it can actually fail on the bug it is meant to guard.
omp bundles six agents; the grid routed five. `scout` — the one its own
prompt marks "MUST be used for exploratory codebase research" — silently
inherited @smol and never appeared in the preview, so its model was
neither visible nor selectable. It is now an agent-backed role, which
means genConfigYAML mirrors it into task.agentModelOverrides for free.
`vision` backs omp's image-describe fallback (describeForTextModels is
on by default) and always leads on a model that accepts images, since
the codex spark variants are text-only.

Both needed roleWeight entries: weightedModels silently skips a role it
cannot weigh, so an omission drops that model out of the cost and speed
meters with no trace. A test now asserts every emitted role is weighted.

Enter no longer launches a combo the catalog does not carry. It walked a
nil block and emitted an overlay whose modelRoles map was empty, handing
omp a session with no routing at all — reproducible today on a stock
scaffold, where the shipped default (spark on) points at a combo no
tier-0-less catalog contains. The dials themselves are now derived from
the catalog: a toggle with no combos is forced off and hidden rather
than offered.

The quota bucket a model draws is read from the catalog's new column,
falling back to the family-substring guess only when a catalog declares
none. Names are not a taxonomy — claude-mythos-5 sits at
claude-fable-5's price yet 404s on this account, and every model omp
adds would otherwise need one more substring arm before it could be
struck through correctly.
- docs/configuration.md documented CODE_USAGE as the usage panel's
  source; nothing reads it, and the panel has been broker-sourced since
  the account-pool work. Removed, with a note that the wrapper still
  exports it for older pins so a reader who greps it is not misled.
  Seven vars the binary does read were undocumented: the three
  OMP_AUTH_BROKER_*, CODE_AUTH_VAULTS(_FILE), CODE_AUTH_ACCOUNT_STATE
  and CODE_USAGE_CACHE.
- README claimed the first run walks you through building a catalog.
  True for a plain install; the dotfiles wrapper always exports
  CODE_GENERATED, so onboarding cannot trigger there.
- docs/status.md's two honest gaps are narrowed rather than dropped:
  speed/ttft placeholders now have --bench, and the quota bucket is a
  catalog fact with the family guess as fallback.
- Documents the generate subcommand, which the README linked to but the
  page never covered.
omp lists models an account cannot call, and nothing in the metadata
says which. `claude-mythos-5` reports `claude-fable-5`'s exact price,
context window and thinking range, and 404s here. Until now the only
thing keeping it off the ladder was a shorter-id tiebreak — rename it
`claude-myth-5` and it wins the smart rung on this account, and every
profile leading with it fails at launch.

So reachability is no longer a flag. `init` probes every candidate and
only a model that answers may become a rung; the file is stamped
`probed: true`, and `code generate` refuses one that is not. That marker
is the whole gate: `--from-json` still scaffolds offline, but stamps
`false`, so an unverified scaffold cannot quietly become live routing.
`--bench` is gone — its measurement is now a side effect of a probe that
always runs, which also retires the placeholder speed/ttft pair that
made the speed meter identical for every model.

A failed request is not the same as an absent model, and conflating them
is expensive. Probing the real catalog, `claude-fable-5` failed: omp's
bundled bench prompt trips Anthropic's safety layer, and the model
refuses it. A rule of "any failure means unreachable" would have
silently deleted a perfectly callable elite from the ladder. The probe
therefore asks something innocuous, and sorts each model three ways:

  reachable   answered — eligible, and measured
  not found   the provider says it does not exist — dropped
  unresolved  refusal, rate limit, missing row — nothing is inferred

Unresolved is a refusal to certify, not a quiet drop: `init` names each
model and its reason and writes nothing, because a `probed: true` file
built from a partial probe is worse than no file.

Second defect, same shape: the elite tier was read only from omp's
tier-scoped quota buckets, and that report turns out to depend on the
ambient auth environment — the anthropic fable bucket is visible from
one environment on this machine and absent from another, minutes apart.
When it was absent the elite did not merely go undetected, it fell back
onto the ordinary ladder and was crowned tier 3, so every routine
"smart" request would drain the scarce bucket that exists to be spent
deliberately. Price now confirms what the bucket used to assert alone:
a pool's top model priced at twice the next is its elite, bucket or no
bucket. Price says nothing about entitlement — that is the probe's job —
but a model in its own price class is not the everyday workhorse.

Verified against the live catalog: 21 models probed in ~47s,
claude-mythos-5 dropped, claude-fable-5 kept and correctly crowned
tier 4, yielding haiku/sonnet-5/opus-5/fable-5 and spark/mini/terra/sol.
The catalog header names the roles the ● marker can appear on. It was a
hand-typed list sitting beside genAgentRoles, so routing scout marked
414 rows the header denied existed.

Derived from genAgentRoles in genRoleOrder, so the legend now reads in
the same order as the rows beneath it and cannot drift again. A test
asserts the legend and the marked rows name the same set - nothing
covered that line before, which is why it went stale unnoticed.
@atyrode
atyrode force-pushed the model-selection-redesign branch from ca95e04 to 60f27f7 Compare July 25, 2026 13:07
@atyrode
atyrode merged commit b1c22ab into main Jul 25, 2026
1 check passed
@atyrode
atyrode deleted the model-selection-redesign branch July 25, 2026 13:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant