Ctrl K
ComunidadGuías…

docs/novaberg-lesson_l_lokale-bindung-vor-nutzung.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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.