Ein echter Bug ist ein ehrlicherer Benchmark als jedes Leaderboard
Jedes Modell kommt mit einem Zeugnis: SWE-bench, HumanEval, ein Contest-Rating. Diese Zahlen sind real, aber sie teilen eine Form — ein in sich geschlossenes Rätsel, eine bekannte Antwort, ein Versuch, bewertet anhand des Diffs. Sie messen Deduktion unter Gewissheit. Sie sagen dir fast nichts über das, worauf du dich in einem Agenten tatsächlich verlässt: was ein Modell tut, wenn es an einem echten Problem feststeckt, in einem laufenden System, ohne Lösungsschlüssel und mit einer Feedback-Schleife, die es anlügt.
Das bekommst du nicht auf ein Leaderboard, denn in dem Moment, in dem eine Aufgabe sauber genug ist, um automatisch bewertet zu werden, hört sie auf, der echten Arbeit zu ähneln. Also fliegen wir bei der wichtigsten Eigenschaft meist blind.
Dieser Post handelt von dem einen Mal, als wir es nicht taten. Die App eines Nutzers ging auf eine bestimmte, reproduzierbare Weise kaputt, und durch Model-Routing endeten wir damit, zwei verschiedene Coder dagegen laufen zu lassen — derselbe Prompt, dieselbe kaputte Datei, dieselbe laufende App, derselbe Verifier. Kein synthetisches Eval. Ein natürliches Experiment. Das eine Modell verbrauchte 51 Schritte und sein gesamtes Budget und brachte den Fix nie ans Ziel; das andere schaffte ihn sauber in 19. Das Interessante ist nicht das Ergebnis. Es ist, warum der Unterschied existiert — denn der Bug stellte sich als nahezu perfekt dafür konstruiert heraus, zwei Arten von Modell zu trennen, und ein Leaderboard hätte sie nahezu gleichauf bewertet.
Um zu sehen, was er maß, musst du den Bug verstehen. Also gehen wir ihn ordentlich durch — die tatsächliche Grafik, die tatsächlichen Formeln — und lesen dann die beiden Traces gegeneinander.
Das Problem unter Test: ein Mesh pro Chunk
Ein Nutzer baute eine Voxel-Welt im Minecraft-Stil in React Three Fiber. Die erste Generation war gut — eine texturierte, beleuchtete, spielbare Welt. Dann kam die Optimierung, die jede Voxel-Engine irgendwann braucht, und sie färbte die ganze Szene schwarz.
Ein naiver Voxel-Renderer gibt jedem Block sein eigenes Mesh, also setzt die GPU einen Draw Call pro Block ab — Tausende pro Frame. Die Lösung ist gemergte Chunk-Geometrie: pack jede sichtbare Fläche eines 16×16×N-Chunks in eine einzige BufferGeometry und zeichne den Chunk in einem einzigen Call. Du baust drei Vertex-Attribute von Hand — position, normal, uv — plus einen Index-Buffer.
Genau dieser Handbau ist die Stelle, an der Korrektheit fragil wird, denn ein beleuchtetes Material zeigt dir keine Positionen. Es zeigt dir Licht. Und Licht hängt von den Normalen ab, und die Normalen hängen — im naiven Ansatz — von der Winding-Order deiner Dreiecke ab. Diese Kette ist die ganze Geschichte, und sie ist der Grund, warum dieser Bug ein guter Benchmark ist: Es gibt keine geschlossene Antwort zum Ablesen. Der fehlende Fakt lebt in der laufenden Pipeline.
Die Shading-Mathematik, und woher das Schwarz kommt
Die Blöcke nutzten MeshLambertMaterial — diffuses Lambert-Shading. Für eine Lichtquelle ist die ausgehende Farbe eines Fragments:
C_out = C_tex · ( k_a·A + k_d · max(0, N̂ · L̂) )
└ ambient ┘ └─── diffuse ───┘
C_tex— das Texel, gesampelt aus dem Atlas an der UV des FragmentsN̂— die Oberflächennormale (Einheitsvektor),L̂— Richtung zur Lichtquelle (Einheitsvektor)k_a·A— Ambient-Term,k_d·max(0, N̂·L̂)— Diffuse-Term
Der Diffuse-Term ist ein geclamptes Skalarprodukt. Das Clamp ist die Falle: Wenn N̂ von der Lichtquelle weg zeigt — oder schlimmer, in den Block hinein — dann ist N̂·L̂ ≤ 0, das max(0, …) setzt ihn auf null, und übrig bleibt nur Ambient:
N̂ pointing inward ⇒ N̂·L̂ ≤ 0 ⇒ C_out = C_tex · k_a · A
Und Ambient war winzig (mehr dazu gleich), also C_out ≈ 0. Schwarz. Nicht fehlend, nicht untexturiert — vollständig schwarz geshadet, was wie ein Beleuchtungs-Bug aussieht, oder ein Textur-Bug, oder was immer du hineindeuten willst.
Also: Wohin zeigen die Normalen? Im naiven Bau sagst du es nie. Du lässt Three.js sie ableiten:
geo.setIndex([0,1,2, 1,3,2]) // two triangles per quad
geo.computeVertexNormals() // normals INFERRED from those triangles
Und computeVertexNormals() berechnet die Normale jedes Dreiecks aus dem Kreuzprodukt seiner Kanten, in Index-Reihenfolge:
N = normalize( (v1 − v0) × (v2 − v0) )
Das Kreuzprodukt ist antisymmetrisch — a × b = −(b × a) — also kehrt das Vertauschen zweier Indizes das Vorzeichen der Normale um. [0,1,2] und [0,2,1] beschreiben dieselben drei Punkte und die entgegengesetzte Normale. Die Winding-Order deines Index-Buffers ist still und leise deine Beleuchtung.
Hier die andere Seite derselben Medaille. WebGLs Backface-Culling (side: FrontSide) behält ein Dreieck nur, wenn es aus Kamerasicht gegen den Uhrzeigersinn gewickelt ist — das Vorzeichen seiner projizierten Screen-Space-Fläche. Dieselbe Index-Reihenfolge, die die Normale umkehrt, kehrt auch die Ausrichtung um. Ein falsches Winding tut also zwei Dinge auf einmal: Es kehrt die Normale um (→ Lambert-Schwarz) und kehrt die Ausrichtung um (→ wird per Backface-Culling entfernt, das Symptom „ich sehe nur dünne/einzelne Flächen"). Eine Ursache, zwei völlig unterschiedlich aussehende Bugs.
Das ist eine Inferenzkette: Ein Wert, der dich interessiert (beleuchtete Farbe), wird Glied für Glied aus einem Wert abgeleitet, der sich leicht subtil falsch machen lässt (Winding), über einen Schritt, der den Fehler versteckt (computeVertexNormals). Das zu debuggen heißt, jedes Glied von Hand als korrekt zu beweisen, für alle sechs Würfelflächen.
Warum dieser Bug ein trennscharfer Benchmark ist
Ein guter Benchmark trennt die Dinge, die er testet. Die meisten Coding-Evals trennen wenig — ein stärkerer Reasoner punktet einfach durchweg höher. Dieser Bug trennt, und zwar wegen zweier Eigenschaften, die er zufällig kombinierte.
Erstens: Er war überbestimmt — drei unabhängige, jeweils für sich hinreichende Gründe, schwarz zu sein. Jeder einzelne allein erzeugt dasselbe Bild.
| # | Ursache | Die Physik | Fix |
|---|---|---|---|
| 1 | Invertiertes Winding | N̂·L̂ ≤ 0 → Diffuse 0; zudem Backface-Culling |
korrektes Winding oder explizite Normalen |
| 2 | MeshStandardMaterial (PBR) |
eine Metal-Rough-BRDF mit roughness 0.85 und ohne Environment Map hat unter einer einzigen Lichtquelle fast keine diffuse Energie — IBL leistet die Arbeit, die nicht da war |
MeshLambertMaterial nutzen |
| 3 | Dämmerungs-Beleuchtung | Tageszyklus startete bei t=0 → Sonne am Horizont |
bei Mittag starten / Untergrenze anheben |
Ursache 3 versteckt sich vor aller Augen. Der Tageszyklus trieb die Sonnenhöhe als sin(t) mit t = 0 beim Laden, sodass das gerichtete Licht bei max(0, sin 0)·3.5 + 0.2 = 0.2 aufging — die Sonne exakt am Horizont — mit einem doppelten, hartkodierten <ambientLight>, das gegen den Zyklus kämpfte. Effektive Ausleuchtung ≈ 0.05. Die Szene lud bei Dämmerung; selbst perfekte Geometrie rendert bei t = 0 nahezu schwarz.
Überbestimmtheit ist die grausamste Eigenschaft, die ein Bug haben kann, denn sie zerstört deine Feedback-Schleife. Fix das Winding → immer noch schwarz (PBR + Dämmerung). Fix das Material → immer noch schwarz (Winding + Dämmerung). Jeder korrekte Fix sieht aus wie ein gescheiterter Fix. Du kannst keinen Gradienten erklimmen, wenn jeder Schritt sich als null liest. Ein Modell, das Debugging als denk härter, dann patche behandelt, bekommt kein Signal, dass es Fortschritte macht.
Zweitens: Trotz alldem war der Bug billig isolierbar — ein Material-Tausch, wie wir sehen werden, lässt die gesamte Suche kollabieren. Also stellt die Aufgabe leise eine Frage: Wenn der Bildschirm dir keinen Gradienten gibt, denkst du weiter, oder führst du ein Experiment durch, um einen Gradienten zu erzeugen? Eine rein deduktive Strategie scheitert hier; eine interaktive geht glatt hindurch. Das ist der Trenner. Ein Leaderboard aus One-Shot-Rätseln kann diese Frage gar nicht stellen.
Was das erste Modell tatsächlich tat
Beobachte, wie der starke Open-Weights-Coder daran arbeitet, und du siehst einen scharfen Ingenieur, gefangen in genau diesem Null-Gradienten. Er ging direkt an das härteste Glied der Kette — das Winding von Hand zu beweisen — und bekam das Vorzeichen immer wieder falsch, in beide Richtungen, in verschiedenen Anläufen. Zwei echte Zitate aus seinem Trace, Stunden auseinander:
…also hat mein „Fix" es tatsächlich schlimmer gemacht — das ursprüngliche
0-1-2war für die obere Fläche korrekt!
Alle 6 Flächen haben invertiertes Winding. Der Fix ist einfach: Indizes von
[0,1,2],[1,3,2]auf[0,2,1],[1,2,3]ändern.
Beides kann nicht stimmen. Er verfolgte das Kreuzprodukt für die obere Fläche von Hand — v0 = (−½,½,−½), v1 = (½,½,−½), v2 = (−½,½,½) — bekam (v1−v0)×(v2−v0) = (0,−1,0), bemerkte korrekt, dass das nach unten in den Block zeigt, und leitete es dann im nächsten Durchgang andersherum neu her. Er kreiste durch dieselben Theorien — Winding, dann „computeVertexNormals erzeugt Normalen der Länge null auf degenerierten Dreiecken", dann „die CanvasTexture wird nicht auf die GPU hochgeladen", dann die Dämmerungs-Beleuchtung, dann PBR, dann ein doppeltes Ambient-Licht, dann outputColorSpace, das von R3F nicht weitergereicht wird, dann dreis <Sky>, das den Hintergrund überschreibt. Mehrere dieser Teildiagnosen waren richtig. Er verkündete „Jetzt habe ich das vollständige Bild" fünf verschiedene Male, jedes ein anderes Bild, und erschöpfte sein Schritt-Budget — 51 Schritte, budget-exhausted — ohne dass der Fix je ankam.
Er war nicht dumm. Er machte die richtige Tätigkeit (über jedes Glied nachdenken) an einem Problem, an dem diese Tätigkeit nicht konvergieren kann: Ein überbestimmter Bug gibt keinen Gradienten, und das spezifische Glied, auf das er fixiert war (Winding ⇄ Normalen-Vorzeichen), ist eine von Hand hergeleitete Rechnung, die er über die Anläufe hinweg nicht konsistent halten konnte. Das ist genau der Versagensmodus, den ein Leaderboard nicht aufdecken kann — an einem sauberen Rätsel mit einer einzigen Ursache ist dasselbe Modell exzellent.
Was das zweite Modell anders machte
Wir richteten den Coder-Slot neu auf GPT-5.3 Codex aus. Er gewann den Winding-Streit nicht. Er weigerte sich, den Streit überhaupt zu führen. Aus seinem Trace:
Ich werde die Winding/Normalen-Mehrdeutigkeit vollständig beseitigen, indem ich die Chunk-Flächen als nicht-indizierte Dreiecke mit expliziten Normalen pro Fläche neu aufbaue.
Dieser eine Satz ist der ganze Fix, und er ist ein Wechsel der Repräsentation. Die Normale einer Würfelfläche ist nichts zum Ableiten — sie ist eine bekannte Konstante: +Y für oben, −Z für hinten, +X für rechts. Also schreib sie hin, statt computeVertexNormals sie aus einem möglicherweise falschen Winding rekonstruieren zu lassen.
Der fragile Bau (der des Open-Weights-Modells, und der ursprüngliche):
// 4 shared verts per quad, 2 triangles via an index buffer
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3))
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
geo.setIndex([0,1,2, 1,3,2]) // ← winding decides normals AND facing
geo.computeVertexNormals() // ← normals INFERRED; a winding bug silently corrupts them
const mat = new THREE.MeshStandardMaterial({ map: atlas, roughness: 0.85 }) // ← PBR → dark, cause #2
Der robuste Bau (der von Codex):
// 6 verts per quad (two triangles, NON-indexed), explicit constant normal
const FACE_NORMAL = { top:[0,1,0], bottom:[0,-1,0], north:[0,0,-1],
south:[0,0,1], east:[1,0,0], west:[-1,0,0] }
for (const [a,b,c] of [[0,1,2],[0,2,3]]) { // one fixed CCW triangulation, written once
for (const i of [a,b,c]) {
pos.push(...quad[i])
nrm.push(...FACE_NORMAL[face]) // ← the normal, stated, not derived
uv.push(...cellUV[i])
}
}
geo.setAttribute('normal', new THREE.BufferAttribute(nrm, 3)) // explicit
const mat = new THREE.MeshLambertMaterial({ map: atlas }) // lit, cheap, correct given good normals
Schau, was das entfernt. Mit explizit angegebener Normale:
N̂ is a constant per face ⇒ N̂·L̂ is correct regardless of winding
⇒ the entire winding → normal → black chain is GONE
Winding kann jetzt nur noch die Ausrichtung (Culling) beeinflussen, nicht die Beleuchtung — und eine weggecullte Fläche ist ein lautes, offensichtliches Symptom, kein stilles dunkles. Das nicht-indizierte Layout (6 Verts, kein geteilter Index) bedeutet außerdem, dass die Normale jedes Dreiecks unabhängig ist — keine Mittelung über einen geteilten Vertex kann die Differenz zwischen zwei Flächen aufteilen. Die Bug-Klasse ist nicht behoben; sie ist nicht mehr darstellbar gemacht.
Dann tat Codex das zweite, was das erste Modell nie tat — es isolierte die verbleibende Variable. Aus seinem Trace: „Ich füge jetzt einen echten Unlit-Debug-Toggle hinzu, damit wir das Texture-Atlas-Sampling von der Beleuchtung trennen können." Im Code ist das ein einziger Material-Tausch:
// MeshBasicMaterial ignores N̂·L̂ entirely: C_out = C_tex
const debug = new THREE.MeshBasicMaterial({ map: atlas })
MeshBasicMaterial lässt den Diffuse-Term fallen — C_out = C_tex, keine Normalen, keine Lichter. Also:
- Unlit zeigt korrekte Texturen → der Atlas/UV-Pfad ist in Ordnung, der Bug ist Beleuchtung/Normalen
- Unlit zeigt ebenfalls falsche Farben → der Bug ist Atlas/UV (ein
flipY- oder Zell-Offset-Fehler)
Ein Toggle lässt eine Suche über zwei Variablen (ist es Geometrie oder ist es Licht?) in zwei Beobachtungen über je eine Variable kollabieren. Das ist der absolut grundlegendste Move im Grafik-Debugging, und es ist der, den das spiralende Modell nie machte, weil es damit beschäftigt war, Kreuzprodukte neu herzuleiten. Es ist außerdem, ganz genau, der Move, nach dem ein Leaderboard nie fragt: Es gibt nichts zu deduzieren, nur etwas zum Ausführen.
Die Bilanz, derselbe Prompt und dieselbe kaputte Datei für beide:
| Coder | Coder-Calls | Ergebnis |
|---|---|---|
| Open-Weights-Coder | 51 | budget-exhausted — Fix kam nie an |
| GPT-5.3 Codex | 31 | all-subtasks-terminal bei Schritt 14, dann 19 — sauber |
Der Benchmark existierte nur, weil der Verifier blind war
Es gibt einen Grund, warum ein Modell das überhaupt blind debuggen musste, und es ist derselbe Grund, der es zu einem so sauberen Benchmark macht. Der Generierungs-Verifier der Plattform ist bewusst objektiv: Er prüft Typen, resolved Imports, prüft die API, lässt die Unit-Tests laufen und browst die Seite in echtem Chromium, auf geworfene Exceptions achtend. Für diese App lieferte er jedes einzelne Mal:
verify_page /: renders OK (title "App")
Stimmt. Die Seite lud. Ein <canvas>, das durchgängig schwarze Pixel malt, wirft nichts — es ist ein vollkommen gesundes Canvas voller C_out ≈ 0. Jedes Gate war ein Text-und-Struktur-Gate, und ein gerendertes Bild ist keines von beidem. Also lebte die einzige Ground Truth, die einen funktionierenden Build von einem kaputten unterscheiden konnte, in den Pixeln — was genau der Grund ist, warum diese Aufgabe Interaktions-Strategie misst und nicht Deduktion. Wir fügten einen Vision-QA-Pass hinzu: Screenshot des Canvas, eine eng gefasste Frage an ein Vision-Modell — ist das objektiv kaputt? Es kam mit dem Satz zurück, den keine Textprüfung erzeugen konnte: „alles ist dunkel und in Graustufen, ohne sichtbare Farben." Das machte das Rudern des Agenten von blind zu bloß schwer — und gab uns eine bewertbare Aufgabe mit einem echten, bildgestützten Lösungsschlüssel.
Ein Fingerabdruck des Ziels
Beide Modelle kannten dieselbe Grafik — der Open-Weights-Coder benannte korrekt das PBR-Material, die Dämmerungs-Beleuchtung, das doppelte Ambient. Auf einem Wissens-Benchmark würden sie gleich abschneiden. Was sie trennte, war die Strategie beim Feststecken, und die Plan-Bilanz hält diese Strategie in der denkbar plumpsten Form fest: die Namen der Subtasks, die jedes Modell erzeugte.
Die Spur des Open-Weights-Modells, der Reihe nach:
fix-chunk-material → fix-merged-material → fix-normals-and-material
→ fix-winding-texture-normals → render-fix-v4-merged
Fünf aufeinanderfolgende fix--Versuche am selben Ziel: erneut auf den Patch losgehen, das Winding neu herleiten, nochmal versuchen. Es gibt darin nirgends einen Schritt zur Informationsbeschaffung. Codex' Spur:
render-debug-isolation → render-fix-v5-rewrite
Der erste ist ein Experiment — ein Subtask, dessen Zweck es ist, zu lernen, nicht auszuliefern (der Unlit-MeshBasicMaterial-Toggle). Der zweite ist ein Rewrite, kein Patch. Über 21 render-bezogene Subtasks in der gesamten Projekthistorie hinweg taucht das Wort „isolation" genau einmal auf, und es ist Codex'. Genauso „rewrite".
Die Zahlen stimmen aus zwei weiteren Blickwinkeln zu:
| Signal | Open-Weights-Coder | GPT-5.3 Codex |
|---|---|---|
| Schritte → Ergebnis | 51 → budget-exhausted (kein Fix) |
14, dann 19 → all-subtasks-terminal (sauber) |
| Output-Tokens pro Schritt | Median 74, Max 6.400 (Neu-Herleitungs-Bursts) | Median 257, Max 4.511 (gleichmäßig) |
| Erster Move beim Feststecken | Kreuzprodukt neu herleiten | die Variable isolieren |
Mehr Schritte, schlechteres Ergebnis. Und die Form ist diagnostisch: Der Output des Open-Weights-Modells war bimodal — knappe Tool-Stupser, durchsetzt von 6.400-Token-Analysewänden, die zweimal entgegengesetzte Schlüsse aus derselben Winding-Herleitung zogen — während der von Codex gleichmäßig war, weil er seine Tokens auf Vorankommen statt auf Wieder-Aufmachen verwendete.
Lies diese drei Signale zusammen, und sie zeigen auf eine Sache — die Sache, die ein echter, umgebungsgestützter Benchmark sehen kann und ein Leaderboard nicht. Es gibt Probleme, die durch Deduktion lösbar sind — ein Beweis, ein Typfehler; mehr Nachdenken ist der Weg — und Probleme, die nur durch Interaktion lösbar sind — eine zustandsbehaftete GPU-Pipeline, in der der fehlende Fakt im laufenden System lebt und nur durch das Ausführen eines Experiments erreichbar ist. Ein Modell, das auf langhorizontigen agentischen Aufgaben mit Execution-Feedback nachtrainiert wurde, lernt, dass der Wert einer Aktion die Unsicherheit ist, die sie beseitigt; es greift zum Isolations-Toggle und zum Repräsentations-Rewrite. Ein Modell, dessen Stärke Reasoning auf Wettbewerbsniveau ist, bringt diese Stärke in jedes Problem ein — auch in die, in denen das Kreuzprodukt ein sechstes Mal neu herzuleiten Bewegung ohne Fortschritt ist. Der Voxel-Bug ist ein nahezu perfekter Trenner, weil er zugleich überbestimmt (kein deduktiver Gradient) und billig isolierbar (ein Material-Tausch) ist: Er belohnt genau die Strategie, die agentisches Training einprägt, und bestraft genau die, die reines Reasoning einprägt.
Die ehrliche Einschränkung, die man im Kopf behalten sollte: Das ist ein Trace, von außen gelesen. Sampling, der Harness, Kontextlimits und schiere Modellgröße konfundieren das alles, und nichts davon ist eine Aussage über Gewichte. Ein einzelner echter Bug ist eine Stichprobengröße von eins — was der Preis dafür ist, die Eigenschaft zu messen, die Leaderboards nicht können. Aber das Muster — durch Experiment debuggen, nicht durch Neu-Herleitung; so umstrukturieren, dass der Bug nicht mehr darstellbar wird, statt ihn niederzudiskutieren — ist reproduzierbar, und es ist das, was die öffentliche Richtung des Coding-Agent-Trainings vorhersagt. Der ehrlichste Benchmark, den wir diesen Monat laufen ließen, war gar kein Benchmark. Es war der schwarze Bildschirm eines Nutzers, und er sagte uns etwas, das die Zeugnisse nie würden: zu welchem Modell man greifen sollte, wenn der Bildschirm aufhört, Antworten zu geben.