Files
6krrt/plans/named-routing-profiles.md
adlee-was-taken 3523dcf93e docs(plans): give every plan a Status line so the queue is greppable
plans/ held 58 documents and exactly one said whether it was open. The rest
mixed finished work, reviews of shipped work, parked specs and genuinely
pending ones, with nothing distinguishing them, so "how many plans are in
the queue" had no answer short of reading all 58.

Now `grep -H '^Status:' plans/*.md` is the answer:

    50 done   3 in progress   2 planned   2 reference   1 parked

Statuses were derived rather than guessed: CLAUDE.md's own built list and
"What's NOT built yet" section, plus checking the subject exists in the
code. A review of work that shipped counts as done -- it records what was
found, it is not a request for anything. `reference` separates the two docs
that are conventions rather than work items (admin-design-standards,
admin-work-framework), which otherwise read as permanently-open plans.

The vocabulary is deliberately five words. A larger one invites "mostly
done" and "blocked-ish", which is how the directory became unreadable.

test_plans_declare_status.py keeps it from rotting: a new plan without a
marker fails, as does an unknown status, one buried below the eighth line,
or an open status with no reason -- "planned" alone is the state that rots,
since nobody can tell later whether it waits on a decision, a dependency,
or just nobody's turn.

Also updates the sweep plan with what landed and what did not, including
that #9 was not a defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRQXz5SYZYVWscxS1QqF6U
2026-09-08 18:55:16 -04:00

186 lines
8.9 KiB
Markdown

