Prune ghost words from affix expansion and stop frequency loader creating words

- Add PruneOrphanWords post-import step that deletes words with no
  definitions, synonyms, translations, pronunciations, etymology,
  synsets, or frequency data
- Remove INSERT path from CETEMPublico loader — frequency data should
  annotate existing words, not create new ones (was inflating pt-PT
  from 136K real words to 718K)
- Add hasLetter filter to hunspell, affix, and dicionario loaders to
  block punctuation entries like ".", ",", "(" from entering the DB
- Move hasLetter helper to scowl.go alongside other shared filters

Impact: en 655K→129K, pt-PT 1.5M→136K, fr 80K→56K (on reimport)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-03 19:55:37 -07:00
parent 2045cc2ada
commit aff583dc28
9 changed files with 125 additions and 36 deletions

View File

@@ -107,8 +107,8 @@ func main() {
)
}
// Difficulty scorer runs last, after all word data is loaded
loaders = append(loaders, loader.DifficultyScorer{})
// Prune ghost words from affix expansion, then score difficulty
loaders = append(loaders, loader.PruneOrphanWords{}, loader.DifficultyScorer{})
start := time.Now()
if err := loader.Run(db, *dataDir, loaders, skipSet); err != nil {

View File

@@ -48,7 +48,7 @@ func (a AffixLoader) Load(db *sql.DB, dataDir string) error {
seen := make(map[string]struct{})
for _, entry := range dicEntries {
for _, form := range expandWord(entry.word, entry.flags, rules) {
if containsDigit(form) || containsSpace(form) {
if !hasLetter(form) || containsDigit(form) || containsSpace(form) {
continue
}
if _, dup := seen[form]; dup {

View File

@@ -9,7 +9,6 @@ import (
"path/filepath"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
@@ -54,12 +53,6 @@ func (CETEMPublicoLoader) Load(db *sql.DB, dataDir string) error {
}
defer stmtUpdate.Close()
stmtInsert, err := tx.Prepare(`
INSERT OR IGNORE INTO words (word, lang, frequency) VALUES (?, 'pt-PT', ?)`)
if err != nil {
return fmt.Errorf("cetempublico: prepare insert: %w", err)
}
defer stmtInsert.Close()
// Read entire file to detect encoding before parsing.
// The file is typically <20 MB so this is fine.
@@ -90,7 +83,7 @@ func (CETEMPublicoLoader) Load(db *sql.DB, dataDir string) error {
}
}
var count, inserted int
var count int
processLine := func(line string) error {
// Support TSV and space-separated, with either column order:
// "word\tcount", "word count", or "count\tword", "count word"
@@ -144,13 +137,6 @@ func (CETEMPublicoLoader) Load(db *sql.DB, dataDir string) error {
}
if n, _ := res.RowsAffected(); n > 0 {
count++
} else {
// Word not in DB yet — insert it with frequency
if _, err := stmtInsert.Exec(word, freq); err != nil {
return fmt.Errorf("cetempublico: insert: %w", err)
}
inserted++
count++
}
return nil
}
@@ -175,19 +161,10 @@ func (CETEMPublicoLoader) Load(db *sql.DB, dataDir string) error {
if err := tx.Commit(); err != nil {
return fmt.Errorf("cetempublico: commit: %w", err)
}
slog.Info("cetempublico loaded", "updated_words", count, "inserted_new", inserted)
slog.Info("cetempublico loaded", "updated_words", count)
return nil
}
func hasLetter(s string) bool {
for _, r := range s {
if unicode.IsLetter(r) {
return true
}
}
return false
}
// latin1ToUTF8 converts ISO-8859-1 bytes to a UTF-8 string.
// Each byte in ISO-8859-1 maps directly to the same Unicode code point.
func latin1ToUTF8(b []byte) string {

View File

@@ -66,20 +66,30 @@ func TestCETEMPublico_ReversedColumns(t *testing.T) {
}
}
func TestCETEMPublico_InsertsMissingWords(t *testing.T) {
func TestCETEMPublico_SkipsUnknownWords(t *testing.T) {
db := setupWiktTestDB(t)
defer db.Close()
// Seed one word, leave "desconhecido" unknown
seedWord(t, db, "que", "pt-PT")
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "pt_50k.txt"), []byte("que 5000000\n"), 0644)
os.WriteFile(filepath.Join(dir, "pt_50k.txt"), []byte("que 5000000\ndesconhecido 1000\n"), 0644)
if err := (CETEMPublicoLoader{}).Load(db, dir); err != nil {
t.Fatal(err)
}
// "que" was not seeded — loader should have inserted it
// "que" was seeded — should get frequency
if f := queryFreq(t, db, "que"); f == 0 {
t.Error("expected non-zero frequency for auto-inserted word 'que'")
t.Error("expected non-zero frequency for known word 'que'")
}
// "desconhecido" was not seeded — should NOT be inserted
var count int
db.QueryRow("SELECT COUNT(*) FROM words WHERE word = 'desconhecido'").Scan(&count)
if count != 0 {
t.Error("expected unknown word 'desconhecido' to be skipped, not inserted")
}
}
@@ -107,6 +117,11 @@ func TestCETEMPublico_FiltersJunk(t *testing.T) {
db := setupWiktTestDB(t)
defer db.Close()
// Seed all candidate words — only "boa" should get frequency
seedWord(t, db, "boa", "pt-PT")
seedWord(t, db, "word1", "pt-PT") // contains digit — filtered
// "." and "," are not valid words so we don't seed them
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "pt_50k.txt"),
[]byte("word1 5000\n. 90000\n, 80000\nboa 3000\n"), 0644)
@@ -115,10 +130,11 @@ func TestCETEMPublico_FiltersJunk(t *testing.T) {
t.Fatal(err)
}
// Only "boa" should get frequency — word1 has digit, . and , aren't words
var count int
db.QueryRow("SELECT COUNT(*) FROM words WHERE lang = 'pt-PT'").Scan(&count)
db.QueryRow("SELECT COUNT(*) FROM words WHERE lang = 'pt-PT' AND frequency > 0").Scan(&count)
if count != 1 {
t.Errorf("expected 1 word (boa only), got %d", count)
t.Errorf("expected 1 word with frequency (boa only), got %d", count)
}
}

View File

@@ -72,7 +72,7 @@ func (DicionarioLoader) Load(db *sql.DB, dataDir string) error {
}
word := strings.ToLower(strings.TrimSpace(entry.Form.Orth))
if word == "" || containsDigit(word) || containsSpace(word) {
if word == "" || !hasLetter(word) || containsDigit(word) || containsSpace(word) {
continue
}

View File

@@ -57,7 +57,7 @@ func loadHunspell(db *sql.DB, path, lang string) error {
word := strings.SplitN(line, "/", 2)[0]
word = strings.ToLower(word)
if containsDigit(word) || containsSpace(word) || containsNonLatin(word) {
if !hasLetter(word) || containsDigit(word) || containsSpace(word) || containsNonLatin(word) {
continue
}

36
internal/loader/prune.go Normal file
View File

@@ -0,0 +1,36 @@
package loader
import (
"database/sql"
"fmt"
"log/slog"
)
// PruneOrphanWords removes words that have no evidence of being real:
// no definitions, no synonyms, no antonyms, no translations,
// no pronunciations, no etymology, no synset membership, and zero frequency.
// These are typically mechanical affix expansion artifacts.
type PruneOrphanWords struct{}
func (PruneOrphanWords) Name() string { return "prune-orphans" }
func (PruneOrphanWords) Load(db *sql.DB, _ string) error {
res, err := db.Exec(`
DELETE FROM words
WHERE frequency = 0
AND NOT EXISTS (SELECT 1 FROM definitions d WHERE d.word_id = words.id)
AND NOT EXISTS (SELECT 1 FROM synonyms s WHERE s.word_id = words.id)
AND NOT EXISTS (SELECT 1 FROM antonyms a WHERE a.word_id = words.id)
AND NOT EXISTS (SELECT 1 FROM translations t WHERE t.word_id = words.id)
AND NOT EXISTS (SELECT 1 FROM pronunciations p WHERE p.word_id = words.id)
AND NOT EXISTS (SELECT 1 FROM etymology e WHERE e.word_id = words.id)
AND NOT EXISTS (SELECT 1 FROM word_synsets ws WHERE ws.word_id = words.id)
`)
if err != nil {
return fmt.Errorf("prune orphans: %w", err)
}
n, _ := res.RowsAffected()
slog.Info("pruned orphan words", "deleted", n)
return nil
}

View File

@@ -0,0 +1,50 @@
package loader
import (
"testing"
)
func TestPruneOrphanWords(t *testing.T) {
db := setupWiktTestDB(t)
defer db.Close()
// Insert a real word with a definition
db.Exec("INSERT INTO words (word, lang, frequency) VALUES ('cat', 'en', 500)")
db.Exec("INSERT INTO definitions (word_id, pos, gloss, source) SELECT id, 'noun', 'a feline', 'test' FROM words WHERE word='cat'")
// Insert a word with only frequency (no definitions) — should survive
db.Exec("INSERT INTO words (word, lang, frequency) VALUES ('the', 'en', 9999)")
// Insert a ghost word — no definitions, no frequency, no anything
db.Exec("INSERT INTO words (word, lang, frequency) VALUES ('cheeseburgered', 'en', 0)")
// Insert a word with synonyms but no definitions — should survive
db.Exec("INSERT INTO words (word, lang, frequency) VALUES ('glad', 'en', 0)")
db.Exec("INSERT INTO synonyms (word_id, synonym, source) SELECT id, 'happy', 'test' FROM words WHERE word='glad'")
pruner := PruneOrphanWords{}
if err := pruner.Load(db, ""); err != nil {
t.Fatal(err)
}
var count int
db.QueryRow("SELECT COUNT(*) FROM words").Scan(&count)
if count != 3 {
t.Errorf("expected 3 surviving words (cat, the, glad), got %d", count)
}
// Verify the ghost was deleted
var exists bool
db.QueryRow("SELECT EXISTS(SELECT 1 FROM words WHERE word='cheeseburgered')").Scan(&exists)
if exists {
t.Error("expected 'cheeseburgered' to be pruned")
}
// Verify real words survived
for _, word := range []string{"cat", "the", "glad"} {
db.QueryRow("SELECT EXISTS(SELECT 1 FROM words WHERE word=?)", word).Scan(&exists)
if !exists {
t.Errorf("expected '%s' to survive pruning", word)
}
}
}

View File

@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"unicode"
)
type SCOWLLoader struct{}
@@ -148,3 +149,12 @@ func containsNonLatin(s string) bool {
}
return false
}
func hasLetter(s string) bool {
for _, r := range s {
if unicode.IsLetter(r) {
return true
}
}
return false
}