Skip to content

V30 — Chat : un banc qui tient debout, un parseur qui avoue, un décodage sous grammaire - #59

Merged
dapiced merged 1 commit into
mainfrom
claude/labml-detailed-plan-nzc98m
Aug 23, 2026
Merged

V30 — Chat : un banc qui tient debout, un parseur qui avoue, un décodage sous grammaire#59
dapiced merged 1 commit into
mainfrom
claude/labml-detailed-plan-nzc98m

Conversation

@dapiced

@dapiced dapiced commented Aug 23, 2026

Copy link
Copy Markdown
Owner

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 :

  • en CI, à chaque commit, sans le moindre modèle — le parseur déterministe, l'automate de grammaire, le masque de jetons ;
  • contre les poids q4f16 réellement livrés, sur CPU via 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 === 0 est 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 survived sur 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

configuration modèle seul appli
V27.3 livré — décodage libre, exemples figés 29 / 12 / 14 33 / 15 / 7
parseur V30 seul, sans modèle 19 / 0 / 36
+ décodage contraint, exemples figés 34 / 19 / 2 45 / 9 / 1 ◊
+ exemples générés, 1ʳᵉ version 32 / 12 / 11 34 / 11 / 10
+ la forme « agrégat avec filtre » 34 / 10 / 11 37 / 9 / 9
+ exemples sur une grandeur — livré 41 / 7 / 7 42 / 7 / 6

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

…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
@dapiced
dapiced marked this pull request as ready for review August 23, 2026 16:59
Copilot AI lite review requested due to automatic review settings August 23, 2026 16:59
@dapiced
dapiced merged commit 46b8eb5 into main Aug 23, 2026
3 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/locales/en.json
Comment on lines +989 to +991
"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."
Comment thread src/locales/fr.json
Comment on lines +989 to +991
"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."
Comment on lines +12 to +16
* 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
Comment thread README.md
Comment on lines +107 to +110
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.
Comment on lines +51 to +54
- name: Bench, decoding inside the grammar
env:
LABML_LLM_OUT: bench-report.json
run: npm run llm:bench:node
dapiced added a commit that referenced this pull request Aug 24, 2026
V30 — Chat : un banc qui tient debout, un parseur qui avoue, un décodage sous grammaire
dapiced added a commit that referenced this pull request Aug 24, 2026
V30 — Chat : un banc qui tient debout, un parseur qui avoue, un décodage sous grammaire
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants