diff --git a/electron/stt/whisperServer.ts b/electron/stt/whisperServer.ts index 087073a2e..af56c7946 100644 --- a/electron/stt/whisperServer.ts +++ b/electron/stt/whisperServer.ts @@ -143,6 +143,13 @@ export class WhisperServerManager { private recordError(message: string): void { this.lastError = message; + // ponytail: also to the log, not only to the field. `lastError` is read by + // the `status` getter, which nothing on the transcribe path calls — so a + // missing or non-executable helper used to leave no trace anywhere in the + // main process, and the only way to find out was to instrument the code. + // The renderer does toast the failure now; a packaged build still needs a + // line someone can point at in a bug report. + console.error(`[stt] ${message}`); } /** True when a process is alive and a model is loaded. */ diff --git a/src/lib/ai-edition/timeline/cursor-track.test.ts b/src/lib/ai-edition/timeline/cursor-track.test.ts index 082f69a38..f6275e156 100644 --- a/src/lib/ai-edition/timeline/cursor-track.test.ts +++ b/src/lib/ai-edition/timeline/cursor-track.test.ts @@ -186,6 +186,66 @@ describe("buildCursorTrack — compression", () => { expect(worst).toBeLessThanOrEqual(0.02); }); + it("keeps the apex of an out-and-back — the case path-space simplification loses", () => { + // The regression test for the choice `simplifyAxis` makes: Douglas–Peucker per + // axis AGAINST TIME, not over the (x,y) path. Both give the same answer on a + // monotonic sweep — the traverse above cannot tell them apart — so this is the + // trajectory that separates them. The pointer runs out to 0.9 and comes back + // along the same line, which in path space deviates from its chord by nothing: + // the whole excursion collapses and interpolation then swears it never happened. + const outAndBack: CursorTrackSample[] = Array.from({ length: 200 }, (_, i) => ({ + timeMs: i * 50, + cx: i <= 100 ? 0.1 + i * 0.008 : 0.9 - (i - 100) * 0.008, + cy: 0.5, // constant, so the path IS its own chord + assetId: "arrow", + interactionType: "move" as const, + })); + const track = build(outAndBack, 5); + + // The turning point survives. Path-space simplification drops it and the best + // remaining point sits near 0.74, so this alone fails on the wrong implementation. + expect(Math.max(...track.points.map((p) => p.cx))).toBeGreaterThan(0.85); + + // And the same bound the straight traverse claims still holds here. + let worst = 0; + for (const sample of outAndBack) { + const t = sample.timeMs / 1000; + const after = track.points.findIndex((p) => p.atSec >= t); + if (after <= 0) continue; + const a = track.points[after - 1]; + const b = track.points[after]; + const k = (t - a.atSec) / (b.atSec - a.atSec || 1); + worst = Math.max(worst, Math.abs(a.cx + k * (b.cx - a.cx) - sample.cx)); + } + expect(worst).toBeLessThanOrEqual(0.02); + }); + + it("says so when the mandatory points push it over maxPoints", () => { + // The ceiling is soft: `maxPoints` budgets the rate and the gap floor, and the + // mandatory points are exempt. `sweep` samples every 50 ms and the shape here + // alternates on every index, so every sample is mandatory and the budget cannot + // hold — and the track has to say so rather than let the model read 100 rows as + // "within budget". + const flipping = sweep(200, { shape: (i) => (i % 2 === 0 ? "arrow" : "text") }); + const track = buildCursorTrack({ + assetId: "asset_1", + samples: flipping, + durationSec: 60, + clips: CLIPS, + hz: 5, + maxPoints: 20, + }); + + expect(track.pointCount).toBeGreaterThan(20); + expect(track.overBudget).toMatch(/ceiling of 20/); + // `truncated` is the other direction — the rate WAS cut — and stays its own signal. + expect(track.truncated).toBe(true); + }); + + it("leaves overBudget off when the ceiling holds", () => { + expect(build(sweep(400), 5).overBudget).toBeUndefined(); + }); + it("restores per-point virtualSec once the two axes diverge", () => { const shiftedClips: AxcutClip[] = [ { diff --git a/src/lib/ai-edition/timeline/cursor-track.ts b/src/lib/ai-edition/timeline/cursor-track.ts index bdc360f53..af2069966 100644 --- a/src/lib/ai-edition/timeline/cursor-track.ts +++ b/src/lib/ai-edition/timeline/cursor-track.ts @@ -66,6 +66,14 @@ export interface CursorTrack { shapeCount: number; /** True when maxPoints forced a coarser rate than `hz` would give. */ truncated: boolean; + /** Present ONLY when the ceiling did not hold. `maxPoints` budgets the rate and + * the gap floor; the MANDATORY points are exempt and stack on top — the first + * and last sample, a pointer-shape change, a non-move event, the ends of a run + * longer than the max gap — so a capture rich in them lands above the ceiling. + * Absent means the budget held. It is a separate field from `truncated` on + * purpose: that one says "you are seeing less than you asked for", this one + * says the opposite. */ + overBudget?: string; /** When true, every point's virtual-timeline position equals its `atSec`, and * `virtualSec` is omitted from the points. Goes false as soon as a clip is * moved, cut or reordered and the two axes diverge. */ @@ -313,6 +321,20 @@ export function buildCursorTrack(options: CursorTrackOptions): CursorTrack { return point; }); + // ponytail: the ceiling is soft, and saying so is the whole point. `maxPoints` + // is spent on the gap floor and the rate above; the mandatory points are added + // afterwards and answer to neither. Charging them to the budget instead would + // mean dropping a shape change to stay under it, which is the one thing this + // track must never do — so the overflow is reported rather than prevented. The + // field is absent when the budget held, so the common payload is unchanged. + const overBudget = + points.length > maxPoints + ? `${points.length} points for a ceiling of ${maxPoints}: the mandatory points are ` + + `never dropped — the first and last sample, pointer-shape changes, non-move ` + + `events and the ends of a parked run — and this recording has enough of them ` + + `to land above the budget.` + : undefined; + return { assetId, sampleCount: samples.length, @@ -321,6 +343,7 @@ export function buildCursorTrack(options: CursorTrackOptions): CursorTrack { coveredSec, shapeCount: shapeIndex.size, truncated, + ...(overBudget ? { overBudget } : {}), virtualEqualsSource: !shifted, timeBase: "atSec is SOURCE time of the asset (the recording's own clock). virtualSec is the same " + diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index f1375ff73..83e889936 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -181,7 +181,7 @@ Zoom, speed, annotation, and full-camera regions are stored against a clip in th ### Local transcription and captions — v1.8.0 - [ ] Confirm the transcript pane states that transcription runs locally and that no upload occurs when it is started. -- [ ] With the Whisper helper binary absent, activate the transcribe action and confirm the UI reports why nothing happened. Observed 2026-07-31: the button produces no message, no error state, and not one line in the main-process log — a build shipped without the helper gives the user a dead button and no way to find out. Verify against a build whose helper was deliberately not packaged, not only against a working one. +- [ ] With the Whisper helper binary absent, activate the transcribe action and confirm the UI reports why nothing happened, and that the main-process log carries a matching `[stt]` line. The failure now reaches a toast (`transcriptionStore.ts`) and the log (`whisperServer.ts`), but the text it shows is the helper's own — a sentence about a build script, which is not an answer for someone running a packaged build. Verify against a build whose helper was deliberately not packaged, not only against a working one. - [ ] Run transcription in the packaged build and confirm the model is fetched or reused without an error about a missing cache directory. - [ ] Confirm a second transcription reuses the cached model instead of downloading it again. - [ ] Confirm the completed transcript reports the detected language on the media asset card. diff --git a/vitest.workbench.config.ts b/vitest.workbench.config.ts index 3e102075b..c18f7964b 100644 --- a/vitest.workbench.config.ts +++ b/vitest.workbench.config.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { defineConfig } from "vitest/config"; +import { DEFAULT_TURN_TIMEOUT_MS } from "./workbench/lib/harness"; // ponytail: separate from vitest.config.ts on purpose — `workbench/` is outside // that config's include glob AND outside tsconfig.test.json's include, so the @@ -15,18 +16,32 @@ export default defineConfig({ globals: true, environment: "node", include: ["workbench/**/*.wb.ts"], - testTimeout: 120_000, + // ponytail: derived from the harness cutoff, and deliberately ABOVE it. A + // `.wb.ts` driving a live turn is cut by whichever deadline fires first; + // this one used to sit at 120 s while the harness moved to 300 s, so vitest + // killed the turn before the harness could classify it. Equal values would + // only make that race unbiased — the margin is what guarantees the harness + // wins and the run gets a TIMEOUT verdict instead of a dead worker. + testTimeout: DEFAULT_TURN_TIMEOUT_MS + 30_000, reporters: ["default"], - // ponytail: the fixed cost of the suite is the dynamic - // `await import("deepagents")` in chat-service.ts:346 — hundreds of - // milliseconds, paid ONCE PER WORKER. One non-isolated thread makes the - // marginal cost of a new file its own runtime. + // ponytail: the fixed cost of the suite is `runChat`'s dynamic + // `await import("./deep-agent/service")` — measured at ~1.25 s, of which + // ~0.38 s is `langchain` itself and the rest is the agent graph, the tool + // schemas and the document model behind them. It is paid ONCE PER WORKER, + // and 7 of the 19 `.wb.ts` files reach that path, so isolating them would + // re-pay it six more times. One non-isolated thread makes the marginal + // cost of a new file its own runtime. // - // The trade this accepts: `sessionsByProject` (chat-service.ts:36) and - // `messageCheckpointsBySession` (:48) are module Maps with no exported - // reset, so state now leaks between files. `runScenario` mints a unique - // projectId per run, which is what makes that safe — anything calling - // `runChat` directly would bypass the guard. + // (This used to name `deepagents`, which 0e53709a removed from the + // dependencies. The cost survived the package: it was never that factory, + // it was the graph underneath. Re-measure before trusting the figure — + // `await import(…)` timed inside a `.wb.ts` is enough.) + // + // The trade this accepts: `sessionsByProject` (chat-service.ts:38) and + // `messageCheckpointsBySession` (:50) are module Maps with no exported + // reset, so state leaks between files. The harness mints a unique + // projectId per run (`lib/harness.ts:239`), which is what makes that + // safe — anything calling `runChat` directly would bypass the guard. pool: "threads", maxWorkers: 1, isolate: false, diff --git a/workbench/README.md b/workbench/README.md index d21987580..ad48fdcd7 100644 --- a/workbench/README.md +++ b/workbench/README.md @@ -3,6 +3,9 @@ Fait tourner l'agent LLM d'OpenScreen **sans interface graphique**, pour itérer vite sur les prompts et sur le contexte fourni au modèle. +Ce fichier décrit le banc tel qu'il est. Ce qu'il a révélé et qui reste à traiter vit à côté, dans +[agent-improvement-leads.md](agent-improvement-leads.md). + Deux axes sont notés séparément, jamais moyennés ensemble : | axe | question | source de vérité | @@ -319,9 +322,16 @@ modèle qui apprend la forme de notre générateur obtient une bonne note sans a `realScreencastDocument()` charge à la place une **vraie prise** — 66,154 s de screencast, transcrites par le Whisper local (129 mots français horodatés, aucun silence stocké : ils se déduisent des écarts), avec son sidecar de curseur (1521 échantillons, ~23 Hz, 11 formes de -pointeur, aucun clic). Les deux fichiers sont dans `workbench/fixtures/`, avec leur provenance et -la liste de ce qui en a été retiré : `workbench/fixtures/README.md`. Rien d'autre ne doit les -ouvrir. +pointeur, aucun clic). Les deux fichiers vivent dans `workbench/fixtures/`, **gitignoré** +(`.gitignore:126`) : ils ne sont dans aucun clone, et leur provenance est ce paragraphe — il n'y a +pas de `fixtures/README.md` versionné à aller lire, seulement celui que se garde qui possède la +prise. Rien d'autre que `lib/real-fixture.ts` ne doit les ouvrir. + +Conséquence à connaître avant de lancer le banc : **44 tests L0 échouent dans un clone neuf**, +tous sur le même `ENOENT` (`l0/real-fixture.wb.ts`, `real-screencast-truth.wb.ts`, +`quality.wb.ts`, et `score.wb.ts` qui construit le document de chaque scénario du registre). +Fournir sa propre prise donnera d'autres chiffres que ceux assertés ici. Rien de tout cela n'est +vu par le CI, qui ne lance pas le banc. Le document arrive **tel qu'il est sur le disque**, y compris son `cameraTrack: null` alors qu'un fichier webcam existe à côté de l'enregistrement. Ce n'est pas un oubli de la copie ; c'est l'état @@ -333,12 +343,17 @@ au-dessus de `electron/media/cursorSidecar.ts`, le parseur de production. Un sc par `cursorReader:` — **exclusif** de `cursorTelemetry:`, que `defineScenario` refuse de voir coexister avec lui. -**Ce que ça coûte au tour, mesuré** : `getCursorTrack` rend **356 points, 24 238 caractères** -(5 Hz + 56 points gardés pour des changements de forme du pointeur). C'est 2,3× le transcript -entier, et la requête suivante passe de ~17 k à ~45 k caractères. Les chiffres sont **assertés** -dans `l0/real-fixture.wb.ts` : ils bougent quand `buildCursorTrack` bouge, et c'est voulu. -Au-delà de ~25 000 caractères, c'est une trouvaille à signaler — pas un défaut à faire disparaître -en baissant `DEFAULT_TRACK_HZ`. +**Ce que ça coûte au tour, mesuré** : `getCursorTrack` rend **148 points, 7 797 caractères** — +une réduction en keyframes des 1521 échantillons, plus les points qu'aucune interpolation ne +remet (changement de forme du pointeur, événement autre qu'un déplacement, bornes d'un arrêt). +C'est **sous** le transcript (10 496), et un appel ajoute ~9 k à la requête. Les chiffres sont +**assertés** dans `l0/real-fixture.wb.ts` : ils bougent quand `buildCursorTrack` bouge, et c'est +voulu. Au-delà de ~25 000 caractères, c'est une trouvaille à signaler — pas un défaut à faire +disparaître en baissant `DEFAULT_TRACK_HZ`. + +C'était 356 points et 24 238 caractères avant la réduction, soit 2,3× le transcript. Le chiffre +est gardé ici parce qu'il continue de circuler dans les notes de l'époque : s'il réapparaît +quelque part, c'est qu'on lit un texte périmé. Quatre scénarios notés tournent maintenant sur cette fixture (`scenarios/real-screencast.scn.ts`). Ce qu'ils mesurent a besoin de la vérité terrain — ce que l'utilisateur faisait, annoté à la main — @@ -379,6 +394,20 @@ trajectoire » sont **deux checks séparés**. repose** : observation live, ou mécanisme lu dans le code. Une prédiction n'y a pas sa place. 9. `npm run wb && npm run wb:typecheck && npx biome check --write workbench`. +### Répondre à un échec sans surajuster au banc + +Un échec mesuré donne envie d'ajouter la ligne de prompt qui règle ce cas précis. Fait huit fois, +le prompt système devient la liste des réponses au jeu de tests, et le banc mesure sa propre +mémoire. Le garde-fou est une question, à se poser avant de committer : + +> **Ce correctif se justifie-t-il sans mentionner le scénario qui l'a révélé ?** + +Si la seule façon de le défendre est « sinon `describe-zooms` est rouge », ce n'est pas un +correctif, c'est une réponse apprise. Un correctif légitime se formule comme une propriété du +produit — « le modèle n'a aucun moyen de savoir qu'un `customScale` rend le `depth` inerte » — et +le scénario n'en est que le témoin. Cela vaut pour le prompt système comme pour les descriptions +d'outils, qui sont du prompt sous un autre nom. + ### Où vit quoi ``` @@ -393,7 +422,7 @@ lib/persist.ts les tours bruts sur disque, bornés, derrière la barrière a lib/language.ts les prédicats de texte partagés, épinglés dans les deux sens lib/fixtures.ts les documents de référence, écrits en code lib/real-fixture.ts le chargeur de la PRISE RÉELLE (projet + sidecar de curseur sur disque) -fixtures/ les deux fichiers de cette prise, et d'où ils viennent (README.md) +fixtures/ les deux fichiers de cette prise — GITIGNORÉ, absent de tout clone lib/score.ts deux axes, porte min(), checks structurels injectés partout lib/baseline.ts le ratchet bidirectionnel l0/ sans LLM, sans réseau (~0,4 s) diff --git a/workbench/agent-improvement-leads.md b/workbench/agent-improvement-leads.md new file mode 100644 index 000000000..278e5776c --- /dev/null +++ b/workbench/agent-improvement-leads.md @@ -0,0 +1,223 @@ +# Pistes d'amélioration de l'agent d'édition + +Ce qui reste à faire sur l'agent, appuyé sur les mesures du banc. Chaque piste porte la mesure qui +la justifie et, quand elle existe, la contre-mesure qui la départagera. L'ordre est celui où je les +traiterais. + +C'est un document de travail, et il vit ici plutôt que dans `technical-documentation/` pour cette +raison : ce tree-là est de la référence — « describe, don't narrate », pas de plans ni de +changelogs — et une piste est exactement ce qu'il ne veut pas héberger. Ce qui sera tranché ira +dans `technical-documentation/architecture/decisions.md`, la ligne de conduite qui en sort dans +[README.md](README.md). + +**Provenance des chiffres.** Sauf renvoi explicite à un test, ils viennent de runs live locaux sur +la prise réelle décrite dans [README.md](README.md) § « La prise réelle ». Ni cette prise ni +`workbench/runs/` ne sont versionnés : ces mesures-là **ne sont pas rejouables** sans fournir sa +propre prise, qui donnera d'autres nombres. Quand une mesure est épinglée par une assertion, le +fichier est cité — c'est la seule forme qui survit à un refactor. + +--- + +## 1. Le tour dure deux minutes, et ce n'est pas la faute du contexte + +**Corrigé, et mon diagnostic initial était faux.** J'avais écrit que le track de 24 Ko faisait +échouer trois tours sur cinq. C'était une corrélation — les échecs tombaient juste après l'appel à +l'outil — servie comme une cause. Les durées disent autre chose : + +| répétition | durée | verdict | +|---|---|---| +| rep-0 | 117,0 s | réussie, 19 appels | +| rep-2 | 112,5 s | réussie | +| rep-1, 3, 4 | 120,0 s | **timeout du banc** | + +Le couperet était posé 3 à 7 secondes au-dessus de la durée normale d'un tour. Ce n'était pas le +modèle qui renonçait, c'était le banc qui mesurait son impatience et l'imputait au modèle. Porté à +300 s (`lib/harness.ts`), et `vitest.workbench.config.ts` importe désormais la même constante au +lieu d'en garder une copie à 120 s qui aurait tué le tour la première. + +Une ligne « timeout » sous-compte d'ailleurs ce qui a été joué : `lib/runner.ts` rejoue une +répétition expirée jusqu'à `maxRetries = 2` et jette les tentatives. Trois lignes, c'est jusqu'à +neuf tours réellement partis. + +Le contexte n'était de toute façon pas en cause : la requête complète tourne autour de 26 000 +caractères, dont ~17 k de message système et de définitions d'outils avant qu'aucune donnée n'y +entre. C'est une estimation, pas une mesure : rien dans le banc ne compte les tokens, et le seul +plafond asserté est large (`l1/real-fixture-turn.wb.ts`). + +**Ce qui reste acquis** : le track est passé de 24 238 à **7 797 caractères**, désormais sous le +transcript (10 496) au lieu de 2,3× au-dessus, et de 356 à **148 points sur 1521**. Deux gains — +`virtualSec` retiré des points quand il égale `atSec`, et une réduction en keyframes — dont le +second est sans perte **dans la tolérance** de 0,02 image, pas sans perte tout court : c'est un +budget d'erreur (`DEFAULT_TRACK_EPSILON`), et le test le dit mieux que moi (« lossless *within the +tolerance* »). Ce qui est réellement intouchable, c'est la classe de points que rien ne rejoue : +changement de forme du pointeur, événement autre qu'un déplacement, bornes d'un arrêt. + +Une leçon d'implémentation à ne pas reperdre : simplifier la **trajectoire** au lieu des courbes +`x(t)` et `y(t)` semble équivalent et ne l'est pas. Un curseur qui part et revient par le même +chemin ne s'écarte pas de la corde, donc l'aller-retour disparaît et l'interpolation jure ensuite +qu'il n'a pas bougé. Mesuré : **0,380** d'image d'erreur pour une tolérance de 0,02, contre 0,084 +par axe. La version fautive était la plus compacte (4,4 Ko) et la plus séduisante. + +Elle est maintenant verrouillée : `src/lib/ai-edition/timeline/cursor-track.test.ts` porte un +aller-retour dont l'apex ne survit qu'à la simplification par axe. Vérifié en substituant la +version fautive — les treize autres tests du fichier passent, seul celui-là tombe. C'était le +trou : la leçon ne tenait que dans un commentaire. + +Le plafond de `buildCursorTrack` reste **mou**, et le dit maintenant. `DEFAULT_MAX_TRACK_POINTS` +budgète le plancher d'écart et le débit ; les points obligatoires ci-dessus sont exemptés et +s'ajoutent par-dessus. Les faire entrer dans le budget reviendrait à jeter un changement de forme +pour tenir un chiffre, ce que ce track ne doit jamais faire — donc le dépassement est **rapporté** +(`overBudget`, absent quand le budget tient) plutôt qu'évité. Ici 148 pour 400, sans conséquence ; +une capture riche en changements de pointeur le dépasse, et le modèle l'apprend au lieu de le subir. + +## 1 bis. Le vrai coût : 19 appels d'outils en série — RECOMMANDATION PRINCIPALE + +**Mesuré.** Le tour wizard émet 19 appels : deux lectures de document, un transcript, un track, +puis **six `addTrim` et neuf `addZoom` un par un**. La sérialisation n'est pas qu'une observation +de ce run : `deep-agent/service.ts` la donne pour acquise là où il relève `recursionLimit`, « an +auto-enhance turn spends one step per silence » — un pas de graphe par coupe, donc un aller-retour +par coupe. + +Six coupes décidées d'un seul raisonnement, sur des plages connues d'avance, coûtent six +allers-retours. Le propre texte du modèle planifie les six avant d'émettre le premier appel — la +décision est déjà prise, seule l'émission est fragmentée. + +Deux précautions de lecture. La surface compte elle aussi **19 outils** (`service.test.ts`) : même +nombre, autre grandeur. Et ce qui coûte du temps, ce sont les **rounds**, que `lib/wire.ts` +distingue des `calls` ; le décompte ci-dessus est en appels. Une piste par lot promet donc « 19 +appels → 6 appels », et le gain de latence n'est démontré qu'en citant `wire.rounds`, ce que ce +document ne fait pas encore. + +**Pistes :** + +- **Des outils par lot.** `addTrims(ranges[])` et `addZooms(regions[])` ramèneraient un tour de 19 + appels à 6. Le raisonnement du modèle n'y perd rien ; trois surfaces sont à refaire autour : le + rapport de l'outil (une borne refusée sur dix doit se lire sans relire le document), le décompte + du banc, et `diffMatches` — un outil par lot que ses `ID_KEYS` ignorent éteindrait silencieusement + le seul check qui vérifie que l'outil ne ment pas sur ce qu'il a fait. +- **Vérifier au banc que le modèle sait s'en servir avant de généraliser.** Un outil par lot est + plus difficile à appeler correctement qu'un outil unitaire — il faut un tableau bien formé du + premier coup, là où l'unitaire pardonne une erreur à la fois. C'est exactement ce qu'un scénario + dédié doit trancher. +- **Ne pas supprimer les outils unitaires.** Une correction ponctuelle (« déplace ce trim », qui + est `setTrim`) n'a pas à passer par un tableau d'un élément. Et le refus d'un lot entier pour une + borne fautive n'est pas une hypothèse : `replaceTimeline` est le précédent du dépôt sur « un + appel, plusieurs coupes », et il refuse en bloc dès qu'un clip serait perdu — « Refused […] + Nothing was modified ». Un `addTrims` qui hériterait de ce comportement rendrait au modèle six + coupes annulées pour une borne mal posée. + +## 2. Le modèle place ses zooms d'après le transcript, pas d'après la trajectoire + +**Observé, pas mesuré par le banc** — et c'est le premier problème. Il appelle bien +`getCursorTrack`. Mais en comparant à la main le `focus` qu'il choisit à la position réelle du +curseur *dans sa propre fenêtre de zoom* : **7 sur 9 sont faux**, trois de plus d'un tiers d'image. +Le pire vise `(0.33, 0.09)` — haut de l'écran — quand le curseur est à `(0.38, 0.60)`. + +Aucun oracle ne calcule cet écart. `lib/quality.ts` ne connaît des zooms que leurs secondes, et +l'`EvalContext` n'expose pas la trajectoire aux scénarios : `real-zoom-grounding` a six checks, +dont aucun sur la position. Tant qu'il manque, ce paragraphe est une anecdote et pas une mesure. + +Son récit le trahit : il annonce un zoom sur des mots cinq secondes avant qu'ils soient prononcés. +Il raconte une lecture de la trajectoire qu'il n'a pas faite. + +Rappel 6/6 zones annotées, mais précision 0,41 — il zoome une large part de la vidéo. Toucher +toutes les zones en arrosant n'est pas de la détection. (Ces deux nombres viennent d'un run non +versionné ; l'oracle qui les produit, lui, vient d'être corrigé — il sommait les secondes de zooms +empilés au dénominateur, ce que `addZoom` autorise en pratique.) + +**Pistes :** + +- **Écrire l'oracle d'ancrage.** `focusAccuracy(after, samples, zones)` dans `lib/quality.ts`, + branché sur `real-zoom-grounding`. C'est la contre-mesure de tout ce qui précède : sans elle, on + ne saura pas si une piste a amélioré quoi que ce soit. +- **Ancrer par le retour d'outil.** `addZoom` pourrait renvoyer la position réelle du curseur sur + la fenêtre demandée, à côté du `focus` reçu. `options.cursorTelemetry.load` est déjà disponible + dans `executeAgentTool` : c'est un appel de plus dans la branche `addZoom`, pas un câblage. Le + modèle apprend l'écart au premier appel, sans qu'on lui impose quoi que ce soit — elle informe au + lieu de contraindre. Prévoir le cas `available:false` : le retour ne doit pas mentir par omission. +- **La lisibilité n'est plus une excuse.** La piste 1 est faite : le track est à 148 points, pas + 356. Si le modèle ne corrèle toujours pas une fenêtre temporelle à la trajectoire, ce n'est plus + le bruit. +- **Ne pas ajouter de détecteur.** Servir au modèle une liste de « moments d'intérêt » le + plafonnerait au rappel de l'heuristique : `src/lib/ai-edition/timeline/zoom-suggestions.ts` + produit 8 faux positifs sur 16, et rate la zone où l'auteur balaye lentement une image — non + parce que le curseur y bouge trop, mais parce que le passage dure plus que + `MAX_DWELL_DURATION_MS` (2,6 s) et sort du plafond. Aucun test ne tient ce 8/16 : il vient du même + run non versionné. + +## 3. `customScale` : ce que le modèle n'apprend qu'à moitié + +**Mesuré.** `describe-zooms` est passé de 60 % à 98 % après correction de la **légende** +depth→échelle annoncée au modèle (la table de constantes, elle, n'a pas bougé : elle a été déplacée +à l'identique dans `zoom-scale.ts`). `describe-zooms-migrated` reste à **33 %** — soit exactement +2/6 sur l'axe comportemental si seul `beh.multiplier` échoue, ce qui est son `expectedFailure` +déclarée. Aucun baseline n'est versionné pour ces deux scénarios ; ce serait le premier à figer. + +Ce que le modèle sait déjà, contrairement à ce que j'avais écrit : `depthIsOverridden` est bien émis +par le snapshot dès qu'un `customScale` existe, le `zoomNote` l'explique, et la description de +`setZoom` dit mot pour mot que passer `depth` efface le `customScale` — l'exécuteur le fait et le +retour porte `clearedCustomScale`. + +**Ce qui manque, une fois l'audit fait.** Le champ atteint deux des quatre surfaces que voit le +modèle : + +| surface | porte `depthIsOverridden` | +|---|---| +| snapshot document (`getCurrentDocument`) | oui | +| description d'outil `setZoom` | oui | +| retours d'outil `setZoom` / `addZoom` | **non** | +| message système | **non** | + +Le trou est donc précis : une édition de span sur un zoom overridé, sans toucher au `depth`, rend +`{depth: 3, renderedScale: 1.1}` et aucun champ ne nomme l'override. Le modèle lit un `depth` qui ne +rend rien, dans un retour qui a l'air complet. + +**Piste.** Ajouter `depthIsOverridden` (ou `customScale`) aux `resultJson` de `setZoom` et +`addZoom`. Accessoirement, la description de `getCurrentDocument` n'annonce ni `renderedScale` ni +`customScale` : le modèle doit deviner que le snapshot les porte. + +## 4. Un patron récurrent : l'absence traitée comme un non-événement + +Trois occurrences rencontrées en pilotant l'app, sans rapport entre elles : + +- Un asset orphelin vidait tout le preview *(corrigé)* — mais le correctif a supprimé le mauvais + état vide sans ajouter de message : quand toutes les sources échouent vraiment, l'éditeur affiche + la même invitation à importer une vidéo. L'information existe (`videoError` porte l'assetId) et + elle est jetée. Le patron s'applique à son propre correctif. +- Le modèle affirmait qu'aucune donnée curseur n'existait, parce qu'il inspectait un système de + fichiers vide *(corrigé)*. C'est aujourd'hui l'exemple à imiter : `no-sidecar` et `unavailable` + sont deux réponses distinctes, dites dans le type, dans la description d'outil, dans le message + système, et épinglées dans les deux sens par les tests. +- Le bouton de transcription, binaire Whisper absent *(corrigé depuis, en deux temps)* : la + plomberie d'échec a atterri avec la transcription automatique — un toast porte le message. Il + manquait la trace côté main, `recordError` rangeait le message dans un champ que personne ne lit + sur ce chemin ; c'est fait. Reste que le texte affiché est un message de développeur (« build it + via `scripts/build-whisper-stt.sh` »), ce qui n'est pas une réponse pour qui a installé un paquet. + Et rien ne rejoue « binaire absent » de bout en bout : `whisperServer.test.ts` couvre le modèle + manquant, pas le binaire. + +Le patron mérite d'être nommé quelque part : distinguer « je n'ai pas trouvé » de « il n'y a rien » +est la même discipline côté UI et côté agent. Le track de curseur vient d'en gagner un troisième +exemple, dans l'autre sens — `overBudget` dit « tu en as plus que le plafond », là où `truncated` +disait déjà « tu en as moins que demandé ». + +## 5. Le banc : ce qui manque encore + +- **Un juge LLM pour l'axe comportemental.** Il repose aujourd'hui sur des regex anglaises, dont + `lib/language.ts` admet lui-même la fragilité — un `no` a déjà matché dans `cannot`, accusant de + mensonge une réponse honnête (corrigé depuis, par des frontières de mot). Une réponse en français + casse la mesure dans les **deux** sens : elle fait passer en silence tous les checks négatifs + (aucun mensonge détectable) et échouer à tort les six checks qui exigent un match positif. Ce qui + se calcule doit rester déterministe ; ce qui demande de lire du sens doit passer à un juge, sur + les tours persistés, avec verdicts conforme / fautif / **indéterminé**. +- **Le surajustement au banc** a maintenant sa règle dans [README.md](README.md) § « Répondre à un + échec sans surajuster au banc » — c'est là qu'elle sera lue au moment de toucher au prompt. +- **Les fixtures ne sont pas versionnées**, et le coût est plus lourd que « reproduire une mesure + demande sa propre prise » : **44 tests L0 échouent dans un clone neuf**, tous sur le même + fichier absent. Le CI ne lance ni le banc ni les e2e, donc rien ne le signale. Voir + [README.md](README.md) § « La prise réelle ». +- **Les mesures live ne laissent aucune trace versionnée.** `workbench/runs/` et + `workbench/reports/` sont gitignorés, et trois baselines seulement sont commitées. La moitié des + chiffres de ce document en dépend. Le premier pas serait de figer les baselines des scénarios de + la prise réelle (`--update-baseline`), puis de capturer l'`usage` du provider dans le proxy : le + décompte de tokens qui manque à tout le §1 est déjà dans la réponse que le recorder voit passer. diff --git a/workbench/l0/quality.wb.ts b/workbench/l0/quality.wb.ts index 13a26357d..8c54b1c38 100644 --- a/workbench/l0/quality.wb.ts +++ b/workbench/l0/quality.wb.ts @@ -415,6 +415,28 @@ describe("zoomPlacement — précision ET rappel, jamais une note unique", () => expect(zoomPlacement(document, ZONES, { coverFraction: 0.2 }).missedZones).toHaveLength(2); }); + it("ne compte pas deux fois les secondes de deux zooms empilés", () => { + // Sur fixture synthétique, à rebours de ce fichier, et pour la raison que + // son en-tête donne : ce qui se vérifie est de l'arithmétique d'intervalles, + // pas une lecture de matériau. `timelineMap.ts:113` interdit à deux zooms de + // se chevaucher, mais seul `setZoom` passe par le clamp qui l'applique — + // `addZoom` ajoute à la suite, donc l'agent PEUT empiler, et cet oracle doit + // être juste sur les documents fautifs qu'il est là pour exposer. + const document = zoom( + zoom(recordingWithSilences({ durationSec: 60, silences: [[10, 12]] }), 8, 13), + 10, + 15, + ); + expect(document.zoomRanges).toHaveLength(2); + + // Union : 8→15, soit 7 s, entièrement sur la zone 8→15. Somme : 5 + 5 = 10 s, + // qui rendrait 0,7 — une précision abaissée par le double comptage et non par + // un défaut de placement. + const placement = zoomPlacement(document, [{ startSec: 8, endSec: 15, label: "zone" }]); + expect(placement.zoomSec).toBeCloseTo(7, 4); + expect(placement.precision).toBeCloseTo(1, 4); + }); + it("rend une précision de 1 quand aucun zoom n'a été émis", () => { // Ne rien émettre est un échec de RAPPEL et jamais de précision : gonfler // la précision d'un document vide serait absurde, l'annuler aussi. diff --git a/workbench/l0/real-fixture.wb.ts b/workbench/l0/real-fixture.wb.ts index bb3011bbb..9547ab6f6 100644 --- a/workbench/l0/real-fixture.wb.ts +++ b/workbench/l0/real-fixture.wb.ts @@ -75,8 +75,8 @@ describe("la fixture réelle — le document", () => { it("n'a PAS de piste caméra dans le document, quoi qu'il y ait sur le disque", () => { // Le dossier d'enregistrement contient bien un fichier webcam ; le document // écrit par l'app, lui, porte `cameraTrack: null`. La fixture ne corrige - // pas ça — voir workbench/fixtures/README.md. Cette assertion existe pour - // que personne ne « complète » la fixture sans s'en apercevoir : le modèle + // pas ça — voir workbench/README.md § « La prise réelle ». Cette assertion + // existe pour que personne ne « complète » la fixture sans le voir : le modèle // verra `hasCameraTrack: false`, et c'est l'état réel du projet. expect(realScreencastDocument().assets[0].cameraTrack).toBeNull(); }); diff --git a/workbench/lib/quality.ts b/workbench/lib/quality.ts index c97d34e2b..5be254075 100644 --- a/workbench/lib/quality.ts +++ b/workbench/lib/quality.ts @@ -48,6 +48,7 @@ import { } from "./editorial"; import { intersectSpans, + mergeSpans, overlapSec, SPAN_EPSILON_SEC, type Span, @@ -592,12 +593,16 @@ export function zoomPlacement( }; }); - // ponytail: the totals are unions, not sums of the per-hit overlaps. Zooms - // are forbidden from overlapping each other (`timelineMap.ts:113`), so on a - // valid document the two agree — but this oracle also has to be right about - // the invalid ones it is partly there to expose, and summing would report a - // precision above 1 on a document carrying two stacked zooms. - const zoomSec = totalSec(spans.map((entry) => entry.span)); + // ponytail: BOTH totals are unions, and `zoomSec` was a sum until it wasn't. + // The rule `timelineMap.ts:113` forbids two zooms from overlapping, but only + // `setZoom` goes through the clamp that enforces it (`replacePillSpan`) — + // `addZoom` appends (`agent-tools.ts:1148`), so an agent CAN stack zooms, and + // `editorial.ts` carries an `overlap` check precisely because it happens. On + // such a document a summed denominator counts the shared seconds twice while + // the intersected numerator counts them once, and `precision` reads low for a + // reason that has nothing to do with placement. Union on both sides or the + // ratio is not a ratio of the same thing. + const zoomSec = totalSec(mergeSpans(spans.map((entry) => entry.span))); const onZoneSec = totalSec( intersectSpans( spans.map((entry) => entry.span), diff --git a/workbench/lib/real-fixture.ts b/workbench/lib/real-fixture.ts index 2efe44c67..17dae60b3 100644 --- a/workbench/lib/real-fixture.ts +++ b/workbench/lib/real-fixture.ts @@ -11,9 +11,10 @@ // transcribed by the local Whisper helper, with its own cursor sidecar. Nothing // is normalised, rounded or tidied on the way in — the document reaches the // model in the state it has on disk, `originalPath`, absent `cameraTrack` and -// all. See `workbench/fixtures/README.md` for where it comes from and for the -// single field that was removed from the sidecar (11 base64 pointer bitmaps, -// 112 kB, referenced by id and never decoded). +// all. Where it comes from — and the single field removed from the sidecar (11 +// base64 pointer bitmaps, 112 kB, referenced by id and never decoded) — is in +// `workbench/README.md` § "La prise réelle". `workbench/fixtures/` is gitignored, +// so it holds no README any clone can read; do not point readers into it. // // The ground truth of what the user was DOING lives nowhere near here. It // belongs to the assertions; a scenario that let it reach the model would be