waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Die Kanonform für die Suche – deutsche Schreibweisen auf einen Nenner. |
| 3 | * |
| 4 | * ## Warum das nötig ist und keine Bibliothek es abnimmt |
| 5 | * |
| 6 | * Wer „Büchse“ sucht, tippt je nach Tastatur und Gewohnheit `Büchse`, |
| 7 | * `Buechse` oder `Buchse`; wer „Schießstätte“ sucht, schreibt `Schiessstätte` |
| 8 | * oder `Schiesstaette`. Alle diese Formen müssen dasselbe finden. Nachgemessen |
| 9 | * am amtlichen Katalog: 91,7 Prozent der Fragen enthalten Umlaute oder ß. |
| 10 | * |
| 11 | * SQLite FTS5 kann das nicht leisten – und zwar keiner seiner Tokenizer: |
| 12 | * `unicode61` faltet Diakritika, aber kein ß (`schiessen` → null Treffer, |
| 13 | * `schießen` → 52); `trigram` faltet überhaupt nichts. Ein ICU-Tokenizer ist |
| 14 | * im mitgelieferten Prebuild nicht enthalten. Die Faltung muss also ohnehin |
| 15 | * von Hand geschrieben werden – und wenn sie geschrieben ist, trägt eine |
| 16 | * Volltextmaschine nichts mehr bei. Die Begründung im Ganzen steht in |
| 17 | * `docs/entscheidung-volltextsuche.md`. |
| 18 | * |
| 19 | * ## Die Reihenfolge der Schritte ist nicht beliebig |
| 20 | * |
| 21 | * 1. Kleinschreibung. |
| 22 | * 2. ä→ae, ö→oe, ü→ue, ß→ss. **Vor** dem Laufkollaps: sonst faltete |
| 23 | * „Schießstätte“ (drei s nach der Ersetzung) anders als die Eingabe |
| 24 | * „Schiesstätte“ (zwei s), und die Funktion wäre nicht idempotent. |
| 25 | * 3. Kombinierende Zeichen entfallen – damit trifft ein zerlegt |
| 26 | * gespeichertes „ä“ (a + U+0308) dasselbe Ergebnis wie das |
| 27 | * zusammengesetzte. Beide enden bei `a`. |
| 28 | * 4. Laufkollaps: mehrfach gleiche Buchstaben werden zu einem. |
| 29 | * 5. ae→a, oe→o, ue→u. Erst hier, damit „Buechse“ und „Büchse“ zusammenfallen. |
| 30 | * |
| 31 | * ## Der Laufkollaps gilt nur für Buchstaben – und das ist wichtig |
| 32 | * |
| 33 | * Die naheliegende Fassung `/(.)\1+/u` frisst auch Ziffern. In einem Korpus |
| 34 | * aus Joule-, Millimeter- und Kaliberangaben wäre das ein stiller |
| 35 | * Falschtrefferautomat: 10 = 100 = 1000, 2 = 22, 7 = 77. Nachgemessen: mit |
| 36 | * der Buchstabenfassung liefert „100“ acht Fragen, „1000“ fünf und „10“ |
| 37 | * achtundzwanzig – sauber getrennt. |
| 38 | * |
| 39 | * ## Was die Faltung kostet |
| 40 | * |
| 41 | * Sie wirft Unterschiede weg, und einige davon sind echte. Über alle 3.579 |
| 42 | * Wortformen des Katalogs gemessen entstehen **20 Kollisionsgruppen** – |
| 43 | * das/dass, wen/wenn, zählen/zahlen, höhe/hohe und so fort. Wer „zahlen“ |
| 44 | * sucht, sieht auch „zählen“. Das ist der Preis dafür, dass „schiessen“ |
| 45 | * überhaupt etwas findet, und er ist bewusst bezahlt. Die Liste steht als |
| 46 | * Festwert im Test, damit eine spätere „Verbesserung“ auffällt. |
| 47 | */ |
| 48 | |
| 49 | /** Zeichen, die als Wortbestandteil gelten – nach der Faltung nur noch ASCII. */ |
| 50 | const WORTZEICHEN = /[a-z0-9]/u; |
| 51 | |
| 52 | interface Zeichen { |
| 53 | readonly ch: string; |
| 54 | /** Erster beitragender Index im Ursprungstext. */ |
| 55 | readonly von: number; |
| 56 | /** Letzter beitragender Index im Ursprungstext. */ |
| 57 | readonly bis: number; |
| 58 | } |
| 59 | |
| 60 | /** Was ein einzelnes Ursprungszeichen zur Kanonform beiträgt. */ |
| 61 | function ersetzung(ch: string): string { |
| 62 | switch (ch) { |
| 63 | case 'ä': |
| 64 | return 'ae'; |
| 65 | case 'ö': |
| 66 | return 'oe'; |
| 67 | case 'ü': |
| 68 | return 'ue'; |
| 69 | case 'ß': |
| 70 | case 'ẞ': |
| 71 | return 'ss'; |
| 72 | default: |
| 73 | return ch; |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | const KOMBINIEREND = /\p{M}/u; |
| 78 | |
| 79 | const BUCHSTABE = /[a-z]/u; |
| 80 | |
| 81 | /** |
| 82 | * Ein Buchstabe – **gleich welcher Schreibung**. |
| 83 | * |
| 84 | * Die Groß-/Kleinschreibung hier zu prüfen wäre ein Fehler, und zwar ein |
| 85 | * stiller: Die Faltung schreibt zuerst alles klein, die Regel sähe aber auf |
| 86 | * das Ursprungszeichen. `falten("Wadcutter-Geschoss")` behielte den Strich |
| 87 | * (»r« vor großem »G«), `falten` derselben bereits gefalteten Zeichenkette |
| 88 | * entfernte ihn – die Kanonform hinge davon ab, wie oft man sie anwendet. |
| 89 | * Nachgemessen war sie das eine Zeit lang, und der Selbstfindungstest hat es |
| 90 | * gefunden: 24 von 575 Fragen fanden sich mit ihrem eigenen längsten Wort |
| 91 | * nicht mehr. |
| 92 | */ |
| 93 | const BUCHSTABE_BELIEBIG = /\p{L}/u; |
| 94 | |
| 95 | /** Mehrfach gleiche Buchstaben zu einem – Ziffern bleiben unberührt. */ |
| 96 | function kollabieren(quelle: readonly Zeichen[]): Zeichen[] { |
| 97 | const aus: Zeichen[] = []; |
| 98 | for (const z of quelle) { |
| 99 | const vorher = aus[aus.length - 1]; |
| 100 | if (vorher?.ch === z.ch && BUCHSTABE.test(z.ch)) { |
| 101 | aus[aus.length - 1] = { ch: vorher.ch, von: vorher.von, bis: z.bis }; |
| 102 | continue; |
| 103 | } |
| 104 | aus.push(z); |
| 105 | } |
| 106 | return aus; |
| 107 | } |
| 108 | |
| 109 | /** ae→a, oe→o, ue→u – erst hier fallen „Buechse“ und „Büchse“ zusammen. */ |
| 110 | function vokaleFalten(quelle: readonly Zeichen[]): Zeichen[] { |
| 111 | const aus: Zeichen[] = []; |
| 112 | for (let i = 0; i < quelle.length; i++) { |
| 113 | const a = quelle[i]; |
| 114 | const b = quelle[i + 1]; |
| 115 | if (a === undefined) { |
| 116 | continue; |
| 117 | } |
| 118 | if (b?.ch === 'e' && (a.ch === 'a' || a.ch === 'o' || a.ch === 'u')) { |
| 119 | aus.push({ ch: a.ch, von: a.von, bis: b.bis }); |
| 120 | i++; |
| 121 | continue; |
| 122 | } |
| 123 | aus.push(a); |
| 124 | } |
| 125 | return aus; |
| 126 | } |
| 127 | |
| 128 | /** |
| 129 | * Der gemeinsame Kern von {@link falten} und {@link faltenMitZuordnung}. |
| 130 | * |
| 131 | * Beide gehen bewusst durch dieselbe Schleife. Zwei getrennte Umsetzungen – |
| 132 | * eine schnelle für den Index, eine buchführende für die Hervorhebung – |
| 133 | * liefen unweigerlich auseinander, und der Schaden wäre still: Die Suche |
| 134 | * fände richtig und markierte falsch. |
| 135 | */ |
| 136 | function kern(roh: string): Zeichen[] { |
| 137 | // Schritt 1 bis 3: je Ursprungszeichen, Index bleibt zuordenbar. |
| 138 | const eins: Zeichen[] = []; |
| 139 | for (let i = 0; i < roh.length; i++) { |
| 140 | const ch = roh[i] ?? ''; |
| 141 | if (KOMBINIEREND.test(ch)) { |
| 142 | // Ein zerlegt gespeicherter Umlaut: der Grundbuchstabe steht schon da. |
| 143 | continue; |
| 144 | } |
| 145 | if ( |
| 146 | ch === '-' && |
| 147 | BUCHSTABE_BELIEBIG.test(roh[i - 1] ?? '') && |
| 148 | BUCHSTABE_BELIEBIG.test(roh[i + 1] ?? '') |
| 149 | ) { |
| 150 | /* |
| 151 | Ein Bindestrich zwischen zwei Buchstaben zählt für die Suche nicht. |
| 152 | |
| 153 | Zwei Gründe. Erstens der amtliche Bestand: „er-klärt“ in Frage |
| 154 | 2.123 b und „orange-farbenen“ in IV-52 stehen so im Original – in |
| 155 | derselben Frage 2.123 schreibt Antwort a) „erklärt“ –, und |
| 156 | „lever-action“ in 1.28 ist ein englisches Kompositum und richtig so. |
| 157 | Am amtlichen Wortlaut wird nicht gearbeitet; also liest die Suche |
| 158 | darüber hinweg. Zweitens die Eingabe: Wer „Double-Action-Revolver“ |
| 159 | sucht, tippt den Strich mal mit und mal ohne. |
| 160 | |
| 161 | Unberührt bleibt der Ergänzungsstrich vor einem Leerzeichen („Hieb- |
| 162 | und Stoßwaffen“) – dort vertritt er ein ganzes Wort – und der Strich |
| 163 | neben einer Ziffer („CO2-Waffen“, „II-45“, „I.2-150“), damit |
| 164 | Fragennummern erkennbar bleiben. |
| 165 | */ |
| 166 | continue; |
| 167 | } |
| 168 | for (const aus of ersetzung(ch.toLowerCase())) { |
| 169 | eins.push({ ch: aus, von: i, bis: i }); |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | /* |
| 174 | Schritt 4 und 5 laufen bis zum Stillstand, höchstens vier Runden. |
| 175 | |
| 176 | Einzeln angewandt wären sie nicht idempotent, und das ist keine graue |
| 177 | Theorie: „ää“ wird zu „aeae“, daraus macht die Vokalfaltung „aa“, und |
| 178 | erst ein zweiter Laufkollaps macht daraus „a“ – wozu `falten("a")` schon |
| 179 | beim ersten Durchgang käme. Eine Kanonform, die von der Zahl der |
| 180 | Anwendungen abhängt, ist keine. |
| 181 | */ |
| 182 | let liste = eins; |
| 183 | for (let runde = 0; runde < 4; runde++) { |
| 184 | const vorher = liste.length; |
| 185 | liste = kollabieren(liste); |
| 186 | liste = vokaleFalten(liste); |
| 187 | if (liste.length === vorher) { |
| 188 | break; |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | // Schritt 6: Leerraum vereinheitlichen und außen abschneiden. |
| 193 | const vier: Zeichen[] = []; |
| 194 | for (const z of liste) { |
| 195 | if (/\s/u.test(z.ch)) { |
| 196 | const vorher = vier[vier.length - 1]; |
| 197 | if (vorher === undefined) { |
| 198 | continue; // führender Leerraum |
| 199 | } |
| 200 | if (vorher.ch === ' ') { |
| 201 | vier[vier.length - 1] = { ch: ' ', von: vorher.von, bis: z.bis }; |
| 202 | continue; |
| 203 | } |
| 204 | vier.push({ ch: ' ', von: z.von, bis: z.bis }); |
| 205 | continue; |
| 206 | } |
| 207 | vier.push(z); |
| 208 | } |
| 209 | while (vier.length > 0 && vier[vier.length - 1]?.ch === ' ') { |
| 210 | vier.pop(); |
| 211 | } |
| 212 | |
| 213 | return vier; |
| 214 | } |
| 215 | |
| 216 | /** |
| 217 | * Bringt einen Text auf die Kanonform. |
| 218 | * |
| 219 | * Idempotent: `falten(falten(x)) === falten(x)` für jede der 3.579 Wortformen |
| 220 | * des Katalogs – der Test misst das, statt es zu behaupten. |
| 221 | */ |
| 222 | export function falten(roh: string): string { |
| 223 | let aus = ''; |
| 224 | for (const z of kern(roh)) { |
| 225 | aus += z.ch; |
| 226 | } |
| 227 | return aus; |
| 228 | } |
| 229 | |
| 230 | /** Die Kanonform samt Rückweg auf die Stellen im Ursprungstext. */ |
| 231 | export interface Faltung { |
| 232 | readonly gefaltet: string; |
| 233 | /** Je gefaltetem Zeichen der erste beitragende Ursprungsindex. */ |
| 234 | readonly von: readonly number[]; |
| 235 | /** Je gefaltetem Zeichen der letzte beitragende Ursprungsindex. */ |
| 236 | readonly bis: readonly number[]; |
| 237 | } |
| 238 | |
| 239 | /** |
| 240 | * Faltung mit Buchführung über die Herkunft jedes Zeichens. |
| 241 | * |
| 242 | * Ohne sie markierte die Trefferhervorhebung daneben: Die Faltung ändert |
| 243 | * Längen – aus „ß“ wird „s“, aus „ü“ wird „u“ –, und 91,7 Prozent der Fragen |
| 244 | * sind betroffen. Eine Fundstelle `[i, j)` im gefalteten Text wird über |
| 245 | * `[von[i], bis[j-1] + 1)` zur Spanne im Ursprungstext. |
| 246 | */ |
| 247 | export function faltenMitZuordnung(roh: string): Faltung { |
| 248 | const zeichen = kern(roh); |
| 249 | const von = new Array<number>(zeichen.length); |
| 250 | const bis = new Array<number>(zeichen.length); |
| 251 | let gefaltet = ''; |
| 252 | for (let i = 0; i < zeichen.length; i++) { |
| 253 | const z = zeichen[i]; |
| 254 | if (z === undefined) { |
| 255 | continue; |
| 256 | } |
| 257 | gefaltet += z.ch; |
| 258 | von[i] = z.von; |
| 259 | bis[i] = z.bis; |
| 260 | } |
| 261 | return { gefaltet, von, bis }; |
| 262 | } |
| 263 | |
| 264 | /** |
| 265 | * Rechnet eine Fundstelle im gefalteten Text auf den Ursprungstext zurück. |
| 266 | * |
| 267 | * Liefert `null`, wenn die Spanne unplausibel ist. Dieses Sicherheitsventil |
| 268 | * ist Absicht: Lieber gar keine Markierung als eine falsche – eine falsch |
| 269 | * markierte Stelle behauptet, das gesuchte Wort stehe dort, wo es nicht steht. |
| 270 | */ |
| 271 | export function aufUrsprung( |
| 272 | faltung: Faltung, |
| 273 | anfang: number, |
| 274 | ende: number, |
| 275 | ): { readonly von: number; readonly bis: number } | null { |
| 276 | if (anfang < 0 || ende <= anfang || ende > faltung.gefaltet.length) { |
| 277 | return null; |
| 278 | } |
| 279 | const a = faltung.von[anfang]; |
| 280 | const b = faltung.bis[ende - 1]; |
| 281 | if (a === undefined || b === undefined || b < a) { |
| 282 | return null; |
| 283 | } |
| 284 | return { von: a, bis: b + 1 }; |
| 285 | } |
| 286 | |
| 287 | /** |
| 288 | * Steht an dieser Stelle des gefalteten Textes ein Wortanfang? |
| 289 | * |
| 290 | * Nach der Faltung gibt es weder Umlaute noch ß, ein Blick auf `[a-z0-9]` |
| 291 | * genügt also. Der Wortanfang ist kein Filter, sondern ein Rangkriterium: |
| 292 | * „Sport“ soll vor „Transport“ stehen, aber „Transport“ soll gefunden werden. |
| 293 | */ |
| 294 | export function istWortanfang(gefaltet: string, stelle: number): boolean { |
| 295 | if (stelle <= 0) { |
| 296 | return true; |
| 297 | } |
| 298 | return !WORTZEICHEN.test(gefaltet[stelle - 1] ?? ''); |
| 299 | } |
| 300 | |
| 301 | /** |
| 302 | * Alle Fundstellen eines gefalteten Suchworts in einem gefalteten Text. |
| 303 | * |
| 304 | * Teilzeichenkette, nicht Wortanfang. Die Begründung liegt im Korpus: |
| 305 | * „besitzkarte“ findet als Wortanfangssuche **null** Fragen und als Teilwort |
| 306 | * **52**; „schein“ eine gegen 61; „karte“ null gegen 52. Deutsche |
| 307 | * Rechtssprache verschluckt das gesuchte Wort im Kompositum – eine Suche, die |
| 308 | * nur Wortanfänge kennt, schweigt bei der häufigsten deutschen Wortbildung. |
| 309 | */ |
| 310 | export function fundstellen(gefaltet: string, wort: string): number[] { |
| 311 | if (wort.length === 0) { |
| 312 | return []; |
| 313 | } |
| 314 | const stellen: number[] = []; |
| 315 | let ab = gefaltet.indexOf(wort); |
| 316 | while (ab !== -1) { |
| 317 | stellen.push(ab); |
| 318 | ab = gefaltet.indexOf(wort, ab + 1); |
| 319 | } |
| 320 | return stellen; |
| 321 | } |