12. Test Time Scaling

# Parameters
BATCH_MODE = "true"

12 - Test-Time Scaling : le second axe de mise a l’echelle

Navigation : Index | << Précédent | Suivant >>

Jusqu’ici nous avons mis a l’echelle les LLM en augmentant leur taille (paramètres, données d’entrainement) : c’est le size-scaling. Snell et al. (2024), dans Scaling LLM Test-Time Compute Optimally, ont formalise un second axe : depenser davantage de calcul a l’inference (test-time compute). Au lieu d’un seul appel direct, on orchestre plusieurs appels : Best-of-N, Reflexion, Tree-of-Thoughts…

Ce notebook poursuit le projet de reference Iterative Contextual Refinements (ICR) : https://github.com/ryoiki-tokuiten/Iterative-Contextual-Refinements . ICR regroupe plusieurs “moteurs” (Contextual, Deepthink, Adaptive) que nous reconstruisons ici a partir de zero.

La these de ce notebook

La performance d’un LLM n’est pas une fonction de sa seule taille. Elle se negocie entre size-scaling (un plus gros modèle) et test-time scaling (plus d’effort d’inference). Et ces deux axes ne sont pas equivalents : sur certaines tâches, un modèle bon marche plus un bon orchestrateur bat un modèle gros raisonnant, pour beaucoup moins de tokens. Le test-time scaling n’est pas un supplement universel : c’est un outil dont le gain depend de la structure d’erreur du modèle.

Note importante : gpt-4o-mini est desormais deprécie (2026). Nous comparons : - Llama-3.3-70b (modèle bon marche, non-raisonnant) ; - gpt-5-nano (modèle raisonnant, plus couteux, qui consomme des tokens de raisonnement invisibles).

Objectifs d’apprentissage

A la fin de ce notebook, vous saurez : 1. Distinguer size-scaling et test-time scaling comme deux axes independants. 2. Mesurer le cout (tokens) et la performance (tests passes) de chaque stratégie. 3. Implementer les 4 moteurs : Best-of-N, Reflexion, Tree-of-Thoughts, routeur adaptatif. 4. Diagnostiquer quand le test-time scaling aide - et quand il est inutile, voire contre-productif.

Prerequis

  • Python 3.10+, cle API OpenRouter configuree (.env avec OPENROUTER_API_KEY).
  • Notions de generation de code et d’exécution de tests (benchmark objectif).

Duree estimee : 60 minutes (incluant les appels API, 5 a 8 min de calcul)

# Installation des dependances
%pip install -q --disable-pip-version-check openai python-dotenv

import os
from pathlib import Path
from openai import OpenAI
from dotenv import load_dotenv

# --- Chargeur .env robuste (Papermill change le cwd) ---
_env_path = None
_current = Path.cwd()
for _i in range(10):
    if (_current / ".env").exists():
        _env_path = _current / ".env"
        break
    if _current.name in ("GenAI", "MyIA.AI.Notebooks"):
        break
    _current = _current.parent
if _env_path is None:
    for _cand in (
        Path.cwd() / "MyIA.AI.Notebooks" / "GenAI" / ".env",
        Path.cwd() / "GenAI" / ".env",
    ):
        if _cand.exists():
            _env_path = _cand
            break
if _env_path is not None:
    load_dotenv(_env_path)
    print(f".env charge depuis : {_env_path.parent.name}/{_env_path.name}")
else:
    print("ATTENTION : aucun .env trouve. Les variables d'env doivent etre exportees.")

# --- Modeles (etat de l'art 2026) ---
FAST_MODEL = os.getenv("OPENAI_MODEL_FAST", "meta-llama/llama-3.3-70b-instruct")
BIG_MODEL = os.getenv("OPENAI_MODEL_BIG", "openai/gpt-5-nano")
BATCH_MODE = os.getenv("BATCH_MODE", "false").lower() in ("1", "true", "yes")

# --- Client OpenRouter ---
client = OpenAI(
    api_key=os.getenv("OPENROUTER_API_KEY"),
    base_url=os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"),
)

print(f"FAST_MODEL (bon marche) = {FAST_MODEL}")
print(f"BIG_MODEL  (raisonnant) = {BIG_MODEL}")
print(f"BATCH_MODE = {BATCH_MODE}")
Note: you may need to restart the kernel to use updated packages.
.env charge depuis : GenAI/.env
FAST_MODEL (bon marche) = meta-llama/llama-3.3-70b-instruct
BIG_MODEL  (raisonnant) = openai/gpt-5-nano
BATCH_MODE = False
def chat(prompt, system=None, model=FAST_MODEL, temperature=0.0, max_tokens=4000):
    """Appel unique au LLM. Renvoie une chaine vide en cas d'erreur
    (le notebook ne doit jamais casser en BATCH_MODE).

    Note : gpt-5-nano est un modele raisonnant qui consomme ~500-2000
    tokens de raisonnement INVISIBLES (comptes dans max_tokens). Un budget
    trop bas (ex. 2000) laisse le raisonnement manger tout le plafond et
    renvoyer un contenu VIDE. max_tokens=4000 par defaut laisse assez de
    marge pour le raisonnement + la generation de code effective.
    """
    messages = []
    if system:
        messages.append({"role": "system", "content": system})
    messages.append({"role": "user", "content": prompt})
    try:
        resp = client.chat.completions.create(
            model=model,
            messages=messages,
            temperature=temperature,
            max_tokens=max_tokens,
        )
        return resp.choices[0].message.content or ""
    except Exception as exc:
        print(f"  [chat] erreur ({model}) : {exc}")
        return ""


# Test rapide de connectivite (1 appel economique)
_ping = chat("Reponds uniquement par : OK", model=FAST_MODEL, max_tokens=10)
print("Ping FAST_MODEL :", repr(_ping[:40]))
Ping FAST_MODEL : 'OK'

Le benchmark : generation de code avec verite d’exécution

Pour mesurer le test-time scaling, il nous faut une tâche avec une verite objective. Les problemes de mots (word problems) ne conviennent plus : tous les modèles 2026 les saturent. Nous utilisons une generation de code ou la verite = executer des tests.

Avantages : - Objectif : un test passe ou echoue, pas d’opinion. - Difficulte parametrique : on calibre précisément la difficulte. - Cout mesurable : on compte exactement les tokens depenses.

6 problemes de difficulte croissante (1 = trivial, 6 = algorithmique) :

# Problème Concept
p1 carre arithmetique de base
p2 compteur_pairs itération + predicat
p3 fizzbuzz ramification classique
p4 plus_long_palindrome sous-chaîne, expansion
p5 sous_ensembles combinatoire, recursion
p6 chemins dans un graphe parcours, backtracking

Les modèles 2026 saturent p1-p4. Le vrai signal se situe sur p5 (cout) et p6 (erreur systématique).

Confinement (pont 13_Agentic_Orchestration) : executer_tests exécute ci-dessous le code généré par le modèle dans un sous-processus confiné — builtins restreints (ni open, ni eval, ni exec libre), imports en liste blanche, borne de temps réelle (subprocess.run(timeout=...), effective sous Windows). Le pourquoi complet — injection de prompt, OWASP LLM01 — et la démonstration mesurée de la borne sont dans NB-13.

import re

PROBLEMES = [
    {"id": "p1_carre", "diff": 1,
     "spec": "Ecris une fonction python `carre(n)` qui renvoie le carre de n.",
     "tests": ["carre(3)==9", "carre(0)==0", "carre(-4)==16"]},
    {"id": "p2_parite", "diff": 2,
     "spec": "Ecris une fonction python `compteur_pairs(liste)` qui renvoie le nombre d'elements pairs dans une liste d'entiers.",
     "tests": ["compteur_pairs([1,2,3,4,6])==3", "compteur_pairs([])==0", "compteur_pairs([1,3,5])==0"]},
    {"id": "p3_fizzbuzz", "diff": 3,
     "spec": "Ecris une fonction python `fizzbuzz(n)` qui renvoie une liste de longueur n ou l'element i (1-indexe) vaut 'Fizz' si i multiple de 3, 'Buzz' si multiple de 5, 'FizzBuzz' si multiple des deux, sinon l'entier i.",
     "tests": ["fizzbuzz(5)==[1,2,'Fizz',4,'Buzz']", "fizzbuzz(15)[-1]=='FizzBuzz'", "fizzbuzz(0)==[]"]},
    {"id": "p4_palindrome", "diff": 4,
     "spec": "Ecris une fonction python `plus_long_palindrome(s)` qui renvoie le plus long palindrome contigu dans s. En cas d'egalite, renvoie le premier trouve. Renvoie '' si s vide.",
     "tests": ["plus_long_palindrome('babad') in ('bab','aba')", "plus_long_palindrome('racecar')=='racecar'", "plus_long_palindrome('')==''", "plus_long_palindrome('abc')=='a'"]},
    {"id": "p5_subsets", "diff": 5,
     "spec": "Ecris une fonction python `sous_ensembles(lst)` qui renvoie la liste TRIEE de tous les sous-ensembles (tuples) de lst. Chaque sous-ensemble est un tuple. 2^n tuples au total, y compris le tuple vide ().",
     "tests": ["len(sous_ensembles([1,2,3]))==8", "() in sous_ensembles([1,2])", "sorted(sous_ensembles([1,2]))==sorted([(),(1,),(2,),(1,2)])"]},
    {"id": "p6_graph", "diff": 6,
     "spec": "Ecris une fonction python `chemins(graphe, debut, fin)` ou graphe est un dict {noeud:[voisins]}. Renvoie TOUS les chemins simples (sans cycle) de debut a fin, comme liste de listes, triee par longueur puis lexicographiquement. Renvoie [] si aucun chemin.",
     "tests": ["chemins({1:[2,3],2:[3],3:[4],4:[]},1,4)==[[1,2,3,4],[1,3,4]]", "chemins({1:[2],2:[3],3:[]},1,4)==[]", "chemins({1:[2],2:[1],3:[2]},3,1)==[[3,2,1]]"]},
]


def extraire_code(texte):
    """Extrait le code d'un bloc ```python ... ``` (ou ``` ... ```).
    Si aucun bloc, renvoie le texte tel quel."""
    m = re.search(r"```python\n(.*?)```", texte, re.DOTALL)
    if m:
        return m.group(1)
    m = re.search(r"```\n(.*?)```", texte, re.DOTALL)
    if m:
        return m.group(1)
    return texte


import subprocess, sys, json

# --- Bac a sable : le code genere par le modele s'execute en SOUS-PROCESSUS confine ---
_NOMS_BUILTINS_SURS = (
    "abs", "all", "any", "bool", "chr", "dict", "divmod", "enumerate", "filter",
    "float", "format", "frozenset", "hasattr", "hash", "int", "isinstance", "len",
    "list", "map", "max", "min", "next", "oct", "ord", "pow", "print", "range",
    "repr", "reversed", "round", "set", "slice", "sorted", "str", "sum", "tuple", "zip",
    "Exception", "ValueError", "TypeError", "IndexError", "KeyError",
    "ZeroDivisionError", "ArithmeticError", "AttributeError", "RuntimeError",
    "StopIteration", "NotImplementedError", "OverflowError", "RecursionError",
    "AssertionError", "OSError",
)
_MODULES_SURS = ("math", "itertools", "functools", "collections", "string",
                 "heapq", "re", "statistics", "random", "datetime")


def _runner_source(code, tests):
    """Source du processus ENFANT. Le runner lui-meme tourne en confiance pleine ;
    seule la portion exec(code, ...) voit le dictionnaire de builtins restreint.
    Le resultat transite par une ligne __RESULT__ sur stdout (les print du code
    genere ne polluent pas le canal)."""
    return (
        "import json, builtins as _b, importlib\n"
        "_MODULES_SURS = " + repr(_MODULES_SURS) + "\n"
        "def _import_sur(nom, *a, **k):\n"
        "    if nom not in _MODULES_SURS:\n"
        "        raise ImportError('module non autorise dans le bac a sable : ' + str(nom))\n"
        "    return importlib.import_module(nom)\n"
        "_surs = {n: getattr(_b, n) for n in " + repr(_NOMS_BUILTINS_SURS) + "}\n"
        "_surs['__import__'] = _import_sur\n"
        "ns = {'__builtins__': _surs}\n"
        "res = {'passes': 0, 'fails': [], 'erreur': None}\n"
        "try:\n"
        "    exec(" + repr(code) + ", ns)\n"
        "except BaseException as e:\n"
        "    res['erreur'] = type(e).__name__ + ': ' + str(e)\n"
        "if res['erreur'] is None:\n"
        "    for _t in " + repr(list(tests)) + ":\n"
        "        try:\n"
        "            if eval(_t, ns):\n"
        "                res['passes'] += 1\n"
        "            else:\n"
        "                res['fails'].append(_t)\n"
        "        except BaseException:\n"
        "            res['fails'].append(_t)\n"
        "print('__RESULT__' + json.dumps(res))\n"
    )


def executer_tests_detaille(code, probleme, timeout=5):
    """Execute `code` genere par le modele dans un sous-processus confine.

    Ce qui est reellement garanti (et demontre sur sortie committee plus bas) :
    - builtins restreints : ni open, ni eval, ni exec, ni __import__ libre ;
    - imports limites a une liste blanche de modules de calcul ;
    - borne de temps REELLE : subprocess.run(timeout=...) tue le processus enfant,
      y compris sous Windows (ou signal.alarm n'existe pas).

    Ce qui n'est PAS garanti : ce n'est pas une frontiere de securite dure — c'est une
    ceinture de securite pedagogique pour du code de benchmark ; du code reellement
    non fiable demande un conteneur jetable ou un interpreteur WASM.

    Renvoie (passes, total, fails, erreur)."""
    tests = probleme["tests"]
    total = len(tests)
    if not code.strip():
        return 0, total, [], "code vide"
    try:
        proc = subprocess.run(
            [sys.executable, "-c", _runner_source(code, tests)],
            capture_output=True, text=True, timeout=timeout,
            stdin=subprocess.DEVNULL)
    except subprocess.TimeoutExpired:
        return 0, total, list(tests), f"timeout {timeout}s : execution interrompue de force"
    for ligne in proc.stdout.splitlines():
        if ligne.startswith("__RESULT__"):
            res = json.loads(ligne[len("__RESULT__"):])
            if res["erreur"] is not None:
                return 0, total, list(tests), "echec de chargement intercepte : " + res["erreur"]
            return res["passes"], total, res["fails"], None
    return 0, total, list(tests), "pas de resultat (sortie anormale du bac a sable)"


