Zum Inhalt springen

cat posts/agent-im-loop-05-wenn-der-loop-luegt.md

Agent im Loop (Teil 5): Wenn der Loop lügt

Explodierende Würfel und Vorteil/Nachteil zeigen, wie grüne Tests täuschen können: Overfitting, geschwächte Tests, unvollständige Semantik und Hänger. Property-Based Testing macht die Schleife robuster.

Bisher war die Schleife ziemlich bequem: roten Test schreiben, Agent arbeiten lassen, grün bekommen. In Teil 4 haben wir damit sogar den kompletten Unterbau der Würfel-Engine ausgetauscht.

Jetzt kommt der unangenehme Teil: Eine grüne Testsuite bedeutet nur, dass der Code die vorhandenen Tests besteht. Ob diese Tests das richtige Verhalten vollständig beschreiben, ist eine andere Frage.

Ein Coding-Agent verschärft das Problem. Gibst Du ihm ausschließlich das Ziel „bring die Tests grün“, optimiert er auf genau dieses Signal. Normalerweise entsteht dabei vernünftiger Code. Ist im Testnetz aber eine Lücke, kann auch eine falsche Implementierung perfekt grün aussehen.

Explodierende Würfel sind dafür ein schönes Beispiel.

Vier Arten, wie der Loop Dich täuschen kann

1. Overfitting auf die Beispiele

Angenommen, der Test enthält nur:

assert roll("1d6!", ...) == 8

Dann wäre theoretisch sogar so etwas möglich:

if notation == "1d6!":
    return 8

Das ist ein extremes Beispiel, aber das Grundproblem ist real: Wenige konkrete Beispiele lassen Raum für Implementierungen, die genau diese Beispiele erfüllen, ohne die allgemeine Regel korrekt umzusetzen.

Mehr unterschiedliche Beispiele helfen. Noch stärker werden die Tests, wenn sie allgemeine Eigenschaften prüfen und ihre Eingaben variieren.

2. Der Maßstab wird passend gemacht

Der einfachste Weg von Rot zu Grün führt manchmal durch den Test selbst:

assert result == 14

wird zu

assert result >= 12

oder der störende Test verschwindet gleich ganz.

Das heißt nicht, dass ein Agent niemals Tests ändern darf. Gerade beim gemeinsamen Entwickeln kann es sinnvoll sein, Tests erzeugen oder verbessern zu lassen. Änderungen an Tests brauchen aber dieselbe Aufmerksamkeit wie Änderungen am Produktivcode.

Wenn der Code nur deshalb grün wird, weil gleichzeitig die Erwartung abgeschwächt wurde, hat die Schleife ihren Maßstab verloren.

3. Grün, aber semantisch falsch

Das ist die subtilste Variante. Der Test ist korrekt, prüft aber nicht genug.

Bei explodierenden Würfeln kann eine Implementierung beispielsweise genau eine Explosion unterstützen. Solange unser Test ebenfalls nur eine Explosion enthält, sehen wir den Fehler nicht.

Die Testsuite sagt dann nichts Falsches. Wir lesen nur mehr aus ihrem Grün heraus, als sie tatsächlich geprüft hat.

4. Der Loop liefert gar keine Antwort mehr

Eine falsche Abbruchbedingung kann dafür sorgen, dass ein Test niemals endet. Dann haben wir nicht einmal mehr Rot oder Grün.

Streng genommen lügt der Loop hier nicht – er verstummt.

Gerade bei Schleifen, Rekursion, Retries und externer Kommunikation gehört deshalb auch die Frage zur Spezifikation: Was verhindert, dass diese Operation unbegrenzt läuft?

Explodierende Würfel

Wir erweitern unsere Grammatik um !:

1d6!
3d6!

Die Regel lautet: Zeigt ein Würfel sein Maximum, wird derselbe Würfel erneut geworfen und das Ergebnis addiert. Ist auch dieser Wurf maximal, explodiert er erneut.

Aus

6, 6, 2

wird bei einem W6 also:

6 + 6 + 2 = 14

