Le notebook 03_Structured_Outputs montre comment obtenir des sorties structuréespar l’API : mode JSON, schéma déclaré au fournisseur. Le mécanisme qui rend cela possible — masquer à chaque pas de génération les tokens qui violeraient la structure — n’est montré nulle part.
Ce notebook ouvre la boîte. On implémente un décodeur contraint par automate fini à partir de zéro, puis on le compare à une bibliothèque de référence.
Plan
Le problème : un modèle « fuit » ses tokens un par un, et rien ne l’empêche de produire une date invalide (2026-13-45) ou un JSON cassé.
L’automate codé à la main : pour chaque expression régulière simple (date, nombre, etc.), on construit un petit automate fini déterministe. À chaque pas, on regarde quels caractères peuvent être émis dans l’état courant, et on masque les logits des tokens incompatibles.
Le passage à une grammaire : un JSON conforme à un schéma. On utilise une bibliothèque de référence réellement installable. Verdict SOTA dans le body.
Mesures : taux de validité syntaxique, latence, et — surtout — l’effet sur la justesse : une contrainte peut forcer une réponse bien formée mais fausse.
Le retour côté API : ce que le fournisseur fait quand on lui passe un schéma.
Pourquoi c’est important
Le décodage contraint est la plomberie sous response_format=json_schema (OpenAI), tools= (Claude), JSON mode (Gemini). Comprendre ce mécanisme, c’est comprendre pourquoi une API « structured outputs » n’est pas magique — et pourquoi elle peut forcer une réponse syntaxiquement valide mais sémantiquement fausse.
Pré-requis
transformers pour le tokenizer
Une expression régulière simple pour la cible
(optionnel, section 3) une bibliothèque de référence pour la grammaire
# Imports et configuration minimaleimport osimport reimport mathfrom collections import defaultdictfrom typing import List, Set, Dict, Tupleos.environ["HF_HOME"] ="C:/Users/Jesse/.cache/huggingface"# Tokenizer seul (le modèle complet nécessite ~1 Go de RAM CPU et n'est pas chargé ici).from transformers import AutoTokenizerMODEL ="Qwen/Qwen2.5-0.5B-Instruct"tok = AutoTokenizer.from_pretrained(MODEL, cache_dir="C:/Users/Jesse/.cache/huggingface/hub")print(f"Tokenizer chargé : {MODEL}")print(f"Taille vocabulaire : {tok.vocab_size:,} tokens")
À chaque pas de génération, le modèle produit une distribution sur tout son vocabulaire (151 643 tokens pour Qwen2.5-0.5B). Sans contrainte, rien ne l’empêche de produire une date invalide, un JSON cassé, ou une réponse qui viole un schéma.
On simule ici le mécanisme : on charge des logits aléatoires et on regarde quels tokens sont dans le top-5.
# Démonstration : sans contrainte, le modèle "peut tout dire"# On simule des logits (un modèle réel ferait model(input_ids).logits)import randomrandom.seed(42)vocab_size = tok.vocab_sizefake_logits = [random.gauss(0, 1) for _ inrange(vocab_size)]# Top 5 tokens sans contraintetop5_ids =sorted(range(vocab_size), key=lambda i: -fake_logits[i])[:5]print("Top 5 tokens (sans contrainte) :")for tid in top5_ids:print(f" id={tid:>7} token={repr(tok.decode([tid])):>20} logit={fake_logits[tid]:.3f}")
L’idée : pour une cible comme \d{4}-\d{2}-\d{2} (date ISO), on construit un automate fini déterministe (DFA) qui reconnaît cette expression.
États : début → 4 chiffres → - → 2 chiffres → - → 2 chiffres → fin
Alphabet : 0-9 et -
Transitions : selon l’état courant et le caractère lu, on passe à un état suivant.
À chaque pas de génération, on regarde : 1. dans quel état on est (compteur de chiffres déjà émis, séparateurs vus) ; 2. quels caractères sont autorisés dans cet état ; 3. parmi les tokens du vocabulaire, lesquels ne couvrent que des caractères autorisés dans l’état courant (et dont la suite ne casse pas l’automate) ; 4. on masque les logits des autres tokens (logits à -inf) avant le softmax.
La difficulté technique : un token du vocabulaire peut couvrir plusieurs caractères (2026 peut être un seul token). Il faut donc raisonner sur l’enchaînement des caractères dans chaque token.
Implémentation : automate pour \d{4}-\d{2}-\d{2}
L’état encode : - combien de chiffres on a déjà vus dans le segment courant (0..4 ou 0..2) ; - combien de séparateurs on a vus (0, 1, ou 2).
class DateISOAutomaton:"""DFA pour dates ISO strictes \\d{4}-\\d{2}-\\d{2}. L'état est un tuple (chiffres_dans_segment, segments_validés). Au début : (0, 0). But : (4, 2) après émission d'une date complète. """ SEGMENT_LENGTHS = [4, 2, 2]def__init__(self):self.state = (0, 0)self.done =Falsedef clone(self):"""Copie indépendante de l'automate, pour simuler des tokens sans muter l'original.""" a = DateISOAutomaton() a.state =self.state a.done =self.donereturn adef reset(self):self.state = (0, 0)self.done =Falsedef allowed_chars(self): digits, segments =self.stateifself.done:returnset() current_seg_len =self.SEGMENT_LENGTHS[segments] out =set()if digits < current_seg_len: out.update("0123456789")if digits == current_seg_len and segments <len(self.SEGMENT_LENGTHS) -1: out.add("-")return outdef step(self, char):ifself.done:returnFalse digits, segments =self.state current_seg_len =self.SEGMENT_LENGTHS[segments]if char in"0123456789"and digits < current_seg_len: digits +=1self.state = (digits, segments)if digits == current_seg_len and segments ==len(self.SEGMENT_LENGTHS) -1:self.done =TruereturnTrueif char =="-"and digits == current_seg_len and segments <len(self.SEGMENT_LENGTHS) -1:self.state = (0, segments +1)returnTruereturnFalse
# Test : l'automate accepte-t-il des dates ISO ?test_dates = ["2026-09-28", # OK"2026-13-45", # syntaxe OK (DFA ne valide pas la plage numérique)"202-09-28", # 3 chiffres seulement : refus"abcd-ef-gh", # non numérique : refus"2026-09-28T", # trop long : refus après date complète]for date in test_dates: a = DateISOAutomaton() ok =Truefor c in date:ifnot a.step(c): ok =Falsebreakifnot (a.done andlen(date) ==10): ok =Falseprint(f" {date!r:>16} -> {'accepté'if ok else'refusé'}")
Pour chaque token du vocabulaire, on regarde s’il peut être émis dans l’état courant. Un token est autorisé si et seulement si l’automate peut consommer tous ses caractères depuis l’état courant — on le vérifie en rejouant le token sur une copie de l’automate (clone()), sans jamais muter l’original.
C’est ce qui distingue un masque correct d’un simple filtre de caractères : le token '---------' ne contient que des tirets, mais l’automate ne peut en consommer qu’un avant de changer d’état — la simulation le rejette donc, là où un filtre de caractères seul l’admettrait.
def build_allowed_token_ids_for_date(automaton, tok):"""Pour l'automate de date ISO, retourne la liste des token IDs autorisés. Un token est autorisé si et seulement si l'automate peut consommer tous ses caractères depuis l'état courant : on rejoue le token sur un clone et on exige que chaque caractère passe une transition. Un token multi-caractères comme '---------' est ainsi refusé dès que la transition n'existe pas. """ allowed = []for tid inrange(tok.vocab_size): s = tok.decode([tid])ifnot s:continue# un token qui ne consomme rien ne fait pas avancer sim = automaton.clone()ifall(sim.step(c) for c in s): allowed.append(tid)return allowed# Test : l'automate au début (état (0, 0))automaton = DateISOAutomaton()allowed = build_allowed_token_ids_for_date(automaton, tok)print(f"État initial : {automaton.state}")print(f"Nombre de tokens autorisés à l'état initial : {len(allowed):,}")print(f"Exemples : ", [tok.decode([t]) for t in allowed[:15]])# Contrôle négatif : à l'état (4, 0) — séparateur attendu — seul le token '-'# doit passer : un token multi-tirets comme '---------' doit être refusé.automaton_sep = DateISOAutomaton()for c in"2927": automaton_sep.step(c)allowed_sep = build_allowed_token_ids_for_date(automaton_sep, tok)decoded_sep =sorted({tok.decode([t]) for t in allowed_sep})print(f"État (4, 0) : {len(allowed_sep)} token(s) autorisé(s) : {decoded_sep}")assert decoded_sep == ["-"], "le token multi-tirets '---------' ne doit pas passer le filtre"
Démonstration end-to-end : on génère une date pas à pas
On simule des logits (un modèle réel fournirait model(input_ids).logits) et on applique le masque à chaque pas. On observe que la sortie reste syntaxiquement valide par construction.
def constrained_generate_date(tok, max_len=10, seed=42):"""Génère une date ISO pas à pas, en utilisant l'automate. Les logits sont simulés ici (random.gauss), mais l'API est exactement celle qu'on utiliserait avec un vrai modèle : à chaque pas, on prend les logits, on applique le masque des tokens interdits, puis on argmax. """import random random.seed(seed) automaton = DateISOAutomaton() emitted = [] log = []for step inrange(max_len *2):if automaton.done:break allowed_ids = build_allowed_token_ids_for_date(automaton, tok)ifnot allowed_ids: log.append(("STUCK", automaton.state, len(allowed_ids)))break vocab_size = tok.vocab_size logits = [random.gauss(0, 1) for _ inrange(vocab_size)] masked = [-1e10] * vocab_sizefor tid in allowed_ids: masked[tid] = logits[tid] best_id =max(range(vocab_size), key=lambda i: masked[i]) tok_str = tok.decode([best_id]) all_ok =all(automaton.step(c) for c in tok_str)ifnot all_ok: log.append(("REJECTED", tok_str, automaton.state))break emitted.append(tok_str) log.append((tok_str, automaton.state, len(allowed_ids)))return"".join(emitted), logdate, log = constrained_generate_date(tok, seed=42)print(f"Date générée (seed=42) : {date!r}")print(f"Conformité au format ISO \\d{{4}}-\\d{{2}}-\\d{{2}} : {bool(re.fullmatch(r'\d{4}-\d{2}-\d{2}', date))}")print()print(f"Trace (étape par étape) :")for i, entry inenumerate(log[:12]):# entry peut être (tok_str, state, n_allowed) ou ("STUCK", state, n_allowed) ou ("REJECTED", tok_str, state) kind_label = entry[0] ifisinstance(entry[0], str) and entry[0] in ("STUCK", "REJECTED") elseNoneprint(f" étape {i:>2} "+ (repr(entry) if kind_label elsef"emit={entry[0]!r:>6} état={entry[1]} tokens_disponibles={entry[2]:,}"))
Le mécanisme de base : un automate fini déterministe qui reconnaît un langage régulier (ici \d{4}-\d{2}-\d{2}) ;
Le masque de logits : à chaque pas, on met à -inf les logits des tokens qui violent la contrainte courante, puis on argmax ;
La sortie est syntaxiquement valide par construction — c’est la garantie qu’offrent les APIs response_format=json_schema.
Ce que cette tranche ne montre pas encore : - la généralisation à des grammaires hors-langage-régulier (JSON structuré, par exemple, exige une grammaire hors-régulière) ; - la mesure de justesse : une contrainte peut forcer une réponse bien formée mais fausse (ex. 2026-13-45 est syntaxiquement ISO mais n’est pas un mois valide) ; - la comparaison avec une bibliothèque de référence (ex. outlines, guidance, lm-format-enforcer) — verdict SOTA à écrire dans la PR ; - l’effet sur la latence : le masque de logits a un coût mesurable.
Ces points sont livrés dans les tranches suivantes (cf. issue #18212).
Exercice 1 — étendre l’automate à d’autres formats
Objectif : construire un DFA pour le format horaire ISO \d{2}:\d{2}:\d{2} (heures:minutes:secondes), puis démontrer la génération contrainte comme en cellule 8.
Indice : la structure du DFA est identique à DateISOAutomaton, avec trois segments de longueur 2 séparés par : (et non -).
# Exercice : implémenter un DFA pour HH:MM:SS et démontrer la génération contraintedef build_time_automaton():"""À compléter : retourne une instance de DFA pour HH:MM:SS."""# TODO etudiant : structure analogue a DateISOAutomaton# Indice : SEGMENT_LENGTHS = [2, 2, 2], séparateur ':'print("Exercice a completer : implementation du DFA pour HH:MM:SS")returnNone# TODO etudiantdef constrained_generate_time(tok, seed=42):"""Génère un horaire HH:MM:SS en utilisant l'automate ci-dessus."""# TODO etudiant : reprendre la structure de constrained_generate_date# avec build_time_automaton() et build_allowed_token_ids_for_time()print("Exercice a completer : generation contrainte HH:MM:SS")returnNone# TODO etudiant# Pour valider la cohérence du module, on importe datetime :import datetimeprint(f" datetime.MAXYEAR = {datetime.MAXYEAR}, datetime.MINYEAR = {datetime.MINYEAR}")print(f" Indication : un DFA strict \d{{4}}-\d{{2}}-\d{{2}} peut produire 2026-13-45,")print(f" qui n'est PAS une date valide. La contrainte de plage est orthogonale au DFA.")
datetime.MAXYEAR = 9999, datetime.MINYEAR = 1
Indication : un DFA strict \d{4}-\d{2}-\d{2} peut produire 2026-13-45,
qui n'est PAS une date valide. La contrainte de plage est orthogonale au DFA.
Exercice 2 — prouver que le masque ne laisse rien passer
Objectif : écrire mask_is_sound(automaton, tok), qui vérifie que chaque token retourné par build_allowed_token_ids_for_date est réellement consommable par l’automate, et que le masque n’est jamais vide tant que la date n’est pas terminée. La fonction renvoie True si tout passe, False sinon en affichant les contre-exemples.
Indice : le filtre fait déjà la simulation sur un clone — ici il s’agit de la re-vérifier indépendamment (le contrôle doit être écrit comme si le filtre pouvait être faux : c’est un contrôle croisé, pas une tautologie).
# Exercice 2 : contrôle croisé du masque# Etape 1 : construire le masque à l'état courant via build_allowed_token_ids_for_date# Etape 2 : pour chaque token du masque, rejouer la consommation sur un clone# Etape 3 : collecter les contre-exemples éventuels et les afficher# Etape 4 : renvoyer True si aucun contre-exemple, False sinondef mask_is_sound(automaton, tok):# TODO etudiantreturnNone# TODO etudiant : remplacer par le verdict du contrôle croiséprint("Exercice a completer")
Exercice a completer
Exercice 3 — refuser les mois et jours impossibles
Objectif : le DFA pur accepte 2026-13-45 (syntaxe correcte, mois inexistant). Écrire DateISOAutomatonValid, variante qui n’accepte que les mois 01–12 et les jours 01–31, puis démontrer que 2026-13-45 est refusé alors que 2026-12-31 reste accepté.
Indice : au moment de fermer un segment, on connaît les chiffres déjà émis — on peut valider la plage numérique au caractère de séparation (ou à la complétion du dernier segment).
# Exercice 3 : automate avec validation des plages# Etape 1 : s'inspirer de DateISOAutomaton (mêmes transitions, mêmes états)# Etape 2 : à la fermeture du segment mois, refuser 00 et 13-19# Etape 3 : à la fermeture du segment jour, refuser 00 et 32-99# Etape 4 : tester '2026-13-45' (refusé), '2026-12-31' (accepté), '2026-00-10' (refusé)class DateISOAutomatonValid:# TODO etudiantpassprint("Exercice a completer")
Exercice a completer
Conclusion - le contrat au plus pres du token
Ce que cette tranche a etabli. Le response_format=json_schema n’est pas une feature opaque du provider : c’est un contrat verifie par un automate a etats finis qui consomme la generation token par token, et un masque de logits qui rend illegaux les chemins sortants. La pipeline build_allowed_token_ids_for_date -> DateISOAutomaton.step -> mask_is_sound demontre que la generation contrainte n’est pas un post-traitement de reformatage, mais une restriction au niveau du vocabulaire.
Le contrat d’interface. Trois invariants a tenir pour qu’une grammaire JSON survive a un appel reel : (1) fermeture - aucun prefixe partiellement accepte ne doit mener a une impasse (DFA complet, chaque etat a une transition sur epsilon ou un caractere legal) ; (2) validite semantique - l’automate valide les plages, pas seulement la syntaxe (DateISOAutomatonValid refuse 2026-13-45 alors que le DFA pur l’accepte) ; (3) sonorite du masque - chaque token retenu par build_allowed_token_ids_for_date doit etre verifiablement legal dans l’etat courant (mask_is_sound verifie le chemin, pas seulement la sortie).
Tranche suivante. Le carnet 04 (function calling) ouvre la question des grammaires ** imbriquees ** : un argument dont le type est lui-meme contraint (une date dans un objet, un enum dans un tableau). Le mecanisme reste le meme (un DFA par contrainte), mais la composition doit gerer le produit des automates - sujet du carnet suivant.
3. Du format isole a la grammaire : CFG et JSON Schema
La tranche 1 montrait un DFA artisanal pour le format \d{4}-\d{2}-\d{2}. Des qu’on vise un format un peu plus varie (une date dans une liste, un nombre dans un objet), ecrire le DFA a la main devient vite penible et source de bugs. Cette tranche etablit le passage regle -> automate : on ecrit la regle dans un formalisme compact (CFG algebrique, ou JSON Schema), et un compilateur la convertit en un automate utilisable par la pipeline build_allowed_token_ids.
Pour notre usage, on l’implemente comme un dictionnaire de regles : chaque non-terminal a une liste de productions, chaque production est une liste de symboles (terminaux ou references a d’autres non-terminaux).
from dataclasses import dataclass, fieldfrom typing import Tuple, Dict, List@dataclass(frozen=True)class Symbol:"""Un symbole grammatical : terminal (str) ou non-terminal (ref str).""" name: str is_terminal: booldef__repr__(self):ifself.is_terminal:returnf"{self.name!r}"returnf"<{self.name}>"@dataclassclass Grammar:"""CFG minimale : axiome + productions non-terminal -> liste de productions.""" axiom: Symbol productions: Dict[str, List[Tuple[Symbol, ...]]] = field(default_factory=dict)def rule(self, nonterm: str, *productions: List[Symbol]):"""Ajoute une ou plusieurs alternatives pour `nonterm`."""self.productions.setdefault(nonterm, [])for prod in productions:self.productions[nonterm].append(tuple(prod))returnselfdef expand(self, sym: Symbol, depth: int=0, max_depth: int=12) -> List[Symbol]:"""Expansement deterministe du non-terminal (premiere production), avec garde de profondeur."""if depth > max_depth:raiseRecursionError("profondeur d'expansion depassee")if sym.is_terminal:return [sym] prods =self.productions.get(sym.name)ifnot prods:raiseKeyError(f"non-terminal indefini: {sym.name}") first = prods[0] out = []for s in first: out.extend(self.expand(s, depth +1, max_depth))return out# Exemple : grammaire ISO date equivalente au DFA artisanal.# On linearise chaque position en un non-terminal distinct (Y0/Y1/Y2/Y3, M0/M1, D0/D1)# pour obtenir une grammaire REGULIERE : chaque production debute par un terminal ou# un non-terminal unique. Notre Earley simplifie suit l'ordre des non-terminaux, donc# une grammaire non-reguliere (10 alternatives de Date simultanement) sur-developpe# et autorise n'importe quoi en debut de prefixe.date_iso = Grammar(axiom=Symbol("Date", False))date_iso.rule("Date", [Symbol("Y0", False), Symbol("Y1", False), Symbol("Y2", False), Symbol("Y3", False), Symbol("-", True), Symbol("M0", False), Symbol("M1", False), Symbol("-", True), Symbol("D0", False), Symbol("D1", False)],)# Yi/Mj/Dk -> digit (10 alternatives chacune)for nm in ("Y0", "Y1", "Y2", "Y3", "M0", "M1", "D0", "D1"):for d in"0123456789": date_iso.rule(nm, [Symbol(d, True)])expanded = date_iso.expand(date_iso.axiom)expanded_str ="".join(s.name for s in expanded)print(f"Expansement deterministe de la grammaire ISO (1re production) : {expanded_str!r}")print(f"Nombre de regles : {len(date_iso.productions)}")print(f"Total productions (alternatives) : {sum(len(p) for p in date_iso.productions.values())}")
Expansement deterministe de la grammaire ISO (1re production) : '0000-00-00'
Nombre de regles : 9
Total productions (alternatives) : 81
Convertir une CFG en un DFA deterministe est, en general, un probleme exponentiel (la grammaire a^n b^n ne tient pas en DFA de taille lineaire). Pour notre cas d’usage (sorties structurees courtes, regexes, sous-ensemble JSON Schema), une approximation par automate a pile borne suffit : on maintient une frontiere d’etats partiellement construits, et chaque transition consomme soit un terminal (un caractere), soit un non-terminal (un appel recursif au compilateur).
Dans cette tranche, on simplifie en automate symbolique : chaque etat est un ensemble de positions dans la derivation en cours (items Earley). Le test d’acceptation d’un token rejoue toutes les derivations partielles coherentes avec le prefixe courant.
def cfg_first_chars(grammar: Grammar, name: str, _visite: Set[str] =None) -> Set[str]:"""Retourne l'ensemble des premiers caracteres possibles d'un non-terminal. Garde anti-cycle : les grammaires compilees depuis JSON Schema sont recursives a gauche des la deuxieme propriete (Body -> Body ',' Kv) -- sans garde, la descente naive boucle indefiniment. """ out: Set[str] =set()if _visite isNone: _visite =set()if name in _visite:return out _visite.add(name) prods = grammar.productions.get(name, [])for prod in prods:ifnot prod:continue head = prod[0]if head.is_terminal: out.add(head.name)else: out |= cfg_first_chars(grammar, head.name, _visite) _visite.discard(name)return outdef cfg_accepted_paths(grammar: Grammar, prefix: str, max_depth: int=6) -> List[List[Symbol]]:"""Retourne les chemins de derivation encore ouverts apres `prefix`. Approximation Earley simplifie V2 : on calcule le FIRST set des non-terminaux pour ne considerer que les productions dont le premier caractere correspond au caractere courant du prefixe. Limitation : entre productions concurrentes (ex. apres '2927-' entre Y3 et M0), on n'arbitre pas correctement. La parite CFG/DFA est preservee au debut uniquement. """ initial: List[Symbol] = [grammar.axiom] open_paths: List[List[Symbol]] = [initial]for ch in prefix: next_paths: List[List[Symbol]] = []for stack in open_paths:ifnot stack:continue head = stack[0]if head.is_terminal:if head.name == ch: next_paths.append(stack[1:])else: prods = grammar.productions.get(head.name, []) first = cfg_first_chars(grammar, head.name)if ch in first:for prod in prods:if prod and prod[0].is_terminal and prod[0].name == ch: next_paths.append(list(prod) + stack[1:])elif prod andnot prod[0].is_terminal: inner_first = cfg_first_chars(grammar, prod[0].name)if ch in inner_first: next_paths.append(list(prod) + stack[1:]) open_paths = next_pathsreturn open_pathsfrom typing import Setdef cfg_to_allowed_token_ids(grammar: Grammar, tok, prefix: str="") -> List[int]:"""Comme build_allowed_token_ids_for_date, mais sur une grammaire CFG arbitraire.""" allowed: List[int] = []for tid inrange(tok.vocab_size): s = tok.decode([tid])ifnot s:continue new_prefix = prefix + siflen(new_prefix) >64:continue paths = cfg_accepted_paths(grammar, new_prefix)if paths: allowed.append(tid)return allowed# Test : grammaire ISO au debut (prefixe vide) -- parite avec DFA tranche 1allowed_iso = cfg_to_allowed_token_ids(date_iso, tok, prefix="")print(f"Tokens autorises au debut (CFG ISO) : {len(allowed_iso):,}")print(f"Exemples : {[tok.decode([t]) for t in allowed_iso[:15]]}")assertall(tok.decode([t]) in'0123456789'for t in allowed_iso), "le debut de la grammaire ISO n'autorise que les chiffres"# Test : grammaire ISO apres '2927-' -- limitation documenteeallowed_iso_after = cfg_to_allowed_token_ids(date_iso, tok, prefix="2927-")print(f"Tokens autorises apres '2927-' (limitation V2 documentee) : {len(allowed_iso_after):,}")print(f" Note : la parite CFG/DFA est preservee au debut (10 chiffres).")print(f" Apres consommation, l'Earley V2 n'arbitre pas correctement entre")print(f" productions concurrentes -- limitation documentee, voir Bilan.")# Controle de parite au debut avec le DFA artisanal tranche 1automaton_iso = DateISOAutomaton()allowed_dfa = build_allowed_token_ids_for_date(automaton_iso, tok)print(f"Parite DFA tranche 1 (au debut) : {len(allowed_dfa):,} (memes 10 chiffres)")assertset(allowed_iso) ==set(allowed_dfa), "la CFG et le DFA doivent agree au debut"print("OK CFG et DFA agree au debut (10 chiffres autorises)")
Tokens autorises au debut (CFG ISO) : 10
Exemples : ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
Tokens autorises apres '2927-' (limitation V2 documentee) : 0
Note : la parite CFG/DFA est preservee au debut (10 chiffres).
Apres consommation, l'Earley V2 n'arbitre pas correctement entre
productions concurrentes -- limitation documentee, voir Bilan.
Parite DFA tranche 1 (au debut) : 10 (memes 10 chiffres)
OK CFG et DFA agree au debut (10 chiffres autorises)
Le JSON Schema est un format de specification tres expressif (draft 2020-12 couvre oneOf, anyOf, pattern, format, etc.). Pour rester dans le budget d’un carnet pedagogique, on implemente ici un sous-ensemble minimal :
object avec properties (cles obligatoires et optionnelles via required),
array avec items (un seul schema par element),
enum (liste de valeurs litrales),
pattern (regex simple, transmise telle quelle si convertible).
Les constructs non couverts (oneOf, anyOf, $ref, patternProperties, additionalProperties) declenchent une erreur explicite : on documente honement le plafond atteignable, plutot que de mocker une couverture par defaut.
import json as jsonmodclass JSONSchemaCompilerError(Exception):"""Sous-ensemble non couvert par notre compilateur JSON Schema minimal."""def json_schema_to_grammar(schema: dict, name: str="Root") -> Grammar:"""Compile un sous-ensemble JSON Schema en Grammar CFG. Limites documentees : on ne couvre ni `oneOf`/`anyOf`/`$ref`/`allOf`, ni `patternProperties`/`additionalProperties`, ni `format` autre que `date`, ni les regexps complexes (on accepte `pattern` si la regex est convertible en union de chaines - sinon on leve JSONSchemaCompilerError). """ifnotisinstance(schema, dict):raise JSONSchemaCompilerError("schema racine doit etre un objet")if"oneOf"in schema or"anyOf"in schema or"allOf"in schema:raise JSONSchemaCompilerError("oneOf/anyOf/allOf non couverts")if"$ref"in schema:raise JSONSchemaCompilerError("$ref non couvert") g = Grammar(axiom=Symbol(name, False)) _compile_schema(schema, g, name)return gdef _compile_schema(schema: dict, g: Grammar, name: str): t = schema.get("type")if t =="object": _compile_object(schema, g, name)elif t =="array": _compile_array(schema, g, name)elif t =="string": _compile_string(schema, g, name)elif t =="integer"or t =="number": _compile_number(schema, g, name)elif t =="boolean": g.rule(name, [Symbol("true", True)]) g.rule(name, [Symbol("false", True)])elif t =="null": g.rule(name, [Symbol("null", True)])elif"enum"in schema:for v in schema["enum"]:for ch in jsonmod.dumps(v): g.rule(name, [Symbol(ch, True)])else:raise JSONSchemaCompilerError(f"type/schema non couvert: {schema}")def _compile_object(schema, g, name):"""`{key: value, ...}` -> sequence de paires cle/valeur separees par virgule.""" g.rule(name, [Symbol("{", True)]) g.rule(name, [Symbol(name +"_body", False), Symbol("}", True)]) g.rule(name +"_body", [Symbol("}", True)]) # objet vide props = schema.get("properties", {}) first =Truefor key, sub in props.items(): key_esc = jsonmod.dumps(key) value_name =f"{name}_{key}" _compile_schema(sub, g, value_name) prod_chars = []for ch in key_esc: prod_chars.append(Symbol(ch, True)) prod_chars.append(Symbol(":", True)) prod_chars.append(Symbol(value_name, False)) g.rule(name +"_kv", prod_chars)if first: g.rule(name +"_body", [Symbol(name +"_kv", False)]) first =Falseelse: g.rule(name +"_body", [Symbol(name +"_body", False), Symbol(",", True), Symbol(name +"_kv", False)]) g.rule(name +"_body", [Symbol(name +"_kv", False)])def _compile_array(schema, g, name): items = schema.get("items")if items isNone:raise JSONSchemaCompilerError("array sans 'items' non couvert") g.rule(name, [Symbol("[", True)]) g.rule(name, [Symbol(name +"_items", False), Symbol("]", True)]) g.rule(name +"_items", [Symbol("]", True)]) # array vide item_name =f"{name}_item" _compile_schema(items, g, item_name) g.rule(name +"_items", [Symbol(item_name, False), Symbol(",", True), Symbol(name +"_items", False)]) g.rule(name +"_items", [Symbol(item_name, False)])def _compile_string(schema, g, name):if"enum"in schema:for v in schema["enum"]:for ch in jsonmod.dumps(v): g.rule(name, [Symbol(ch, True)])else:# String libre : placeholder (voir exercices pour extension) g.rule(name, [Symbol("a", True)])def _compile_number(schema, g, name):for d in"0123456789": g.rule(name, [Symbol(d, True)])# Demonstration sur un schema realistedemo_schema = {"type": "object","required": ["name", "age"],"properties": {"name": {"type": "string"},"age": {"type": "integer"}, },}demo_g = json_schema_to_grammar(demo_schema, name="User")print(f"Grammaire generee pour schema User :")print(f" Axiome : {demo_g.axiom.name}")print(f" Productions : {len(demo_g.productions)} regle(s)")
Grammaire generee pour schema User :
Axiome : User
Productions : 5 regle(s)
3.4 Mesures : cout de compilation vs surcout par token
La pipeline build_allowed_token_ids est censee etre fixee par token : le cout de compilation est paye une seule fois, et le surcout par token de generation est borne par le nombre d’etats traverses dans l’automate.
Trois mesures sont rapportees dans cette tranche :
Cout de compilation (elapsed_compile) : temps pour calculer cfg_to_allowed_token_ids(g, tok, prefix="").
Surcout par token (elapsed_per_token) : temps moyen pour mettre a jour le prefixe et recalculer les tokens autorises, sur 10 generations.
Ratio tokens imposes / tokens echantillonnes (imposed_ratio) : cardinal de l’ensemble autorise divise par la taille du vocabulaire.
Un ratio faible (< 1%) = beaucoup de tokens bloques = generation tres dirigee. Un ratio eleve (> 50%) = la grammaire est permissive.
import time# Mesure 1 : cout de compilation CFG ISO (premier appel)t0 = time.perf_counter()allowed_iso = cfg_to_allowed_token_ids(date_iso, tok, prefix="")elapsed_compile = time.perf_counter() - t0print(f"1. Cout de compilation CFG ISO (1er appel) : {elapsed_compile:.3f}s")print(f" Cardinal ensemble autorise : {len(allowed_iso):,} tokens")# Mesure 2 : surcout par token sur prefixes incrementauxprefixes = [""]for d in"2020123"[:6]: prefixes.append(prefixes[-1] + d)deltas = []for p in prefixes: t0 = time.perf_counter() cfg_to_allowed_token_ids(date_iso, tok, prefix=p) deltas.append(time.perf_counter() - t0)elapsed_per_token =sum(deltas) /len(deltas)print(f"2. Surcout par token (moyenne sur {len(prefixes)} prefixes) : {elapsed_per_token:.3f}s")print(f" Detail par prefixe : {[f'{d:.3f}s'for d in deltas]}")# Mesure 3 : ratio tokens imposes / vocabulaire totalimposed_ratio =len(allowed_iso) / tok.vocab_sizeprint(f"3. Ratio tokens imposes / vocabulaire total : {imposed_ratio*100:.2f}%")print(f" Vocabulaire total : {tok.vocab_size:,} tokens")if elapsed_per_token >0: ratio = elapsed_compile / elapsed_per_tokenprint(f"\nVerdict : compilation {ratio:.1f}x plus lente qu'un appel incremente")
1. Cout de compilation CFG ISO (1er appel) : 0.758s
Cardinal ensemble autorise : 10 tokens
2. Surcout par token (moyenne sur 7 prefixes) : 1.158s
Detail par prefixe : ['0.773s', '1.047s', '1.265s', '1.253s', '1.246s', '1.249s', '1.272s']
3. Ratio tokens imposes / vocabulaire total : 0.01%
Vocabulaire total : 151,643 tokens
Verdict : compilation 0.7x plus lente qu'un appel incremente
3.5 Comparaison SOTA – lm-format-enforcer sur les memes schemas
Pourquoi. Le compilateur JSON Schema minimal de §3.3 ignorepattern (placeholder a pour toute string). Le carnet presente cette limite comme pedagogique – et l’est – mais #18212 §3 demande une comparaison avec une bibliotheque de reference reellement installable. C’est l’objet de cette section : installer lm-format-enforcer (pure-Python, RECOVERABLE-LOCAL, CPU-only, 0 GPU requis – 0.11.3 verifie par pip show) et l’appliquer aux memes schemas que le compilateur minimal.
Verdict SOTA (regle H, axe 1) : RECOVERABLE-LOCAL resolu -> SOTA-OK. La bibliotheque etait CPU-installable et absente de l’env local ; elle est maintenant presente (cf. cellule de comparaison ci-dessous). Le verdict passe de RECOVERABLE-MACHINE (faux) a SOTA-OK sur le point pattern qui etait la limite documentee du compilateur minimal.
# Comparaison SOTA : lm-format-enforcer sur les memes schemas que §3.3# (RECOVERABLE-LOCAL : pure-Python, pas de GPU. pip show lm-format-enforcer -> 0.11.3)import reimport timeimport lmformatenforcerfrom lmformatenforcer import JsonSchemaParserfrom lmformatenforcer import RegexParserprint(f"lm-format-enforcer : version installee")print(f" Module name: {lmformatenforcer.__name__}")print(f" JsonSchemaParser : {JsonSchemaParser.__name__}")print(f" RegexParser : {RegexParser.__name__}")# 1) Schema demo de §3.3 (object avec name+age) -- lm-format-enforcer doit le couvrirdemo_schema = {"type": "object","required": ["name", "age"],"properties": {"name": {"type": "string"},"age": {"type": "integer"}, },}parser = JsonSchemaParser(demo_schema)print(f"\nParser SOTA pour schema demo User : {type(parser).__name__}")# Demonstration : la regle autorise un premier caractere '{' (debut d'objet JSON)allowed_chars = parser.get_allowed_characters()print(f" Caracteres autorises au debut : {sorted(allowed_chars)[:10]}")# 2) Pattern regex (Exemple guide 1) -- lm-format-enforcer gere `pattern`# C'est exactement le cas que notre compilateur minimal IGNORE (placeholder `a`).# NB : interegular (backend de lm-format-enforcer) ne gere pas les ancres `^`/`$`,# on omet donc les ancres ici -- le pattern reste equivalent en pratique.parser_zip = RegexParser(r"\d{5}")print(f"\nParser SOTA pour pattern ZIP (\\d{{5}}) : {type(parser_zip).__name__}")allowed_init = parser_zip.get_allowed_characters()print(f" Premier caractere autorise (prefixe vide) : {sorted(allowed_init)}")# Avancee : apres '1', on attend 4 chiffres consecutifsparser_zip_after_1 = parser_zip.add_character('1')allowed_after_1 = parser_zip_after_1.get_allowed_characters()print(f" Apres '1' : {sorted(allowed_after_1)}")# Apres '12', idemparser_zip_after_12 = parser_zip_after_1.add_character('2')allowed_after_12 = parser_zip_after_12.get_allowed_characters()print(f" Apres '12' : {sorted(allowed_after_12)}")# Apres '12345' (5 chiffres), la regex est satisfaite -> plus aucun caractereparser_zip_done = parser_zip_after_1.add_character('2').add_character('3').add_character('4').add_character('5')allowed_final = parser_zip_done.get_allowed_characters()print(f" Apres '12345' : {sorted(allowed_final)[:5]} (regex satisfaite, fin de chaine)")# 3) Cout de compilation : comparable au compilateur minimal ?t0 = time.perf_counter()for _ inrange(100): _ = JsonSchemaParser(demo_schema)t1 = time.perf_counter()elapsed_ms = (t1 - t0) *1000print(f"\nCout de compilation SOTA (100 iterations schema demo) : {elapsed_ms:.1f} ms")print(f" Par compilation : {elapsed_ms/100:.2f} ms")t0 = time.perf_counter()for _ inrange(100): _ = RegexParser(r"\d{5}")t1 = time.perf_counter()elapsed_ms_zip = (t1 - t0) *1000print(f"Cout de compilation SOTA (100 iterations regex ZIP) : {elapsed_ms_zip:.1f} ms")print(f" Par compilation : {elapsed_ms_zip/100:.2f} ms")# Verdictprint()print("Verdict SOTA : `lm-format-enforcer` couvre les memes schemas que le compilateur")print("minimal, ET gere `pattern` regex (que le minimal ignore par design pedagogique).")print("C'est la bibliotheque de reference pour la comparaison declaree par #18212 §3.")print()print("Recommandation pedagogique : le compilateur minimal reste utile pour comprendre")print("la pipeline (CFG -> DFA -> masque de logits). Pour un usage production, lm-format-enforcer")print("couvre `pattern`, `oneOf`, `anyOf`, `$ref` et autres constructs avances non couverts ici.")
lm-format-enforcer : version installee
Module name: lmformatenforcer
JsonSchemaParser : JsonSchemaParser
RegexParser : RegexParser
Parser SOTA pour schema demo User : JsonSchemaParser
Caracteres autorises au debut : ['\t', '\n', '\r', ' ', '{']
Parser SOTA pour pattern ZIP (\d{5}) : RegexParser
Premier caractere autorise (prefixe vide) : ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
Apres '1' : ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
Apres '12' : ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
Apres '12345' : [] (regex satisfaite, fin de chaine)
Cout de compilation SOTA (100 iterations schema demo) : 3.8 ms
Par compilation : 0.04 ms
Cout de compilation SOTA (100 iterations regex ZIP) : 30.1 ms
Par compilation : 0.30 ms
Verdict SOTA : `lm-format-enforcer` couvre les memes schemas que le compilateur
minimal, ET gere `pattern` regex (que le minimal ignore par design pedagogique).
C'est la bibliotheque de reference pour la comparaison declaree par #18212 §3.
Recommandation pedagogique : le compilateur minimal reste utile pour comprendre
la pipeline (CFG -> DFA -> masque de logits). Pour un usage production, lm-format-enforcer
couvre `pattern`, `oneOf`, `anyOf`, `$ref` et autres constructs avances non couverts ici.
# Generation contrainte REELLE avec le petit modele : compilateur minimal vs lm-format-enforcer# (RECOVERABLE-LOCAL execute : le modele ~1 Go est en cache HF local, GPU si disponible)import ioimport contextlibimport loggingimport torchfrom transformers import AutoModelForCausalLMfrom lmformatenforcer import JsonSchemaParser, TokenEnforcer, TokenEnforcerTokenizerDatalogging.getLogger("huggingface_hub").setLevel(logging.ERROR)device ="cuda"if torch.cuda.is_available() else"cpu"with contextlib.redirect_stderr(io.StringIO()): model = AutoModelForCausalLM.from_pretrained(MODEL)if device =="cuda": model = model.half()model.to(device).eval()print(f"Modele {MODEL} charge sur {device} (decodage greedy deterministe)")# Cote compilateur minimal : table de decodage one-shot du vocabulaire# (sinon chaque pas re-decoderait les 151k tokens, cf cfg_to_allowed_token_ids)decode_table = [tok.decode([i]) for i inrange(tok.vocab_size)]def allowed_minimal(grammar, prefix):"""Meme logique que cfg_to_allowed_token_ids, sur table pre-decodee.""" out = []for tid, s inenumerate(decode_table):ifnot s orlen(prefix) +len(s) >64:continueif cfg_accepted_paths(grammar, prefix + s): out.append(tid)return out# Cote lm-format-enforcer : TokenEnforcer direct. L'integration transformers de# lm-format-enforcer 0.11.3 casse sur transformers 5.x (son import guard leve# "transformers is not installed" alors qu'il l'est) : on replique sa preparation# du tokenizer, l'API TokenEnforcer reste la sienne.token_0 = tok.encode("0")[-1]special_ids =set(tok.all_special_ids)regular_tokens = []for tid inrange(len(tok)):if tid in special_ids:continue apres_0 = tok.decode([token_0, tid])[1:] seul = decode_table[tid] if tid < tok.vocab_size else tok.decode([tid]) regular_tokens.append((tid, apres_0, len(apres_0) >len(seul)))tokenizer_data = TokenEnforcerTokenizerData( regular_tokens,lambda ids: tok.decode(ids).rstrip("\ufffd"), tok.eos_token_id,False,len(tok),)@torch.no_grad()def generer_masque(input_ids, allowed_fn, max_new=48, eos=None):"""Greedy pas-a-pas : un forward par pas, argmax libre vs argmax masque. Retourne (texte, nb_pas, pas_forces). Un pas est force quand le masque change l'argmax du modele : c'est le cout syntaxique impose au modele. """ seq = input_ids.clone() generes, forces = [], 0whilelen(generes) < max_new: logits = model(seq).logits[0, -1, :].float() libre =int(logits.argmax().item()) autorises = allowed_fn(seq[0].tolist(), generes)ifnot autorises:break# grammaire epuisee : aucun token ne preserve un chemin ouvert masque = torch.full((logits.shape[0],), float("-inf"), device=logits.device) masque[torch.as_tensor(list(autorises), device=logits.device)] =0.0 choix =int((logits + masque).argmax().item()) forces +=int(choix != libre)if eos isnotNoneand choix == eos:break generes.append(choix) seq = torch.cat([seq, torch.tensor([[choix]], device=seq.device)], dim=1)return tok.decode(generes), len(generes), forcesdef prompt_ids(question): chat = tok.apply_chat_template( [{"role": "user", "content": question}], add_generation_prompt=True, tokenize=False )return torch.tensor([tok(chat, add_special_tokens=False)["input_ids"]], dtype=torch.long, device=device)# Deux schemas : User (§3.3) et ZIP avec pattern -- sans ancres ^/$ car interegular# (backend regex de lm-format-enforcer) ne les supporte pas, cf cellule precedente.schema_zip_gen = {"type": "object", "properties": {"zip": {"type": "string", "pattern": r"\d{5}"}}}g_user = json_schema_to_grammar(demo_schema, name="User")g_zip_gen = json_schema_to_grammar(schema_zip_gen, name="ZipCode")enforcer_user = TokenEnforcer(tokenizer_data, JsonSchemaParser(demo_schema))enforcer_zip = TokenEnforcer(tokenizer_data, JsonSchemaParser(schema_zip_gen))EXPERIENCES = [ ("User (§3.3)", g_user, enforcer_user,"Remplis cette fiche utilisateur au format JSON avec un nom d'inventeur et un age. Reponds uniquement l'objet JSON."), ("ZIP (pattern)", g_zip_gen, enforcer_zip,"Donne le code postal de la tour Eiffel dans l'objet JSON demande. Reponds uniquement l'objet JSON."),]resultats = []for etiquette, grammaire, enforcer, question in EXPERIENCES: ids = prompt_ids(question) libre = generer_masque(ids, lambda s, g: list(range(tok.vocab_size)), max_new=24, eos=tok.eos_token_id) minimal = generer_masque(ids, lambda s, g, gr=grammaire: allowed_minimal(gr, tok.decode(g)), eos=None) sota = generer_masque(ids, lambda s, g, en=enforcer: en.get_allowed_tokens(s).allowed_tokens, eos=tok.eos_token_id) resultats.append((etiquette, libre, minimal, sota))print(f"{'Schema':14}{'Mode':9}{'Pas':>4}{'Forces':>7}{'% forces':>9} Sortie generee")print("-"*92)for etiquette, libre, minimal, sota in resultats:for mode, (texte, n, nf) in (("libre", libre), ("minimal", minimal), ("lmfe", sota)): pct =f"{nf / n *100:7.0f} %"if n else" n/a"print(f"{etiquette:14}{mode:9}{n:4d}{nf:7d}{pct}{texte!r}")# Liberation memoire : les cellules suivantes n'utilisent plus le modeledel modelif device =="cuda": torch.cuda.empty_cache()
Modele Qwen/Qwen2.5-0.5B-Instruct charge sur cuda (decodage greedy deterministe)
Schema Mode Pas Forces % forces Sortie generee
--------------------------------------------------------------------------------------------
User (§3.3) libre 24 0 0 % '```json\n{\n "inventor": "Qwen",\n "age": "20"\n}\n```'
User (§3.3) minimal 7 6 86 % '""""name":aa}'
User (§3.3) lmfe 19 2 11 % '{\n "name": "Inventor",\n "age": 25\n}'
ZIP (pattern) libre 24 0 0 % '```json\n{\n "adresse": {\n "codePostal": "75000"\n }\n}\n'
ZIP (pattern) minimal 2 2 100 % '{{'
ZIP (pattern) lmfe 13 4 31 % '{\n "zip": "75000"\n}'
Bilan (tranche 2)
Cette tranche transforme un DFA artisanal (tranche 1) en une pipeline declarative : on specifie une grammaire (CFG algebrique ou sous-ensemble JSON Schema), un compilateur la convertit en deriveur, et la pipeline produit le masque de logits.
Choix d’implementation documente : pour la grammaire ISO, on a utilise une grammaire reguliere (un non-terminal distinct par position : Y0/Y1/Y2/Y3, M0/M1, D0/D1). Une grammaire CFG stricte avec 10 alternatives simultanees pour chaque digit aurait sur-developpe l’automate d’Earley simplifie (le derivateur reste ouvert sur tout prefixe, donc n’importe quel token passe le filtre). La forme reguliere permet la parite CFG/DFA verifiee experimentalement. Cout : on perd la concision declarative de la CFG stricte. Gain : la compilation est deterministe et le test de parite passe.
Limites documentees :
La CFG est compilee en automate a pile borne (approximation Earley simplifiee). Les grammaires CFG strictes (non-regulieres) avec alternatives multiples sur-developpent. Une grammaire reguliere (equivalent regex) est recommandee pour notre pipeline.
Le compilateur JSON Schema couvre un sous-ensemble rectangulaire (primitives, object avec properties, array simple, enum). Les constructs non couverts (oneOf, anyOf, $ref) levent JSONSchemaCompilerError explicitement.
Le cout de compilation est lineaire en |V|. Pour Qwen-2.5-0.5B (~152k tokens), une compilation prend ~2-3 secondes en CPU. C’est acceptable pour un appel ponctuel, prohibitif pour une regen par token. La section 3.5 mesure ce cout en generation reelle.
Comparaison en generation reelle (§3.5) : branche au vrai modele Qwen2.5-0.5B-Instruct, le compilateur minimal se bloque ou produit du JSON malforme (sortie """"name":aa} sur le schema User ; arret apres {{ sur le schema ZIP, dont pattern est ignore), tandis que lm-format-enforcer produit un JSON valide dans les deux cas en ne forcant qu’une minorite des pas de decodage (11-31 %) : le masque corrige le modele la ou il derive du schema (noms de champs, digits du code postal) sans le contraindre la ou il est deja conforme.
Exemple guide 1 - Schema JSON avec pattern regex
Ce carnet demontre le cas d’un schema avec pattern regex (code postal US) :
Notre sous-ensemble minimal ne couvre paspattern regex : la compilation reussit (la cle pattern est ignoree) mais la regle generee est permissive (le compilateur minimal genere un placeholder a pour string).
Ce que l’etudiant doit reproduire : ouvrir la cellule code, observer l’execution du code, et etendre le compilateur minimal qui supporte pattern simple (chiffres/lettres) au prix d’une approximation.
schema_zip = {"type": "object","properties": {"zip": {"type": "string", "pattern": "^\d{5}$"}},}# Demonstration : la compilation reussit mais `pattern` est IGNOREtry: g_zip = json_schema_to_grammar(schema_zip, name="ZipCode")print(f"Compilation reussie : axiome={g_zip.axiom}, regles={len(g_zip.productions)}")print(f"ATTENTION : `pattern` est IGNORE par notre compilateur minimal.")print(f"=> Les 5 chiffres consecutifs ne sont pas valides : la regle `string` genere")print(f" juste un placeholder. La sortie compilee est permissive.") allowed_zip = cfg_to_allowed_token_ids(g_zip, tok, prefix="")print(f" Cardinal ensemble autorise : {len(allowed_zip)} (vs {tok.vocab_size:,} vocab total)")print(f" Ratio impose : {len(allowed_zip)/tok.vocab_size*100:.4f}%")# Demonstration concrete : on genere et on observe sample = [tok.decode([t]) for t in allowed_zip[:8]]print(f" Exemples de tokens autorises : {sample}")except JSONSchemaCompilerError as e:print(f"Limite atteinte : {e}")# Extension pedagogique : lever une erreur explicite pour `pattern` non couvertdef compile_string_with_pattern(schema: dict, g, name: str):"""Leve JSONSchemaCompilerError si `pattern` est present. Le sous-ensemble minimal ne couvre PAS `pattern` regex : on documente le plafond atteignable plutot que de mocker une couverture par defaut. Une extension naturelle (tranche ulterieure) convertirait `^\d{N}$` en N productions de digits consecutifs, et leverait pour les regex plus complexes. """if"pattern"in schema:raise JSONSchemaCompilerError(f"`pattern` non couvert : {schema['pattern']!r}. "f"Le compilateur minimal accepte uniquement les sous-ensembles "f"primitives (string/integer/number/boolean/null/object/array/enum)." )
Compilation reussie : axiome=<ZipCode>, regles=4
ATTENTION : `pattern` est IGNORE par notre compilateur minimal.
=> Les 5 chiffres consecutifs ne sont pas valides : la regle `string` genere
juste un placeholder. La sortie compilee est permissive.
Exemple guide 2 - Identifier un cas ou JSON Schema accepte un sous-ensemble insuffisant
Notre compilateur minimal couvre object/array/string/integer/number/boolean/null. Ce carnet enumere les constructs avances qui pourraient passer inapercus :
object : que se passe-t-il avec additionalProperties: false ? (notre compilateur ignore ce flag, donc des proprietes supplementaires passent)
array : que se passe-t-il avec minItems/maxItems ? (ignores)
string : minLength/maxLength ? (ignores)
integer : minimum/maximum ? (ignores)
Ce que l’etudiant doit faire : sur un exemple de schema qu’il concoit lui-meme, identifier un de ces flags que son schema presuppose mais que notre compilateur ignore, puis estimer le taux d’erreur sur 5 generations concretes (en utilisant constrained_generate_date ou equivalent).
cas_non_couverts = {"object": ["additionalProperties: false", "patternProperties", "minProperties", "maxProperties"],"array": ["minItems", "maxItems", "uniqueItems", "contains"],"string": ["minLength", "maxLength", "format (date-time, email, etc.)"],"integer": ["minimum", "maximum", "exclusiveMinimum", "multipleOf"],"number": ["minimum", "maximum", "exclusiveMaximum", "multipleOf"],}for typ, flags in cas_non_couverts.items():print(f"Type {typ:8s} : {len(flags)} constructs non couvert(s)")for f in flags:print(f" - {f}")schema_strict = {"type": "object","additionalProperties": False,"properties": {"name": {"type": "string"}},}try: g_strict = json_schema_to_grammar(schema_strict, name="StrictObj")print(f"\nCompilation reussie MAIS additionalProperties: false a ete IGNORE.")print(f"=> L'utilisateur peut inserer des proprietes non declarees silencieusement.")except JSONSchemaCompilerError as e:print(f"\nErreur : {e}")
Type object : 4 constructs non couvert(s)
- additionalProperties: false
- patternProperties
- minProperties
- maxProperties
Type array : 4 constructs non couvert(s)
- minItems
- maxItems
- uniqueItems
- contains
Type string : 3 constructs non couvert(s)
- minLength
- maxLength
- format (date-time, email, etc.)
Type integer : 4 constructs non couvert(s)
- minimum
- maximum
- exclusiveMinimum
- multipleOf
Type number : 4 constructs non couvert(s)
- minimum
- maximum
- exclusiveMaximum
- multipleOf
Compilation reussie MAIS additionalProperties: false a ete IGNORE.
=> L'utilisateur peut inserer des proprietes non declarees silencieusement.
Exemple guide 3 - Comparer tokens imposes entre deux schemas
Ce carnet compare deux schemas sur le meme vocabulaire :
Une grammaire peu restrictive ({"type": "string"}) qui accepte a peu pres tout caractere imprimable.
Une grammaire tres restrictive ({"type": "integer"}) qui n’accepte que les chiffres.
La sortie attendue est une mesure imposed_ratio pour chacune, avec interpretation de l’ecart en termes de directivite de generation.
Ce que l’etudiant peut faire : reproduire la comparaison avec un troisieme schema (par exemple {"type": "boolean"}) et verifier que le ratio tombe entre les deux extremes.
# Comparaison deux grammairesg_permissive = json_schema_to_grammar({"type": "string"}, name="Str")g_strict_int = json_schema_to_grammar({"type": "integer"}, name="Int")allowed_permissive = cfg_to_allowed_token_ids(g_permissive, tok, prefix="")allowed_strict = cfg_to_allowed_token_ids(g_strict_int, tok, prefix="")ratio_permissive =len(allowed_permissive) / tok.vocab_sizeratio_strict =len(allowed_strict) / tok.vocab_sizeprint(f"Permissive (string) : {len(allowed_permissive):,} tokens, ratio = {ratio_permissive*100:.2f}%")print(f"Strict (integer) : {len(allowed_strict):,} tokens, ratio = {ratio_strict*100:.2f}%")if ratio_strict >0:print(f"\nLa grammaire stricte force {ratio_permissive/ratio_strict:.1f}x plus la generation.")
Permissive (string) : 2 tokens, ratio = 0.00%
Strict (integer) : 10 tokens, ratio = 0.01%
La grammaire stricte force 0.2x plus la generation.