auto-experiment — boucle locale d'amélioration par escalade
Ceci est la version locale, pilotée par Claude Code, du worker Temporal/Atlas auto_experiments
(domains/ml_observability/apps/apis/auto_experiments/). Là-bas, un agent distant Bits/Code-Gen exécute
la boucle ; ici VOUS (Claude Code) êtes l'agent et l'exécutez directement sur le checkout git actuel.
Pas de Temporal, pas d'API Code-Gen — juste des commits git, un harness d'évaluation local, et les outils MCP
Datadog LLM-Obs pour les données.
Lisez references/rubrics.md en entier avant l'itération 1 et gardez-le à l'esprit à chaque itération.
Il contient les règles non négociables (ne jamais inventer un score ; quoi évaluer ; où vivent les données ;
la spec du harness ; le schéma des métriques). Ce fichier est la boucle de contrôle ; ce fichier est la loi.
Entrées (la configuration de l'expérience)
Collectez celles-ci auprès de l'utilisateur (ne demandez que ce qui manque — déduisez le reste). Repo = répertoire de travail actuel.
| Champ | Sens | Défaut |
|---|---|---|
files_to_optimize |
la portée d'édition : un ou plusieurs fichiers, un dossier, ou des globs. Tout code à l'intérieur de la portée est fair game pour être modifié — code de tool/retrieval, pipeline, config, data-shaping, ou prompts — pas seulement la formulation des prompts. Tout ce qui est en dehors de la portée est interdit. | requis |
goal |
ce que « mieux » signifie ; le rubric du juge + direction d'optimisation | requis |
evaluators |
texte d'évaluateur/rubric explicite, le cas échéant | fallback sur goal |
model |
id du modèle juge | le modèle Claude sélectionné dans cette session (voir rubric) |
ml_app |
app Datadog LLM-Obs d'où récupérer les traces | requis sauf si dataset_id/trace_ids donné |
| source de données | dataset_id | trace_ids | traces récentes pour ml_app |
voir priorité ci-dessous |
max_iterations |
combien de changements essayer (clamper 1–50) | 2 |
base_branch |
branche sur laquelle mesurer la baseline | branche actuelle / main |
Persistez la config à .auto_experiment/config.json et mettez-la à jour au fur et à mesure que l'exécution progresse (c'est
l'état de l'exécution + audit trail) :
{
"repo_url": "...", "base_branch": "...", "files_to_optimize": [...],
"goal": "...", "evaluators": "...", "ml_app": "...",
"dataset_id": "...", "trace_ids": [...],
"max_iterations": 2,
"reps": 3,
"min_delta": 0.02,
"iteration_results": [],
"final_result": {}
}
Portée — optimiser toute la surface sélectionnée, pas juste le prompt
files_to_optimize est une portée, pas un pointeur de prompt. Il peut être un ensemble de fichiers, un répertoire, ou
des globs — développez un répertoire en ses fichiers éditables (p. ex. chaque *.py dedans) et traitez tous comme le code testé.
À l'intérieur de cette portée vous pouvez changer n'importe quoi qui fait bouger la métrique :
code de retrieval/tool, logique de requête, filtrage, forme de sortie, ranking, config, ou prompts. Laissez le
census des défaillances décider quel fichier contient le levier — ne defaultez pas à reformuler un
prompt. En pratique les plus gros gains sont souvent dans le code de tool/retrieval (ce que le modèle peut récupérer),
pas la formulation du prompt ; une recherche prompt-only ne trouve rien quand la marge est dans les tools.
Garde de portée stricte : ne jamais éditer un fichier en dehors de files_to_optimize. Si le levier dominant du census
est hors portée, dites-le (c'est une découverte) — ne tweakez pas silencieusement des fichiers in-scope-mais-hors-de-propos.
Setup
- Confirmez un arbre de travail propre-ish (stash ou warn sur les changements non liés). Notez le SHA de départ.
Si
files_to_optimizenomme un dossier/globs, résolvez-le à la liste concrète de fichiers éditables et enregistrez cette liste dansconfig.json(c'est la portée pour chaque itération + la limite de restauration). - Créez une branche scratch off
base_branchpour l'expérience (p. ex.auto-experiment/<short-goal>). Tous les commits d'itération vont ici ; l'utilisateur revoit/garde le meilleur commit à la fin. - Écrivez
.auto_experiment/config.json. Ajoutez les fichiers de sortie.auto_experiment/à rien de spécial — ils sont committés exprès (c'est l'audit trail). - Cette exécution rapporte un score par itération au LLM-Obs experiment identifié par la
variable d'environnement
DD_AUTO_EXPERIMENT_ID(à définir dans l'environnement avant que ce skill soit invoqué — lisez-la, ne la demandez pas à l'utilisateur). Voir Reporter le score de chaque itération à LLM-Obs. - Enregistrez le contexte de la run sur l'expérience avant le démarrage des itérations. Appelez
update_llmobs_experimentune fois avecexperiment_id=$DD_AUTO_EXPERIMENT_ID(skip si unset) etmetadatadéfini à une struct JSON contenant le nom du repo, le nom de la branche scratch, et le modèle exécutant ce skill, p. ex.{"repo": "<repo>", "branch": "<scratch-branch>", "model": "<model>"}. Dérivezrepodu remote git (basename -s .git $(git remote get-url origin), ouowner/repo),branchde la branche créée à l'étape 2, etmodel= leprovider/model-iddu modèle/agent pilotant cette session (p. ex.openai/gpt-4-turbo,anthropic/claude-opus-4-8).metadataremplace les métadonnées existantes, donc incluez les trois clés dans l'appel unique. Faites ceci dans Setup, avant l'étape 1.
Modèle d'exécution — orchestrateur + sous-agents fresh par itération
Séparez les deux rôles pour que le contexte reste propre et les itérations ne s'ancrent pas l'une sur l'autre :
- Vous êtes l'orchestrateur. Vous possédez l'état durable (
config.json,census.json,best_sha, la branche), le harness, et chaque décision keep/discard. Vous n'accumulez PAS le travail brut de chaque tentative dans votre propre contexte. - Chaque itération d'amélioration s'exécute dans un sous-agent FRESH (spawné via l'outil Agent). Donnez-lui un
briefing compact — pas votre transcription entière : le
goal/evaluators, la portée éditable complète (files_to_optimizedéveloppée — il peut changer N'IMPORTE QUEL fichier en portée, pas juste un prompt), les bucketscensus.jsonrangés (+ le bucket à cibler cette itération), lebest_shaactuel, et résumés d'une ligne des tentatives antérieures (qu'a-t-on essayé → kept/discarded, depuisiteration_results) pour qu'il ne les répète pas. Son travail : faire UN changement + retourner un court résumé (ce qu'il a changé, quel bucket, résultat de sonde de faisabilité). Vous (orchestrateur) exécutez le harness, appliquez la porte de bruit + audit mécanisme, commit/keep/discard, et mettez à jour l'état. - Pourquoi : un contexte limité fresh par itération évite l'ancrage sur les idées mortes et empêche le contexte
de l'orchestrateur de gonfler sur une longue exécution — la même raison que la boucle de production spawne un
nouveau
claude --printpar itération au lieu d'un agent long-lived unique. Si les sous-agents sont indisponibles, émoulez-le : avant chaque itération, relisez seulement le briefing ci-dessus et ignorez délibérément la narration des tentatives antérieures au-delà de leurs résultats d'une ligne.
Itération 1 — baseline + première amélioration
Reflète build_initial_prompt. Quatre étapes, dans l'ordre.
Étape 1 — Charger les données d'évaluation
Sélectionnez la source de données dans cet ordre de priorité et matérialisez-la à .auto_experiment/data.jsonl
(un point de données scorable par ligne : l'input, plus la sortie attendue/référence si présente) :
dataset_idprésent →get_llmobs_dataset_records+get_llmobs_full_dataset_records.- sinon non-empty
trace_ids→get_llmobs_trace(arbre complet),get_llmobs_span_details,get_llmobs_span_content. - sinon → récupérez les ~30 dernières traces LLM pour
ml_app(searchez les spans LLM-Obs), et enregistrez les IDs de trace que vous avez utilisés dansconfig.jsontrace_idspour que les itérations ultérieures réutilisent le MÊME corpus.
Extrayez input/output selon les directives de source messages dans references/rubrics.md (évaluez le
champ messages sur le span LLM enfant, pas le mince root input.value). Appliquez les
directives de sélection de données : gardez seulement les traces avec un span cible scorable ; excluez entièrement
les spans infra/setup du set.
Puis splittez une fois, déterministiquement (hash d'id de datapoint, ~70/30) en
.auto_experiment/data.val.jsonl (la porte de la colline) et .auto_experiment/data.test.jsonl
(tenu en dehors) — voir le split Held-out du rubric. Chaque itération évalue sur val
(AUTO_EXP_DATA=.auto_experiment/data.val.jsonl) ; test ne s'exécute que dans le rapport final.
Étape 2 — Construire le harness et calculer AVANT (baseline)
Copiez references/eval_harness_template.py vers .auto_experiment/eval_harness.py et remplissez
generate_output (exécutez le VRAI code sous test depuis files_to_optimize) et judge. Préférez une
métrique de vérité au sol déterministe (sortie de référence / vérificateur programmatique / compte de pipeline) et
utilisez un LLM-as-judge seulement quand aucune vérité au sol n'existe — voir la sélection de Métrique du rubric. Aucun
littéral de score nulle part.
Exécutez-le contre le code original, non modifié avec AUTO_EXP_REPS (= config reps, défaut 3) :
le harness ré-exécute l'éval entier R fois et imprime {mean, stdev, rep_means, ...}.
before_score = le mean imprimé ; enregistrez aussi stdev (le plancher de bruit). Les deux nombres calculés,
jamais des littéraux — obéissez à la politique de score et la politique de Bruit & keep/discard du rubric.
Committez eval_harness.py, data.jsonl, data.val.jsonl, data.test.jsonl, eval_results.jsonl.
Puis reportez la baseline à LLM-Obs comme itération 0 — avant de démarrer la première itération.
Soumettez exactement un point de donnée eval-metric avec score_value = before_score et tags
["iteration:0", "git.commit.sha:<baseline_commit_sha>", "decision:baseline"] (le sha est le
hash 40-caractères complet du commit que vous venez de faire — git rev-parse HEAD, pas un short hash). Même forme d'appel et règles que Reporter le score de chaque itération à LLM-Obs ;
c'est la seule soumission avec iteration:0 et decision:baseline.
Étape 2.5 — Recenser les défaillances de la baseline
Avant de rien changer, décomposez où la baseline perd selon le census de défaillances de Baseline du rubric : bucketez
chaque point de donnée en défaut par cause racine, écrivez .auto_experiment/census.json,
committez-le, et surfacez les buckets classés. Ceci vous dit quel levier vaut la peine d'être tiré — et si le
mode de défaillance dominant est même atteignable en éditant files_to_optimize.
Étape 3 — Améliorer
Lisez la portée complète (files_to_optimize, développée). Faites UN changement focalisé vers goal,
visant le plus grand bucket du census que vous pouvez plausiblement déplacer (nommez ce bucket dans la reasoning
de l'itération), dans whichever fichier in-scope qui tient le levier — éditez le code de tool/retrieval si le
census dit que les misses sont retrieval, le code de sortie/format s'ils sont du formatting, etc. Ne defaultez
pas à reformuler un prompt quand le levier est ailleurs. Committez-le sur la branche scratch
avec un message expliquant ce qui a changé et pourquoi.
Avant l'éval complet (cher), exécutez une sonde de faisabilité selon la Sonde de faisabilité du rubric :
la vérification offline la moins chère que ce changement pourrait déplacer un bucket du census en défaut. Si la sonde
atteint 0 points de donnée en défaut, enregistrez l'itération no_change avec le résultat de la sonde et skippez à la
prochaine hypothèse — ne dépensez pas une éval complète sur un levier mort.
Étape 4 — Calculer APRÈS (ré-exécuter le MÊME harness)
Ré-exécutez .auto_experiment/eval_harness.py (même evaluate_line, mêmes données) contre le code modifié.
after_score = la nouvelle moyenne imprimée. Ré-écrivez eval_results.jsonl. Écrivez l'objet métrique
(schéma dans le rubric) à .auto_experiment/result.json et committez-le dans le même commit que
le changement. delta = after_score - before_score.
Décidez is_best selon la direction d'optimisation dans goal et la politique de Bruit & keep/discard :
gardez seulement si |after_score − before_score| > max(pooled_stdev, min_delta). Un gain dans-le-bruit est
is_best: false (discarded), pas gardé. Si la bande est franchie, exécutez l'audit Mécanisme (rubric) —
diffez l'eval_results.jsonl de cette itération contre celui de la baseline (même dénominateur de count ; le
gain vient des points de donnée que le changement a touchés) avant de garder. Si l'itération 1 franchit la bande ET
passe l'audit, elle devient la meilleure (best_sha = ce commit, best_score = after_score). Appendez
la ligne à config.json iteration_results.
Puis reportez le score de cette itération à LLM-Obs (tag iteration:1) — voir Reporter le score de chaque itération à LLM-Obs.
Itérations 2+ — escalade de colline
Reflète build_followup_prompt. La baseline est déjà connue — ne la ré-calculez pas.
- Restaurez au best-so-far, pour qu'une tentative discardée ne contamine pas celle-ci :
- si un commit a été gardé →
git reset --hard <best_sha>(reste sur la branche scratch ; le harness committés + données vivent dans ce commit, donc ils sont préservés — ne les recréez pas). - si rien n'a été gardé encore →
git checkout <base_branch> -- <files_to_optimize>(restaurez seulement les fichiers cibles ; le harness/données vivent seulement dans le commit antérieur sur cette branche, donc un hard reset à base les supprimerait).
- si un commit a été gardé →
before_score= le meilleur score actuel (depuisiteration_results; baseline d'itération 1 si rien n'a été gardé encore). Ne ré-exécutez PAS la baseline.- Réutilisez les données depuis
data.jsonlet l'eval_harness.pycommité — ne rechargez ou ne rebuildez. - Faites UN nouveau changement, différent de toute tentative antérieure (vous pouvez voir les tentatives antérieures dans
iteration_results), visant un bucketcensus.jsonnommé, dans whichever fichier in-scope qui tient le levier (tool/retrieval/pipeline/config/prompt — pas prompt-only). Committez-le. - Sonde de faisabilité en premier (rubric) : vérification offline bon marché que le changement peut déplacer son bucket cible ;
si elle atteint 0 points de donnée en défaut, enregistrez
no_changeet skippez l'éval complète. Sinon ré-exécutez le MÊME harness surval→after_score. Ré-écrivezeval_results.jsonl+result.json, committez. - Gardez ou discardez : gardez seulement si le delta franchit la bande de bruit (`|after_score − before_score|
max(pooled_stdev, min_delta)
, selon la politique de Bruit) dans la direction degoal**et passe l'audit Mécanisme** (rubric) — diffezeval_results.jsonlvs le commit best (git show <best_sha>:.auto_experiment/eval_results.jsonl) ; même dénominateur, gain depuis les points de donnée que le changement a touchés. Puis → mettez à jourbest_sha/best_score, décisionkept. Un gain dans-le-bruit, un artefact de dénominateur, ou une régression →discarded`. Appendez la ligne. - Reportez le score de cette itération à LLM-Obs (tag
iteration:<n>) — voir Reporter le score de chaque itération à LLM-Obs.
Reporter le score de chaque itération à LLM-Obs (chaque itération scorée)
Une fois que vous avez un score calculé pour une itération, soumettez exactement un point de donnée eval-metric à
LLM-Obs avec l'outil MCP submit_llmobs_experiment_events. Faites ceci une fois par itération, juste
après que le score soit calculé et le commit de l'itération / result.json soit écrit — incluant
l'itération 1 et la baseline itération-0 (voir étape 2 ; là score_value = before_score et
le tag de décision est decision:baseline).
Appelez submit_llmobs_experiment_events avec une seule métrique façonnée exactement comme ceci :
experiment_id: la valeur de la variable d'environnementDD_AUTO_EXPERIMENT_ID(lisez-la depuis l'environnement ; ne la demandez pas à l'utilisateur et ne l'inventez pas). Si elle est unset ou vide, skippez la soumission et notez-le dans lareasoningde l'itération.metrics: un array contenant exactement un objet avec ces champs et aucun autre :label: toujours la chaîne littéraleauto_experiment_score.metric_type:score.score_value: le score que cette itération a produit (after_score) — le nombre calculé par le harness, jamais un littéral ou une valeur arrondie-pour-display.timestamp_ms: le temps mur actuel comme un timestamp epoch en millisecondes.tags:["iteration:<n>", "git.commit.sha:<sha>", "decision:<decision>"], où<n>est le numéro de cette itération (1pour la première amélioration,2pour la prochaine, etc.),<sha>est le 40-caractères complet Git commit SHA du commit que cette itération a créé pour son changement — le hash complet depuisgit rev-parse HEADaprès committing l'itération (p. ex.fd0fbab7c1232e125df7b22d9df856a2ef73ab65), jamais le short hash 7/8-caractères abbrégé — et<decision>est la décision keep/discard de cette itération enregistrée dansiteration_results(keptoudiscarded;baselinepour itération 0).reasoning: la chaînereasoningde cette itération depuisiteration_results— ce qui a été essayé, quel bucket du census il ciblait, et pourquoi il a été gardé ou discardé (pour itération 0, que c'est la baseline). Utilisez le même texte enregistré dansresult.json; ne le fabriquez pas.- Ne pas inclure
span_id,categorical_value, ouboolean_value.
Exemple d'arguments pour l'itération 5 dont le harness a calculé un score de 0.72 :
{
"experiment_id": "<valeur de $DD_AUTO_EXPERIMENT_ID>",
"metrics": [
{
"label": "auto_experiment_score",
"metric_type": "score",
"score_value": 0.72,
"reasoning": "Rescript du constructeur de requête retrieval pour inclure les synonymes d'entité (ciblant le bucket du census 'missed-retrieval') ; a franchi la bande de bruit et a passé l'audit de mécanisme, donc gardé.",
"timestamp_ms": 1752430000000,
"tags": ["iteration:5", "git.commit.sha:33ec6e0959bd46b0ea9c337cf6a28a763d3eeb0a", "decision:kept"]
}
]
}
Règles :
- Exactement une métrique par itération. Ne soumettez jamais plus d'une métrique pour la même itération et ne batchez jamais plusieurs itérations en un appel.
- Seulement pour les itérations scorées. Une itération
no_changen'a aucun score calculé, donc pas descore_valueà envoyer — skippez la soumission (émettre une métriquescoresans un vrai score violerait la politique de score). Enregistrez le skip dansreasoning. - La valeur que vous soumettez est le même
after_scorecalculé enregistré dansresult.json; les deux doivent toujours être d'accord.
Conditions d'arrêt & gardes
- Arrêtez quand
iteration == max_iterations. - Plateau dans le bruit — arrêtez tôt. Si les 3 dernières itérations ont toutes atterri
discardedà l'intérieur de la bande de bruit (aucun delta n'a franchimax(pooled_stdev, min_delta)), arrêtez et rapportez le meilleur actuel avecstop_reason: "plateau (deltas within noise)". Continuer au-delà d'un plateau de bruit brûle juste le budget en générant du wiggle dans-le-bruit ; escaladez plutôt (un nouveau bucket du census, une dimension différente, ou acceptez le plafond). Distinguez ceci d'une vraie streak de régression. - Un changement sans score computable est
no_change, jamais un nombre fabriqué (harness ne s'exécutera pas / pas de nouveau commit / juge inatteignable / sonde de faisabilité a atteint 0). Enregistrez le blocker dansreasoning. - Tracez les itérations
no_changeconsécutives ; après 5 d'affilée, arrêtez tôt et rapportez le meilleur résultat jusqu'à présent avec une raison d'arrêt (ne continuez pas à brûler les itérations).
Rapport final
- Posez-vous la question du wrap-up au niveau de la run et écrivez
final_resultdansconfig.json:{ "baseline_score", "best_score", "best_iteration", "best_sha", "iterations_run", "stop_reason", "reasoning" }(reasoning = ce qui a été essayé à travers toutes les itérations, ce qui a marché, ce qui n'a pas marché, pourquoi le gagnant a gagné). - Comparaison
testheld-out (le vrai titre). Exécutez le harness une fois sur le baseline commit et une fois sur le meilleur commit contre.auto_experiment/data.test.jsonl(AUTO_EXP_DATA=.auto_experiment/data.test.jsonl), tous deux àrepsreps. Rapportez la baseline-vs-besttestdelta avec sa bande de bruit comme résultat de la run — le gain hill-climbvaln'est pas le titre. Sitestn'a pas franchi la bande de bruit même sivall'a fait, dites-le explicitement (le gain n'a pas généralisé) et traitez la baseline comme meilleure. - Imprimez un tableau par itération (itération, delta val, décision, sha) et nommez le meilleur commit.
- Si rien n'a battu la baseline sur
test: rapportez la baseline comme meilleur résultat et laissez le code original en place (best_shavide). Ne fabriquez pas une amélioration. - Dites à l'utilisateur la branche scratch + meilleur commit pour qu'il puisse ouvrir un PR dessus s'il le souhaite.
- Marquez l'expérience terminée dans LLM-Obs. Appelez
update_llmobs_experimentavecexperiment_id=$DD_AUTO_EXPERIMENT_ID(skip si unset) exactement une fois à la très fin — après la dernière itération, ou immédiatement quand vous renoncez tôt. Définissezstatus: "completed"pour toute run qui a atteint le rapport final (incluant une où baseline a resté meilleure — une run qui a fini proprement est complétée, pas failed). Définissezstatus: "failed"avec un courterrorquand la run n'a pas pu terminer — le harness ne s'est jamais exécuté, le setup a été bloqué, ou vous avez renoncé avant toute itération scorée. Cette mise à jour de status est séparée des soumissions de métrique par itération ; faites-la une fois, en dernier.
Notes
- Chaque score est calculé en exécutant du code. Si vous vous trouvez jamais sur le point de taper un nombre de score, arrêtez — exécutez le harness à la place.
- Gardez
.auto_experiment/committé ; c'est le dossier reproductible de la run.