Router features, candidates and Thompson policy
Epic 12251, child C3b. Replaces C3a's default-only `policy.pick` stub. Needs C3a (decisions, personas, wrapper) and C1 (modes, org policy).
## Original requirement (verbatim)
Ask 2: "Features: persona; complexity bucket (body length, files named, repo language, clarity score, prior similar tasks); org policy (allowed providers, model floor/ceiling, budget headroom from budgets.py); live availability (quota gates and headroom from jaah_scheduler.py, node capacity, SDK-capable nodes); per-configuration ledger statistics for the stratum, org first, operator-plane prior where the org has too little data."
Ask 3: "Policy: Thompson sampling over configurations in the allowed set; objective expected survival probability per dollar with a latency cap; exploration capped at a per-project budget share, default 10%. Falls back to today's precedence chain whenever the allowed set is one, data is below the minimum, or the mode is off; with the feature OFF resolve_agent and placement stay byte-identical."
## Settled decisions
D3: router chooses agent + model tier + review configuration; placement stays `jaah_scheduler.gate_and_tier` via `_best_account` unchanged. A3: Recommend/Auto act on coder and review personas only. A4: pins win. R2: operator-plane prior is aggregate only — no org name or per-org figure. A9: "Prefer a different vendor" for blind children.
## Approach (Architect §4.2–§4.4, AD4, AD10–AD13, AD15, AD16)
- `features.py`: `{persona, complexity:{body_chars, files_named, repo_language, clarity_score, similar_prior, bucket:'S'|'M'|'L'|'XL'}, policy:{allowed, tier_floor, tier_ceiling, budget:{state, percent, headroom_usd}|null}, availability:{<config_key>:{accounts_gate:'ok'|'tiered'|'blocked', five_h_pct, seven_d_pct, capable_nodes_online, sdk_capable, placement_ok}}, stratum:"<persona>|<bucket>|<repo_language>"}`. files_named = distinct repo-path tokens; clarity_score = parent epic BA settle; budget from `budgets.statuses()` (`budgets.py:199`); availability via `placement_grants.allowed_accounts(task)` + a READ-ONLY `gate_and_tier` evaluation + `nodes.runner_clis` + `sdk_capable_nodes` + project placement class; memoised per org per pass. Bucket thresholds in one function.
- `candidates.py`: providers = grid-allowed ∩ `orgs.routing_allowed.providers` ∩ placement_ok; agents = org SDK + enabled catalog agents with persona in agent.personas, `unrunnable_reason is None`, ∩ allowed agent_ids, ONLY when `plan_terms.intelligent_routing(org)` (AC15); registry agents excluded in MicroVM projects; tiers within floor/ceiling; Bedrock (jaah_routing.org_route not None) orgs → Claude only at profiled tiers. `config_key = "<kind>:<provider|agent_id>:<tier>"`, sorted. A9 rule (deterministic, every mode): with `blind_review_other_vendor` ON pick the first available candidate whose provider/agent differs from the author's `actual` (order `agent_providers.PROVIDERS`, then agents by display name; tier persona default `high` clamped); none → author's config and the reason says so. Export `router_eligibility(session, org_id, agent_ref) -> (bool, reason)` for C8.
- `ledger_stats.py`: `ConfigStats(n, survived, cost_usd, p50_secs, source)`, `LedgerStats` Protocol (`version`, `config_stats`, `agent_profile`), `NullLedgerStats` (version 'none', everything insufficient), `MIN_OUTCOMES = 20`.
- `policy.py`: frozen rows never recomputed; fallbacks persona_not_routed / playbook / human_pinned / system_pinned / vendor_bound / mode_off (still computes router_pick) / allowed_set_one / data_below_minimum; RNG seeded `sha256(task_id, attempt, inputs_hash)`, candidates in config_key order; cost = stats cost if n ≥ MIN else `model_prices` estimate (unpriced agent → default config's estimate, marked estimated); mature = n ≥ MIN minus latency-capped (`routing_latency_cap_hours`, NULL = no cap); greedy = argmax mature E[Beta]/cost; sampled = argmax eligible Beta-sample/cost; exploratory = sampled != greedy. (The exploration BUDGET step is C4's.) AD16: a used='router' pending row unleased after `ROUTER_UNPLACED_FALLBACK_SECS=600` is superseded, candidate excluded `unplaced`, fallback `candidate_unavailable`.
- `reason.py`: 1–2 full sentences, display names only, e.g. "Codex/high chosen: 0.82 expected survival per $ vs 0.61 for Claude/high on similar Python tasks; 8% of this project's exploration budget used." Fallback reasons in plain words (e.g. "kept on the vendor it first ran on").
Credentials & reach: API tests — the worktree only (`./dc` stack + pytest) — verified.
Out of scope: changing account quota semantics or spillover; training a model from scratch; routing analysis personas before coder tasks have data; public marketplace billing; any ledger metric definition (owned by the outcome ledger epic). MUST NOT change `gate_and_tier`, `account_auth.py`, runner caps or spillover; availability reads gates read-only. MUST NOT read or create `task_outcomes` (C9 swaps the LedgerStats implementation). No UI.
Done means:
- AC8 Fallbacks (allowed set 1; data below minimum; human-pinned). → Verify: an API test shows that in each case the dispatched configuration equals the default chain's and the record names the fallback reason. (Dev part with C4.)
- AC15 Router may pick an enabled catalog agent. → Verify: an API test shows that a catalog agent enabled for an org with the feature and in the allowed set appears in the candidate set, and is absent when disabled or when the org lacks the feature.
- Tests: seeded determinism (same pending row → same pick); with a fake LedgerStats seeding mature arms, greedy picks the best survival/$ and sampling ignores no data (R4b); A9 rule picks a different vendor and states it when none exists; AD16 timeout supersedes; operator prior carries no org name; Bedrock org → Claude-only; MicroVM → no registry agent.
- reason.py test: no slug, enum name or config_key appears in any generated reason across all fallback reasons and picks (R4a).
- AC2 matrix from C3a still passes unchanged.
- Gates (AC16): `./dc check quick` green; changed-area suites green; merge lane `./dc check full` green.
Activity
- Draft
- Ready