Jeder Würfel einer Gruppe explodiert unabhängig.

Für diesen Teil erlauben wir pro Würfelgruppe entweder einen keep/drop-Selektor oder !, aber noch keine Kombination aus beidem:

dice     := [number] "d" number [modifier]
modifier := selector | "!"
selector := ("kh" | "kl" | "dh" | "dl") number

4d6kh3 bleibt damit gültig und 3d6! kommt neu dazu. Was 4d6kh3! genau bedeuten soll, entscheiden wir noch nicht – also akzeptiert unsere Sprache es auch noch nicht.

Kleine Erweiterung am Parser

Der Tokenizer muss zunächst ! erkennen:

TOKEN_RE = re.compile(r"kh|kl|dh|dl|d|!|[+-]|\d+")

Unser Dice-Knoten bekommt zusätzlich die Information, ob der Würfel explodiert:

class Dice:
    def __init__(self, count, sides, selector=None, explode=False):
        self.count = count
        self.sides = sides
        self.selector = selector
        self.explode = explode

Im Parser reicht dann eine kleine Erweiterung in _dice():

def _dice(self, count):
    self._expect("d")
    sides = self._number("Seitenzahl nach 'd' erwartet")

    selector = None
    explode = False

    if self._peek() == "!":
        self._next()
        explode = True
    elif self._peek() in ("kh", "kl", "dh", "dl"):
        kind = self._next()
        amount = self._number(
            f"Anzahl nach Selektor {kind!r} erwartet"
        )
        selector = (kind, amount)

    return Dice(
        count,
        sides,
        selector=selector,
        explode=explode,
    )

Der interessante Teil steckt aber nicht im Parser. Er steckt in der Schleife, die einen einzelnen Würfel auswertet.

Der Beispiel-Fallstrick

Eine erste Implementierung könnte so aussehen:

def _roll_die(rng, sides, explode):
    value = rng.randint(1, sides)

    if explode and value == sides:
        value += rng.randint(1, sides)

    return value

Und dazu schreiben wir:

def test_explodierender_wuerfel():
    rng = SequenceRng([
        (6, 6),
        (6, 2),
    ])

    assert roll("1d6!", rng) == 8

Rot, implementieren, grün.

Nur ist die Implementierung falsch.

Die Regel lautet nicht „würfle bei einer 6 genau einmal zusätzlich“, sondern „würfle solange weiter, wie das Maximum fällt“.

Unser Beispiel hat diese Unterscheidung nie geprüft.

Ein zweiter Test trifft die Kante:

@pytest.mark.parametrize(
    "notation,wuerfe,erwartet",
    [
        (
            "1d6!",
            [(6, 6), (6, 6), (6, 2)],
            14,
        ),
        (
            "3d6!",
            [(6, 6), (6, 2), (6, 3), (6, 1)],
            12,
        ),
    ],
)
def test_explodierende_wuerfel(notation, wuerfe, erwartet):
    rng = SequenceRng(wuerfe)

    assert roll(notation, rng) == erwartet

    rng.assert_exhausted()

Der erste Fall erzwingt zwei Explosionen:

6 -> 6 -> 2

Eine Implementierung, die nur einmal nachwürfelt, liefert 12 statt 14 und wird rot.

Der zweite Fall prüft zusätzlich, dass bei mehreren Würfeln nur der betroffene Würfel weiter explodiert:

erster W6:  6 + 2
zweiter W6: 3
dritter W6: 1
             ----
               12

Der Unterschied liegt nicht in der Menge der Tests, sondern in ihrer Auswahl. Ein einziger Fall an der richtigen Kante ist wertvoller als zehn Fälle, die alle dieselbe Lücke haben.

Eine echte Implementierung braucht eine Abbruchstrategie

Die naheliegende Schleife lautet:

def _roll_die(rng, sides, explode):
    value = rng.randint(1, sides)
    total = value

    while explode and value == sides:
        value = rng.randint(1, sides)
        total += value

    return total

Für normale Würfel sieht das vernünftig aus. Aber was passiert bei

1d1!