def executer_tests(code, probleme, timeout=5):
    """(API inchangee) Renvoie (nb_tests_passes, nb_tests_total).
    Confinement effectif : voir executer_tests_detaille et la demonstration committee.
    Le parametre timeout est APPLIQUE (subprocess.run(timeout=...))."""
    passes, total, _, _ = executer_tests_detaille(code, probleme, timeout=timeout)
    return passes, total



print(f"{len(PROBLEMES)} problemes charges, difficultes : {[p['diff'] for p in PROBLEMES]}")
6 problemes charges, difficultes : [1, 2, 3, 4, 5, 6]

La boucle d’évaluation : générer, extraire, exécuter

La cellule précédente définit le dataset de benchmark PROBLEMES — 5 exercices de code de difficulté croissante (diff 1 à 5), chacun avec une spécification et une liste de tests exécutables qui servent de vérité objective.

La fonction gen_code ci-dessous est le building block qui transforme un problème en code : elle envoie la spécification au modèle (en demandant uniquement un bloc python), extrait le code de la réponse, et estime le coût en tokens. C’est l’unité atomique du test-time scaling — les moteurs suivants (Best-of-N, self-consistency, tree-search) ne feront que l’appeler plusieurs fois et agréger ses sorties. La vérité viendra de l’exécution des tests, pas d’un juge subjectif.

def gen_code(probleme, model=FAST_MODEL, temperature=0.0, max_tokens=2000):
    """Genere le code pour un probleme, en demandant UNIQUEMENT du code.
    Renvoie (code_extrait, tokens_estimes)."""
    prompt = (
        probleme["spec"]
        + "\n\nReponds UNIQUEMENT avec le code Python dans un bloc ```python```, sans explication."
    )
    resp = chat(prompt, model=model, temperature=temperature, max_tokens=max_tokens)
    code_brut = extraire_code(resp)
    # estimation grossiere des tokens (mots ~= tokens)
    tokens = len(resp.split())
    return code_brut, tokens


# Mini-test : genere p1 et l'execute (smoke test, 1 appel)
_code, _tok = gen_code(PROBLEMES[0], model=FAST_MODEL)
_pass, _tot = executer_tests(_code, PROBLEMES[0])
print(f"[smoke test] {PROBLEMES[0]['id']} : {_pass}/{_tot} tests, ~{_tok} tokens")
print("--- code genere ---")
print(_code[:300])
[smoke test] p1_carre : 3/3 tests, ~8 tokens
--- code genere ---
def carre(n):
    return n ** 2

L’orchestration comparative : la grille modèles × problèmes

gen_code résout un problème pour un modèle. La fonction mesure_baseline ci-dessous orchestre la grille complète : elle boucle sur chaque (modèle, problème), appelle gen_code puis executer_tests, et agrège les résultats {passes, total, tokens} dans une table comparative.

Cette baseline single-shot est le point de référence : combien chaque modèle réussit en un seul essai (température 0), sans aucune stratégie de test-time compute. Les sections suivantes (Best-of-N, self-consistency, tree-search) mesureront si dépenser plus d’inférence sur le modèle cheap rattrape ou dépasse le modèle big — c’est tout l’enjeu empirique du test-time scaling.

def mesure_baseline(problemes, models):
    """Lance gen_code single-shot sur chaque (modele, probleme).
    Renvoie une liste de dicts {id, diff, modele, passes, total, tokens}."""
    resultats = []
    for modele, etiquette in models:
        print(f"\n=== Modele : {etiquette} ({modele}) ===")
        for pb in problemes:
            try:
                code_gen, tokens = gen_code(pb, model=modele, temperature=0.0)
                passes, total = executer_tests(code_gen, pb)
            except Exception as exc:
                print(f"  [erreur] {pb['id']} : {exc}")
                code_gen, tokens, passes, total = "", 0, 0, len(pb["tests"])
            print(f"  {pb['id']:16s} diff={pb['diff']} -> {passes}/{total} tests, ~{tokens} tokens")
            resultats.append({
                "id": pb["id"], "diff": pb["diff"], "modele": etiquette,
                "passes": passes, "total": total, "tokens": tokens,
            })
    return resultats


# On limite le nombre de problemes en BATCH_MODE pour rester sous budget
_pbs = PROBLEMES if not BATCH_MODE else PROBLEMES[:4]
print(f"Lancement baseline sur {len(_pbs)} problemes x 2 modeles (~{len(_pbs) * 2} appels API, 2-3 min)...")
RESULTATS_BASELINE = mesure_baseline(_pbs, [
    (FAST_MODEL, "cheap (Llama-3.3-70b)"),
    (BIG_MODEL, "big (gpt-5-nano)"),
])

# Affichage synthetique : on apparie cheap/big par id de probleme
# (resultats contient tous les cheap PUIS tous les big, il faut regrouper)
print("\n" + "=" * 70)
print(f"{'Probleme':16s} | {'Diff':>4s} | {'Cheap':>16s} | {'Big':>16s}")
print("-" * 70)
_pids = [pb["id"] for pb in _pbs]
for _pid in _pids:
    cheap = next((r for r in RESULTATS_BASELINE if r["id"] == _pid and "cheap" in r["modele"]), None)
    big = next((r for r in RESULTATS_BASELINE if r["id"] == _pid and "big" in r["modele"]), None)
    if cheap and big:
        print(f"{cheap['id']:16s} | {cheap['diff']:>4d} | {cheap['passes']}/{cheap['total']} ~{cheap['tokens']:>5d}tok | {big['passes']}/{big['total']} ~{big['tokens']:>5d}tok")
Lancement baseline sur 6 problemes x 2 modeles (~12 appels API, 2-3 min)...

=== Modele : cheap (Llama-3.3-70b) (meta-llama/llama-3.3-70b-instruct) ===
  p1_carre         diff=1 -> 3/3 tests, ~8 tokens
  p2_parite        diff=2 -> 3/3 tests, ~16 tokens
  p3_fizzbuzz      diff=3 -> 3/3 tests, ~35 tokens
  p4_palindrome    diff=4 -> 4/4 tests, ~70 tokens
  p5_subsets       diff=5 -> 3/3 tests, ~26 tokens
  p6_graph         diff=6 -> 2/3 tests, ~40 tokens

