V30 — Chat : un banc qui tient debout, un parseur qui avoue, un décodage sous grammaire - #59
Conversation
…décodage sous grammaire
La vague commence par l'instrument, parce que celui de V27 tenait sur 18 cas qui
exigeaient un GPU : une seule machine, rejouable par personne. Le corpus compte
maintenant 55 questions FR/EN couvrant toutes les formes de la grammaire, plus
trois auxquelles aucune requête ne répond — où refuser est la seule bonne
réponse. Deux harnais : l'un en CI à chaque commit sans le moindre modèle, l'autre
contre les poids q4f16 réellement livrés, sur CPU via onnxruntime-node.
L'instrument a démenti la prémisse de la vague. Sur ces 55 questions l'appli
livrée répondait 33 justes, 15 FAUSSES, 7 refus — et sept des quinze fausses
venaient du parseur déterministe, qui passe en premier et n'est jamais rattrapé.
« Combien de femmes ? » répondait 891 au lieu de 314. Le parseur vérifie
désormais sa propre couverture : un mot qu'il ne sait rattacher à rien est un
refus, jamais une réponse à une question plus courte. Mesuré : 19 justes,
0 fausse, 36 refus — les sept fausses sont devenues des refus sans perdre une
seule bonne réponse. « zéro fausse » est asserté en CI.
Décodage contraint : un automate sur la grammaire de requêtes et un
LogitsProcessor écrit à la main qui masque, à chaque jeton, tout ce qui en
sortirait. Il marche sur les octets UTF-8 et non sur les caractères, parce que le
vocabulaire de Qwen est en BPE d'octets. Seul, il a empiré les choses (fausses
12 → 19) : forcer une réponse valable transforme un refus en chiffre faux. D'où
{"kind":"none"}, laissé atteignable.
Exemples construits sur les colonnes du fichier chargé. Sept des 55 questions du
corpus figuraient mot pour mot dans les exemples figés de V27 : le prompt avait
été ajusté sur le banc. Deux versions des exemples générés ont été pires que les
figées, et les deux raisons sont dans le code — la forme « agrégat avec filtre »
manquait, et le premier choix de colonne prenait un drapeau 0/1 plutôt qu'une
grandeur.
Deux tirages / un vote : abandonné sur mesure. Les deux critères de départage du
plan sont morts avec le décodage contraint.
Bilan, 0 Mo téléchargé en plus : 33 justes / 15 fausses → 42 justes / 7 fausses.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKw6oNC8iZ9Kn7q6x4qom4
There was a problem hiding this comment.
🟡 Changes recommended
The new workflow claims grammar-constrained decoding but doesn’t enable it, and several user-facing docs/UI strings overstate the guarantees of constrained decoding about “values,” which should be corrected for accuracy.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR upgrades the local chat assistant (V30) to be measurable and safer by construction, by introducing a reproducible reference corpus + shared bench/reporting, tightening the deterministic parser to refuse partially-read questions, and adding grammar-constrained decoding (token masking) for the local LLM—plus tests, CI plumbing, and UI copy to surface the new behavior.
Changes:
- Add a V30 bench instrument: a 55-question corpus, shared generation core, and unified reporting across browser (WebGPU) and Node (CPU) harnesses.
- Prevent “confidently wrong deterministic answers” by requiring full-question coverage (
unreadWords) and refusing otherwise; add regression tests and e2e coverage. - Implement grammar-constrained decoding via a byte-level grammar automaton + custom logits processor, with UI/locale updates and an on-demand GitHub Actions workflow.
File summaries
| File | Description |
|---|---|
| src/locales/fr.json | Adds UI strings for “grammar-constrained” decoding and updates deterministic coverage messaging (FR). |
| src/locales/en.json | Adds UI strings for “grammar-constrained” decoding and updates deterministic coverage messaging (EN). |
| src/features/ai/llm/report.ts | New shared bench report formatting + tallying logic. |
| src/features/ai/llm/prompt.ts | Updates system prompt rules, adds {"kind":"none"} refusal shape handling, switches to generated examples. |
| src/features/ai/llm/prompt.test.ts | Updates prompt tests; asserts generated examples only reference real columns and include refusal. |
| src/features/ai/llm/interpret.ts | Refactors browser model path to shared generation + adds constrained capability flag. |
| src/features/ai/llm/grammar.ts | New byte-level grammar automaton for query-string generation constraints. |
| src/features/ai/llm/grammar.test.ts | New grammar-level unit tests (completeness, prefixes, validator agreement, accents, caps). |
| src/features/ai/llm/generate.ts | New shared generation core and optional constrained decoding integration. |
| src/features/ai/llm/examples.ts | New prompt examples generated from the user’s columns (incl. “quantity vs flag” selection). |
| src/features/ai/llm/examples.test.ts | New tests asserting examples are valid, grammar/validator-accepted, and column-safe. |
| src/features/ai/llm/corpus.ts | New 55-case reference corpus + scoring utilities. |
| src/features/ai/llm/corpus.test.ts | CI-side corpus validation + asserts deterministic parser produces zero wrong answers. |
| src/features/ai/llm/constrain.ts | New vocab reading/indexing + logits processor + allowed-token masking implementation. |
| src/features/ai/llm/constrain.test.ts | New unit tests for vocab decoding, allowed tokens, processor masking semantics, tokenizer vocab extraction. |
| src/features/ai/llm/bench.ts | Refactors browser bench to use shared corpus/reporting and shipped pipeline ordering. |
| src/features/ai/llm/bench.node.test.ts | New Node CPU bench (onnxruntime-node via transformers), gated by env vars and optionally constrained. |
| src/features/ai/chat/suggestions.test.ts | New guard ensuring app suggestion chips remain parseable under stricter coverage rules. |
| src/features/ai/chat/parser.ts | Adds distinct to ColumnInfo, expands shape lexicon, and implements unreadWords coverage refusal. |
| src/features/ai/chat/parser.test.ts | Updates deterministic parser expectations to reflect stricter refusal behavior. |
| src/features/ai/chat/EnginePicker.tsx | Surfaces “grammar-constrained” vs “unconstrained” state in the UI. |
| src/features/ai/chat/chat.worker.ts | Computes capped distinct counts, returns model constrained capability in llm-ready. |
| src/features/ai/chat/chat-store.ts | Stores llmConstrained and updates state on model readiness. |
| scripts/prepare-llm.mjs | Adds --flat mode for Node bench while preserving size checks and sharded deploy behavior. |
| README.md | Documents V30 assistant design + measurement harnesses and workflow separation. |
| PLAN.md | Updates V30 section with measured outcomes and bench details. |
| package.json | Adds llm:bench:node and llm:fetch scripts. |
| e2e/chat.spec.ts | Adds e2e regression test for “half-read” question refusal vs short answering. |
| .gitignore | Ignores .llm-cache/ for Node bench weights. |
| .github/workflows/llm-bench.yml | Adds on-demand CPU bench workflow and artifact upload. |
Review details
- Files reviewed: 29/30 changed files
- Comments generated: 5
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| "constrainedTag": "grammar-constrained", | ||
| "constrainedNote": "The model writes its query under constraint: at every token, anything outside the query grammar is made impossible to pick. It cannot invent a column, an operator, or a value your column does not hold. It keeps one way of saying no, deliberately: forcing a valid answer would turn « I did not understand » into a wrong number.", | ||
| "unconstrainedNote": "Grammar-constrained decoding is unavailable for this model: its answers are still checked before they run, but nothing stops them from being malformed in the first place." |
| "constrainedTag": "sous grammaire", | ||
| "constrainedNote": "Le modèle écrit sa requête sous contrainte : à chaque jeton, tout ce qui sortirait de la grammaire de requêtes est rendu impossible à choisir. Il ne peut donc pas inventer une colonne, un opérateur ni une valeur que votre colonne n'a pas. Il lui reste un mot pour dire non, et c'est délibéré : forcer une réponse valable transformerait un « je n'ai pas compris » en un chiffre faux.", | ||
| "unconstrainedNote": "Décodage sous grammaire indisponible sur ce modèle : ses réponses sont toujours vérifiées avant d'être exécutées, mais rien ne les empêche d'être malformées." |
| * This module describes the set of legal query strings as a small | ||
| * non-deterministic automaton over CHARACTERS. Given the text generated so | ||
| * far, it answers two questions: which characters may come next, and is what | ||
| * we have a complete query. A logits processor (`constrain.ts`) turns those | ||
| * two answers into a mask over the model's vocabulary, so the only tokens the |
| badge under each answer names which engine produced it. The translation is decoded | ||
| **inside** the query grammar: a hand-written logits processor masks, at every token, | ||
| everything that would leave the grammar, so an invented column, an operator that does | ||
| not exist or a category the column does not hold cannot be written in the first place. |
| - name: Bench, decoding inside the grammar | ||
| env: | ||
| LABML_LLM_OUT: bench-report.json | ||
| run: npm run llm:bench:node |
V30 — Chat : un banc qui tient debout, un parseur qui avoue, un décodage sous grammaire
V30 — Chat : un banc qui tient debout, un parseur qui avoue, un décodage sous grammaire
La question du propriétaire était : un modèle plus gros améliorerait-il la part de réponses justes ? La vague commence donc par l'instrument, parce qu'aucun chiffre ne permettait d'y répondre.
Le banc d'abord
Celui de V27 tenait sur 18 cas qui exigeaient un GPU avec
shader-f16: une seule machine, rejouable par personne. Le corpus compte désormais 55 questions FR/EN, couvrant toutes les formes de la grammaire de requêtes, plus trois auxquelles aucune requête ne répond — où refuser est la seule bonne réponse. Deux harnais le font tourner :onnxruntime-node: mêmes fichiers épinglés, même prompt, même chemin de décodage que la production, sans le GPU.« Mesurable » a cessé de vouloir dire « sur une machine ».
L'instrument a démenti la prémisse de la vague
Le défaut principal n'était pas le modèle. Sur ces 55 questions, l'appli livrée répondait 33 justes, 15 FAUSSES, 7 refus — et sept des quinze fausses venaient du parseur déterministe, qui passe en premier et ne peut jamais être rattrapé. « Combien de femmes ? » répondait 891 au lieu de 314 : la grammaire connaît
combien, ne connaît rien àfemmes, garde le comptage et laisse tomber la condition — sous le badge censé signifier « exact ». Quatre de ces sept, le modèle local les lit correctement, et on ne le lui demandait jamais.Le parseur vérifie donc maintenant sa propre couverture : chaque mot doit être expliqué par une expression du lexique, une colonne que la réponse utilise, une valeur sur laquelle elle filtre, ou l'une de trois listes fermées. Un mot qui reste est un refus. Mesuré : 19 justes, 0 fausse, 36 refus — les sept fausses sont devenues des refus, sans perdre une seule bonne réponse.
wrong === 0est asserté en CI.Décodage contraint — et ce qu'il coûte
Un automate sur la grammaire et un
LogitsProcessorécrit à la main qui masque, à chaque jeton, tout ce qui en sortirait. Il marche sur les octets UTF-8 et non sur les caractères : le vocabulaire de Qwen est en BPE d'octets et 1 457 de ses 151 669 jetons sont des fragments de caractère — un automate par caractères aurait rendu « Île-de-France » inécrivable comme valeur de filtre.Seul, il a empiré les choses : refus 14 → 2, justes 29 → 34, mais fausses 12 → 19. Forcer une réponse valable transforme « je n'ai pas su lire » en chiffre faux assené avec aplomb. D'où
{"kind":"none"}, laissé atteignable dans la grammaire.Les exemples du prompt, et une contamination
Sept des 55 questions du corpus figuraient mot pour mot dans les exemples figés de V27 : le prompt avait été ajusté sur le banc au fil de V27.1 et V27.2. Vérifié plutôt que supposé : sur ces sept-là, avant et après donnent 5 justes / 1 fausse / 1 refus à l'identique — le défaut est méthodologique, son effet mesuré sur cette comparaison est nul.
Les exemples sont désormais construits sur les colonnes du fichier chargé. Deux versions ont été pires que les figées, et les deux raisons sont dans le code : la première omettait la forme « agrégat avec filtre » (sous contrainte, une forme jamais montrée ressort en réponse fausse, pas en refus) ; la seconde prenait encore la première colonne numérique, soit
survivedsur Titanic — les exemples disaient « average survived » et le modèle allait chercher cette colonne sur des questions qui ne la mentionnent pas.Deux tirages, un vote — abandonné sur mesure
Les deux critères de départage du plan meurent avec le décodage contraint : tout candidat valide par construction, et « n'invente aucune colonne que la question ne nomme pas » est démenti par le corpus (« did women pay more than men? » se répond correctement avec
fare, jamais nommée).Mesures
Justes / fausses / refus. ◊ ligne non comparable : son prompt contient sept des 55 questions.
Pour 0 Mo téléchargé en plus : 33 justes / 15 fausses → 42 justes / 7 fausses. Neuf réponses justes de plus, 53 % de fausses en moins.
Ce que la vague ne fait délibérément pas
Livrer un second modèle plus gros en téléchargement (les leviers gratuits n'étaient pas épuisés quand le plan le proposait ; ils le sont maintenant), deviner une colonne par ressemblance de nom, ni laisser l'automate remplacer
validateIntent— il sur-approxime en deux endroits nommés et reste un filtre, pas l'autorité.Validation
lint+format:check+tsc --noEmit+ 557 tests unitaires (63 fichiers) + 79 e2e +build— tout vert.Generated by Claude Code