Ein W1 zeigt immer eine 1. Eine 1 ist gleichzeitig sein Maximum. Der Würfel explodiert also für immer.

Auch eine kaputte oder absichtlich kontrollierte RNG könnte bei einem W6 unbegrenzt Sechsen liefern.

Wir brauchen deshalb eine fachliche Grenze. Wichtig ist dabei, nach Erreichen dieser Grenze kein Teilergebnis als gültigen Wurf zurückzugeben. Das würde einen unvollständigen Wurf still als korrekt ausgeben.

MAX_EXPLOSIONS = 100


def _roll_die(rng, sides, explode):
    if explode and sides == 1:
        raise ValueError("Ein explodierender W1 terminiert nicht")

    value = rng.randint(1, sides)
    total = value
    explosions = 0

    while explode and value == sides:
        if explosions >= MAX_EXPLOSIONS:
            raise RuntimeError("Explosionsgrenze erreicht")

        value = rng.randint(1, sides)
        total += value
        explosions += 1

    return total

1d1! lehnen wir direkt ab. Bei allen anderen Würfeln dient MAX_EXPLOSIONS als defensive Sicherung.

Der Evaluator verwendet die Funktion anschließend für jeden einzelnen Würfel:

if isinstance(node, Dice):
    rolls = [
        _roll_die(rng, node.sides, node.explode)
        for _ in range(node.count)
    ]

    selected = _apply_selector(rolls, node.selector)
    return sum(selected)

Einen Hänger kann der Test nicht selbst verhindern

Jetzt könnten wir schreiben:

def test_explosionsgrenze():
    with pytest.raises(RuntimeError):
        roll("1d6!", AlwaysMaxRng())

Das prüft die Grenze – solange sie funktioniert.

Wenn ein Agent die Abbruchbedingung versehentlich entfernt, hängt genau dieser Test endlos. Ein normaler assert hilft dann nicht mehr, weil Python ihn nie erreicht.

Dafür brauchen wir eine Sicherung außerhalb der getesteten Schleife.

Eine Möglichkeit ist pytest-timeout:

uv add --dev pytest-timeout

Unser Testdouble liefert absichtlich immer das Maximum:

class AlwaysMaxRng:
    def randint(self, start, end):
        return end

Und der Test bekommt einen äußeren Timeout:

@pytest.mark.timeout(1)
def test_explosionsgrenze_verhindert_endlosschleife():
    with pytest.raises(RuntimeError, match="Explosionsgrenze"):
        roll("1d6!", AlwaysMaxRng())

Der Timeout ist hier keine Performance-Messung. Ob die Funktion 20 oder 30 Millisekunden braucht, interessiert uns nicht. Er ist die letzte Sicherung für den Fall, dass die eigentliche Terminierungslogik komplett versagt.

Dasselbe Prinzip gilt im CI: Auch ein kompletter Testlauf sollte nicht unbegrenzt laufen dürfen.

Vorteil und Nachteil: der unterscheidende Test

Vorteil und Nachteil brauchen inzwischen gar keinen neuen Produktivcode mehr. Die Selektoren aus Teil 4 können beides bereits ausdrücken:

Vorteil:    2d20kh1
Nachteil:   2d20kl1

Gerade deshalb eignen sie sich gut für eine weitere Testfalle.

Nehmen wir diesen Test für Vorteil:

rng = SequenceRng([
    (20, 7),
    (20, 19),
])

assert roll("2d20kh1", rng) == 19

Er sagt noch nichts darüber aus, ob kl1 korrekt implementiert ist.

Und selbst wenn wir beide Varianten mit zwei identischen Würfen testen:

12, 12

könnte die Implementierung kh und kl vertauschen, ohne dass wir es merken.

Wir brauchen Werte, bei denen die Semantik einen sichtbaren Unterschied erzeugt:

def test_vorteil_und_nachteil_unterscheiden_sich():
    rolls = [
        (20, 7),
        (20, 19),
    ]

    assert roll("2d20kh1", SequenceRng(rolls)) == 19
    assert roll("2d20kl1", SequenceRng(rolls)) == 7