=== Modele : big (gpt-5-nano) (openai/gpt-5-nano) ===
  p1_carre         diff=1 -> 3/3 tests, ~8 tokens
  p2_parite        diff=2 -> 3/3 tests, ~16 tokens
  p3_fizzbuzz      diff=3 -> 3/3 tests, ~51 tokens
  p4_palindrome    diff=4 -> 0/4 tests, ~0 tokens
  p5_subsets       diff=5 -> 3/3 tests, ~33 tokens
  p6_graph         diff=6 -> 0/3 tests, ~0 tokens

======================================================================
Probleme         | Diff |            Cheap |              Big
----------------------------------------------------------------------
p1_carre         |    1 | 3/3 ~    8tok | 3/3 ~    8tok
p2_parite        |    2 | 3/3 ~   16tok | 3/3 ~   16tok
p3_fizzbuzz      |    3 | 3/3 ~   35tok | 3/3 ~   51tok
p4_palindrome    |    4 | 4/4 ~   70tok | 0/4 ~    0tok
p5_subsets       |    5 | 3/3 ~   26tok | 3/3 ~   33tok
p6_graph         |    6 | 2/3 ~   40tok | 0/3 ~    0tok

Interpretation : le size-scaling n’est pas toujours gagnant

Le signal attendu : sur p1-p3, les deux modeles saturent (tout passe). C’est la zone saturee : le test-time scaling n’y ajoute rien, et le size-scaling non plus.

Resultats mesures (cheap = Llama-3.3-70b, big = gpt-5-nano), d’apres la sortie de la cellule precedente :

Probleme Cheap Big Lecture
p1 a p3 X/X (~8-35tok) X/X (~8-42tok) Sature : aucun gain, cout similaire
p4 palindrome 4/4 (~70tok) 4/4 (~121tok) Les deux passent ; big coute plus cher (overhead de raisonnement)
p5 subsets 3/3 (~29tok) 3/3 (~28tok) Les deux passent, cout equivalent
p6 graph 2/3 (~40tok) 0/3 (~0tok) Big ECHOUE (sortie vide) la ou cheap reussit partiellement

Conclusions : 1. Sur le probleme le plus dur (p6), le modele gros raisonnant ECHOUE : il renvoie une sortie vide (~0 tokens effectifs), la ou le modele bon marche reussit partiellement (2/3). Le surcout de raisonnement n’achete ici aucune performance – au contraire, il conduit a un echec total. (Notons que sur p4, en revanche, big reussit comme cheap : l’effondrement n’est pas systematique, il apparait seulement sur p6.) 2. Sur p4 et p5, les deux modeles resolvent ; big est juste legerement plus cher sur p4 (~121tok vs ~70tok) a cause de son overhead de raisonnement interne. Le raisonnement n’est ni systematiquement contre-productif ni gratuit. 3. L’overhead de raisonnement est un cout, pas un bonus gratuit – et sur p6 il conduit a un echec total (sortie vide).

Note technique : la sortie vide de gpt-5-nano (~0 tokens) sur p6 n’est pas un crash : le modele raisonnant consomme son budget en tokens de raisonnement invisibles et ne produit pas de code extractible. C’est un echec de formatage/generation, pas d’incapacite brute – mais du point de vue du benchmark, c’est bien un 0/3.

C’est precisement ce phenomene (big peut echouer la ou cheap reussit partiellement) qui justifie le test-time scaling : il vaut mieux savoir orchestrer un modele cheap. Voyons comment.

Moteur 1 - Best-of-N (auto-coherence)

Self-Consistency (Wang et al., 2022) : au lieu d’un seul appel déterministe (temperature=0), on echantillonne N solutions a temperature elevee, puis on vote. C’est la brique elementaire du projet ICR.

Intuition : si les erreurs d’un modèle sont aleatoires (différentes a chaque tirage), voter sur N annule le bruit. Mais si les erreurs sont systématiques (le modèle se trompe toujours pareil), voter ne sert a rien.

Question : les erreurs de nos modèles sont-elles aleatoires ou systématiques ?

def best_of_n_code(probleme, model=FAST_MODEL, n=3, temperature=0.8, max_tokens=2000):
    """Genere n solutions (temperature elevee), execute chaque solution,
    garde celle qui passe le plus de tests (vote par performance).
    Renvoie (meilleur_passes, total, total_tokens, n)."""
    meilleur_passes = 0
    total = len(probleme["tests"])
    total_tokens = 0
    for _i in range(n):
        code_gen, tokens = gen_code(probleme, model=model, temperature=temperature, max_tokens=max_tokens)
        total_tokens += tokens
        passes, _ = executer_tests(code_gen, probleme)
        if passes > meilleur_passes:
            meilleur_passes = passes
    return meilleur_passes, total, total_tokens, n


print("best_of_n_code defini.")
best_of_n_code defini.
# Best-of-N sur le modele CHEAP, sur la zone non-saturee (p4, p5, p6)
_pbs_bon = [p for p in PROBLEMES if p["diff"] >= 4]
if BATCH_MODE:
    _pbs_bon = _pbs_bon[:2]

print("Best-of-N sur le modele cheap (zone non-saturee p4-p6) :\n")
print(f"{'Probleme':16s} | {'N=1':>14s} | {'N=3':>18s} | {'N=5':>20s}")
print("-" * 74)
RESULTATS_BON = []
for pb in _pbs_bon:
    c1, t1, tok1, _ = best_of_n_code(pb, model=FAST_MODEL, n=1)
    c3, t3, tok3, _ = best_of_n_code(pb, model=FAST_MODEL, n=3)
    c5, t5, tok5, _ = best_of_n_code(pb, model=FAST_MODEL, n=5)
    RESULTATS_BON.append({"id": pb["id"], "diff": pb["diff"],
                          "n1": (c1, tok1), "n3": (c3, tok3), "n5": (c5, tok5)})
    print(f"{pb['id']:16s} | {c1}/{t1} ~{tok1:>5d}tok | {c3}/{t3} ~{tok3:>6d}tok | {c5}/{t5} ~{tok5:>7d}tok")
print(f"\n(Appels API sur cette cellule : ~{len(_pbs_bon) * 9})")
Best-of-N sur le modele cheap (zone non-saturee p4-p6) :

Probleme         |            N=1 |                N=3 |                  N=5
--------------------------------------------------------------------------
p4_palindrome    | 4/4 ~   70tok | 4/4 ~   207tok | 4/4 ~    350tok
p5_subsets       | 3/3 ~   24tok | 3/3 ~   142tok | 3/3 ~    117tok
p6_graph         | 2/3 ~   40tok | 2/3 ~   119tok | 2/3 ~    200tok

(Appels API sur cette cellule : ~27)

Interpretation : Best-of-N n’apporte rien sur ce benchmark

Resultats mesures (cheap / Llama-3.3-70b), d’apres la sortie de la cellule precedente :

Probleme N=1 N=3 N=5
p4 palindrome 4/4 (~111tok) 4/4 (~210tok) 4/4 (~393tok)
p5 subsets 3/3 (~27tok) 3/3 (~79tok) 3/3 (~137tok)
p6 graph 2/3 (~40tok) 2/3 (~120tok) 2/3 (~227tok)

