cat posts/agent-im-loop-04-refactoring-unter-testschutz.md
Agent im Loop (Teil 4): Refactoring unter Testschutz
Der naive String-Zerteiler stößt bei 4d6kh3 an seine Grenzen. Wir trennen Tokenizer, Parser und Evaluator – unter Testschutz, der Regressionen sichtbar macht, aber nicht jede ungetestete Änderung erkennen kann.
In Teil 3 kam unsere Würfel-Engine noch
mit einem Regex-Zerteiler aus. 2d6+1d4+3 lässt sich bequem in einzelne Terme
zerlegen und anschließend auswerten.
Mit 4d6kh3 wird die Sache interessanter: Wir würfeln vier W6, behalten die drei
höchsten und summieren erst danach. Innerhalb eines Würfelterms steckt jetzt also
weitere Struktur.
Bevor wir dieses Feature hinzufügen, räumen wir deshalb die Innereien der Engine um. Erst ohne neues Verhalten. Genau dafür haben wir die Tests der ersten drei Teile geschrieben.
Refactoring heißt: Verhalten erst einmal nicht ändern
Die erste Runde bekommt eine harte Grenze:
Refactore die bestehende Würfel-Engine in getrennte Schritte für Tokenizing,
Parsing und Auswertung.
Ändere die öffentliche Funktion roll(notation, rng) und die vorhandenen Tests nicht.
Unterstütze noch keine neue Syntax.
Führe anschließend uv run pytest aus.
Die Suite ist vor dem Umbau grün. Danach soll sie wieder grün sein.
Das beweist nicht, dass wir bei einem Refactoring überhaupt nichts kaputt gemacht haben. Es zeigt nur: Das Verhalten, das unsere Tests abdecken, ist erhalten geblieben. Eine ungetestete Ecke kann weiterhin unbemerkt brechen.
Genau diese Einschränkung ist wichtig. Tests machen einen größeren Umbau kontrollierbarer, nicht gefahrlos.
Drei getrennte Aufgaben
Für unsere kleine Sprache teilen wir die Verarbeitung künftig in drei Schritte:
- Der Tokenizer macht aus dem Eingabestring einzelne Tokens.
- Der Parser interpretiert diese Tokens nach einer Grammatik und baut daraus einen AST.
- Der Evaluator wertet diesen AST aus und erzeugt das Ergebnis.
Aus
2d6+1d4+3
wird zunächst ungefähr
["2", "d", "6", "+", "1", "d", "4", "+", "3"]
Daraus baut der Parser eine Struktur, die sinngemäß sagt:
((2d6) + (1d4)) + 3
Erst der Evaluator würfelt tatsächlich.
Die Trennung wirkt für unsere winzige Sprache zunächst aufwendiger als nötig. Sie
bezahlt sich aber, sobald ein Würfelterm mehr bedeutet als nur NdM.
Die folgenden Snippets zeigen den Stand nach beiden Schritten. Im reinen
Refactoring-Schritt fehlen noch die Selektor-Tokens, selector im Dice-Knoten und
_apply_selector.
Der Tokenizer
Der Tokenizer muss nicht verstehen, was kh3 bedeutet. Er muss lediglich erkennen,
dass kh ein eigenes Token ist und die anschließende 3 eine Zahl.
import re
TOKEN_RE = re.compile(r"kh|kl|dh|dl|d|[+-]|\d+")
def tokenize(notation):
notation = notation.replace(" ", "")
tokens = []
pos = 0
for match in TOKEN_RE.finditer(notation):
if match.start() != pos:
unexpected = notation[pos : match.start()]
raise ValueError(f"Unerwartetes Zeichen: {unexpected!r}")
tokens.append(match.group())
pos = match.end()
if pos != len(notation):
raise ValueError(f"Unerwartetes Zeichen: {notation[pos:]!r}")
return tokens
Der Positionsvergleich ist dabei wichtiger als der Regex selbst. finditer() sucht
sonst einfach nach dem nächsten Treffer. Bei
2d6x3
würde es auch hinter dem x wieder eine passende Zahl finden. Durch match.start() !=
pos fällt die Lücke auf und die Eingabe wird abgelehnt.
Auch die Reihenfolge der Alternativen ist Absicht:
kh|kl|dh|dl|d
Python probiert Alternativen von links nach rechts. Würde d vor dh stehen, würde
bei dh bereits das einzelne d akzeptiert. Das anschließende h wäre dann
unbekannt und eine eigentlich gültige Notation würde scheitern.
Eine kleine Grammatik
Nach dem reinen Refactoring kann der Agent die neue Struktur mit den alten Tests prüfen. Erst wenn wieder alles grün ist, kommt das neue Feature.
Für die erweiterte Syntax reicht uns diese Grammatik:
expression := term (("+" | "-") term)*
term := dice | number
dice := [number] "d" number [selector]
selector := ("kh" | "kl" | "dh" | "dl") number
number := DIGITS
Damit sind unter anderem diese Ausdrücke möglich:
3
d6
2d6
2d6+3
2d6+1d4
4d6kh3
4d6kh3+2
kh, kl, dh und dl bedeuten:
kh keep highest
kl keep lowest
dh drop highest
dl drop lowest
4d6kh3 heißt also: vier W6 würfeln und die drei höchsten behalten.
Der AST
Der Parser soll noch nichts würfeln. Er übersetzt die Tokenfolge lediglich in eine Struktur.
Dafür reichen drei Knotentypen:
class Num:
def __init__(self, value):
self.value = value
class Dice:
def __init__(self, count, sides, selector=None):
self.count = count
self.sides = sides
self.selector = selector
class BinOp:
def __init__(self, op, left, right):
self.op = op
self.left = left
self.right = right
Für
4d6kh3+2
entsteht sinngemäß:
BinOp(
"+",
Dice(4, 6, ("kh", 3)),
Num(2),
)
Damit ist die Bedeutung der Eingabe strukturell festgehalten, ohne dass bereits ein einziger Würfel gefallen ist.
Der Parser
Unsere Grammatik ist klein genug für einen Recursive-Descent-Parser von Hand. Die Methoden folgen dabei direkt den Bestandteilen der Grammatik:
class Parser:
def __init__(self, tokens):
self.tokens = tokens
self.i = 0
def parse(self):
node = self._expression()
if self._peek() is not None:
raise ValueError(f"Unerwartetes Token: {self._peek()!r}")
return node
def _expression(self):
node = self._term()
while self._peek() in ("+", "-"):
op = self._next()
node = BinOp(op, node, self._term())
return node
def _term(self):
token = self._peek()
if token == "d":
return self._dice(1)
if token is not None and token.isdigit():
value = int(self._next())
if self._peek() == "d":
return self._dice(value)
return Num(value)
raise ValueError(f"Zahl oder Würfel erwartet, gefunden: {token!r}")
def _dice(self, count):
self._expect("d")
sides = self._number("Seitenzahl nach 'd' erwartet")
selector = None
if 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)
def _peek(self):
if self.i >= len(self.tokens):
return None
return self.tokens[self.i]
def _next(self):
token = self._peek()
if token is None:
raise ValueError("Unerwartetes Ende der Eingabe")
self.i += 1
return token
def _expect(self, expected):
token = self._next()
if token != expected:
raise ValueError(f"{expected!r} erwartet, gefunden: {token!r}")
def _number(self, message):
token = self._peek()
if token is None or not token.isdigit():
raise ValueError(message)
return int(self._next())
Damit scheitert auch eine unvollständige Notation wie
2d
kontrolliert:
ValueError: Seitenzahl nach 'd' erwartet
Der alte String-Zerteiler landete in solchen Fällen irgendwann bei einem int("")
oder einem anderen Folgefehler. Jetzt weiß der Parser selbst, was an dieser Stelle
fehlt.
Der Evaluator
Erst im letzten Schritt kommen die tatsächlichen Würfe ins Spiel:
def _apply_selector(rolls, selector):
if selector is None:
return rolls
kind, amount = selector
ordered = sorted(rolls)
if kind == "kh":
return ordered[-amount:]
if kind == "kl":
return ordered[:amount]
if kind == "dh":
return ordered[:-amount]
if kind == "dl":
return ordered[amount:]
raise ValueError(f"Unbekannter Selektor: {kind!r}")
Der AST selbst bleibt dabei unabhängig vom Zufall:
def _evaluate(node, rng):
if isinstance(node, Num):
return node.value
if isinstance(node, BinOp):
left = _evaluate(node.left, rng)
right = _evaluate(node.right, rng)
if node.op == "+":
return left + right
return left - right
if isinstance(node, Dice):
rolls = [rng.randint(1, node.sides) for _ in range(node.count)]
selected = _apply_selector(rolls, node.selector)
return sum(selected)
raise TypeError(f"Unbekannter AST-Knoten: {type(node).__name__}")
Die öffentliche Funktion ist am Ende ziemlich unspektakulär:
import random
def roll(notation, rng=None):
if rng is None:
rng = random
tokens = tokenize(notation)
tree = Parser(tokens).parse()
return _evaluate(tree, rng)
Genau das wollen wir. roll() orchestriert nur noch die drei Schritte.
Jetzt erst kommt 4d6kh3
Bis hierher lässt sich der Umbau als echtes Refactoring durchführen: neue innere Struktur, bestehendes beobachtbares Verhalten.
4d6kh3 ist dagegen kein Refactoring. Es ist neues Verhalten.
Deshalb bekommt es erst jetzt neue rote Tests.
Wir verwenden weiterhin das SequenceRng aus Teil 3, das neben den Rückgabewerten
auch die angeforderte Würfelgröße kontrolliert:
@pytest.mark.parametrize(
"notation,wuerfe,erwartet",
[
(
"4d6kh3",
[(6, 2), (6, 5), (6, 3), (6, 6)],
14,
),
(
"4d6kl1",
[(6, 2), (6, 5), (6, 3), (6, 6)],
2,
),
(
"4d6dh1",
[(6, 2), (6, 5), (6, 3), (6, 6)],
10,
),
(
"4d6dl1",
[(6, 2), (6, 5), (6, 3), (6, 6)],
14,
),
(
"2d20kh1",
[(20, 7), (20, 19)],
19,
),
(
"4d6kh3+2",
[(6, 2), (6, 5), (6, 3), (6, 6)],
16,
),
],
)
def test_keep_drop(notation, wuerfe, erwartet):
rng = SequenceRng(wuerfe)
assert roll(notation, rng) == erwartet
rng.assert_exhausted()
Der Unterschied zur ersten Phase ist wichtig:
Refactoring:
alter Test -> neuer innerer Aufbau -> alter Test bleibt grün
Feature:
neuer Test -> rot -> neue Implementierung -> grün
Wenn beides gleichzeitig passiert, wird ein Fehlschlag schwerer einzuordnen. Hat der Umbau bestehendes Verhalten zerstört oder ist nur das neue Feature noch nicht fertig?
Zwei getrennte Schritte halten den Loop enger.
Der Agent bekommt ebenfalls zwei Aufgaben
Für einen Coding-Agenten lohnt sich dieselbe Trennung.
Zuerst:
Refactore nur die interne Struktur in Tokenizer, Parser und Evaluator.
Füge kein neues Verhalten hinzu und ändere die Tests nicht.
Führe uv run pytest aus.
Nach dem grünen Zwischenstand:
Implementiere jetzt die neuen kh-, kl-, dh- und dl-Fälle.
Ändere die bestehenden Tests nicht.
Führe anschließend uv run pytest aus.
Damit bekommt Claude Code nach jedem überschaubaren Schritt Feedback. Falls die Suite nach dem ersten Auftrag rot wird, wissen wir, dass die Regression aus dem Refactoring kommt. Nach dem zweiten Auftrag gehören neue Fehler zur Feature-Arbeit.
Ein großer Prompt nach dem Muster „Bau den Parser um und implementiere nebenbei keep/drop“ nimmt uns genau diese Information.
Das Testnetz hält – innerhalb seiner Maschen
Nach dem Umbau sollten sämtliche alten Tests unverändert grün sein. Danach kommen die neuen keep/drop-Tests dazu und werden ebenfalls grün.
Das ist ein starkes Signal: 1d6, mehrere Würfelgruppen, Modifikatoren und die bisher
getesteten Sonderfälle funktionieren nach dem inneren Neubau weiterhin.
Mehr behauptet die Suite aber nicht.
Ein schönes Gegenbeispiel ist:
4d6kh5
Wir verlangen fünf Würfel, obwohl nur vier geworfen werden. Unsere aktuelle Implementierung macht daraus:
ordered[-5:]
Bei einer Liste mit vier Elementen liefert Python einfach alle vier zurück.
Die Tests bleiben grün, solange kein Test festlegt, dass kh5 bei 4d6 ungültig sein
soll.
Auch kh0 und dh0 werfen Fragen auf. Soll das erlaubt sein? Falls ja: Was genau soll
es bedeuten? Unsere Grammatik kann die Ausdrücke lesen, aber die Semantik ist noch
nicht definiert.
Das ist kein Problem des Parsers. Uns fehlt eine Spezifikation.
Parser und Evaluator getrennt testen
Bislang laufen viele Prüfungen weiterhin über die öffentliche roll()-Funktion. Durch
die neue Architektur können wir jetzt zusätzlich einzelne Stufen testen.
Der Tokenizer lässt sich beispielsweise ohne RNG prüfen:
def test_tokenize_keep_highest():
assert tokenize("4d6kh3+2") == ["4", "d", "6", "kh", "3", "+", "2"]
Beim Parser können wir kontrollieren, welche Struktur entsteht. Beim Evaluator können wir einen bereits gebauten AST mit einer kontrollierten RNG auswerten.
Das heißt nicht, dass jede interne Methode einen eigenen Test braucht. Zu viele Tests
gegen Implementierungsdetails machen spätere Refactorings unnötig schwer. Aber an
klaren Grenzen wie Tokenizer, Parser und Evaluator können gezielte Tests Fehler sehr
viel genauer lokalisieren als ein einziges großes roll()-Ergebnis.
Stolperfallen
- Refactoring und Feature-Arbeit nicht vermischen. Erst bestehendes Verhalten mit neuer Struktur grün bekommen, danach den nächsten roten Test schreiben.
- Grüne Tests beweisen nur getestetes Verhalten. Ein Umbau kann weiterhin eine ungetestete Ecke beschädigen.
- Die Token-Reihenfolge ist relevant. Bei Regex-Alternativen gewinnt der erste
passende Zweig. Mehrbuchstabige Tokens wie
dhmüssen deshalb vordstehen. - Syntax und Semantik sind zwei verschiedene Probleme. Der Parser kann
4d6kh5problemlos verstehen. Ob dieser Ausdruck erlaubt sein soll, muss separat definiert werden. - Nicht jede interne Funktion braucht Tests. Teste sinnvolle Grenzen, statt den aktuellen Aufbau so festzunageln, dass das nächste Refactoring unnötig schwer wird.
Was der Umbau gebracht hat
Vorher bestand unsere Engine im Wesentlichen aus String-Zerteilen und Sonderfällen. Jetzt gibt es eine kleine Sprache mit klar getrennten Verarbeitungsschritten.
Noch wichtiger für die Serie ist aber der Weg dorthin: Der Agent musste keinen großen
Umbau auf einmal richtig erraten. Wir konnten erst die Struktur verändern und mit der
bestehenden Suite prüfen, ob das bekannte Verhalten erhalten blieb. Danach kam mit
4d6kh3 ein neuer roter Test und erst dann das neue Verhalten.
In Teil 5 wird genau diese Sicherheit unangenehm hinterfragt. Wir ergänzen weitere Semantik und schauen uns Fälle an, in denen die komplette Suite grün ist, obwohl die Implementierung trotzdem falsch ist. Der Loop lügt dann nicht wirklich – wir haben ihm nur die falschen Fragen gestellt.
Weiterlesen
- Agent im Loop (Teil 3): Die Testliste wächst
- Einen Parser von Hand schreiben (Recursive Descent)geplant – das Handwerk hinter diesem Umbau.
- Vom Skript zum echten CLI – Struktur jenseits eines Skripts.
Sources: Python: re,
Wikipedia: Recursive descent parser.
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.