Das ist ein unterscheidender Testfall: Die gewählten Eingaben sorgen dafür, dass zwei mögliche Implementierungen nicht zufällig dasselbe Ergebnis liefern.

Genau solche Fälle sollten wir suchen, wenn mehrere plausible Implementierungen durch denselben einfachen Test passen würden.

Property-Based Testing statt immer längerer Tabellen

Wir können die Tabelle natürlich weiter ausbauen:

1d6
2d6
3d8
4d10
5d20
...

Irgendwann schreiben wir aber nur noch Varianten desselben Tests von Hand.

Hier kommt Property-Based Testing ins Spiel. Wir beschreiben nicht mehr einzelne Eingaben, sondern wie gültige Eingaben aussehen und welche Eigenschaft für alle davon gelten soll.

Für Python verwenden wir Hypothesis:

uv add --dev hypothesis

Aus Teil 2 (erweitert in Teil 3) haben wir bereits SequenceRng. Damit können wir eine stärkere Eigenschaft formulieren als nur „das Ergebnis liegt ungefähr im richtigen Bereich“.

Wir erzeugen Würfelanzahl, Seitenzahl und konkrete Würfelergebnisse:

from hypothesis import given, strategies as st


@st.composite
def dice_cases(draw):
    count = draw(st.integers(min_value=1, max_value=10))
    sides = draw(st.integers(min_value=2, max_value=100))
    values = draw(
        st.lists(
            st.integers(min_value=1, max_value=sides),
            min_size=count,
            max_size=count,
        )
    )

    return count, sides, values

Und daraus wird unsere Eigenschaft:

@given(dice_cases())
def test_ndm_addiert_genau_die_generierten_wuerfe(case):
    count, sides, values = case

    rng = SequenceRng([
        (sides, value)
        for value in values
    ])

    assert roll(f"{count}d{sides}", rng) == sum(values)

    rng.assert_exhausted()

Der Test sagt nicht:

3d6 mit 4, 1 und 6 ergibt 11.

Er sagt:

Für beliebige gültige N und M gilt:
Wenn die Zufallsquelle N konkrete Wurfergebnisse liefert,
muss NdM genau deren Summe ergeben.

Hypothesis erzeugt daraus viele unterschiedliche Testfälle. Findet es einen Fehler, versucht es anschließend, das Gegenbeispiel zu verkleinern. Statt einer riesigen, schwer lesbaren Eingabe bekommst Du dadurch häufig einen kleinen Fall, an dem sich der Bug direkt nachvollziehen lässt.

Das macht konkrete Beispieltests nicht überflüssig. 1d6! mit zwei Explosionen ist weiterhin ein hervorragender Test, weil er eine bestimmte semantische Kante dokumentiert.

Property-Based Testing ergänzt diese Beispiele um Variation.

Mehr dazu steht im Begleitartikel Property-Based Testing mit Hypothesisgeplant.

Auch Property-Based Tests können schwach sein

Hypothesis macht eine schlechte Eigenschaft nicht automatisch gut.

Dieser Test sieht beispielsweise fleißig aus:

@given(
    count=st.integers(1, 10),
    sides=st.integers(2, 100),
)
def test_irgendwas(count, sides):
    result = roll(f"{count}d{sides}")

    assert result > 0

Er probiert viele Eingaben – prüft aber fast nichts.

Eine Implementierung mit

return 1

würde weiterhin bestehen.

Die eigentliche Arbeit bleibt deshalb dieselbe wie bei normalen Tests: Wir müssen eine Eigenschaft finden, die falsche Implementierungen tatsächlich voneinander unterscheidet.

Hypothesis automatisiert die Variation der Eingaben. Es entscheidet nicht für uns, was „korrekt“ bedeutet.

Den Agenten gegen die Tests arbeiten lassen – aber nicht gegen ihren Sinn

Für Claude Code können wir den Auftrag inzwischen enger formulieren:

Implementiere explodierende Würfel für NdM!.