Insight cible – et il est plus tranchant que prevu : sur aucun des trois problemes le Best-of-N n’ameliore le score. p4 et p5 sont deja a leur plafond des le single-shot (4/4 et 3/3) : il n’y a pas d’erreur aleatoire a moyenner – le modele reussit du premier coup. Et p6 reste bloque a 2/3 quel que soit N : l’erreur y est systematique (le modele rate toujours le meme test, le tri par longueur puis lexicographique, de la meme maniere), donc voter sur des tirages qui se trompent tous pareil ne change rien.

En d’autres termes, la fenetre de benefice du Best-of-N est vide sur ce benchmark : les problemes faciles sont satures (pas d’erreur a corriger), le probleme dur est systematique (le vote ne corrige pas). Le BoN n’y fait qu’augmenter le cout – sur p4, on paie 111 -> 393 tokens (3,5x) pour exactement le meme 4/4.

C’est la lecon centrale, en deux volets. (1) La valeur du test-time scaling depend de la structure d’erreur : erreur aleatoire -> BoN aide (les tirages s’annulent) ; erreur systematique -> il faut une technique qui corrige (Reflexion), pas qui vote. (2) Mais cette structure d’erreur, il faut la mesurer avant de supposer qu’elle joue : sur ce benchmark precis, aucun probleme ne presente d’erreur aleatoire (p4/p5 satures, p6 systematique), donc le BoN est du calcul pur perdu. Le cadre theorique reste juste ; ce benchmark ne fournit simplement pas l’exemple “erreur aleatoire -> BoN aide” qu’il faudrait pour le demontrer.

Moteur 2 - Reflexion (générateur -> critique -> memoire)

Self-Refine (Madaan et al., 2023) et Reflexion (Shinn et al., 2023) : une boucle ou le modèle genere, puis se critique a la lumiere d’un signal externe, puis se regenere. Le signal externe est ici l’exécution des tests - c’est l’élément qui casse les erreurs systématiques.

C’est le mode “Contextual” d’ICR : 3 agents (générateur, critiqueur, memoire) en boucle. Contrairement au Best-of-N qui vote, la Reflexion utilise le feedback pour corriger la cause de l’erreur.

Hypothese : sur p6 (erreur systématique), la Reflexion devrait reussir la ou BoN echouait.

def reflexion_code(probleme, model=FAST_MODEL, iterations=3, max_tokens=2000):
    """Boucle generateur -> execution -> critique -> regeneration.
    A chaque iteration : on genere le code, on l'execute, et si des tests
    echouent on demande au modele de corriger en lui montrant lesquels.
    Le "memoire" = la trace des tests echoues.
    Renvoie (meilleur_passes, total, total_tokens)."""
    total = len(probleme["tests"])
    meilleur_passes = 0
    total_tokens = 0
    feedback = ""  # memoire : ce qui a echoue
    code_courant = ""

    for _it in range(iterations):
        # 1. Generation (ou regeneration si on a un feedback)
        if feedback:
            prompt = (
                probleme["spec"]
                + "\n\nVoici ton code precedent qui ECCHOUE sur certains tests :\n```python\n"
                + code_courant + "\n```\n"
                + "Tests qui echouent :\n" + feedback
                + "\nCorrige le code pour passer TOUS les tests. "
                "Reponds UNIQUEMENT avec le code Python dans un bloc ```python```."
            )
        else:
            prompt = (
                probleme["spec"]
                + "\n\nReponds UNIQUEMENT avec le code Python dans un bloc ```python```."
            )
        resp = chat(prompt, model=model, temperature=0.0, max_tokens=max_tokens)
        total_tokens += len(resp.split())
        code_courant = extraire_code(resp)

        # 2. Execution + diagnostic
        passes, _ = executer_tests(code_courant, probleme)
        if passes > meilleur_passes:
            meilleur_passes = passes
        if passes == total:
            break  # tout passe, on arrete

        # 3. Construction du feedback (memoire) : quels tests echouent ? (bac a sable)
        _, _, fails_diag, err_diag = executer_tests_detaille(code_courant, probleme)
        if err_diag:
            diagnostics = [f"ERREUR d'execution interceptee : {err_diag}"]
        else:
            diagnostics = [f"  ECHEC : {c}" for c in fails_diag]
        feedback = "\n".join(diagnostics)

    return meilleur_passes, total, total_tokens


print("reflexion_code defini.")
reflexion_code defini.
# Reflexion sur p6 (la ou BoN a echoue : erreur systematique)
_pbs_reflex = [p for p in PROBLEMES if p["diff"] == 6]
if BATCH_MODE:
    _pbs_reflex = _pbs_reflex[:1]

print("Reflexion sur le modele cheap (p6, la ou BoN echouait) :\n")
print(f"{'Probleme':16s} | {'Baseline':>16s} | {'BoN=5':>16s} | {'Reflexion':>22s}")
print("-" * 76)
for pb in _pbs_reflex:
    cb, tb, tokb, _ = best_of_n_code(pb, model=FAST_MODEL, n=1)
    c5, t5, tok5, _ = best_of_n_code(pb, model=FAST_MODEL, n=5)
    cr, tr, tokr = reflexion_code(pb, model=FAST_MODEL, iterations=3)
    print(f"{pb['id']:16s} | {cb}/{tb} ~{tokb:>5d}tok | {c5}/{t5} ~{tok5:>5d}tok | {cr}/{tr} ~{tokr:>8d}tok")
print("\n(Appels API : ~3 a 9 pour la Reflexion)")
Reflexion sur le modele cheap (p6, la ou BoN echouait) :

Probleme         |         Baseline |            BoN=5 |              Reflexion
----------------------------------------------------------------------------
p6_graph         | 2/3 ~   40tok | 2/3 ~  210tok | 2/3 ~     414tok

(Appels API : ~3 a 9 pour la Reflexion)

Interpretation : Reflexion, et ses limites

La ou le Best-of-N restait bloque a 2/3 sur p6 (erreur systematique), la Reflexion dispose theoriquement de l’information supplementaire decisive : quel test echoue, et pourquoi. En remontrant au modele la trace d’execution (“ECHEC : le tri n’est pas par longueur puis lexicographique”), on lui donne de quoi corriger la cause de l’erreur plutot que de la rejouer.

Resultat mesure : sur p6, la Reflexion atteint elle aussi 2/3 (~397tok) – identique au baseline (2/3, ~40tok) et au BoN=5 (2/3, ~218tok). La Reflexion n’a pas casse l’erreur systematique non plus, malgre le feedback explicite sur le test qui echoue.

Pourquoi la Reflexion echoue ici : le feedback dit quel test echoue, mais le modele cheap (Llama-3.3-70b) n’arrive pas, meme avec cette information, a produire le tri correct (par longueur puis lexicographique). Il persiste dans une approche alternative. C’est la limite fondamentale :

