Skip to content

feat(experiments): A/B routing — serve the candidate to a fraction of users (Step 5.7c) - #146

Merged
officialCodeWork merged 1 commit into
mainfrom
build/phase-5/step-5.7c-ab-routing
Jun 7, 2026
Merged

feat(experiments): A/B routing — serve the candidate to a fraction of users (Step 5.7c)#146
officialCodeWork merged 1 commit into
mainfrom
build/phase-5/step-5.7c-ab-routing

Conversation

@officialCodeWork

Copy link
Copy Markdown
Owner

Summary

Step 5.7c — A/B routing: deterministic variant assignment that actually serves the candidate retrieval config to a fraction of live users. This is the first 5.7 slice that can change which response a user receives (5.7a was observe-only analyzer/tracker; 5.7b shadow mode is observe-only background fan-out).

  • ABRouter (rag_gateway.experiments, beside ShadowRunner) — assign(request_id) hashes deterministically to a variant (hash(request_id) < routing_sample_rate ? candidate : control); also is_candidate / candidate / record / tag.
  • Served-path wiring in /v1/query + /v1/retrieve: the candidate arm routes inline via _route_query (one retrieval, no added latency vs. a normal query). Not degrade-open — a candidate failure surfaces as a normal RetrievalError (→ 502) rather than silently swapping back to control, which would corrupt the experiment and hide a broken candidate. The served arm's outcome_metric (mean retrieval score) is recorded once per fresh retrieval (skipped on a cache hit, same apples-to-apples discipline as shadow).
  • Variant-partitioned retrieval cachecompute_plan_hash / compute_params_hash fold in the assigned variant, so control and candidate never share a cache entry. Byte-identical key when routing is off.
  • ExperimentAssignment wire tag (experiment / variant / is_candidate) on QueryResponse / RetrieveResponse (experiment field, additive + defaulted None). REST-only for now — the gRPC RagService predates A/B routing, so the proto mirror is deferred exactly like corpus_decision. Served variant also rides the existing gateway.query_complete log.
  • Config: ShadowCandidateConfig generalised to the shared CandidateConfig (used by shadow_candidate + the new routing_candidate); cfg.experiments gains routing_enabled / routing_sample_rate / routing_experiment / routing_candidate. Gated separately from shadow_enabled because routing can change a response; both feed one tracker under distinct experiment ids.
  • Governance: the candidate routes through the same HybridRetriever.retrieve (the canonical read_chunk PDP site), so ACLs are enforced identically and the policy coverage linter needs no new entry.
  • Inert + opt-in by default: build_app leaves ab_router unset; build_app_from_config builds it only when enabled and routing_enabled. Production injects a candidate over real backends via build_app(ab_router=…).
  • ragctl ab drives the full assign → serve → record → analyze flow against in-process stubs (e.g. ragctl ab --rate 0.5 --lift 0.25).

dist/schemas (+ ExperimentAssignment.json), dist/openapi.*, and dist/rag.schema.* regenerated.

Documentation

Test plan

  • ruff check + ruff format --check clean
  • mypy --strict clean (292 source files)
  • RAG001 logging gate clean
  • schema-drift + openapi-drift regenerated (committed)
  • gRPC proto-compat gate (REST-only experiment allowlisted with rationale)
  • policy-coverage + SPI-contract gates pass
  • full suite 2497 passed, 4 skipped (integration deselected)
  • ~25 new tests: ABRouter unit (assignment/record/tag), gateway integration (candidate served + tagged, control not calling candidate, /v1/retrieve, dashboard sees served experiment, inert default), variant cache-partition (test_cache_keys), config wiring + routing_* (test_experiments_config)
  • ragctl ab ran end-to-end (52% candidate split, control mean ≈0.50 vs candidate ≈0.75, lift +50%, significant)

🤖 Generated with Claude Code

… users (Step 5.7c)

Add ABRouter (rag_gateway.experiments) — deterministic, request_id-hashed
variant assignment that *serves* the candidate retrieval config to the assigned
fraction of users (the first 5.7 slice that can change a response). Wired into
the served /v1/query + /v1/retrieve path: the candidate arm routes inline via
_route_query (not degrade-open — a candidate failure surfaces as 502, never a
silent fallback that would corrupt the experiment), the retrieval cache is
partitioned by variant so control/candidate never share an entry, and the served
arm's outcome is recorded once per fresh retrieval.

Responses carry a new frozen ExperimentAssignment (experiment/variant/
is_candidate) on QueryResponse/RetrieveResponse (REST-only — gRPC proto mirror
deferred like corpus_decision). Gated separately by cfg.experiments.routing_*
(independent of shadow_enabled); ShadowCandidateConfig generalised to the shared
CandidateConfig. ragctl ab drives assign -> serve -> record -> analyze.

dist/schemas + dist/openapi + dist/rag.schema regenerated. All gates green
(ruff, mypy --strict 292 files, RAG001, schema/openapi-drift, proto-compat,
policy-coverage); ~25 new tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@officialCodeWork
officialCodeWork merged commit 849d21e into main Jun 7, 2026
12 of 17 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.

1 participant