TASK PLAN — ab-experiment-contracts-5a0f

Source: TASK-PLAN-ab-experiment-contracts-5a0f.md · Rendered: 2026-07-04 06:03 UTC · agents read the .md, humans read the .html.

1. Problem

Running an A/B today would mean writing engine code per experiment. The design says an experiment is a YAML file — champion + challengers + the one contended knob + reward keys + window — interpreted by the replay engine. Nothing interprets such a file yet, and there is no candidate-agnostic scorecard schema for evaluators to emit (the only rich schema, backtest, is LLM/TQQQ-shaped).

One trap makes validation load-bearing: merge_stock_screener_config performs no key validation, and StockScreenerConfig(**merged) silently drops unknown kwargs (core/champion.py:86-88). Without interpreter-side validation, a typo'd override key yields a challenger identical to the champion — a silent no-op A/B that "finds" no difference.

2. The fix

experiment.yaml ──► interpreter ──► validate override keys ──► deep-merge ──► challenger configs
                        │              (model_fields +           (existing
                        │               pattern names;            merge_stock_
                        │               reject unknown)           screener_config)
                        └──► layer = declared field set → mutual exclusion

Spec interpreter (src/rainier/research/experiment.py): - Spec home + activation (pinned here; consumed by ab-paper-shadow-arm-cbcd and ab-replay-evaluator-edb5): experiment YAMLs live in config/experiments/*.yaml. Each spec carries status: active | retired; load_active_specs(dir=config/experiments) returns only active specs and is the single discovery entry point (cron shadow arm, evaluator CLI). Mutual exclusion is evaluated across the active set. - Parses the §10.4 YAML shape: id, status, champion, layer, challengers[{id, override}], primary, guardrails, window{train, holdout, embargo_days: 20} (embargo_days optional, default 20 = the max forward-return horizon H — the purge/embargo carrier for walk-forward validation). - Validates every override key against StockScreenerConfig.model_fields + known pattern names (reusing the load_champion_overrides check pattern, champion.py:91-110), translating pattern_weights.<pattern> dotted paths into the nested dict. Rejects the spec on any unknown key — validation cannot be delegated downstream (see trap above). - Only after validation: challenger = champion deep-merged via the existing merge_stock_screener_config. - layer: names a declared field set for mutual exclusion (two concurrent specs claiming the same layer → error), not a config path. - Reward keys are strings resolved at run time — no import of the rewards module needed here (keeps this task independent of ab-reward-registry-b3b8).

Scorecard schemas (additive — validate() is already generic over named schemas): - New output_schema.yaml entries: base_scorecard (candidate_id, candidate_type, window, n_selection_days, corpus_hash, reward values by role, regime_scores, deflated_sharpe nullable, evaluator_sha) + llm_extension (valid_thesis_rate, cost_usd, filled_R, filled_rate, tqqq_bh_R, skill_yaml_sha). - A composition helper validating base + optional extensions. The LLM-shaped backtest entry stays untouched for its existing consumer.

3. Expected files

4. Test plan (one line each)

5. Non-goals

6. Worker contract

Per the Subagent Dispatch Contract (~/.claude/CLAUDE.md): unit tests with the feature; codex + /review loops to clean; PR against main; PR URL in the return message.