From 4054078f19a213321956cd88ed283bf8417b5d32 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 1 Aug 2026 00:47:53 +0200 Subject: [PATCH 01/11] =?UTF-8?q?docs(agent):=20pistes=20d'am=C3=A9liorati?= =?UTF-8?q?on=20mesur=C3=A9es,=20=C3=A0=20traiter=20plus=20tard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document de travail: chaque piste porte la mesure qui la justifie et la contre-mesure qui la départagera. Rien n'est appliqué ici. --- .../architecture/agent-improvement-leads.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 technical-documentation/architecture/agent-improvement-leads.md diff --git a/technical-documentation/architecture/agent-improvement-leads.md b/technical-documentation/architecture/agent-improvement-leads.md new file mode 100644 index 000000000..a119e8224 --- /dev/null +++ b/technical-documentation/architecture/agent-improvement-leads.md @@ -0,0 +1,55 @@ +Pistes d'amélioration de l'agent d'édition, appuyées sur les mesures du workbench. **Rien à fusionner ici** — cette PR est un document de travail, à traiter plus tard. + +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. + +--- + +## 1. Le poids du track fait échouer un tour sur deux + +**Mesuré.** Sur la prise réelle de 66 s, `getCursorTrack` rend 356 points pour **24 238 caractères**. La requête suivante passe à ~45 000 caractères. Sur 5 répétitions du prompt wizard, **3 ont expiré** à 120 s, toujours au même endroit : juste après l'appel à l'outil. Les 2 qui aboutissent produisent un montage correct. + +Ce n'est pas un défaut du modèle : lui donner la donnée le fait échouer. + +**Pistes, de la moins à la plus intrusive :** + +- **Supprimer `virtualSec` quand il est égal à `atSec`.** 28 % du payload, strictement redondant tant qu'aucune coupe n'existe. Un champ `virtualEqualsSource: true` en tête suffirait. Gain immédiat, aucune perte d'information. +- **Relever le timeout du banc.** Ne corrige rien, mais évite de confondre lenteur et refus. +- **Baisser la résolution à 2–3 Hz.** À tester *après* les deux précédentes, jamais avant : ça change ce que le modèle voit, donc on ne saurait plus attribuer une amélioration à la place gagnée ou à la lisibilité. + +Le plafond de `buildCursorTrack` est par ailleurs mou : `DEFAULT_MAX_TRACK_POINTS` borne la grille, mais les points gardés pour un changement de forme s'ajoutent par-dessus sans que `truncated` le signale. Ici 356 pour 400, sans conséquence — une capture riche en changements de pointeur dépasserait silencieusement. + +## 2. Le modèle place ses zooms d'après le transcript, pas d'après la trajectoire + +**Mesuré.** Il appelle bien `getCursorTrack`. Mais en comparant 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)`. + +Son récit le trahit : il annonce un zoom sur « Iceman, Views » cinq secondes avant que ces mots 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 38 % de la vidéo. Toucher toutes les zones en arrosant n'est pas de la détection. + +**Pistes :** + +- **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. Le modèle apprend l'écart au premier appel, sans qu'on lui impose quoi que ce soit. C'est la piste que je préfère : elle informe au lieu de contraindre. +- **Vérifier la lisibilité avant d'accuser la capacité.** 356 lignes de `{atSec, cx, cy}` sont peut-être trop plates pour qu'il y corrèle une fenêtre temporelle. À tester en réduisant d'abord le bruit (piste 1), pas en changeant la forme. +- **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 — mesuré : le détecteur d'immobilité produit 8 faux positifs sur 16 et rate par construction la zone où l'auteur balaye lentement une image. + +## 3. `customScale` rend `depth` inopérant en silence + +**Mesuré.** `describe-zooms` est passé de 60 % à 98 % après correction de la table depth→échelle. `describe-zooms-migrated` reste à **33 %** : quand un zoom porte un `customScale`, le `depth` ne rend plus rien et aucun champ ne le dit au modèle. + +**Piste.** Le snapshot expose déjà `depthIsOverridden`. Reste à vérifier qu'il atteint le modèle dans tous les chemins, et que `setZoom` dit clairement que passer `depth` efface le `customScale`. + +## 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, sans message *(corrigé)*. +- Le modèle affirmait qu'aucune donnée curseur n'existait, parce qu'il inspectait un système de fichiers vide *(corrigé)*. +- Le bouton de transcription ne produit **rien** quand le binaire Whisper est absent : ni message, ni état d'échec, ni une ligne de log *(non corrigé)*. + +Le troisième mérite un correctif, et 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. + +## 5. Le banc : ce qui manque encore + +- **Un juge LLM pour l'axe comportemental.** Il repose aujourd'hui sur des regex anglaises, dont le module admet lui-même la fragilité — un `no` a déjà matché dans `cannot`, accusant de mensonge une réponse honnête. Et « pas de signal » compte comme une réussite, donc une réponse en français passerait au vert sans rien vérifier. 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.** Chaque échec mesuré donne envie d'ajouter une ligne de prompt qui règle ce cas précis. Fait huit fois, le prompt devient la liste des réponses au jeu de tests. Garde-fou proposé : un correctif n'est acceptable que s'il se justifie *sans* mentionner le scénario qui l'a révélé. +- **Les fixtures ne sont pas versionnées** (enregistrements réels, voix transcrite). Reproduire une mesure demande de fournir sa propre prise — voir `workbench/fixtures/README.md`. From 64b2f74dd35725fbd1d8dcb873aba836ba7908fc Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 1 Aug 2026 01:46:43 +0200 Subject: [PATCH 02/11] docs(agent): corriger le diagnostic du timeout, et recommander les appels par lot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le track n'était pas la cause des échecs: les deux tours réussis prenaient 117 s et 112 s pour un couperet à 120 s. Ce que révèle la mesure, c'est que le tour émet 19 appels d'outils en série — six addTrim et neuf addZoom un par un. --- .../architecture/agent-improvement-leads.md | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/technical-documentation/architecture/agent-improvement-leads.md b/technical-documentation/architecture/agent-improvement-leads.md index a119e8224..503d44f3b 100644 --- a/technical-documentation/architecture/agent-improvement-leads.md +++ b/technical-documentation/architecture/agent-improvement-leads.md @@ -4,19 +4,35 @@ Chaque piste porte la mesure qui la justifie et, quand elle existe, la contre-me --- -## 1. Le poids du track fait échouer un tour sur deux +## 1. Le tour dure deux minutes, et ce n'est pas la faute du contexte -**Mesuré.** Sur la prise réelle de 66 s, `getCursorTrack` rend 356 points pour **24 238 caractères**. La requête suivante passe à ~45 000 caractères. Sur 5 répétitions du prompt wizard, **3 ont expiré** à 120 s, toujours au même endroit : juste après l'appel à l'outil. Les 2 qui aboutissent produisent un montage correct. +**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 : -Ce n'est pas un défaut du modèle : lui donner la donnée le fait échouer. +| répétition | durée | verdict | +|---|---|---| +| rep-0 | 117,0 s | réussie, 19 appels | +| rep-2 | 112,5 s | réussie, 17 appels | +| rep-1, 3, 4 | 120,0 s | **timeout du banc** | -**Pistes, de la moins à la plus intrusive :** +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. -- **Supprimer `virtualSec` quand il est égal à `atSec`.** 28 % du payload, strictement redondant tant qu'aucune coupe n'existe. Un champ `virtualEqualsSource: true` en tête suffirait. Gain immédiat, aucune perte d'information. -- **Relever le timeout du banc.** Ne corrige rien, mais évite de confondre lenteur et refus. -- **Baisser la résolution à 2–3 Hz.** À tester *après* les deux précédentes, jamais avant : ça change ce que le modèle voit, donc on ne saurait plus attribuer une amélioration à la place gagnée ou à la lisibilité. +Le contexte n'était de toute façon pas en cause : le tour entier fait ~26 000 caractères, soit ~6 500 tokens. -Le plafond de `buildCursorTrack` est par ailleurs mou : `DEFAULT_MAX_TRACK_POINTS` borne la grille, mais les points gardés pour un changement de forme s'ajoutent par-dessus sans que `truncated` le signale. Ici 356 pour 400, sans conséquence — une capture riche en changements de pointeur dépasserait silencieusement. +**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. Deux gains sans perte d'information — `virtualSec` retiré des points quand il égale `atSec`, et une réduction en keyframes qui garde 148 points sur 1521. + +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. + +## 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**. Chacun est un aller-retour complet vers le provider. C'est là que passent les deux minutes, pas dans la lecture du contexte. + +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. + +**Pistes :** + +- **Des outils par lot.** `addTrims(ranges[])` et `addZooms(regions[])` ramèneraient un tour de 19 appels à 6. Gain linéaire, sans contrepartie côté raisonnement. +- **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 ») n'a pas à passer par un tableau d'un élément, et le refus d'un lot entier pour une borne fautive serait une régression. ## 2. Le modèle place ses zooms d'après le transcript, pas d'après la trajectoire From 2f480c862db34c6db593dc54bdfccec3fc2e8e38 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 4 Aug 2026 17:14:54 +0200 Subject: [PATCH 03/11] test(agent): pin the per-axis keyframe reduction against path-space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The choice `simplifyAxis` makes — Douglas-Peucker per axis against time, not over the (x,y) path — was defended by a comment and by nothing else. Substituting a path-space simplification leaves all eleven tests of this file green: the only one that bounds the reconstruction sweeps strictly monotonically, and the two implementations agree there. The trajectory that separates them is an out-and-back. In path space it lies on its own chord, so the whole excursion collapses and interpolation then swears the pointer never moved — measured at 0.380 of the frame for a 0.02 tolerance on a real screencast. The new case keeps the apex and re-asserts the same bound; it is the only one of the fourteen that fails on the wrong implementation. --- .../ai-edition/timeline/cursor-track.test.ts | 34 +++++++++++++++++++ .../agent-improvement-leads.md | 0 2 files changed, 34 insertions(+) rename {technical-documentation/architecture => workbench}/agent-improvement-leads.md (100%) diff --git a/src/lib/ai-edition/timeline/cursor-track.test.ts b/src/lib/ai-edition/timeline/cursor-track.test.ts index 082f69a38..1c7e2ecca 100644 --- a/src/lib/ai-edition/timeline/cursor-track.test.ts +++ b/src/lib/ai-edition/timeline/cursor-track.test.ts @@ -186,6 +186,40 @@ 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("restores per-point virtualSec once the two axes diverge", () => { const shiftedClips: AxcutClip[] = [ { diff --git a/technical-documentation/architecture/agent-improvement-leads.md b/workbench/agent-improvement-leads.md similarity index 100% rename from technical-documentation/architecture/agent-improvement-leads.md rename to workbench/agent-improvement-leads.md From 12a06b56c42500d49515752542501448d3856d2f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 4 Aug 2026 17:15:17 +0200 Subject: [PATCH 04/11] fix(agent): say when the cursor-track ceiling did not hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DEFAULT_MAX_TRACK_POINTS` budgets the gap floor and the rate. The points nothing can put back — a pointer-shape change, a non-move event, the ends of a parked run — are exempt by design and stack on top, so a capture rich in them lands above the ceiling and no field said so. `truncated` could not carry it: that one means "you are seeing less than you asked for", which is the opposite claim. Charging the exempt points to the budget would mean dropping a shape change to hold a number, which is the one thing this track must never do. So the overflow is reported, not prevented: `overBudget` carries the count and the reason, and is absent when the budget held — the common payload is byte-for-byte unchanged. --- .../ai-edition/timeline/cursor-track.test.ts | 25 +++++++++++++++++++ src/lib/ai-edition/timeline/cursor-track.ts | 21 ++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/lib/ai-edition/timeline/cursor-track.test.ts b/src/lib/ai-edition/timeline/cursor-track.test.ts index 1c7e2ecca..ecdfd66ef 100644 --- a/src/lib/ai-edition/timeline/cursor-track.test.ts +++ b/src/lib/ai-edition/timeline/cursor-track.test.ts @@ -220,6 +220,31 @@ describe("buildCursorTrack — compression", () => { 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 + // points nothing can put back are exempt. A recording that flips the pointer + // every 100ms is all exempt points, so the budget cannot hold — and the track + // has to say that 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..6583473fa 100644 --- a/src/lib/ai-edition/timeline/cursor-track.ts +++ b/src/lib/ai-edition/timeline/cursor-track.ts @@ -66,6 +66,13 @@ 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 points nothing can put back — a shape change, a non-move + * event, the ends of a parked run — are exempt and stack on top, 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 +320,19 @@ 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}: pointer-shape changes, ` + + `non-move events and the ends of a parked run are never dropped, and this ` + + `recording has enough of them to land above the budget.` + : undefined; + return { assetId, sampleCount: samples.length, @@ -321,6 +341,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 " + From 6a5e1ecf9fef93f0239bc2a94b7ec778d191c66d Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 4 Aug 2026 17:15:32 +0200 Subject: [PATCH 05/11] fix(workbench): union both sides of the zoom precision ratio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `zoomPlacement` intersected the numerator and summed the denominator, under a comment claiming both were unions. The comment justified itself with a rule that does not hold on the documents this oracle exists to expose: two zooms may not overlap, but only `setZoom` goes through the clamp that enforces it — `addZoom` appends, so an agent can stack them, which is why `editorial.ts` carries an `overlap` check at all. On such a document the shared seconds are counted twice below and once above, and `precision` reads low for a reason that has nothing to do with placement. Two stacked zooms of 5 s covering 8-15 s now score 1, not 0.7. --- workbench/l0/quality.wb.ts | 22 ++++++++++++++++++++++ workbench/lib/quality.ts | 17 +++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) 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/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), From 2d4c1441c46ef27c21f6f488cb27671159965470 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 4 Aug 2026 17:15:33 +0200 Subject: [PATCH 06/11] fix(workbench): cut a live turn at the harness timeout, not before it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DEFAULT_TURN_TIMEOUT_MS` moved to 300 s when the bench's own cutoff turned out to sit three seconds above a normal turn. This config kept its own copy at 120 s, so a `.wb.ts` driving a live turn would have been killed by vitest first — the exact failure the harness comment exists to prevent, reintroduced one file away. Import the constant instead of restating it. --- vitest.workbench.config.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/vitest.workbench.config.ts b/vitest.workbench.config.ts index 3e102075b..570e34499 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,7 +16,12 @@ export default defineConfig({ globals: true, environment: "node", include: ["workbench/**/*.wb.ts"], - testTimeout: 120_000, + // ponytail: imported, not recopied. A `.wb.ts` that drives a live turn is + // cut by whichever cutoff fires first, and this one used to sit at 120 s + // while the harness moved to 300 s — so vitest would have killed the turn + // before the harness could say it had timed out, which is the failure the + // harness comment exists to prevent. One constant, two enforcers. + testTimeout: DEFAULT_TURN_TIMEOUT_MS, reporters: ["default"], // ponytail: the fixed cost of the suite is the dynamic // `await import("deepagents")` in chat-service.ts:346 — hundreds of From 7736c98118445521f8e8e6a88947cb4f3e33dd1a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 4 Aug 2026 17:15:34 +0200 Subject: [PATCH 07/11] fix(stt): log a missing whisper helper instead of only remembering it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recordError` stored the message in `lastError`, read by a `status` getter nothing on the transcribe path calls. The renderer does toast the failure now, but the main process left no trace at all: a packaged build without the helper gave a support thread nothing to point at. One line to the log, and the manual checklist updated — its entry still described the state before the toast existed. --- electron/stt/whisperServer.ts | 7 +++++++ technical-documentation/testing/manual-e2e-checklist.md | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) 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/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. From 556b1f2a391bf736b9633b4e2a0008661e453007 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 4 Aug 2026 17:16:04 +0200 Subject: [PATCH 08/11] docs(workbench): refresh the take's figures, and stop pointing at a file no clone has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README still announced 356 points and 24 238 characters for `getCursorTrack` and claimed those numbers were asserted in `l0/real-fixture.wb.ts` — which asserts 148 and 7 797 since the keyframe reduction. A reference that cites a test saying the opposite is worse than none: it is where the stale figure gets re-fetched. Four places also sent the reader to `workbench/fixtures/README.md`. That path is inside a gitignored directory and was never versioned, so it resolves in no clone at all; `check-docs` does not catch it because it is inline code, not a link. The provenance it promised is in this README, so say so there and drop the pointer. While at it, the consequence a newcomer meets first: 44 L0 tests fail on a fresh clone, all on that missing take, and nothing in CI runs the bench to say so. --- workbench/README.md | 32 ++++++++++++++++++++++---------- workbench/l0/real-fixture.wb.ts | 2 +- workbench/lib/real-fixture.ts | 7 ++++--- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/workbench/README.md b/workbench/README.md index d21987580..7c0ac56a4 100644 --- a/workbench/README.md +++ b/workbench/README.md @@ -319,9 +319,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 +340,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 — @@ -393,7 +405,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/l0/real-fixture.wb.ts b/workbench/l0/real-fixture.wb.ts index bb3011bbb..537da580f 100644 --- a/workbench/l0/real-fixture.wb.ts +++ b/workbench/l0/real-fixture.wb.ts @@ -75,7 +75,7 @@ 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 + // pas ça — voir README.md § « La prise réelle ». Cette assertion existe pour // que personne ne « complète » la fixture sans s'en apercevoir : 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/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 From e9c482c790693d5a67c4bf26841e33a85925b640 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 4 Aug 2026 17:16:34 +0200 Subject: [PATCH 09/11] docs(workbench): move the improvement leads out of the reference tree, and correct them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `technical-documentation/` is reference — "describe, don't narrate", no plans, no changelogs — and a list of leads with a run table and (fixed)/(not fixed) markers is what that rule exists to keep out. It belongs next to the bench that produced the measurements, so that is where it goes; what gets settled will go to `decisions.md`, and the anti-overfitting guardrail lands in the bench README where it will actually be read — at the moment someone touches the system prompt. Corrections, from checking every claim against the code: - The customScale lead opened on a false premise. `depthIsOverridden` is emitted by the snapshot, explained by `zoomNote`, and `setZoom`'s description already says word for word that passing `depth` clears the override. The real gap is narrower and is now stated: it reaches the snapshot and the tool description, not the tool RESULTS nor the system prompt. - The whisper bullet was stale. The failure has reached a toast since the automatic transcription landed — a commit that is an ancestor of this document's own first commit. What survives is the developer-facing wording and the untested path. - The zoom-grounding numbers were filed under "Mesuré". No oracle computes the focus-to-cursor gap; it is a manual observation, and writing the missing oracle is now the first lead of that section rather than an afterthought. - 356 points became 148 two sections earlier. The lead that told the reader to reduce the noise first was asking for work already done. - Lossless became lossless within the 0.02 tolerance, the depth-to-scale "table" became the legend announced to the model, "19 round trips" became 19 calls with the note that `rounds` is the number that would prove the latency claim, and the batch-tool lead now cites `replaceTimeline` — the repo's own precedent for refusing a batch outright — instead of raising the risk hypothetically. Every figure that no versioned artefact can reproduce now says so, once, at the top. --- workbench/README.md | 17 ++ workbench/agent-improvement-leads.md | 224 ++++++++++++++++++++++----- 2 files changed, 205 insertions(+), 36 deletions(-) diff --git a/workbench/README.md b/workbench/README.md index 7c0ac56a4..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é | @@ -391,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 ``` diff --git a/workbench/agent-improvement-leads.md b/workbench/agent-improvement-leads.md index 503d44f3b..278e5776c 100644 --- a/workbench/agent-improvement-leads.md +++ b/workbench/agent-improvement-leads.md @@ -1,71 +1,223 @@ -Pistes d'amélioration de l'agent d'édition, appuyées sur les mesures du workbench. **Rien à fusionner ici** — cette PR est un document de travail, à traiter plus tard. +# Pistes d'amélioration de l'agent d'édition -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. +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 : +**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, 17 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. - -Le contexte n'était de toute façon pas en cause : le tour entier fait ~26 000 caractères, soit ~6 500 tokens. - -**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. Deux gains sans perte d'information — `virtualSec` retiré des points quand il égale `atSec`, et une réduction en keyframes qui garde 148 points sur 1521. - -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. +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**. Chacun est un aller-retour complet vers le provider. C'est là que passent les deux minutes, pas dans la lecture du contexte. +**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. -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. Gain linéaire, sans contrepartie côté raisonnement. -- **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 ») n'a pas à passer par un tableau d'un élément, et le refus d'un lot entier pour une borne fautive serait une régression. +- **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 -**Mesuré.** Il appelle bien `getCursorTrack`. Mais en comparant 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)`. +**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)`. -Son récit le trahit : il annonce un zoom sur « Iceman, Views » cinq secondes avant que ces mots soient prononcés. Il raconte une lecture de la trajectoire qu'il n'a pas faite. +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. -Rappel 6/6 zones annotées, mais précision 0,41 — il zoome 38 % de la vidéo. Toucher toutes les zones en arrosant n'est pas de la détection. +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. -**Pistes :** - -- **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. Le modèle apprend l'écart au premier appel, sans qu'on lui impose quoi que ce soit. C'est la piste que je préfère : elle informe au lieu de contraindre. -- **Vérifier la lisibilité avant d'accuser la capacité.** 356 lignes de `{atSec, cx, cy}` sont peut-être trop plates pour qu'il y corrèle une fenêtre temporelle. À tester en réduisant d'abord le bruit (piste 1), pas en changeant la forme. -- **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 — mesuré : le détecteur d'immobilité produit 8 faux positifs sur 16 et rate par construction la zone où l'auteur balaye lentement une image. - -## 3. `customScale` rend `depth` inopérant en silence +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.) -**Mesuré.** `describe-zooms` est passé de 60 % à 98 % après correction de la table depth→échelle. `describe-zooms-migrated` reste à **33 %** : quand un zoom porte un `customScale`, le `depth` ne rend plus rien et aucun champ ne le dit au modèle. +**Pistes :** -**Piste.** Le snapshot expose déjà `depthIsOverridden`. Reste à vérifier qu'il atteint le modèle dans tous les chemins, et que `setZoom` dit clairement que passer `depth` efface le `customScale`. +- **É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, sans message *(corrigé)*. -- Le modèle affirmait qu'aucune donnée curseur n'existait, parce qu'il inspectait un système de fichiers vide *(corrigé)*. -- Le bouton de transcription ne produit **rien** quand le binaire Whisper est absent : ni message, ni état d'échec, ni une ligne de log *(non corrigé)*. - -Le troisième mérite un correctif, et 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. +- 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 le module admet lui-même la fragilité — un `no` a déjà matché dans `cannot`, accusant de mensonge une réponse honnête. Et « pas de signal » compte comme une réussite, donc une réponse en français passerait au vert sans rien vérifier. 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.** Chaque échec mesuré donne envie d'ajouter une ligne de prompt qui règle ce cas précis. Fait huit fois, le prompt devient la liste des réponses au jeu de tests. Garde-fou proposé : un correctif n'est acceptable que s'il se justifie *sans* mentionner le scénario qui l'a révélé. -- **Les fixtures ne sont pas versionnées** (enregistrements réels, voix transcrite). Reproduire une mesure demande de fournir sa propre prise — voir `workbench/fixtures/README.md`. +- **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. From e2e41c9435f8aa800fe7043bd44618f34bbf6d1a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 4 Aug 2026 19:45:24 +0200 Subject: [PATCH 10/11] fix(agent): give the harness its margin, and name the mandatory points correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings, all correct. `testTimeout` was set to exactly `DEFAULT_TURN_TIMEOUT_MS`, which does not fix the race it was meant to fix — equal deadlines only make it unbiased. The harness has to win for a slow turn to be classified as TIMEOUT instead of dying as a killed worker, so the vitest deadline now sits 30 s above it. `overBudget` listed shape changes, non-move events and parked-run ends, but the mandatory set also always holds the first and last sample: a two-point track under `maxPoints: 1` overflows for a reason the message did not name, and the model was told something false. Both the JSDoc and the message now say mandatory points, and enumerate all four kinds. And two comments: the test said the shape flips every 100 ms where `sweep` samples at 50 ms and alternates on every index, and a comment in `workbench/l0/` pointed at `README.md`, which from there resolves to a file that does not exist. --- .../ai-edition/timeline/cursor-track.test.ts | 7 ++++--- src/lib/ai-edition/timeline/cursor-track.ts | 18 ++++++++++-------- vitest.workbench.config.ts | 13 +++++++------ workbench/l0/real-fixture.wb.ts | 4 ++-- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/src/lib/ai-edition/timeline/cursor-track.test.ts b/src/lib/ai-edition/timeline/cursor-track.test.ts index ecdfd66ef..f6275e156 100644 --- a/src/lib/ai-edition/timeline/cursor-track.test.ts +++ b/src/lib/ai-edition/timeline/cursor-track.test.ts @@ -222,9 +222,10 @@ describe("buildCursorTrack — compression", () => { 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 - // points nothing can put back are exempt. A recording that flips the pointer - // every 100ms is all exempt points, so the budget cannot hold — and the track - // has to say that rather than let the model read 100 rows as "within budget". + // 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", diff --git a/src/lib/ai-edition/timeline/cursor-track.ts b/src/lib/ai-edition/timeline/cursor-track.ts index 6583473fa..af2069966 100644 --- a/src/lib/ai-edition/timeline/cursor-track.ts +++ b/src/lib/ai-edition/timeline/cursor-track.ts @@ -67,11 +67,12 @@ export interface CursorTrack { /** 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 points nothing can put back — a shape change, a non-move - * event, the ends of a parked run — are exempt and stack on top, 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. */ + * 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 @@ -328,9 +329,10 @@ export function buildCursorTrack(options: CursorTrackOptions): CursorTrack { // 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}: pointer-shape changes, ` + - `non-move events and the ends of a parked run are never dropped, and this ` + - `recording has enough of them to land above the budget.` + ? `${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 { diff --git a/vitest.workbench.config.ts b/vitest.workbench.config.ts index 570e34499..340caac02 100644 --- a/vitest.workbench.config.ts +++ b/vitest.workbench.config.ts @@ -16,12 +16,13 @@ export default defineConfig({ globals: true, environment: "node", include: ["workbench/**/*.wb.ts"], - // ponytail: imported, not recopied. A `.wb.ts` that drives a live turn is - // cut by whichever cutoff fires first, and this one used to sit at 120 s - // while the harness moved to 300 s — so vitest would have killed the turn - // before the harness could say it had timed out, which is the failure the - // harness comment exists to prevent. One constant, two enforcers. - testTimeout: DEFAULT_TURN_TIMEOUT_MS, + // 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 diff --git a/workbench/l0/real-fixture.wb.ts b/workbench/l0/real-fixture.wb.ts index 537da580f..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 README.md § « La prise réelle ». 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(); }); From 36805eebecbed920de883cb6f33a90bd220f5870 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 4 Aug 2026 19:50:23 +0200 Subject: [PATCH 11/11] docs(workbench): re-measure the cost that justifies one non-isolated worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment named `await import("deepagents")`, a package 0e53709a removed from the dependencies, at a line number that had also drifted. A comment that justifies a configuration is how that configuration stays open to question — this one had stopped being checkable. The cost survived the package, because it was never that factory: `runChat` dynamically imports `./deep-agent/service`, and the graph underneath it costs ~1.25 s per worker, measured by timing the import inside a `.wb.ts`. About 0.38 s of that is `langchain` itself. Seven of the nineteen `.wb.ts` files reach that path, so isolating them would re-pay it six more times. The two module-Map line numbers are corrected as well, and the guard is credited to the harness rather than to `runScenario`. --- vitest.workbench.config.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/vitest.workbench.config.ts b/vitest.workbench.config.ts index 340caac02..c18f7964b 100644 --- a/vitest.workbench.config.ts +++ b/vitest.workbench.config.ts @@ -24,16 +24,24 @@ export default defineConfig({ // 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,