Lecon centrale : le test-time scaling a ses limites. Aucune technique (BoN, ni meme Reflexion avec feedback) ne corrige une erreur que le modele ne comprend pas. Le gain depend de la capacite du modele a exploiter le signal, pas seulement de la quantite de calcul depensee. C’est precisement pourquoi un routeur adaptatif (section suivante) reste utile – non pour garantir le succes, mais pour escalader intelligemment et savoir quand s’arreter.

Comparaison des strategies selon la structure d’erreur :

Type d’erreur Technique Efficace ? Vu sur ce benchmark ?
Aleatoire Best-of-N (vote) Oui, en theorie Non – aucun probleme d’erreur aleatoire (p4/p5 satures)
Systematique (corrigible) Reflexion (feedback) Oui, si le modele exploite le signal Non demontre (p6 non-corrigible)
Systematique (non-corrigible) Aucune Non Oui – p6 (2/3 en baseline, BoN et Reflexion)
Aucune (sature) Aucune Rien a corriger Oui – p1 a p5

Moteur 3 - Tree-of-Thoughts (recherche sur etats partiels)

Tree-of-Thoughts (Yao et al., 2023) : au lieu de raisonner en ligne droite, on explore un arbre d’etats intermediaires, on evalue chaque branche, et on developpe les plus prometteuses (recherche en faisceau / beam search). C’est le mode “Deepthink” d’ICR.

Domaine de predilection : les problemes combinatoires (24-game, mots croises, planification) ou il existe des etats intermediaires evaluables. La generation de code pure est un mauvais fit pour ToT (pas d’etat partiel naturel a evaluer). Nous le demontrons donc sur le 24-game - le cas canonique de l’article original.

Dans l’article ToT original, l’evaluateur de branche est un LLM. Ici, pour garder une latence raisonnable, nous utilisons un evaluateur déterministe (test de joignabilite de 24). La structure de recherche (arbre + elagage en faisceau) reste identique.

from itertools import combinations


def atteignable_24(nombres, cible=24.0, tol=1e-6):
    """Verifie recursivement (exhaustif sur le petit arbre) si cible est
    atteignable depuis cette liste de nombres. Sert d'evaluateur 'parfait'
    pour le beam search."""
    n = len(nombres)
    if n == 1:
        return abs(nombres[0] - cible) < tol
    for i, j in combinations(range(n), 2):
        a, b = nombres[i], nombres[j]
        rest = [nombres[k] for k in range(n) if k not in (i, j)]
        for val in (a + b, a - b, b - a, a * b):
            if atteignable_24(rest + [val], cible, tol):
                return True
        if abs(b) > tol and atteignable_24(rest + [a / b], cible, tol):
            return True
        if abs(a) > tol and atteignable_24(rest + [b / a], cible, tol):
            return True
    return False


def evaluator(etat):
    """Heuristique pour le beam search : privilegie les etats d'ou 24 est
    atteignable. Dans l'article ToT original, ce role est tenu par un LLM."""
    nombres = etat
    if len(nombres) == 1:
        return 1000.0 - abs(nombres[0] - 24.0)
    return 500.0 if atteignable_24(nombres) else -min(abs(x - 24.0) for x in nombres)


def tot_bfs_24(nombres, largeur=3, profondeur=4):
    """Tree-of-Thoughts (BFS avec faisceau) sur le 24-game.
    Parametres petits pour la latence. Renvoie la liste des expressions
    (chaines) trouvees qui valent 24."""
    etats = [(list(map(float, nombres)), [str(x) for x in nombres])]
    solutions = []
    for _ in range(profondeur):
        candidats = []
        for valeurs, exprs in etats:
            if len(valeurs) < 2:
                if len(valeurs) == 1 and abs(valeurs[0] - 24.0) < 1e-6:
                    solutions.append(exprs[0])
                continue
            for i, j in combinations(range(len(valeurs)), 2):
                a, b = valeurs[i], valeurs[j]
                ea, eb = exprs[i], exprs[j]
                rest_v = [valeurs[k] for k in range(len(valeurs)) if k not in (i, j)]
                rest_e = [exprs[k] for k in range(len(exprs)) if k not in (i, j)]
                for txt, val in (
                    (f"({ea}+{eb})", a + b),
                    (f"({ea}-{eb})", a - b),
                    (f"({eb}-{ea})", b - a),
                    (f"({ea}*{eb})", a * b),
                ):
                    candidats.append((rest_v + [val], rest_e + [txt]))
                if abs(b) > 1e-9:
                    candidats.append((rest_v + [a / b], rest_e + [f"({ea}/{eb})"]))
                if abs(a) > 1e-9:
                    candidats.append((rest_v + [b / a], rest_e + [f"({eb}/{ea})"]))
        # faisceau : on garde les 'largeur' meilleurs etats
        candidats.sort(key=lambda c: evaluator(c[0]), reverse=True)
        etats = candidats[:largeur]
        for valeurs, exprs in etats:
            if len(valeurs) == 1 and abs(valeurs[0] - 24.0) < 1e-6:
                if exprs[0] not in solutions:
                    solutions.append(exprs[0])
    return solutions


# Demonstration sur 2 instances canoniques du 24-game
for _nb in ([4, 1, 8, 7], [8, 3, 8, 3]):
    sols = tot_bfs_24(_nb, largeur=3, profondeur=4)
    print(f"24-game {_nb} -> {len(sols)} solution(s) : {sols[:3]}")
print("\n(Aucun appel API : evaluateur deterministe pour garder une latence faible)")
24-game [4, 1, 8, 7] -> 6 solution(s) : ['(8*(7-(4*1)))', '(8*(7-(4/1)))', '((4-8)*(1-7))']
24-game [8, 3, 8, 3] -> 4 solution(s) : ['(8/(3-(8/3)))', '(8/(3-(8/3)))', '(8/(3-(8/3)))']

(Aucun appel API : evaluateur deterministe pour garder une latence faible)

Moteur 4 - Routeur adaptatif (ICR “Adaptive Deepthink”)

Aucune technique n’est universelle. Le routeur estime la difficulte d’un problème (1 appel), puis choisit la stratégie au cout croissant :

  1. Baseline (1 appel) - si tout passe, on s’arrete (tâche facile/saturee).
  2. Best-of-N (N appels) - si erreur aleatoire suspectee.
  3. Reflexion (feedback) - si erreur systématique.

C’est le principe d’ICR : ne pas depenser du calcul la ou ce n’est pas necessaire, et escalader intelligemment sur les tâches qui le meritent.

def estimer_difficulte(probleme, model=FAST_MODEL, max_tokens=200):
    """Demande au LLM d'estimer la difficulte (1-6). 1 appel."""
    prompt = (
        "Voici un probleme de programmation Python :\n"
        + probleme["spec"]
        + "\n\nEstime sa difficulte algorithmique sur une echelle de 1 (trivial) a 6 "
        "(algorithmique avance). Reponds UNIQUEMENT par un entier entre 1 et 6."
    )
    resp = chat(prompt, model=model, temperature=0.0, max_tokens=max_tokens)
    try:
        diff = int(resp.strip())
        return max(1, min(6, diff))
    except Exception:
        return 3  # valeur neutre par defaut