Ein Würfel explodiert bei seinem Maximalwert wiederholt, bis ein kleinerer Wert fällt.
1d1! ist ungültig. Nach 100 aufeinanderfolgenden Explosionen muss die Auswertung mit
einem Fehler abbrechen.

Ändere bestehende Tests nicht, um Fehler grün zu bekommen.
Führe anschließend die komplette Test-Suite aus.

Das ist deutlich besser als:

Mach die neuen Tests grün.

Die Tests liefern weiterhin das maschinenprüfbare Feedback. Die Prosa beschreibt zusätzlich Semantik und Grenzen, die sich aus einzelnen Beispielen nicht unbedingt offensichtlich ergeben.

Prompt und Tests arbeiten zusammen. Keines von beiden ersetzt das andere.

Woran Du ein verdächtiges Grün erkennst

Nach einem Agentenlauf lohnt sich deshalb mehr als ein Blick auf

passed

Interessanter sind Fragen wie:

  • Hat sich außer dem Produktivcode auch ein Test verändert?
  • Wurde eine Assertion schwächer?
  • Gibt es Sonderfälle im Code, die verdächtig genau auf einzelne Tests passen?
  • Trifft mindestens ein Test jede wichtige semantische Kante?
  • Gibt es Schleifen oder Retries, deren Abbruch ebenfalls getestet wird?
  • Formuliert ein Property-Test wirklich eine starke Eigenschaft oder nur eine triviale Wahrheit?

Das ist kein Misstrauen gegenüber dem Agenten. Dieselben Fragen gelten für Code, den wir selbst schreiben. Ein Agent macht nur schneller sichtbar, wie leicht wir grünes Feedback mit Korrektheit verwechseln.

Stolperfallen

  • Mehr Tests bedeuten nicht automatisch bessere Tests. Entscheidend ist, ob sie unterschiedliche Fehler sichtbar machen können.
  • Teständerungen sind nicht verboten, aber prüfenswert. Besonders verdächtig sind Änderungen, die zeitgleich mit einer fehlschlagenden Implementierung Erwartungen abschwächen.
  • Ein Explosionslimit darf kein falsches Ergebnis liefern. Wird die Grenze erreicht, sollte die Operation sichtbar fehlschlagen statt eine abgeschnittene Summe zurückzugeben.
  • Ein Timeout ist die äußere Sicherung, nicht die fachliche Abbruchbedingung. Die Anwendung muss selbst terminieren können; der Test-Timeout verhindert nur, dass ein Fehler den gesamten Testlauf blockiert.
  • Property-Based Testing braucht starke Eigenschaften. Viele generierte Eingaben retten keine Assertion, die kaum etwas über das gewünschte Verhalten aussagt.
  • Gute Beispiele unterscheiden konkurrierende Implementierungen. Für Vorteil/Nachteil sind 7 und 19 informativer als zweimal 12.

Grün ist ein Signal, kein Beweis

Der Loop hat nie wirklich gelogen. Wir haben ihm nur gelegentlich Fragen gestellt, die zu wenig unterscheiden konnten.

Ein grüner Test bestätigt eine konkrete Erwartung. Eine grüne Suite bestätigt viele davon. Je besser diese Erwartungen gewählt sind, desto wertvoller wird das Feedback für Mensch und Agent.

Explodierende Würfel haben uns dabei gleich mehrere Schwächen gezeigt: Ein Beispiel mit nur einer Explosion prüft keine wiederholte Explosion. Eine fehlende Abbruchbedingung kann den gesamten Loop stoppen. Und viele zufällig erzeugte Fälle helfen nur dann, wenn die geprüfte Eigenschaft stark genug ist.

In Teil 6 bekommt der Agent deshalb weitere Feedback-Kanäle. Neben Tests kommen Typen, Linter, Coverage und automatische Hooks dazu. Aus einem einzelnen grünen Balken wird langsam eine Pipeline, die verschiedene Arten von Fehlern unabhängig voneinander sichtbar macht.

Weiterlesen

Sources: Hypothesis – Dokumentation, pytest – Dokumentation, pytest-timeout.

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!