docs/novaberg-lesson_l_lokale-bindung-vor-nutzung.md
Rama master · réplica de lectura del repositorio original.
# Novaberg — Lesson: Lokale Bindung vor Nutzung
**Projekt:** Novaberg — The Nova Anima Resonance System
**Dokument:** Lesson — `UnboundLocalError` beim Einfügen von Argumenten an bestehende Call-Sites
**Stand:** 11. Juli 2026, Chat 104
**Pfad:** novaberg/docs/novaberg-lesson_l_lokale-bindung-vor-nutzung.md
**Auslöser:** Paar-Verkabelung des `pipeline_log` (Chat 104), Node `enricher`
**Verwandt:** `novaberg-lesson_l_pattern-vor-namen-suche.md`, `novaberg-lesson_l_code-vor-doku.md`
---
## 1. Der Fund
Bei der Chat-104-Serie bekamen alle `pipeline_log`-Schreib-Call-Sites zwei neue
Keyword-Argumente (`user_id=…`, `character_id=…`). Sieben Dateien, 36 Call-Sites,
jede einzeln durch Import-Smoke-Test gegangen, jede committet, alles grün.
Der erste echte Turn danach starb sofort nach `ei_calc`:
```
[ERROR] ki_server.event_consumer: Graph-Fehler —
cannot access local variable 'character_id' where it is not associated with a value
```
`UnboundLocalError` in `_enrich_character` (enricher.py). Ursache:
| Zeile | Code |
|-------|------|
| 385 | `span_id = span_start(…, character_id=character_id)` ← **Nutzung** |
| 400 | `character_id: str = state.get("character_id", "")` ← **Bindung** |
Weil `character_id` weiter unten *lokal zugewiesen* wird, ist der Name für die
gesamte Funktion lokal — an Zeile 385 aber noch nicht gebunden. Python wirft
`UnboundLocalError`, nicht `NameError`. Der CharacterGraph starb bei **jedem**
Durchlauf; der HumanGraph (`_enrich_human`, Bindung auf Z. 222, vor allen
Call-Sites) lief unauffällig weiter.
---
## 2. Warum es durchrutschte
**Die Asymmetrie war die Falle.** Von den sieben verkabelten Dateien hatten fünf
das Paar strukturell unkritisch:
| Datei | Herkunft von `character_id` | Reihenfolge-Risiko |
|---|---|---|
| `db_zugriff`, `ei_calc_persist` | `state.get()` **am Node-Kopf** | keins — Bindung vor allem |
| `kzg`, `kzg/speicher` | **Funktions-Argument** | keins — immer gebunden |
| `synapsen_promotion` | lokale Auflösung mitten in der Funktion | **geprüft** — Halb-Paar-Zonen bewusst behandelt |
| `enricher` | lokale Zuweisung mitten in der Funktion | **nicht geprüft** ← der Fehler |
Bei `synapsen_promotion` wurde die Reihenfolge explizit auditiert („liegt der Call
vor oder nach der Auflösung?"), weil die Auflösung sichtbar an einer Closure
(`_hget`) hing — die Abhängigkeit *sah aus wie* ein Problem. Beim `enricher` wirkte
`character_id = state.get("character_id", "")` wie eine triviale Zeile ohne
Abhängigkeit und wurde nicht auf ihre **Position** geprüft. Das Audit lieferte die
Zeilennummer der Bindung (400) *und* die der Call-Sites (u.a. 390) — der Vergleich
wurde schlicht nicht gezogen.
**Die Ironie:** Die harmlos aussehende Variante war die gefährliche. Die
offensichtlich komplizierte wurde geprüft und war sicher.
---
## 3. Warum die Tests es nicht fingen
- **Import-Smoke** (`python -c "import graph.nodes.enricher"`) — grün. Ein
`UnboundLocalError` entsteht zur **Laufzeit**, beim Betreten des Zweigs, nicht
beim Import.
- **`compile()` / Syntax-Check** — grün. Es ist kein Syntaxfehler; der Code ist
wohlgeformt.
- **Linter** hätten es gemeldet (`pyflakes`: *local variable referenced before
assignment*) — laufen aber nicht in der Kette.
- **Guard-Smoke** des Dispatchers — grün, aber am falschen Node.
Gefunden hat es **der erste echte Turn**. Nichts anderes hätte es gefunden.
---
## 4. Die Lehre
> **Wer eine lokal gebundene Variable an einer bestehenden Call-Site einfügt, prüft
> ihre Bindungs-Position — immer, nicht nur wenn die Bindung kompliziert aussieht.**
Konkret, als Checkliste beim Einfügen von Argumenten in bestehenden Code:
1. **Woher kommt der Wert?**
- Funktions-Argument → immer gebunden, sicher.
- Lokale Zuweisung → **Zeilennummer der Bindung gegen die Zeilennummer *jeder*
Call-Site halten.** Nicht nur gegen die erste.
2. **Liegt eine Call-Site vor der Bindung?** Dann eine von zwei Antworten:
- **Bindung hochziehen**, wenn sie an nichts hängt (`state.get(…)` hängt nur an
`state` → frei nach oben ziehbar). Der saubere Fix: eine Quelle, ein Wert.
- **Konstante/Fallback einsetzen**, wenn die Bindung an vorgelagertem Code hängt
(Closure, Vorbedingungs-Checks) und nicht hochziehbar ist.
3. **Nie ein Halb-Paar schreiben.** `user_id` gesetzt, `character_id` NULL ist
schlimmer als beides NULL — es fällt bei `WHERE user_id=… AND character_id=…`
durchs Raster, statt sauber als „herrenlos" erkennbar zu sein.
**Und für die Abnahme:** Import-Smoke ist die Untergrenze, nicht der Beweis. Bei
Änderungen an Node-internen Bindungen ist **ein echter Turn** das einzige
belastbare Gate. Genau deshalb steht die Live-Abnahme am Ende jedes Sprints — sie
hat hier funktioniert, sofort und eindeutig.
---
## 5. Der Fix
Die Bindung wurde nach oben gezogen, neben `turn_id_log`/`quelle_log`, vor den
ersten `span_start`. Sie hing an nichts außer `state`. Alle Call-Sites tragen jetzt
die echte lokale ID, konsistent — kein Konstanten-Workaround, kein Sonderfall.
Commit: `744ec38` — *fix(enricher): character_id binding before span_start*.
---
## 6. Anschluss
Ein pattern-basierter Grep über alle Call-Sites bestätigte hinterher die
Vollständigkeit der Verkabelung (36 verkabelt, 1 bewusst NULL, 0 übersehen) — aber
**Vollständigkeit ist nicht Korrektheit**. Der Grep hätte die falsche Reihenfolge nie
gesehen; er zählt Argumente, nicht ihre Gültigkeit. Zwei verschiedene Fragen, zwei
verschiedene Prüfungen:
- *Ist überall etwas eingefügt?* → Grep (`pattern-vor-namen-suche`).
- *Ist das Eingefügte an dieser Stelle gültig?* → Reihenfolge-Audit + echter Turn.
Nebenbefund derselben Prüfung: Auch der Grep brauchte einen zweiten Anlauf. Der
erste (naiver String-Match `user_id=`) meldete 36 Fehlalarme, weil die Codebase die
Argumente ausgerichtet schreibt (`user_id = user_id`). Erst
`\buser_id\s*=` traf. Auch das ist `pattern-vor-namen-suche` — im Kleinen.