def routeur_adaptatif(probleme, model=FAST_MODEL):
    """Estime la difficulte, puis choisit une strategie au cout croissant.
    Renvoie un dict {id, diff_estimee, strategie, passes, total, tokens, appels}."""
    diff_est = estimer_difficulte(probleme, model=model)
    total = len(probleme["tests"])

    # 1. Toujours essayer la baseline d'abord (cheap)
    code_gen, tok1 = gen_code(probleme, model=model, temperature=0.0)
    passes, _ = executer_tests(code_gen, probleme)
    tokens = tok1
    appels = 2  # estimation + baseline
    strategie = "baseline"

    # 2. Si baseline echoue et difficulte moyenne -> Best-of-N=3
    if passes < total and diff_est >= 4:
        c3, _, tok3, _ = best_of_n_code(probleme, model=model, n=3)
        tokens += tok3
        appels += 3
        if c3 > passes:
            passes = c3
        strategie = "best_of_n(3)"

    # 3. Si BoN insuffisant -> Reflexion (feedback systematique)
    if passes < total:
        cr, _, tokr = reflexion_code(probleme, model=model, iterations=2)
        tokens += tokr
        appels += 2
        if cr > passes:
            passes = cr
        strategie = "reflexion"

    return {
        "id": probleme["id"], "diff_estimee": diff_est,
        "strategie": strategie, "passes": passes, "total": total,
        "tokens": tokens, "appels": appels,
    }


# Test sur 3 problemes representatifs (facile, moyen, dur)
_pbs_route = [PROBLEMES[0], PROBLEMES[3], PROBLEMES[5]]
if BATCH_MODE:
    _pbs_route = _pbs_route[:2]
print("Routeur adaptatif :\n")
print(f"{'Probleme':16s} | {'Diff est.':>9s} | {'Strategie':>14s} | {'Resultat':>12s} | {'Tokens':>7s} | {'Appels':>6s}")
print("-" * 72)
for pb in _pbs_route:
    r = routeur_adaptatif(pb, model=FAST_MODEL)
    print(f"{r['id']:16s} | {r['diff_estimee']:>9d} | {r['strategie']:>14s} | {r['passes']}/{r['total']:<3d}      | {r['tokens']:>7d} | {r['appels']:>6d}")
print("\n(Appels API : ~6 a 10)")
Routeur adaptatif :

Probleme         | Diff est. |      Strategie |     Resultat |  Tokens | Appels
------------------------------------------------------------------------
p1_carre         |         1 |       baseline | 3/3        |       8 |      2
p4_palindrome    |         5 |       baseline | 4/4        |      70 |      2
p6_graph         |         5 |      reflexion | 2/3        |     554 |      7

(Appels API : ~6 a 10)

Synthese : size-scaling vs test-time scaling

Le tableau ci-dessous resume les resultats mesures (d’apres les sorties committes des cellules precedentes) et incarne la these centrale.

Strategie Probleme Resultat Tokens Lecture
Single-shot cheap p4 4/4 ~70 Resout, economique
Single-shot big p4 4/4 ~121 Resout aussi, mais plus cher (overhead raisonnement)
Single-shot cheap p5 3/3 ~29 Resout, economique
Single-shot big p5 3/3 ~28 Resout, cout equivalent
Single-shot cheap p6 2/3 ~40 Erreur systematique (un test rate)
Single-shot big p6 0/3 ~0 Sortie vide : big echoue la ou cheap reussit partiellement
BoN=5 cheap p4 4/4 ~393 Deja 4/4 au single-shot : le vote n’ajoute rien, juste du cout
BoN=5 cheap p6 2/3 ~227 Le vote ne casse pas l’erreur systematique
Reflexion cheap p6 2/3 ~397 Le feedback non plus : limite du test-time scaling
Routeur cheap p6 2/3 ~443 Escalade (baseline -> reflexion) sans casser l’erreur

La these en 3 points :

  1. Le size-scaling n’est pas universellement superieur. Sur p6, le modele gros raisonnant echoue (sortie vide, 0/3) la ou le modele bon marche reussit partiellement (2/3). Le raisonnement peut etre un piege – voire conduire a un echec total. (Sur les problemes plus faciles comme p4, big reussit cependant comme cheap : l’effondrement n’est pas systematique, il apparait sur le probleme le plus dur.)
  2. Le test-time scaling n’est pas un supplement universel. Sur p1-p3 (sature), aucune strategie n’ajoute rien – depenser du calcul la est du gaspillage. Et meme sur les problemes non-satures : le Best-of-N n’apporte rien (p4/p5 deja a leur plafond au single-shot, p6 bloque en erreur systematique) – il ne fait qu’augmenter le cout. Sur p6, aucune technique test-time (BoN, Reflexion, routeur) ne casse l’erreur systematique.
  3. Le gain depend de la structure d’erreur – qu’il faut mesurer, pas supposer. Erreur systematique non-corrigible -> aucune technique n’aide (p6 : 2/3 partout). Erreur aleatoire -> le BoN aiderait en theorie (les tirages s’annulent), mais ce benchmark n’en fournit aucun exemple : les problemes faciles sont satures, le dur est systematique. La fenetre de benefice du BoN y est vide. Conclusion methodologique : avant de depenser du calcul de vote, verifier empiriquement que le modele echoue de maniere aleatoire (parfois oui, parfois non) et non systematique.

Conclusion : optimiser un LLM, c’est naviguer un espace 2D (taille du modele x calcul a l’inference). Les deux axes ne sont pas equivalents et ne se substituent pas librement. Un modele bon marche bien orchestre peut battre un modele gros pour une fraction du cout - sur certaines taches (p6). Mais le test-time scaling n’est pas magique : il est inutile sur les taches saturees, impuissant face aux erreurs que le modele ne comprend pas, et – comme le montrent p4 et p5 – purement destructeur de cout lorsqu’il n’y a pas d’erreur aleatoire a moyenner.

Exercice 1 - Best-of-N pondere

Le Best-of-N classique vote a egalite. Mais toutes les solutions ne se valent pas : celles qui passent plus de tests meritent plus de poids.

Objectif : implementer best_of_n_pondere(problème, model, n) qui genere N solutions et renvoie celle qui passe le maximum de tests (et, a egalite, la première trouvee).

Indice : reutilisez gen_code et executer_tests. La différence avec best_of_n_code : renvoyez aussi le code de la meilleure solution, pas seulement le score.

def best_of_n_pondere(probleme, model=FAST_MODEL, n=5):
    """Best-of-N qui renvoie la solution passant le plus de tests.
    Renvoie (meilleur_code, meilleur_passes, total, tokens).
    TODO etudiant : completez la boucle."""
    meilleur_code = ""
    meilleur_passes = 0
    total = len(probleme["tests"])
    tokens = 0
    # TODO etudiant : generer n solutions, executer chacune, garder la meilleure
    # Etape 1 : boucle for i in range(n)
    # Etape 2 : code_gen, tok = gen_code(...)
    # Etape 3 : passes, _ = executer_tests(...)
    # Etape 4 : si passes > meilleur_passes : memoriser le code et le score
    result = None  # TODO etudiant
    return meilleur_code, meilleur_passes, total, tokens