# Named routing profiles
Status: done -- BUILTIN_PROFILES plus overlay profiles
**Status: FINAL — decision-complete. §3 was answered by the user on 2026-09-03
(filter only).** Written 2026-09-03 against `main` at `c2893b5`.
## What this is
Today the router has exactly one behaviour: classify, hard-filter, rank
quality-first with cost as a tiebreak. The user wants named alternatives —
`locality`, `onlycheaps`, `bigboybritches` — alongside a `default` that stays
the feature-rich automatic router it is now.
**The mechanism already exists and is half-built.** `auto:batch` is a profile:
a named variant of `auto` that changes one hard-filter parameter
(`latency_tolerance`), documented in `README` and shipped for months. This plan
generalises that one hard-coded special case into a named, configurable set.
It is deliberately NOT a new ranking path.
## 1. Where it slots in, precisely
Three call sites, all already doing most of the work:
- **`src/dispatcher.py:2937`** —
`wants_routing = bare in (ROUTER_MODEL, ROUTER_MODEL_BATCH)`. An exact
membership test against two constants. This becomes a *parse*: split
`auto:<profile>`, look the profile up, reject an unknown one.
- **`src/dispatcher.py:2955`** —
`latency = BATCH if requested == ROUTER_MODEL_BATCH else INTERACTIVE`. This
is the existing profile, expressed as a ternary. It must become one case of
the general mechanism, not survive alongside it.
- **`src/routing.py:313` `select_candidates`** — already takes
`exclude_models: set[str]`, `task_category`, `latency_tolerance`,
`allowed_access_levels`, `min_tool_proficiency`. A profile supplies or
overrides these. **All the filtering machinery is already there.**
`routing.py` is a pure module with injected dependencies and
`rejection_reason` is the single copy of the rules (`is_eligible` is a thin
wrapper over it). Keep that: a profile must not introduce a second place where
eligibility is decided.
## 2. The one genuinely missing primitive
`select_candidates` has a **denylist** (`exclude_models`) and no **allowlist**.
Every profile the user named is naturally an allowlist:
- `locality` → only `provider = 'ollama-local'` rows
- `onlycheaps` → only rows under a cost bar
- `bigboybritches` → only frontier rows
Add **one** parameter, `restrict_to: set[str] | None = None`, threaded through
`rejection_reason` → `is_eligible` → `select_candidates`, with `None` meaning
"unrestricted" (NOT an empty set, which must mean "nothing eligible"). Give it
its own `rejection_reason` string so a 422 says *which* profile emptied the
candidate set — the catalog-staleness incident is the precedent: an empty
candidate set with an opaque reason cost ~19 hours to diagnose.
Prefer expressing profiles as **predicates over catalog columns** (provider,
cost, tier) rather than hard-coded model-id lists, so a profile does not go
stale the moment the poller adds a row. A literal id list stays available for
the cases where that is genuinely what the user means.
## 3. THE OPEN DECISION — filter only, or objective overrides too?
**DECIDED 2026-09-03 by the user: FILTER ONLY.** A profile restricts the
candidate set and nothing else. Ranking stays quality-first with cost as the
tiebreak, one rule for the whole system. Do NOT implement objective overrides,
and do NOT add a `quality_tolerance` field to a profile definition — if a
future preference-shaped profile needs one, that is a separate plan with its
own justification.
The reasoning is preserved below because it explains WHY filter-only is
sufficient, which is not obvious.
A profile is unambiguously a candidate-set filter. The question is whether it
may ALSO override `objective.*` (`quality_tolerance`,
`max_energy_per_request`).
It matters concretely, and we have the measurement:
`plans/local-dispatch-inert-and-test-coupling.md` established that the local
model scores 0.767 on `file_summarization` against cloud's 0.95 — a 0.183 gap
against a `quality_tolerance` of 0.1 — so local **never routes** under
quality-first ranking.
- A **pure-filter** `locality` still works: restrict the set to local rows and
the best local model wins by default, because there is no cloud row left to
lose to. This covers the user's stated case.
- A **mixed** profile that *prefers* local without excluding cloud does NOT
work as a pure filter. It reproduces the dormancy exactly.
So: pure filters satisfy the three named profiles. Objective overrides are only
needed for preference-shaped profiles nobody has asked for yet.
**Recommendation: ship filter-only.** It is the smaller change, it satisfies
every named use case, and it keeps one ranking rule in the system. Add
overrides later if a real preference-shaped profile appears. If the user wants
overrides now, they must be scoped strictly to the profile and never mutate
global config — and `quality_tolerance` in particular describes measurement
noise on 2-3 samples, so overriding it means asserting a preference through a
knob that means something else.
## 4. Naming and selection
Profiles are selected the way `auto:batch` already is: the model field.
```
auto -> default profile
auto:batch -> MUST keep working, byte-identical behaviour
auto:locality -> named profile
<real model id> -> passthrough, unchanged
```
`auto:batch` is load-bearing: it is in `README`, `docs/`, and possibly user
clients. **Reimplement it as a profile whose definition sets
`latency_tolerance: batch`, and pin its behaviour with a test that would fail
if the general mechanism changed it.** Do not leave the ternary in place beside
the new path — two mechanisms for one behaviour is how they drift.
An unknown profile (`auto:nonsense`) must fail **loudly** with a 422 naming the
valid profiles. It must NOT silently fall back to `default`: a user who
mistypes `auto:locallity` would otherwise get cloud routing and a surprising
bill, with nothing indicating why.
Config lives under a new top-level `profiles:` key. Config is strict
(`extra="forbid"`), so every field needs declaring; an unknown key is an error
by design.
## 5. Observability
`route_decisions` must record which profile served each decision, or the
proficiency loop cannot tell a `bigboybritches` win from a `default` one and
will read profile-forced selections as evidence of model quality. This is the
same reasoning that made the local fallback record
`kind='local_dispatch_fallback'` rather than `chat`.
Add a `profile` column (code-side migration in `ensure_route_decisions`, as
every prior column addition did — `config/schema.sql` is
`CREATE TABLE IF NOT EXISTS` and does not alter a live DB). Surface it in the
admin decisions table and its filters, which already filter by kind, category
and tier.
## 6. Interactions to get right
- **`eligible_categories`** (local dispatch) is a per-MODEL filter; a profile
is a per-REQUEST filter. They compose as AND. A `locality` profile must not
bypass `eligible_categories` — that gate is what keeps a summarization-grade
model away from code, and `nemotron-mini:4b` detecting 0/4 bugs is why.
- **The local-dispatch fallback** (`kind='local_dispatch_fallback'`) is a
degraded path, not a profile. Leave it alone. Under `locality` the local
model is the primary choice, so the fallback should simply never fire.
- **Circuit breaker and admin overrides** already feed `exclude_models`.
Profiles must AND with them, never replace them: a profile must not resurrect
a model an operator deprecated or the breaker has marked down.
- **`exploration`** (epsilon-greedy) picks from eligible candidates. Confirm it
reads the profile-filtered set, or exploration will select models the profile
excluded.
## Non-goals
- No new ranking algorithm. Quality-first with cost as tiebreak stays.
- No change to `objective.quality_tolerance`'s global value.
- No per-category preference logic (that was §1(a) of the previous plan and is
explicitly out of scope here).
- No changes to the local-dispatch fallback path.
- Do not touch the classifier: profiles select candidates, not categories.
## Success criteria
- `auto` and `auto:batch` behave **exactly** as today, pinned by tests that
would catch a regression in the general mechanism.
- At least the three named profiles ship, defined as predicates over catalog
columns where possible.
- An unknown profile returns 422 naming the valid ones; it never silently
degrades to `default`.
- A profile that empties the candidate set returns a rejection reason naming
the profile.
- `route_decisions.profile` is populated and visible in the admin decisions
table.
- Profiles AND with circuit-breaker exclusions, admin overrides, and
`eligible_categories` — proven by a test for each, not by inspection.
- `rejection_reason` remains the single copy of the eligibility rules.
- Full suite green with `local_energy.enabled` both true and false (the Plan 5
invariant must not regress).
- The user's `config/config.yaml` tariff lines remain uncommitted and verbatim.