docs/novaberg-lesson_l_paket-init-zyklus.md
Rama master · réplica de lectura del repositorio original.
# Novaberg — Lesson: Paket-`__init__`-Zyklus
**Projekt:** Novaberg — The Nova Anima Resonance System
**Dokument:** Lesson — Ein Symbol-Import aus einem Submodul lädt das Paket-`__init__`; ein zustandsloser Util gehört nicht hinter ein `__init__`, das schwere Abhängigkeiten initialisiert
**Stand:** 23. Mai 2026, Chat 94
**Pfad:** novaberg/docs/novaberg-lesson_l_paket-init-zyklus.md
**Kategorie:** Architektur — Import-Topologie und Paket-Struktur
**Schwester-Lessons:** `novaberg-lesson_l_pattern-vor-namen-suche.md`, `novaberg-lesson_l_async-bruecken.md`, `novaberg-lesson_l_loop-binding.md`
**Konzept-Bezug:** `novaberg-microservice-modell-queue_k.md`
**Handbuch-Bezug:** `DEVELOPER_HANDBOOK.md`
---
## 1. Der Vorfall
Der Kahlschlag in Chat 94 (Block 3 Teil 2) war reiner Code-Tod — verhaltensneutral, durch viele kleine pattern-verifizierte Schnitte abgesichert. Greps grün, Schnitte sauber. Weil die Testsuite im Container nicht lief (`pytest` fehlt), wurde als Ersatz-Verifikation ein Import-Smoke-Test über alle berührten Module gefahren: schlicht jedes betroffene Modul importieren und prüfen, ob es lädt.
Er lud nicht:
```
ImportError: cannot import name 'AnthropicProvider' from partially initialized
module 'services.llm_provider' (most likely due to a circular import)
```
Ein zentrales Modul — `llm_provider` — war nicht mehr importierbar. Der Server war nicht lauffähig. Und das, obwohl jeder einzelne Lösch-Schnitt verhaltensneutral war und jeder Grep grün.
Der Bug stammte nicht aus dem Kahlschlag selbst. Er stammte aus einem früheren Edit derselben Welle — der Postprocess-Konsolidierung, bei der `OllamaProvider.chat` und `AnthropicProvider.chat` von ihren privaten JSON-Helfern auf die zentralen Funktionen in `postprocess.py` umgestellt wurden. Dafür war eine neue Import-Zeile nötig:
```python
from services.model_services.postprocess import (
clean_json_response,
deduplicate_repetition,
repair_truncated_json,
)
```
Eine harmlose Zeile. Sie holt drei zustandslose Funktionen aus einem Modul, das selbst nur `json` und `re` importiert. Nichts daran sah gefährlich aus.
## 2. Die Ursache
Der Zyklus war genau eine Kante lang:
```
llm_provider.py:23 from services.model_services.postprocess import ...
↓ Python MUSS zuerst das Paket laden → model_services/__init__.py
model_services/__init__.py:19 from services.model_services.registry import model_service
↓ Python lädt registry.py
registry.py:37 from services.llm_provider import AnthropicProvider, LLMProvider, OllamaProvider
↑ ImportError — llm_provider ist noch in seiner eigenen Init bei Zeile 23,
die Provider-Klassen (ab Zeile 72) sind noch nicht definiert
```
Der entscheidende, leicht zu übersehende Mechanismus steht in Schritt 1: **Ein `from paket.submodul import x` importiert nicht nur das Submodul — es führt zuerst das `__init__.py` des Pakets aus.** Das ist Python-Standardverhalten, kein Sonderfall. Wer ein Submodul anfasst, zahlt immer den vollen Paket-Init-Preis.
`postprocess.py` selbst war vollkommen sauber — keine Rück-Importe, nur Stdlib. Aber es lag *innerhalb* des Pakets `model_services`, dessen `__init__.py` beim Laden die Registry hochzieht, und die Registry braucht (völlig zu Recht) die Provider-Klassen aus `llm_provider`. Solange `llm_provider` selbst nichts aus `model_services` importierte, lief das in eine Richtung und war zyklusfrei. Die neue Import-Zeile schloss den Ring: Jetzt importierte `llm_provider` aus einem Paket, dessen Init auf `llm_provider` zurückzeigt.
Die `registry`→`llm_provider`-Kante war dabei nicht der Fehler. Sie ist eine legitime, beabsichtigte Abhängigkeit — eine Factory muss die Klassen kennen, die sie baut. Der Fehler war, einen zustandslosen Util-Helfer in ein Paket zu legen, dessen `__init__` Lifecycle-Code initialisiert, und ihn dann von der Schicht zu importieren, die dieser Lifecycle-Code seinerseits braucht.
## 3. Die strukturelle Lösung
Drei Wege standen zur Wahl:
1. **Lazy-Import in `llm_provider.chat`** — die Import-Zeile in die Methoden ziehen, die die Helfer nutzen. Trifft die selbst eingezogene Kante, aber verteilt einen Import auf zwei Hot-Path-Methoden und verschleiert die Abhängigkeit.
2. **Lazy-Import in `registry`** — die Provider-Importe in die Factory-Funktion ziehen. Funktioniert, aber verbiegt eine *gesunde, beabsichtigte* Top-Level-Abhängigkeit, um einen Zyklus zu brechen, der woanders entstand. Wer später `registry` liest, sieht einen grundlosen Lazy-Import.
3. **Die Datei aus dem Paket lösen** — `postprocess.py` per `git mv` von `services/model_services/postprocess.py` nach `services/postprocess.py`, als flacher Top-Level-Util neben `llm_provider.py`.
Option 3 wurde gewählt, weil sie als einzige die *Wurzel* behebt statt sie zu verstecken. Ein zustandsloser Util ohne Lifecycle gehört nicht hinter ein Paket-`__init__`, das die halbe Service-Schicht initialisiert. Gegencheck vor dem Move: `services/__init__.py` war leer (0 Bytes). Damit triggert `from services.postprocess import ...` nur das leere `services/__init__.py` und `postprocess.py` selbst — kein Pfad zurück zu `model_services`, `registry` oder `llm_provider`. Der Zyklus ist nach dem Move strukturell unmöglich, nicht bloß verlagert.
```bash
git mv novaberg/server/services/model_services/postprocess.py \
novaberg/server/services/postprocess.py
# + vier Import-Pfade umgestellt (llm_provider, chat_worker, background_worker, ein Docstring)
```
Der Import-Smoke-Test, der den Bug fand, war anschließend grün: `alle betroffenen Module importieren sauber`.
## 4. Das Prinzip
### Ein Submodul-Import zahlt den Paket-Init-Preis
`from paket.submodul import x` führt das `paket/__init__.py` aus, bevor `x` verfügbar wird. Was im `__init__` steht, erbt jeder, der irgendein Submodul des Pakets importiert — auch wenn das Submodul selbst harmlos ist.
Daraus folgt eine Platzierungs-Regel: **Zustandslose Utilities gehören nicht in ein Paket, dessen `__init__` schwere Abhängigkeiten oder Lifecycle-Code initialisiert.** Ein reiner Helfer (nur Stdlib, keine Rück-Importe, kein Zustand) sollte so flach liegen, dass sein Import nichts außer ihm selbst lädt. Liegt er hinter einem `__init__`, das Provider, Worker, Registries hochzieht, dann zwingt jeder Importeur des Helfers die gesamte Maschinerie hochzufahren — und riskiert genau dann einen Zyklus, wenn der Importeur selbst Teil dieser Maschinerie ist.
Die zweite, allgemeinere Lehre betrifft die **Verifikations-Ebene**: Greps finden tote Symbole, aber keine Import-Topologie. Ein verhaltensneutraler Code-Tod-Commit kann jeden Grep bestehen und trotzdem den Import-Graphen brechen — durch eine Kante, die ein *früherer* Edit derselben Welle eingezogen hat. Der Import-Smoke-Test (`python -c "import …"` über alle berührten Module plus `main`) ist die Mindest-Verifikation, die diese Klasse von Fehlern fängt. Er ist kein Ersatz für eine Testsuite, aber er ist die untere Schranke: Wenn ein Modul nicht einmal importiert, ist jede weitere Aussage über sein Verhalten gegenstandslos.
## 5. Die Konsequenz
**Erstens:** Neue zustandslose Utilities werden flach unter `services/X.py` angelegt, nicht innerhalb eines Subpakets mit aktivem `__init__`. `services/postprocess.py` ist jetzt das Muster — neben `llm_provider.py`, `event_consumer.py`, `shadow_delivery.py`.
**Zweitens:** Nach jedem Edit, der eine neue Import-Kante zwischen Modulen einzieht — besonders zwischen einer Schicht und einem Paket, das auf diese Schicht zurückzeigt —, läuft ein Import-Smoke-Test über die berührten Module plus `main`. Greps allein genügen nicht; sie sehen die Topologie nicht.
**Drittens:** Diese Lesson dient als Archiv. Wer in einem Jahr fragt, warum `postprocess.py` flach unter `services/` liegt und nicht bei den anderen Worker-Modulen in `services/model_services/`, liest hier nach. Die Platzierung wirkt inkonsistent, bis man sich an den Zyklus erinnert, den sie verhindert.
## 6. Der Preis
Ein nicht-lauffähiger Server zwischen dem Postprocess-Edit und seiner Entdeckung. Der Bug wurde nicht im Moment seines Entstehens bemerkt — er lag latent, weil zwischen dem Edit und dem Import-Test mehrere weitere Kahlschlag-Schnitte lagen, alle mit grünen Greps. Hätte es keinen Import-Smoke-Test gegeben (etwa weil man sich auf die grünen Greps verlassen hätte), wäre der Server erst beim nächsten echten Start gecrasht — möglicherweise erst Blöcke später, mit dann größerem Suchraum, weil mehrere Wellen dazwischen lägen.
Der eigentliche Wert lag im billigen Fund: Der Import-Test kostete eine Zeile pro Modul und einen Terminal-Aufruf. Er machte einen latenten, server-tötenden Topologie-Bug sofort sichtbar — an genau der Stelle, an der die Greps blind waren. Die Lehre ist nicht „Zyklen sind schlimm" (das weiß jeder), sondern: Die Verifikations-Ebene muss zur Fehler-Klasse passen. Ein Code-Tod-Commit braucht Greps *und* einen Import-Test, weil Löschen das eine Risiko (übersehenes totes Symbol) und ein begleitender Struktur-Edit das andere (gebrochene Topologie) trägt.
---
*Diese Lesson ist Archiv. Wenn Aspekte zu ergänzen sind, wird eine neue Lesson geschrieben, nicht diese hier überarbeitet. Lessons = Gegenwart-mit-Datum, nicht Gegenwart-evolvierend.*
→ Schwester-Lessons: `novaberg-lesson_l_pattern-vor-namen-suche.md`, `novaberg-lesson_l_async-bruecken.md`, `novaberg-lesson_l_loop-binding.md`
→ Konzept-Dokument: `novaberg-microservice-modell-queue_k.md`