Files
swp-02-aufbereitung/README.md
T
irrlichtandClaude Opus 5 7e4c31912c Akteursmodul mit Wikidata-Spiegel
Modul 4 der Spezifikation, dazu das Lexikon, auf das es sich stuetzt.

Drei Schritte, streng getrennt: die NER liefert Kandidatenspannen, das
Lexikon entscheidet den Typ, die Amtstabelle loest Aemter gegen das Datum
des Items auf. Die Trennung ist gemessen begruendet und nicht uebernommen:
de_core_news_lg haelt "Merz" fuer einen Ort, "Landtagswahl" fuer einen Ort
und "Ukraine-Krieg" fuer eine Person. Der NER-Typ steht als ner_typ in der
Nutzlast, entscheidet aber nichts.

Modellwahl gemessen: sm und lg finden fast gleich viele Entitaeten (14249
gegen 14261), aber nicht dieselben - nur 10,7 Prozent der Items bekommen
von beiden dasselbe Ergebnis, der Jaccard ueber alle Spannen liegt bei
0,590. lg findet 170-mal zusaetzlich "AfD" sowie Merz, Putin, Trump,
Kushner, Witkoff. Zeitlich kostet das nichts (7,3 gegen 8,0 Sekunden fuer
den Gesamtbestand), an Plattenplatz 568 MB.

