ABC test: model × thinking effort (Opus 4.8 / Opus 5)
Status: CONCLUDED 2026-08-20 —opus48-medium shipped as the default.
Owner: chat / AI platform
Outcome
opus48-medium (Claude Opus 4.8 @ medium) won and is now the production
default: DEFAULT_LLM_CONFIG.defaultThinkingEffort moved 'xhigh' → 'medium'
with the model held at claude-opus-4-8. Effort was the lever that mattered;
the model contrast (opus5-medium) did not justify a switch.
Per §8.7, the experiment scaffolding has been removed rather than left at 100%
winner. What that entailed, and what deliberately survives:
Removed (all of it dead code once the default changed):
- The arm read, router override (
live_suppressed:experiment),$feature_flag_calledexposure, and everyexperiment_arm/$feature/<flag>property on the completion and failure events. - The
experimentEffortrung ofresolveThinkingEffortand the'experiment'member ofThinkingEffortSource. experimentArmfrom the checkpoint,markChatLogCompleted,markChatLogFailed, andreportChatError.
lib/flags/chat-model-effort.tsand its test suite, as the reference template for the next chat-side experiment. It is wired to nothing — see the header there for the two things to fix before reusing it (theEXPERIMENT_ARMSliterals describe this concluded test, and the flag key must be bumped off-v01).ai_chat_logs.experiment_arm, its partial index, and theai_chat_experiment_armsview — the historical record. Nothing writes the column any more, so it stays NULL on all new rows.20260817_01_create_ai_chat_judge_runs.sqlalso references it.thinking_effort/thinking_effort_source/thinking_supported/duration_msonchat_response_completed_server, and thestreamStartAtMscheckpoint carry. Per §5 these stand on their own; the latter was a real bug fix (multi-slice runs reported only their last slice).
thinking_effort_sourcerows written before 2026-08-20 can carry the string'experiment', which is no longer in the TS union. The column ships no CHECK constraint, so read it as an open set.- Router downgrades (
shortturns → Sonnet 5) were suppressed for enrolled users while the test ran and are live again as of 2026-08-20. Latency and model-mix comparisons that straddle that date are not apples-to-apples.
Original plan follows
Related:supabase/migrations/20260803_01_add_thinking_to_ai_chat_logs.sql,
lib/flags/workflow-dispatch.ts, app/api/claude/config/llm_config.ts,
lib/router/model-router.ts
The question we’re answering
Today every external chat turn runsDEFAULT_LLM_CONFIG.model
(claude-opus-4-8) at defaultThinkingEffort = 'xhigh'. Internal (team)
turns run at internalThinkingEffort (default 'medium', env-tunable via
INTERNAL_THINKING_EFFORT). That internal-vs-external split is an
observational comparison, not an experiment: the cohorts differ in who they
are, what they ask, and how they use the product, so it cannot answer “should
we ship a cheaper/faster configuration to everyone?”.
This plan makes it a randomized ABC test over the two things we can actually
change — which model serves the turn and how hard it thinks — measured
on latency, token cost, and answer quality.
The three arms
Adjacent pairs each vary exactly one factor, which is what makes the result
readable:
control→opus48-mediumisolates effort (same model).opus48-medium→opus5-mediumisolates model (same effort).
xhigh). Three arms at
our traffic volume already stretches the time-to-significance; the two
contrasts above answer the actual decision (“can we drop to medium?” and “is
Opus 5 better at medium?”) without it. If the chain shows an interaction
worth chasing, the fourth cell is a follow-up experiment, not a fourth arm on
this one.
What we already capture (verified against the code)
Four gaps to close before the experiment is readable:
chat_response_completed_servercarries neitherthinking_effort,thinking_effort_source, nor a turnduration_ms. Without them the PostHog side cannot split latency by arm at all.- No variant property on any server event, so PostHog would not attribute metrics to an arm.
- No persisted arm label on the row, so the SQL side can’t group by arm either (model+effort is a proxy for the arm, and a contaminated turn breaks the mapping).
- No accuracy signal.
Design
1. The flag
One PostHog multivariate flag whose variant selects a(model, effort)
pair — not two independent flags. Two flags would let a user land in the
un-tested Opus 5 @ xhigh cell and would make the cohorts un-analyzable.
clerkId: PostHog hashes the distinct id, so a user consistently
gets one arm — the same property lib/flags/workflow-dispatch.ts relies on,
and it matters more here than in a UI test. Chat is conversational, and
flipping model or reasoning depth between turns of one conversation would
both confuse the user and mix arms inside a single session.
Start control 100 / others 0, then ramp to 34/33/33.
The variant → (model, effort) mapping lives in our code, never in the
flag payload, so a fat-fingered PostHog edit can’t send an invalid model
alias or an invalid output_config.effort to Anthropic.
2. Resolution module — lib/flags/chat-model-effort.ts (NEW, server-only)
Copy the layered shape of lib/flags/workflow-dispatch.ts; it is the house
pattern and already solved the hard parts (single-flight, bounded cache,
timeouts, kill switch):
-
Redis override
chat-model-effort:arm=off| variant name — ≤5s TTL emergency kill with no deploy. Non-negotiable here: this flag can change the model, so a bad arm is a product-quality incident. -
Non-prod: return
null(fall through to today’s config). Do not randomize on staging — it makes QA non-reproducible (“why did my answer change?”). To exercise an arm off-prod, set the Redis key from step 1 — it is read before this bail-out, which is what makes a staging smoke test possible at all. There is deliberately no per-request?arm=override. That would be a client-supplied model + effort selector, which is exactly what was stripped fromChatRequestonce already (“client-trusted LLM-config overrides that no legitimate client ever sent” —app/api/claude/types.ts). Scoped production targeting is what a percentage rollout is for. If per-session pinning is ever genuinely needed, it belongs on an internal-only debug route keyed by clerkId, not on the public chat contract. -
PostHog (production only):
client.getFeatureFlag(key, clerkId)viagetPostHogFlagClient(). Bound withwithTimeout(…, 2_000), cached 60s per clerkId, single-flight — a chat turn must never block on/decide.posthog-nodehas nopersonalApiKeyconfigured here, so every cache miss is a remote round-trip (documented inlib/posthog-server.ts). -
Fail-closed: any error, timeout, unknown variant, or missing client →
null→ caller keepsDEFAULT_LLM_CONFIG.model+defaultThinkingEffort. Fail-closed must equalcontrol, so a PostHog outage degrades to today’s behavior rather than into a treatment arm.
readInternalThinkingEffortEnv():
null (fall through), never a raw
passthrough.
3. Wiring into the turn — three load-bearing details
Resolve the arm once per turn, next to the existingisInternalUser()
lookup in lib/claude/chat-turn.ts (~line 2985), then thread it exactly the
way isInternal already is.
a. Snapshot it into the checkpoint. ChatTurnCheckpoint (~line 2027)
carries isInternal?: boolean for precisely this reason: a resumed workflow
slice re-derives the turn config, and a fresh flag read mid-turn could hand
slice 2 a different model than slice 1. Add experimentArm?: string beside
it, set it in the checkpoint literal (~line 4746), and restore it in the
resume branch (~line 3542) with the same ?? null back-compat treatment.
b. Compose with resolveThinkingEffort, don’t bypass it. That function
(llm_config.ts) is the single authority, with precedence: capability gate
(Haiku can’t think) → internal cohort → fallback clamp → default. The arm
slots in below the capability gate and below the internal cohort:
- Haiku still wins — sending
output_config.effortto Haiku is a 400. - Internal users still win (see §4).
- The provider fallback clamp still wins. If the primary 529s and we swap to
Sonnet 5, the re-clamp at ~line 1693 must keep running;
xhighis not valid on Sonnet 5.
ThinkingEffortSource value ('experiment') so the persisted
thinking_effort_source records why a turn ran where it ran. The column
already distinguishes default / internal_lower / fallback_clamp, and
the migration deliberately ships no CHECK constraint — so this needs no
schema change.
c. Decide who wins between the arm and the router — and write it down.
decideChatModel already overrides the model for short turns in live mode.
If both are live, an opus5-medium user whose turn is classified short
gets Sonnet and the arm means nothing for that turn. Recommendation: the
arm wins over a router downgrade while the test runs, stamped as
router_reason = 'live_suppressed:experiment' so the suppression is visible
and the router’s own accuracy analysis can exclude those turns.
4. Internal users: excluded
Internal users stay pinned tointernalThinkingEffort and are not
enrolled. The cohort is a few dozen accounts whose usage looks nothing like a
customer’s (smoke tests, deliberate edge cases), so they add noise rather
than power — and they’d otherwise lose the low-latency config the pin exists
to give them. resolveThinkingEffort already checks the internal cohort
first, so this falls out of the precedence above for free; the enrollment
call should also skip the PostHog read entirely for internal users, keeping
the experiment’s denominator clean (the “short-circuit before
getFeatureFlag” rule the sidebar-CTA hook documents).
5. Telemetry — closing the four gaps
Gap 1 + 2 — the PostHog event. Extend the existingCHAT_EVENTS.RESPONSE_COMPLETED capture in chat-turn.ts (~line 4985); do
not add a new event. It already carries model, tokens, loop count, and tool
counts, so we only need:
$feature/<key> property is what makes PostHog attribute the event to
an arm — posthog-node writes exactly this shape internally
(dist/client.js), and without it an experiment metric built on this event
shows no variant breakdown. Note chat_response_completed_server fires via
after() with an explicit timestamp, so this stays off the user’s critical
path.
Set send_event: false on the flag read and fire exposure explicitly at
enrollment, so exposure is recorded once per turn at a known point rather
than incidentally on every cache miss.
Gap 3 — persist the arm on the row. Add one column:
markChatLogCompleted through the same
omit-the-key-when-absent pattern the thinking block already uses, so
pre-migration rows and non-enrolled turns stay NULL. This is what makes the
SQL analysis possible: model + thinking_effort is only a proxy for the
arm, and it breaks exactly on the contaminated turns we most need to find.
Contamination — the reason the arm label matters. A turn can leave its
arm mid-flight and the row will still look plausible:
- provider fallback (529 → Sonnet 5 → effort re-clamped to
max), - Bedrock last-resort hop,
- Haiku capability gate forcing
effort: 'off'.
experiment_arm persisted alongside the realized model /
thinking_effort, those turns are detectable (assigned arm ≠ realized
config) and can be reported as a contamination rate rather than silently
diluting the treatment effect. Analyze intent-to-treat (group by assigned
arm) and report contamination separately.
6. Accuracy — LLM-as-judge, offline
Judge offline in a batch job, never inline. Inline judging would add latency to the very metric we’re measuring, double the token bill, and put a second model call on the critical path of every turn. Shape:- Nightly script sampling completed
ai_chat_logsrows per arm (status = 'completed', response non-null), stratified bywork_classso a tool-heavy arm isn’t judged against a chatty one. - Judge input:
prompt+response(+interim_narrationtool list for context). Start ungrounded — a rubric score (1–5) on answers-the-question / correct-use-of-the-data / no-hedging, plus a short rationale. Grounding against the user’s actual financial data is a later iteration; ungrounded is enough to detect a regression between arms, which is the decision at hand. - Blind the judge: strip model name and effort from the input, and randomize presentation order for pairwise comparisons. A judge told it is looking at “the cheaper config” will find it worse.
- Prefer pairwise on matched prompts over absolute scoring where we can get it — pairwise preference is far more sensitive at small n than a 1–5 scale, and n is our binding constraint.
- Write scores to a
chat_judge_scorestable keyed byai_chat_logs.id, so scoring can be re-run and re-versioned without touching the chat table. Store the judge model + rubric version on every row; a judge upgrade invalidates cross-period comparisons otherwise. - Calibrate once against
feedback_typebefore trusting it: if judge scores don’t correlate with real thumbs, the judge is measuring its own taste.
7. Reading the result
SQL view — extend the existingai_chat_thinking_latency pattern rather
than inventing a new shape. Group by experiment_arm (assigned) instead of
model × thinking_effort (realized), keep the loop_count <= 5 → 'short'
work-class bucketing that view already uses, and keep p50/p95 alongside the
mean. Per arm, per work class: n, p50_seconds, p95_seconds,
avg_output_tokens, avg_total_tokens, avg_loop_count, contamination
rate, thumbs rate, and mean judge score.
The work-class split is load-bearing for the same reason the existing view
documents: a turn that ran 20 tool loops is slow regardless of arm, so
without bucketing you measure work mix, not the treatment.
PostHog experiment — create it against chat-model-effort-v01 with
chat_response_completed_server as the metric source. Use PostHog for the
“is this significant” read and the funnel/retention side effects; use SQL for
the latency percentiles and token math, which is where the detail lives.
Decision criteria — write these down before launching, not after seeing
the data:
- Ship a treatment arm if p50 and p95 latency both improve materially, total tokens/turn do not regress, and judge score is non-inferior to control within a pre-declared margin.
- Kill an arm early if thumbs-down rate or judge score drops beyond a pre-set floor — quality regressions do not need statistical significance to be worth stopping.
- Latency-only wins with a quality regression are not a ship.
chat_response_failed_server), chat_model_fallback rate (a new model may
529 at a different rate, which would confound everything), and
chat_checkpoint_too_large.
8. Rollout
-
Ship telemetry first (§5, gaps 1–3) with no flag. Confirm
thinking_effortandduration_msland on the event and the column populates. This alone answers “are we capturing what we need?” and is independently useful. -
Create the flag at 100%
control. Verify a real turn resolvescontrol, that the checkpoint carries the arm across a workflow slice, and that fail-closed works with PostHog unreachable. -
Smoke the Opus 5 arm on staging by setting the Redis key
chat-model-effort:arm = opus5-medium, then sending a few turns. Confirms chat acceptsclaude-opus-5withoutput_config.effort: 'medium'— we keep no per-model effort support table on purpose (see theThinkingEffortdocstring), so an unsupported level would be a 400 on every turn in that arm. Also confirms the arm overrides a router downgrade. Unset the key when done. Note the pin is environment-wide, not per-session: on staging that’s fine, in production it would put every enrolled user on one arm and destroy the comparison while it’s set. The production equivalent of a smoke test is step 4’s canary, not a Redis pin. -
Canary in production:
opus5-mediumat 1–2%,controlholding the rest. Real traffic, real config, small blast radius, clean data from turn one, and the Redis kill is still 5s away. - Ramp 34/33/33. Watch guardrails daily.
- Run to the pre-declared sample size. Do not peek-and-stop on latency — latency will separate almost immediately and will tempt an early call before the quality signal has any power.
-
Ship the winner by changing
DEFAULT_LLM_CONFIG, then rewrite the flag to 100% winner and leave it as a kill switch (the patternpost-signup-onboarding-experiment.tsdocuments), then remove it.
9. Tests
Mirrorlib/flags/__tests__/workflow-dispatch.test.ts — it already
establishes the mocking shape for getPostHogFlagClient + redis:
- Redis
offbeats a PostHogopus5-medium. - Unknown/garbage variant → fall through to control config.
- PostHog timeout / throw / null client → control, never a treatment arm.
- Internal user → never calls
getFeatureFlag. - Cache + single-flight: N concurrent turns for one clerkId → one eval.
llm_config thinking suite (19 tests today):
- Arm effort applies for an enrolled external user.
- Internal cohort still beats the arm.
- Haiku capability gate still beats the arm (
off,supported: false). - Fallback clamp still re-clamps an arm’s effort on a Sonnet 5 swap.
chat-turn-golden.test.ts that the arm
survives a checkpoint round-trip (the isInternal snapshot tests are the
template).
Open questions
- Router precedence — recommendation is arm-wins; needs a call, because it temporarily degrades the router’s own live-mode coverage.
- Sample size — at current chat volume, how long to detect a
pairwise-preference difference? If the answer is “months”, drop to two
arms (control vs
opus5-medium) and test effort separately afterward. - Cost ceiling for the judge — nightly sample size × judge model is a real line item; cap it explicitly.