/** * Die Kanonform für die Suche – deutsche Schreibweisen auf einen Nenner. * * ## Warum das nötig ist und keine Bibliothek es abnimmt * * Wer „Büchse“ sucht, tippt je nach Tastatur und Gewohnheit `Büchse`, * `Buechse` oder `Buchse`; wer „Schießstätte“ sucht, schreibt `Schiessstätte` * oder `Schiesstaette`. Alle diese Formen müssen dasselbe finden. Nachgemessen * am amtlichen Katalog: 91,7 Prozent der Fragen enthalten Umlaute oder ß. * * SQLite FTS5 kann das nicht leisten – und zwar keiner seiner Tokenizer: * `unicode61` faltet Diakritika, aber kein ß (`schiessen` → null Treffer, * `schießen` → 52); `trigram` faltet überhaupt nichts. Ein ICU-Tokenizer ist * im mitgelieferten Prebuild nicht enthalten. Die Faltung muss also ohnehin * von Hand geschrieben werden – und wenn sie geschrieben ist, trägt eine * Volltextmaschine nichts mehr bei. Die Begründung im Ganzen steht in * `docs/entscheidung-volltextsuche.md`. * * ## Die Reihenfolge der Schritte ist nicht beliebig * * 1. Kleinschreibung. * 2. ä→ae, ö→oe, ü→ue, ß→ss. **Vor** dem Laufkollaps: sonst faltete * „Schießstätte“ (drei s nach der Ersetzung) anders als die Eingabe * „Schiesstätte“ (zwei s), und die Funktion wäre nicht idempotent. * 3. Kombinierende Zeichen entfallen – damit trifft ein zerlegt * gespeichertes „ä“ (a + U+0308) dasselbe Ergebnis wie das * zusammengesetzte. Beide enden bei `a`. * 4. Laufkollaps: mehrfach gleiche Buchstaben werden zu einem. * 5. ae→a, oe→o, ue→u. Erst hier, damit „Buechse“ und „Büchse“ zusammenfallen. * * ## Der Laufkollaps gilt nur für Buchstaben – und das ist wichtig * * Die naheliegende Fassung `/(.)\1+/u` frisst auch Ziffern. In einem Korpus * aus Joule-, Millimeter- und Kaliberangaben wäre das ein stiller * Falschtrefferautomat: 10 = 100 = 1000, 2 = 22, 7 = 77. Nachgemessen: mit * der Buchstabenfassung liefert „100“ acht Fragen, „1000“ fünf und „10“ * achtundzwanzig – sauber getrennt. * * ## Was die Faltung kostet * * Sie wirft Unterschiede weg, und einige davon sind echte. Über alle 3.579 * Wortformen des Katalogs gemessen entstehen **20 Kollisionsgruppen** – * das/dass, wen/wenn, zählen/zahlen, höhe/hohe und so fort. Wer „zahlen“ * sucht, sieht auch „zählen“. Das ist der Preis dafür, dass „schiessen“ * überhaupt etwas findet, und er ist bewusst bezahlt. Die Liste steht als * Festwert im Test, damit eine spätere „Verbesserung“ auffällt. */ /** Zeichen, die als Wortbestandteil gelten – nach der Faltung nur noch ASCII. */ const WORTZEICHEN = /[a-z0-9]/u; interface Zeichen { readonly ch: string; /** Erster beitragender Index im Ursprungstext. */ readonly von: number; /** Letzter beitragender Index im Ursprungstext. */ readonly bis: number; } /** Was ein einzelnes Ursprungszeichen zur Kanonform beiträgt. */ function ersetzung(ch: string): string { switch (ch) { case 'ä': return 'ae'; case 'ö': return 'oe'; case 'ü': return 'ue'; case 'ß': case 'ẞ': return 'ss'; default: return ch; } } const KOMBINIEREND = /\p{M}/u; const BUCHSTABE = /[a-z]/u; /** * Ein Buchstabe – **gleich welcher Schreibung**. * * Die Groß-/Kleinschreibung hier zu prüfen wäre ein Fehler, und zwar ein * stiller: Die Faltung schreibt zuerst alles klein, die Regel sähe aber auf * das Ursprungszeichen. `falten("Wadcutter-Geschoss")` behielte den Strich * (»r« vor großem »G«), `falten` derselben bereits gefalteten Zeichenkette * entfernte ihn – die Kanonform hinge davon ab, wie oft man sie anwendet. * Nachgemessen war sie das eine Zeit lang, und der Selbstfindungstest hat es * gefunden: 24 von 575 Fragen fanden sich mit ihrem eigenen längsten Wort * nicht mehr. */ const BUCHSTABE_BELIEBIG = /\p{L}/u; /** Mehrfach gleiche Buchstaben zu einem – Ziffern bleiben unberührt. */ function kollabieren(quelle: readonly Zeichen[]): Zeichen[] { const aus: Zeichen[] = []; for (const z of quelle) { const vorher = aus[aus.length - 1]; if (vorher?.ch === z.ch && BUCHSTABE.test(z.ch)) { aus[aus.length - 1] = { ch: vorher.ch, von: vorher.von, bis: z.bis }; continue; } aus.push(z); } return aus; } /** ae→a, oe→o, ue→u – erst hier fallen „Buechse“ und „Büchse“ zusammen. */ function vokaleFalten(quelle: readonly Zeichen[]): Zeichen[] { const aus: Zeichen[] = []; for (let i = 0; i < quelle.length; i++) { const a = quelle[i]; const b = quelle[i + 1]; if (a === undefined) { continue; } if (b?.ch === 'e' && (a.ch === 'a' || a.ch === 'o' || a.ch === 'u')) { aus.push({ ch: a.ch, von: a.von, bis: b.bis }); i++; continue; } aus.push(a); } return aus; } /** * Der gemeinsame Kern von {@link falten} und {@link faltenMitZuordnung}. * * Beide gehen bewusst durch dieselbe Schleife. Zwei getrennte Umsetzungen – * eine schnelle für den Index, eine buchführende für die Hervorhebung – * liefen unweigerlich auseinander, und der Schaden wäre still: Die Suche * fände richtig und markierte falsch. */ function kern(roh: string): Zeichen[] { // Schritt 1 bis 3: je Ursprungszeichen, Index bleibt zuordenbar. const eins: Zeichen[] = []; for (let i = 0; i < roh.length; i++) { const ch = roh[i] ?? ''; if (KOMBINIEREND.test(ch)) { // Ein zerlegt gespeicherter Umlaut: der Grundbuchstabe steht schon da. continue; } if ( ch === '-' && BUCHSTABE_BELIEBIG.test(roh[i - 1] ?? '') && BUCHSTABE_BELIEBIG.test(roh[i + 1] ?? '') ) { /* Ein Bindestrich zwischen zwei Buchstaben zählt für die Suche nicht. Zwei Gründe. Erstens der amtliche Bestand: „er-klärt“ in Frage 2.123 b und „orange-farbenen“ in IV-52 stehen so im Original – in derselben Frage 2.123 schreibt Antwort a) „erklärt“ –, und „lever-action“ in 1.28 ist ein englisches Kompositum und richtig so. Am amtlichen Wortlaut wird nicht gearbeitet; also liest die Suche darüber hinweg. Zweitens die Eingabe: Wer „Double-Action-Revolver“ sucht, tippt den Strich mal mit und mal ohne. Unberührt bleibt der Ergänzungsstrich vor einem Leerzeichen („Hieb- und Stoßwaffen“) – dort vertritt er ein ganzes Wort – und der Strich neben einer Ziffer („CO2-Waffen“, „II-45“, „I.2-150“), damit Fragennummern erkennbar bleiben. */ continue; } for (const aus of ersetzung(ch.toLowerCase())) { eins.push({ ch: aus, von: i, bis: i }); } } /* Schritt 4 und 5 laufen bis zum Stillstand, höchstens vier Runden. Einzeln angewandt wären sie nicht idempotent, und das ist keine graue Theorie: „ää“ wird zu „aeae“, daraus macht die Vokalfaltung „aa“, und erst ein zweiter Laufkollaps macht daraus „a“ – wozu `falten("a")` schon beim ersten Durchgang käme. Eine Kanonform, die von der Zahl der Anwendungen abhängt, ist keine. */ let liste = eins; for (let runde = 0; runde < 4; runde++) { const vorher = liste.length; liste = kollabieren(liste); liste = vokaleFalten(liste); if (liste.length === vorher) { break; } } // Schritt 6: Leerraum vereinheitlichen und außen abschneiden. const vier: Zeichen[] = []; for (const z of liste) { if (/\s/u.test(z.ch)) { const vorher = vier[vier.length - 1]; if (vorher === undefined) { continue; // führender Leerraum } if (vorher.ch === ' ') { vier[vier.length - 1] = { ch: ' ', von: vorher.von, bis: z.bis }; continue; } vier.push({ ch: ' ', von: z.von, bis: z.bis }); continue; } vier.push(z); } while (vier.length > 0 && vier[vier.length - 1]?.ch === ' ') { vier.pop(); } return vier; } /** * Bringt einen Text auf die Kanonform. * * Idempotent: `falten(falten(x)) === falten(x)` für jede der 3.579 Wortformen * des Katalogs – der Test misst das, statt es zu behaupten. */ export function falten(roh: string): string { let aus = ''; for (const z of kern(roh)) { aus += z.ch; } return aus; } /** Die Kanonform samt Rückweg auf die Stellen im Ursprungstext. */ export interface Faltung { readonly gefaltet: string; /** Je gefaltetem Zeichen der erste beitragende Ursprungsindex. */ readonly von: readonly number[]; /** Je gefaltetem Zeichen der letzte beitragende Ursprungsindex. */ readonly bis: readonly number[]; } /** * Faltung mit Buchführung über die Herkunft jedes Zeichens. * * Ohne sie markierte die Trefferhervorhebung daneben: Die Faltung ändert * Längen – aus „ß“ wird „s“, aus „ü“ wird „u“ –, und 91,7 Prozent der Fragen * sind betroffen. Eine Fundstelle `[i, j)` im gefalteten Text wird über * `[von[i], bis[j-1] + 1)` zur Spanne im Ursprungstext. */ export function faltenMitZuordnung(roh: string): Faltung { const zeichen = kern(roh); const von = new Array(zeichen.length); const bis = new Array(zeichen.length); let gefaltet = ''; for (let i = 0; i < zeichen.length; i++) { const z = zeichen[i]; if (z === undefined) { continue; } gefaltet += z.ch; von[i] = z.von; bis[i] = z.bis; } return { gefaltet, von, bis }; } /** * Rechnet eine Fundstelle im gefalteten Text auf den Ursprungstext zurück. * * Liefert `null`, wenn die Spanne unplausibel ist. Dieses Sicherheitsventil * ist Absicht: Lieber gar keine Markierung als eine falsche – eine falsch * markierte Stelle behauptet, das gesuchte Wort stehe dort, wo es nicht steht. */ export function aufUrsprung( faltung: Faltung, anfang: number, ende: number, ): { readonly von: number; readonly bis: number } | null { if (anfang < 0 || ende <= anfang || ende > faltung.gefaltet.length) { return null; } const a = faltung.von[anfang]; const b = faltung.bis[ende - 1]; if (a === undefined || b === undefined || b < a) { return null; } return { von: a, bis: b + 1 }; } /** * Steht an dieser Stelle des gefalteten Textes ein Wortanfang? * * Nach der Faltung gibt es weder Umlaute noch ß, ein Blick auf `[a-z0-9]` * genügt also. Der Wortanfang ist kein Filter, sondern ein Rangkriterium: * „Sport“ soll vor „Transport“ stehen, aber „Transport“ soll gefunden werden. */ export function istWortanfang(gefaltet: string, stelle: number): boolean { if (stelle <= 0) { return true; } return !WORTZEICHEN.test(gefaltet[stelle - 1] ?? ''); } /** * Alle Fundstellen eines gefalteten Suchworts in einem gefalteten Text. * * Teilzeichenkette, nicht Wortanfang. Die Begründung liegt im Korpus: * „besitzkarte“ findet als Wortanfangssuche **null** Fragen und als Teilwort * **52**; „schein“ eine gegen 61; „karte“ null gegen 52. Deutsche * Rechtssprache verschluckt das gesuchte Wort im Kompositum – eine Suche, die * nur Wortanfänge kennt, schweigt bei der häufigsten deutschen Wortbildung. */ export function fundstellen(gefaltet: string, wort: string): number[] { if (wort.length === 0) { return []; } const stellen: number[] = []; let ab = gefaltet.indexOf(wort); while (ab !== -1) { stellen.push(ab); ab = gefaltet.indexOf(wort, ab + 1); } return stellen; }