Der Spiegel ist eine Abschrift, keine Auslegung: lexikon/*.json enthaelt
die Wikidata-Antworten unveraendert, mit MANIFEST.json als Beleg. Jede
Ableitung geschieht in lexikon_laden.py. Nur lexikon.py --spiegeln fasst
das Netz an, und es laeuft von Hand - die WDQS ist ein spendenfinanzierter
Dienst, kein Endpunkt fuer eine Pipeline im Viertelstundentakt.

p2-005 legt akteure und amtsinhaber an, dazu zwei Betriebssichten. Keine
Ausschlussbeschraenkung gegen ueberlappende Amtszeiten: Doppelspitzen sind
im deutschen Parteiensystem der Normalfall, und Bremen fuehrt
"Buergermeister" als kollegiales Amt. Die Sicht
amtsinhaber_ueberschneidungen macht Ueberlappungen sichtbar, statt sie zu
verbieten. Amtszeiten sind halboffen - der Tag der Uebergabe gehoert dem
Nachfolger, sonst waere jeder Amtswechsel eine Ueberschneidung.

Messung ueber den Gesamtbestand: 3407 Items in 21,1 Sekunden, 14652
Nennungen, 2889 aufgeloest. Ein Slot-Lauf erzeugt bitgleich dieselbe
Nutzlast wie der Gesamtlauf, 0 Abweichungen. spaCy ist stapelunabhaengig
(Stapelgroesse 64, 7, 1 und rueckwaerts: identische Spannen), der
Kunstgriff mit der festen Tokenlaenge aus dem Embeddingmodul ist hier
nicht noetig.

Zwei Fallen sind in NOTES.md festgehalten: eine geratene QID haengt einen
Eintrag an ein fremdes Objekt (Q157617 ist die Commerzbank, nicht der
Bundesrat), und FILTER (lang(?name) = "de") verliert lautlos alles ohne
deutsches Label - Apple, Microsoft und Meta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NupWEBPzsyohVPG47HsTz8
2026-09-08 08:27:05 +02:00

514 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Wurzelwerk — Phase 2: Aufbereitung
Reichert `raw_items` deterministisch an: Dubletten, Akteure, Orte, Tonalität,
Embeddings. Kein LLM, kein Sampling, keine Netzabfrage zur Laufzeit — das
Ergebnis hängt allein an der Eingabe und der `coder_version`.
Setzt das Ingest-Schema aus [swp-01-ingest](https://git.bnd.wtf/irrlicht/swp-01-ingest)
voraus und schreibt ausschließlich nach `codings`. `raw_items` bleibt unberührt.
Spezifikation: [`phase_02_spec.md`](phase_02_spec.md).
## Dateien
| Datei | Zweck |
|---|---|
| `phase_02_spec.md` | Spezifikation der fünf Module und ihrer Abnahmekriterien |
| `migrations/` | Schemaänderungen, aufsteigend anzuwenden |
| `db.py` | Auflösung von `DATABASE_URL`, Verbindung |
| `check_db.py` | Rauchtest: Anmeldung, Bestand, fehlende Voraussetzungen |
| `normalisierung.py` | Textnormalisierung und Trigramme, gemeinsam für alle Module |
| `version.py` | `coder_version` aus den Komponenten |
| `dubletten.py` | Modul 1: exakte Übernahmen |
| `test_dubletten.py` | Testfälle dazu, ohne Datenbank |
| `revisionen.py` | Modul: nachträglich geänderte Schlagzeilen |
| `test_revisionen.py` | Testfälle dazu, ohne Datenbank |
| `embeddings.py` | Modul 5: Satzvektoren |
| `akteure.py` | Modul 4: Akteursauflösung gegen das Lexikon |
| `test_akteure.py` | Testfälle dazu, ohne Datenbank und ohne spaCy |
| `lexikon.py` | Wikidata spiegeln (`--spiegeln`), von Hand, nicht im Takt |
| `lexikon_laden.py` | Spiegel in `akteure` und `amtsinhaber` laden |
| `lexikon/` | Der Spiegel selbst, versioniert, plus `hand.json` |
| `phase2.py` | Läufer: alle Module in fester Reihenfolge |
| `Containerfile` | Laufzeitumgebung, identisch auf Dev und Prod |
| `deploy/` | systemd-Units und Umgebungsbeispiel für Prod |
## Einrichtung
```sh
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env && chmod 600 .env && $EDITOR .env
.venv/bin/python check_db.py
```
## Migrationen
Aufsteigend anwenden, auf Dev und Prod dieselben Dateien in derselben
Reihenfolge. Welche schon liefen, steht in `schema_migrationen`:
```sh
psql "$ADMIN_DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/p2-000-migrationsbuch.sql
psql "$ADMIN_DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/p2-001-codings-mehrschichtig.sql
# Stand abfragen
psql "$DATABASE_URL" -c 'select name, angewandt_am from schema_migrationen order by name'
```
Die Zählung trägt das Präfix `p2-`, weil die Ingest-Migrationen eine eigene
haben und beide auf dieselbe Datenbank laufen.
Zwei Migrationen brauchen einen Superuser, weil sie Rechte vergeben, die sie
selbst noch nicht haben: `p2-002` (`create extension vector` — pgvector ist
nicht als `trusted` gekennzeichnet) und `p2-003` (Besitzerwechsel). Im
Dev-Container über den lokalen Socket:
```sh
podman exec -i postgres psql -U postgres -d wurzelwerk -v ON_ERROR_STOP=1 -f - < migrations/p2-003-eigentuemer.sql
```
Alle übrigen laufen über `DATABASE_URL`. Seit `p2-003` gehören die Tabellen
der Rolle `wurzelwerk` statt `postgres` — vorher scheiterte jedes `alter table`
mit `must be owner of table codings`, und die Datenbank war damit der einzige
Ausreißer auf der Instanz: `gitea` und `hedgedoc` besitzen ihre Tabellen
vollständig. Die Erweiterungen selbst (`pg_trgm`, `vector`) bleiben beim
Superuser, wo sie hingehören.
## Voraussetzungen an die Datenbank
Das Embedding-Modul braucht `pgvector`. Im offiziellen Image `postgres:latest`
ist die Erweiterung nicht enthalten; der Postgres-Container läuft deshalb auf
`docker.io/pgvector/pgvector:pg18-trixie` — derselbe Build wie das offizielle
Image (`18.6-1.pgdg13+2`, Debian 13, glibc 2.41), nur mit pgvector zusätzlich.
Der Tag ist mit Bedacht gewählt. Auf derselben Instanz liegen weitere Dienste;
ein Wechsel der Hauptversion oder der Distribution wäre ein Ausfall, ein
Wechsel der glibc ein Collation-Problem in den Textindizes aller Datenbanken
(`datlocprovider = 'c'`). `pg18-trixie` schließt beides aus und zieht nur
Minor-Updates nach. Die versionierten Tags (`0.8.4-pg18-trixie`) taugen dafür
nicht: sie hängen der Postgres-Minor-Version hinterher und hätten 18.6 auf
18.4 zurückgedreht.
Das Anlegen der Erweiterung in `wurzelwerk` ist davon getrennt und geschieht
in `p2-002`, nicht beim Containerstart.
Ein Vektor belegt 4100 Byte. Bei den derzeit rund 1400 Items pro Tag sind das
etwa 2 GB im Jahr, mit HNSW-Index eher das Doppelte — tragbar. Die in der
Spezifikation angenommenen 200–400 Items je Slot wären rund 38.000 am Tag und
damit etwa 55 GB im Jahr, **je `coder_version`**. Ein Modellwechsel verdoppelt
den Bedarf, bis der alte Bestand gelöscht ist. Sollte es eng werden, halbiert
`halfvec(1024)` den Platz.
## Grenzen des Datensatzes
Volltext liefert nur ein Teil der Quellen — Ingest liest RSS, und viele Häuser
geben dort keinen `content:encoded` heraus (Stand 7.9.2026: faz 311/489,
heise 174/196, aber derstandard, welt, dlf, taz, sz jeweils 0). Das ist keine
Lücke, die sich schließen lässt, sondern eine Eigenschaft der Quellen. Module
müssen auf Titel und Teaser allein tragfähig bleiben und dürfen die
Vergleichsbasis nicht davon abhängig machen, ob ein Item zufällig Volltext hat.
## Modul 1: Dubletten
```sh
.venv/bin/python dubletten.py --backfill # Gesamtbestand
.venv/bin/python dubletten.py --slot 2026-09-07T08:00 # ein Poll-Slot
.venv/bin/python dubletten.py --backfill --trocken --stichprobe 200
.venv/bin/python dubletten.py --backfill --basis titel+teaser # die andere Basis
.venv/bin/python -m unittest test_dubletten
```
Normalisierung, SimHash über Wort-Trigramme, Kandidaten über gemeinsame
SimHash-Bänder, Bestätigung über Jaccard, Union-Find. Jedes verarbeitete Item
bekommt eine Zeile, auch ein Einzelstück — sonst ließe sich „keine Dublette"
nicht von „nicht verarbeitet" unterscheiden. Unveränderte Zeilen werden nicht
angefasst, `kodiert_am` bleibt also stehen, wenn sich nichts geändert hat.
Gesamtbestand (3407 Items) 0,2 s, ein Slot 0,4 s. Das Zeitbudget von 3 Minuten
je Slot ist für dieses Modul kein Thema.
**Ergebnis auf dem Bestand vom 4.–7.9.2026:** 135 Gruppen mit 290 Mitgliedern,
davon 63 Gruppen mit 143 Items über mehrere Häuser hinweg — das ist der
eigentliche Zweck. Größte Gruppe: fünf Häuser mit derselben Zeile über den
Start der Isar-Aerospace-Rakete.
### Vier Abweichungen von der Spezifikation
**Vergleichsbasis ist der Titel, nicht Titel + Teaser.** Gemessen wurde beides.
Über Titel + Teaser findet das Verfahren auf diesem Bestand *keine einzige*
hausübergreifende Übernahme; über den Titel allein 63 Gruppen. Grund: die
Häuser übernehmen die Agenturüberschrift wörtlich und schreiben den Teaser
selbst, der Teaser verdünnt also genau das Signal, das trägt. Die Zahlen
stehen in `NOTES.md`. `--basis titel+teaser` schaltet auf die alte Basis
zurück; sie ergibt eine eigene `coder_version` und überschreibt nichts.
**Das Ressortkürzel vor dem Titel wird abgetrennt.** `Raumfahrt: Deutsche
Rakete …` und `Deutsche Rakete …` sind dieselbe Meldung, `Notfälle:` und
`Flugverkehr:` stehen vor derselben dpa-Zeile. Die Regel ist eng gefasst:
höchstens vier Wörter vor dem Doppelpunkt, mindestens fünf danach — sonst
verlöre `Habeck: Wir haben uns geirrt` seine Aussage.
**Die Gruppen-UUID kommt vom Leitartikel, nicht aus der Mitgliedermenge.**
Sonst änderte ein später eintreffendes Mitglied die Kennung einer Gruppe, die
Phase 3 bereits kodiert hat. Der Leitartikel ist das älteste Mitglied; ein
neues Mitglied ist zwangsläufig jünger und verschiebt das Minimum nicht.
**Dasselbe Haus an verschiedenen Tagen wird nicht gepaart.** Wiederkehrende
Formate — „tagesschau in 100 Sekunden", „Wetter", „Die Nachrichten" — sind
zeichengleich, aber jeweils ein eigener Vorgang. Ohne diese Regel wuchsen sie
transitiv zu einer Gruppe aus 19 Sendungen über drei Tage zusammen.
### Warum ein Slot-Lauf seinen Rand wachsen lässt
Die Items eines Slots brauchen ihre Partner im Datumsfenster, diese Partner
ihrerseits ihr eigenes Fenster. Ein fester Rand genügt trotzdem nicht:
Übernahmen bilden Ketten — dieselbe Schlagzeile erscheint am Montag bei einem,
am Dienstag beim zweiten, am Mittwoch beim dritten Haus. Wird eine solche
Kette am geladenen Rand abgeschnitten, fällt die Gruppe anders aus als im
Backfill.
Der Lauf lädt deshalb, gruppiert, und lädt erneut, solange eine Gruppe bis an
den Rand reicht. Geschrieben wird nur der innere Bereich, in dem jedes Item
seine vollständige Nachbarschaft gesehen hat.
Nachgewiesen: nach einem Backfill ändern beliebig viele Slot-Läufe nichts mehr
(`neu 0, geändert 0`), und ein anschließender Backfill ebenso wenig. Live-Lauf
und Neuaufbau liefern dasselbe Ergebnis — Invariante 2 und 4.
### Was bleibt
Auf dem gemessenen Tag findet das Verfahren 42 von 45 hausübergreifenden
Paaren. Die drei fehlenden gehen am SimHash-Bandvorfilter verloren, nicht an
den Schwellen — bei sechs bis acht Trigrammen je Titel ist SimHash grob. Ein
Trigramm-Index als zweiter Kandidatenweg würde sie einfangen; das ist nicht
gebaut, weil die Spezifikation Präzision über Recall stellt.
Die Stichprobe (`--stichprobe 200`) schreibt die Paare zum Prüfen von Hand
heraus, die zweifelhaftesten zuerst. Sie enthält Titel im Klartext und ist
deshalb nicht eingecheckt.
## Modul: Revisionen
Nicht in der Spezifikation. Der Befund fällt aus einer Eigenschaft der
Phase-1-Umsetzung ab: Ingest schreibt jede geänderte Fassung eines Items nach
`raw_item_versionen` fort, statt sie zu überschreiben. Damit steht in der
Datenbank, was sonst nur mit eigener Erhebung zu bekommen ist — welches Haus
seine Schlagzeile nachträglich umschreibt, und wie.
```bash
python3 revisionen.py --backfill
python3 revisionen.py --slot 2026-09-07T08:00
python3 revisionen.py --backfill --trocken --stichprobe 140
```
Das Modul braucht kein Modell, kein Lexikon und keinen Netzzugriff — nur einen
Wortvergleich zweier Fassungen desselben Artikels (`difflib`).
### Die acht Einstufungen
Zwischen zwei aufeinanderfolgenden Fassungen, von der engsten Prüfung zur
weitesten:
| Einstufung | Bedeutung |
|---|---|
| `unveraendert` | nichts geändert |
| `formal` | nur Zeichensetzung, Anführungszeichen, Schreibung |
| `tippfehler` | ein Wort korrigiert (»Waldesaster« → »Wahldesaster«) |
| `kopfwechsel` | nur der Teil vor dem Doppelpunkt |
| `erweiterung` | nur ergänzt — meist eine neue Tatsache |
| `kuerzung` | nur gestrichen |
| `umformulierung` | beides, die Aussage steht noch |
| `neufassung` | die Schlagzeile ist ausgetauscht |
Dazu `zurueckgenommen`, das nur auf Item-Ebene vorkommt: zwischendurch geändert,
am Ende steht wieder die erste Fassung. Genau ein Fall im Bestand — die FAZ
setzte ein »dessen« ein und strich es wieder.
`art` und `aehnlichkeit` vergleichen die **erste mit der letzten** Fassung —
was ist aus der Schlagzeile geworden. `arten` hält jeden einzelnen Schritt
fest — auf welchem Weg.
### Fortschreibungen müssen abziehbar sein
Ein Liveticker wechselt seine Überschrift stündlich, ohne dass eine Redaktion
ihre Darstellung revidiert hätte. Als »Neufassung« gezählt überdeckt er genau
den Befund, um den es geht. Zwei Merkmale in der Nutzlast machen ihn abziehbar:
* `ticker_markiert` — der Titel nennt sich selbst so (»Liveticker«,
»Newsblog«, »Liveblog«, »Livestream«, »Live:«). Trifft 10 Items.
* `kopf_stabil` — der Text vor dem ersten Doppelpunkt blieb über alle Fassungen
derselbe. Zusammen mit `art = neufassung` ist das die Bauform einer
Fortschreibung: feste Rubrik, ausgetauschter Stand. Trifft 33 Items.
Absichtlich zwei Merkmale statt einer Kategorie »Ticker«: das erste ist eine
Selbstauskunft des Hauses, das zweite eine Beobachtung am Satzbau. Sie zu
einem Urteil zu verschmelzen würde eine Sicherheit behaupten, die keins von
beiden hat.
Auf `+++`-Kästen wird bewusst nicht geprüft: das Handelsblatt setzt
»+++ USA +++:« als Ressortmarke, nicht als Tickerkennzeichen.
### Zwei Kopfbegriffe, mit Absicht
`normalisierung.ressort_teilen()` prüft, ob der Kopf höchstens vier Wörter
misst und dahinter noch ein vollständiger Satz steht. Diese Vorsicht ist nötig,
wo der Kopf *abgeschnitten* wird — »Habeck: Wir haben uns geirrt« darf seinen
nicht verlieren.
`revisionen.teilen()` verzichtet darauf, weil hier nichts abgeschnitten, nur
verglichen wird. Die Wortgrenze schadete dort: »CDU-Desaster in Sachsen-Anhalt:«
hat drei Wörter, »CDU-Desaster bei Wahl in Sachsen-Anhalt:« fünf, und der
Wechsel zwischen beiden fände sonst nicht statt.
`kopfwechsel` ist damit eine Aussage über den Satzbau, nicht über das Gewicht:
bei »Fußball-Bundesliga: X« → »X« wechselt eine Rubrik, bei »Habeck: X« →
»Scholz: X« der Sprecher. Welcher Fall vorliegt, steht in `hinzu` und
`entfernt`.
### Kein Rand nötig
Anders als beim Dublettenmodul hängt eine Zeile allein an den Fassungen ihres
eigenen Items — keine Nachbarschaft, kein Datumsfenster, kein mitzuladender
Rand. Ein Slot-Lauf lädt die Items, von denen im Slot eine neue Fassung
eintraf, samt ihrer *älteren* Fassungen, und rechnet für sie dasselbe wie ein
Backfill.
Nachgewiesen über den ganzen Bestand: 106 Slot-Läufe nacheinander erzeugen für
alle 3407 Items bitgleich dieselbe Nutzlast wie ein Backfill — 0 fehlend,
0 überzählig, 0 abweichend (Invariante 2 und 4).
### `konfidenz` bleibt leer
Die Einstufung ist regelbasiert und trifft zu oder nicht; eine Zahl daneben
behauptete eine Wahrscheinlichkeit, die das Verfahren nicht kennt. Das Maß der
Änderung steht als `aehnlichkeit` in der Nutzlast, wo es hingehört.
### Wortlisten in der Nutzlast
`hinzu` und `entfernt` halten die geänderten Wörter fest, auf je zwölf gekappt
(`gekappt` sagt, ob gekürzt wurde). Ohne sie wäre die Angabe »umformulierung«
nicht nachprüfbar; aus zwei Listen von je einem Dutzend Wörtern lässt sich
keine Schlagzeile zurückbauen. Der Constraint `codings_kein_volltext` bleibt
gewahrt — ein Testfall prüft es mit.
## Container
Phase 2 läuft auf Prod in einem rootless Podman-Container, der selbst rootless
liegt. Für die Module ist das folgenlos: Encoder-Inferenz ist reine
Userspace-Rechnung — keine Geräteknoten, keine Kernelmodule, keine
privilegierten Syscalls, kein GPU-Durchgriff.
```bash
podman build -t wurzelwerk-p2 .
podman volume create wurzelwerk-modelle
podman run --rm --network=pasta \
--volume wurzelwerk-modelle:/cache:U \
--memory=8g --shm-size=1g \
--env DATABASE_URL="postgresql://wurzelwerk:…@host.containers.internal:5432/wurzelwerk" \
localhost/wurzelwerk-p2 phase2.py --aufholen
```
`host.containers.internal` zeigt aus dem Container auf den Host, wo Postgres
seinen Port veröffentlicht. Das `:U` am Volume ist nicht optional — der
Container läuft als UID 10001, ein frisch angelegtes Volume gehört root im
User-Namespace, und ohne die Umschreibung scheitert der erste Schreibzugriff
auf den Modell-Cache.
### Vier Dinge, die der Container ausdrücklich braucht
* **`OMP_NUM_THREADS`.** PyTorch liest die CPU-Zahl am Host ab, nicht die
cgroup-Quota; ohne feste Zahl startet es mehr Threads als es Kontingent hat.
Wichtiger noch: die Threadzahl bestimmt die Reihenfolge der
Gleitkommaadditionen und damit die letzten Stellen jedes Vektors. Sie steht
deshalb in der `coder_version`.
* **Ein Volume für `/cache`.** 2,2 GB Modellgewichte im Container-Layer wären
bei jedem Neubau weg.
* **`--memory`.** Damit ein Ausrutscher den Host nicht mitnimmt.
* **`--shm-size`.** Podmans Vorgabe von 64 MB reicht nicht, sobald je
Worker-Prozesse dazukommen.
### torch aus dem CPU-Index
`pip install torch` von PyPI zieht mehrere Gigabyte CUDA-Bibliotheken nach, die
auf dem Server nichts ausrichten können — der SM750 ist ein Anzeigechip. Das
Containerfile installiert deshalb ausdrücklich aus
`https://download.pytorch.org/whl/cpu`, in einer eigenen Schicht vor dem
Kopieren der Anforderungsdateien, damit 196 MB nicht bei jeder Änderung neu
geladen werden.
### Nachgewiesene Portabilität
Die regelbasierten Module liefern im Container (Debian trixie, Python 3.13)
für alle 3407 Items bitgleich dieselbe Nutzlast wie auf der
Entwicklungsmaschine (Fedora, Python 3.14) — `neu 0, geändert 0, unverändert
3407` bei Dubletten wie Revisionen.
### Betrieb
`deploy/` enthält `wurzelwerk-p2.service` und `.timer` als Vorlage. Kein
Quadlet: Phase 2 ist kein Dienst, der läuft, sondern ein Auftrag, der fällig
wird. Der Timer feuert versetzt zum 15-Minuten-Raster von Phase 1
(`*:03,18,33,48`), damit der Slot vollständig in `raw_items` steht, bevor
daraus kodiert wird. `phase2.py --aufholen` arbeitet alle Slots nach, denen
eine Kodierung fehlt — ein ausgefallener Lauf holt sich beim nächsten Mal
selbst ein.
Zugangsdaten stehen in `~/.config/wurzelwerk/p2.env` (chmod 600), nicht im
Repo und nicht in der Unit. Vorlage: `deploy/p2.env.beispiel`.
## Modul 5: Embeddings
```bash
python3 embeddings.py --backfill --index
python3 embeddings.py --slot 2026-09-07T08:00
python3 embeddings.py --nachweis 64 # Determinismus messen, nichts schreiben
```
`intfloat/multilingual-e5-large`, 1024 Dimensionen, MIT-Lizenz. Der Vektor
liegt in `item_embeddings`, die Coding-Zeile der Schicht `embedding` hält nur
die Kennung — ein Vektor in JSONB wäre weder indizierbar noch platzsparend.
Kein LLM: ein Encoder. Text hinein, Vektor heraus — kein Prompt, keine
Sampling-Temperatur, keine Textausgabe, feste Gewichte, nach dem ersten Laden
kein Netzzugriff (`HF_HUB_OFFLINE=1`, Modell lädt in 2 s aus dem Volume).
### Warum feste Tokenlänge das Kernstück ist
Ein Encoder rechnet stapelweise. Füllt man nur bis zur längsten Zeile des
Stapels auf — was jede Bibliothek von sich aus tut —, ändert sich die Form der
Matrizen mit der Zusammensetzung des Stapels und damit die Reihenfolge der
Gleitkommaadditionen. Ein Slot-Lauf über 31 Items lieferte dann andere letzte
Stellen als ein Backfill über 3407, und die Zusage »Live-Lauf gleich Neuaufbau«
wäre hin.
`embeddings.py` füllt deshalb **jede** Zeile auf feste 128 Token auf. Jeder
Stapel hat dieselbe Form, eine Zeile hängt nicht mehr von ihren Nachbarn ab.
Gemessen (`--nachweis 64`, im Container):
```
bitgleich einzeln: 64/64 groesste Abweichung 0.00e+00
bitgleich rueckwaerts:64/64 groesste Abweichung 0.00e+00
```
Dieselben Zeilen einzeln kodiert, im vollen Stapel und in umgekehrter
Reihenfolge — bitidentisch. Deshalb wird `transformers` direkt benutzt und
nicht `sentence-transformers`: letzteres kapselt genau diese Stelle weg (und
zöge scipy und scikit-learn nach, die hier nichts tun).
128 Token ist gemessen, nicht geraten: Median 62, p95 90, p99 107. Drei von
3407 Items reißen die Grenze, davon zwei Sendungsabläufe (»tagesschau 20:00
Uhr«), die alle Themen der Sendung auflisten. Der Lauf meldet die Zahl und
warnt erst oberhalb von einem Prozent.
### Was in die `coder_version` eingeht
Modell **und Modellrevision** (`3d7cfbda…`), Tokenlänge, Füllart, Pooling,
Normierung, dtype, Stapelgröße, **Threadzahl** und **torch-Version**. Die
letzten beiden, weil sie die Reihenfolge der Summen bzw. die gewählten Kernel
bestimmen. Der Hash sagt damit nicht nur, welches Modell gemeint war, sondern
welche Gewichte in welcher Umgebung gerechnet haben.
### Was die Vektoren leisten
Sie finden dieselbe Geschichte in anderer Formulierung — genau das, was das
Dublettenmodul nicht kann. Paare über 0.94 Kosinus, die Modul 1 nicht
zusammengeführt hat:
```
0.992 [zeit] Michael Mendl: Schauspieler Michael Mendl mit 82 Jahren gestorben
[faz] Im Alter von 82 Jahren: Charakterdarsteller Michael Mendl gestorben
0.991 [tagesschau] Friedensbemühungen - Trump-Gesandte reisen nach Moskau und Kiew
[welt] Trump-Gesandte in Moskau und Kiew erwartet
```
Phase 2 clustert daraus nichts. Der Vektor ist Vorfilter für die
Kandidatengenerierung in Phase 3b (Abschnitt 7 der Spezifikation).
### Laufzeit und Platz, gemessen
3407 Items in **683 s** bei 4 Threads (5 Items/s). Beim jetzigen Aufkommen von
durchschnittlich 32 Items je Slot sind das ~7 s, beim größten Slot des
Bestands (812 Items) ~160 s — bei einem 15-Minuten-Raster unkritisch.
`item_embeddings` belegt 19 MB Heap für 3407 Vektoren, der HNSW-Index weitere
26 MB. Der Index kostet also mehr als die Daten; die Faustregel »mit HNSW eher
das Doppelte« aus dem Abschnitt zum Platzbedarf ist damit bestätigt.
## Modul 4: Akteure
Löst Personen, Organisationen, Parteien, Staaten und Ämter auf stabile IDs
auf. Kein Netzzugriff zur Laufzeit, kein Modell, das etwas benennt — die NER
liefert nur, *wo* etwas steht, das Lexikon entscheidet, *was* es ist.
```
lexikon.py --spiegeln # Wikidata abfragen, von Hand, selten
lexikon_laden.py # Spiegel in die Tabellen
akteure.py --backfill # Gesamtbestand kodieren
akteure.py --slot '<ts>' # ein Slot
```
### Der Spiegel ist eine Abschrift, keine Auslegung
`lexikon/*.json` enthält, was Wikidata geantwortet hat — unverändert, mit
`MANIFEST.json` als Beleg (Stand, Zeilenzahl, Hash der Abfrage). Jede
Ableitung daraus — IDs, Rollen, Aliase, Amtszeiten — geschieht in
`lexikon_laden.py`. Wer eine Zuordnung ändern will, ändert den Lader und
lädt neu; Wikidata wird dafür nicht ein zweites Mal befragt.
Das ist kein Selbstzweck. Die Wikidata Query Service ist ein
spendenfinanzierter öffentlicher Dienst, und eine Pipeline, die alle
15 Minuten dagegenläuft, wäre Schmarotzertum.
### Warum das Lexikon den Typ bestimmt, nicht das Modell
`de_core_news_lg` hält `Merz` für einen Ort, `Landtagswahl` für einen Ort und
`Ukraine-Krieg` für eine Person. Die NER-Typen sind deshalb nur
Herkunftsvermerk; sie stehen in der Nutzlast als `ner_typ`, entscheiden aber
nichts. Für die Geokodierung bleiben sie als Vorfilter nützlich.
### Was eine Auflösung wert ist
| Methode | Konfidenz | Bedeutung |
|---|---|---|
| `alias` | 0,95 | volle Namensform oder Alias aus dem Lexikon |
| `alias_ohne_anrede` | 0,90 | nach Abzug von »US-Präsident«, »Bundesminister« |
| `amt` | 0,90 | Amt, gegen `pubdate` auf eine Person aufgelöst |
| `nachname` | 0,80 | Nachname, im Lexikon eindeutig |
| `fuzzy` | 0,60 | Editierdistanz, längenabhängig begrenzt |
Bei Mehrdeutigkeit entscheidet der Kontext (wird die Partei eines Kandidaten
im selben Item genannt?), und wenn auch der nicht reicht, bleibt die Nennung
**unaufgelöst**. Ein Münzwurf wäre hier der schlimmere Fehler, weil er in
der Zeitreihe nicht auffällt.
### Ämter sind zeitabhängig, und das ist der Punkt
`amtsinhaber` hält fest, wer wann welches Amt hatte. »Der Kanzler« löst am
1. März 2023 auf Scholz auf und am 7. September 2026 auf Merz. Die Intervalle
sind halboffen: der Tag der Übergabe gehört dem Nachfolger. Ohne diese
Konvention wäre jeder Amtswechsel eine Überschneidung, weil Wikidata den
Übergabetag bei beiden einträgt.
### Keine Ausschlussbeschränkung gegen Überschneidungen
Sie wäre falsch. Doppelspitzen sind im deutschen Parteiensystem der
Normalfall, und Bremen führt »Bürgermeister« als kollegiales Amt mit mehreren
gleichzeitigen Inhabern. Gleichzeitig *ist* eine Überschneidung beim
Kanzleramt ein Datenfehler. Die Unterscheidung hängt am Amt, nicht an der
Zeile, und lässt sich in einer Beschränkung nicht ausdrücken — also macht die
Sicht `amtsinhaber_ueberschneidungen` sie sichtbar, statt sie zu verbieten.
### Handpflege
`lexikon/hand.json` ergänzt, was durch jede Spiegelabfrage fällt: die NATO
(bei Wikidata ein Militärbündnis, keine internationale Organisation), der
Bundestag (ein Parlament), Apple und Microsoft (haben kein deutsches Label).
Jeder Eintrag trägt eine Begründung. QIDs gehören **geprüft** hinein — eine
geratene QID hängt den Eintrag an ein fremdes Objekt.
Die Sicht `akteure_unaufgeloest` zählt aus, was die NER fand und das Lexikon
nicht kannte. Das ist der laufende Handarbeitsposten der Phase.