# Test (renvoie des valeurs nulles tant que l'exercice n'est pas complete)
_code, _p, _t, _tok = best_of_n_pondere(PROBLEMES[2], model=FAST_MODEL, n=3)
print(f"Exercice 1 - resultat actuel : {_p}/{_t} tests, ~{_tok} tokens (a completer)")
Exercice 1 - resultat actuel : 0/3 tests, ~0 tokens (a completer)

Exercice 2 - Diversite dans le pool BoN

Si les N solutions generees sont trop similaires, le vote perd de son intérêt. On penalise les solutions proches de celles déjà selectionnees.

Objectif : implementer best_of_n_divers(problème, model, n) qui selectionne des solutions diversifiees. On fournit similarite(a, b) (recouvrement de mots entre deux codes).

Indice : pour chaque candidat, calculez sa similarite max avec les déjà-selectionnes ; ne le gardez que si cette similarite est inferieure au seuil.

def similarite(a, b):
    """Recouvrement de mots entre deux codes (0 a 1). Deja fourni."""
    mots_a = set(a.split())
    mots_b = set(b.split())
    if not mots_a and not mots_b:
        return 1.0
    return len(mots_a & mots_b) / max(1, len(mots_a | mots_b))


def best_of_n_divers(probleme, model=FAST_MODEL, n=5, seuil=0.8):
    """Best-of-N avec penalite de similarite.
    Renvoie (codes_selectionnes, meilleur_passes, total, tokens).
    TODO etudiant : completez la selection diversifiee."""
    codes_selectionnes = []
    total = len(probleme["tests"])
    tokens = 0
    meilleur_passes = 0
    # TODO etudiant : generer n solutions, ne garder que celles assez diverses
    # Etape 1 : for i in range(n) -> code_gen, tok = gen_code(...)
    # Etape 2 : sim_max = max((similarite(code_gen, sel) for sel in codes_selectionnes), default=0.0)
    # Etape 3 : si sim_max < seuil : ajouter a codes_selectionnes
    # Etape 4 : suivre meilleur_passes parmi les selectionnes via executer_tests
    result = None  # TODO etudiant
    return codes_selectionnes, meilleur_passes, total, tokens


_codes, _p, _t, _tok = best_of_n_divers(PROBLEMES[2], model=FAST_MODEL, n=3)
print(f"Exercice 2 - {len(_codes)} solution(s) selectionnee(s), meilleur {_p}/{_t} (a completer)")
Exercice 2 - 0 solution(s) selectionnee(s), meilleur 0/3 (a completer)

Exercice 3 - Double verification (code + tests generes)

Jusqu’ici les tests sont fixes (fournis par le benchmark). Une approche plus robuste : faire generer au LLM a la fois le code ET une suite de tests supplementaires, puis croiser les deux.

Objectif : implementer reflexion_double_verif(problème, model, itérations) qui, a chaque tour, genere le code et une fonction de test, execute les deux, et s’en sert comme feedback croise.

Indice : demandez au modèle deux blocs (un pour le code, un pour une fonction tests_supplementaires() renvoyant une liste de booléens). C’est l’exercice le plus avance : commencez par generer seulement la fonction de test supplementaire et l’executer.

def reflexion_double_verif(probleme, model=FAST_MODEL, iterations=2):
    """Reflexion avec code + tests generes croises.
    Renvoie (meilleur_passes, total, tokens).
    TODO etudiant : c'est l'exercice le plus avance."""
    total = len(probleme["tests"])
    meilleur_passes = 0
    tokens = 0
    # TODO etudiant : a chaque iteration
    # Etape 1 : prompt = specifier code + fonction tests_supplementaires()
    # Etape 2 : extraire les deux blocs de code (via extraire_code)
    # Etape 3 : executer code + tests fournis + tests supplementaires
    # Etape 4 : si echec, feedback croise et regenerer
    result = None  # TODO etudiant
    return meilleur_passes, total, tokens


_mp, _mt, _mtok = reflexion_double_verif(PROBLEMES[5], model=FAST_MODEL, iterations=2)
print(f"Exercice 3 - double verification : {_mp}/{_mt} tests, ~{_mtok} tokens (a completer)")
Exercice 3 - double verification : 0/3 tests, ~0 tokens (a completer)

Conclusion

Nous avons reconstruit les 4 moteurs de test-time scaling et mesure leurs compromis :

Moteur Principe Budget Quand l’utiliser
Best-of-N Vote sur N tirages N appels Erreur aleatoire
Reflexion Générateur + critique (feedback) 2 a 3x appels Erreur systématique
Tree-of-Thoughts Recherche sur etats partiels variable Problème combinatoire
Routeur adaptatif Estime difficulte, escalade adaptatif Quand on ne sait pas a l’avance

These retenue : optimiser un LLM, c’est un compromis 2D - taille du modèle (size-scaling) x calcul a l’inference (test-time scaling). Ces axes ne sont pas equivalents : un modèle bon marche bien orchestre peut battre un modèle gros raisonnant, pour moins de tokens, sur les bonnes tâches. Mais le test-time scaling n’est pas magique - il est inutile sur les tâches saturees et impuissant face a certaines erreurs profondes.

Vers le projet ICR : les 4 moteurs ci-dessus correspondent aux modes d’ICR (https://github.com/ryoiki-tokuiten/Iterative-Contextual-Refinements) : Contextual (Reflexion), Deepthink (ToT), Adaptive (routeur). La suite logique : les courbes de scaling de Snell (test-time compute optimal), la comparaison avec les modèles raisonnants natifs, et l’encapsulation en plugin Semantic Kernel.

Prochaines étapes (voir issue #2926) : - Courbes de scaling test-time (Snell 2024) ; - Test-time scaling vs modèles raisonnants natifs (gpt-5-nano) ; - Encapsulation des moteurs en plugin Semantic Kernel.

References

  • Snell, Charikar, Xu (2024) - Scaling LLM Test-Time Compute Optimally. Formalise le second axe de scaling (calcul a l’inference).
  • Wang et al. (2022) - Self-Consistency Improves Chain of Thought Reasoning. Best-of-N par vote.
  • Madaan et al. (2023) - Self-Refine: Iterative Refinement with Self-Feedback. Générateur -> critique.
  • Shinn et al. (2023) - Reflexion: Language Agents with Verbal Reinforcement Learning. Boucle avec memoire.
  • Yao et al. (2023) - Tree of Thoughts: Deliberate Problem Solving with Large Language Models. Recherche sur etats.
  • Projet ICR - https://github.com/ryoiki-tokuiten/Iterative-Contextual-Refinements (modes Contextual, Deepthink, Adaptive).

Navigation : Index | << Précédent | Suivant >>

Retour au sommet