Files
noitu/server/cmd/build-dictionary/main.go
T
tiennm99 7fb724d29e fix(server): drain live games on SIGTERM and honour a second signal
The wsapi server was built on the signal context, so SIGTERM cancelled
every room before StartDraining ran and the restart notice never went
out. Build it on a background context, release the signal context as
soon as it fires, and wait a bounded moment after Shutdown so the notice
reaches open sockets.

Also: Hard prefers the slower loss in lost positions; resigning out of
turn no longer settles a pending dead end on the spot; a self-closing
<ref> with a slash in its name no longer swallows definition text; the
store enforces builder_version; both listeners get an IdleTimeout; the
real-corpus ladder is reproducible from its seed; a -healthcheck flag
probes /healthz for the container health check.
2026-09-29 20:33:16 +07:00

543 lines
19 KiB
Go

// Command build-dictionary derives the game's wordlist and word meanings from
// the Wikimedia dump of Wiktionary tiếng Việt.
//
// The upstream is a ~61 MB bzip2-compressed XML file: every page of the wiki
// with its current wikitext, regenerated monthly. The game needs the
// Vietnamese word forms of at least two syllables, indexed by first and last
// syllable, and the plain text of each word's definitions. This tool performs
// that reduction and records provenance in a meta table — including the
// SHA-256 of the file it read, since the upstream is fetched fresh for every
// build rather than pinned.
//
// The derived database is a modified version of CC BY-SA 4.0 licensed data.
// See data/ATTRIBUTION.md.
//
// Usage:
//
// go run ./cmd/build-dictionary --dump ../data/viwiktionary-latest-pages-articles.xml.bz2 --out ../data/noitu.db
package main
import (
"database/sql"
"errors"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/tiennm99dev/noitu/server/internal/dictionary"
_ "modernc.org/sqlite"
)
// builderVer changes whenever the meta table's contract does, so two databases
// with different provenance rows never claim the same builder. It is the
// store's own constant, so the version the builder writes and the one Open
// requires cannot drift apart.
const builderVer = dictionary.RequiredBuilderVersion
// minMeaningCoverage is the share of words a dump build must carry a meaning
// for. The 2026-09-01 dump measured well above it; the floor exists to catch a
// stripper or section scanner that suddenly returns nothing, not to demand
// quality. Fixture builds are exempt: their meanings are hand-written.
const minMeaningCoverage = 0.6
type config struct {
// dump is the corpus: the Wikimedia pages-articles export.
dump string
// words is an alternative source: a plain list, one word per line with an
// optional tab-separated meaning column, used to build a small fixture
// database without the upstream download.
words string
out string
minWords int
// minPages is the floor on pages with a Vietnamese section. Distinct from
// minWords so a scanner that silently misses a dialect is caught before
// the word floor is.
minPages int
}
func main() {
log.SetFlags(0)
var cfg config
flag.StringVar(&cfg.dump, "dump", "", "upstream Wikimedia pages-articles.xml.bz2 dump to read")
flag.StringVar(&cfg.words, "words", "", "read a plain word list instead of the dump (one word per line, optional tab-separated meanings, # comments)")
flag.StringVar(&cfg.out, "out", "../data/noitu.db", "derived database to write")
flag.IntVar(&cfg.minWords, "min-words", 30000, "fail if fewer words survive filtering")
flag.IntVar(&cfg.minPages, "min-pages", 20000, "fail if the dump has fewer pages with a Vietnamese section")
flag.Parse()
if err := run(cfg); err != nil {
log.Fatalf("build-dictionary: %v", err)
}
}
func run(cfg config) error {
// Exactly one input. Picking silently between two would let a stray flag
// ship a corpus nobody meant to build.
switch {
case cfg.dump == "" && cfg.words == "":
return errors.New("no input given: pass --dump (the corpus) or --words (a plain list)")
case cfg.dump != "" && cfg.words != "":
return errors.New("--dump and --words are mutually exclusive")
case cfg.dump != "":
return runFromDump(cfg)
default:
return runFromWordList(cfg)
}
}
// runFromDump derives the database from the Wikimedia dump, keeping every
// page with a Vietnamese section and recording the hash of the bytes it read.
func runFromDump(cfg config) error {
if _, err := os.Stat(cfg.dump); err != nil {
return fmt.Errorf("dump not found at %s — run 'make fetch-dict' first: %w", cfg.dump, err)
}
started := time.Now()
words, meanings, rejects, stats, prov, err := readDump(cfg.dump)
if err != nil {
return err
}
logDumpStats(stats)
logRejects(rejects)
if prov.pages < cfg.minPages {
return fmt.Errorf("only %d pages have a Vietnamese section, expected at least %d — "+
"the dump's markup may have changed", prov.pages, cfg.minPages)
}
log.Printf("accepted %d distinct words, %d with a meaning, from %s (%d pages, sha256 %s) in %s",
len(words), len(meanings), cfg.dump, prov.pages, prov.sha256, time.Since(started).Round(time.Second))
return finish(cfg, words, meanings, dumpSourceSpec(cfg.dump, prov), true)
}
// finish is the tail every input mode shares: the size floor, alias
// generation, the atomic write and the re-read verification. Keeping it in one
// place is what stops a fixture from drifting into a different shape from the
// database production loads. requireCoverage applies the meaning-coverage
// floor, which only a corpus build can be held to.
func finish(cfg config, words map[string]entry, meanings map[string][]sense, src sourceSpec, requireCoverage bool) error {
if len(words) < cfg.minWords {
return fmt.Errorf("only %d words survived filtering, expected at least %d — "+
"the source content may have changed", len(words), cfg.minWords)
}
aliases, collisions := buildAliases(words)
log.Printf("generated %d spelling aliases (%d skipped as ambiguous or already real words)", len(aliases), collisions)
if err := write(cfg.out, words, meanings, aliases, src); err != nil {
return err
}
if err := verify(cfg.out, cfg.minWords, requireCoverage); err != nil {
return fmt.Errorf("output failed verification: %w", err)
}
log.Printf("wrote %s", cfg.out)
return nil
}
// runFromWordList derives a database from a plain list of words instead of the
// dump.
//
// It exists so tests and CI have a real dictionary to play against without the
// upstream download. The filtering, alias generation, writing and verification
// below are the same functions the real build uses — only the source of the
// raw strings differs — so a fixture cannot drift into being shaped
// differently from what production loads.
//
// A line is `word`, or `word<TAB>sense<TAB>sense…` where a sense is
// `pos|gloss` or just `gloss`. The pipe never survives the stripper, so it is
// a safe separator for hand-written meanings.
func runFromWordList(cfg config) error {
raw, err := os.ReadFile(cfg.words)
if err != nil {
return fmt.Errorf("read word list: %w", err)
}
words := make(map[string]entry)
meanings := make(map[string][]sense)
rejects := make(map[rejectReason]int)
for line := range strings.Lines(string(raw)) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
cells := strings.Split(line, "\t")
word, syllables, reason, ok := accept(cells[0])
if !ok {
rejects[reason]++
continue
}
words[word] = entry{
word: word,
first: syllables[0],
last: syllables[len(syllables)-1],
syllables: len(syllables),
}
if senses := parseSenses(cells[1:]); len(senses) > 0 {
meanings[word] = senses
}
}
logRejects(rejects)
log.Printf("accepted %d distinct words, %d with a meaning, from %s", len(words), len(meanings), cfg.words)
// The source spec is what lands in the meta table. Naming the list rather
// than a table makes it obvious in the output which build produced a given
// database — and a hand-written list carries no upstream licence, so the
// fixture must not claim one.
return finish(cfg, words, meanings, sourceSpec{
table: "wordlist:" + filepath.Base(cfg.words),
license: "none: hand-written fixture wordlist, no upstream data",
attribution: "Fixture written by this project; no third-party attribution applies.",
}, false)
}
// parseSenses reads the tab-separated meaning cells of a fixture line. A cell
// is `pos|gloss` or a bare gloss; empty cells are skipped and the cap applies
// as it does to the dump.
func parseSenses(cells []string) []sense {
var senses []sense
for _, cell := range cells {
cell = strings.TrimSpace(cell)
if cell == "" {
continue
}
s := sense{gloss: cell}
if pos, gloss, ok := strings.Cut(cell, "|"); ok {
s = sense{pos: strings.TrimSpace(pos), gloss: strings.TrimSpace(gloss)}
}
if s.gloss == "" {
continue
}
s.gloss, _ = capGloss(s.gloss)
if len(senses) < maxSenses {
senses = append(senses, s)
}
}
return senses
}
// verify re-opens the finished database and re-checks the invariants the game
// depends on. The in-memory checks above can only prove what the builder
// intended; this proves what actually landed on disk.
func verify(path string, minWords int, requireCoverage bool) error {
db, err := sql.Open("sqlite", dictionary.DSN(path, true))
if err != nil {
return err
}
defer func() { _ = db.Close() }()
checks := []struct {
desc string
query string
want func(int) bool
}{
{"words below the minimum", `SELECT COUNT(*) FROM words`, func(n int) bool { return n >= minWords }},
{"words with fewer than 2 syllables", `SELECT COUNT(*) FROM words WHERE syllables < 2`, func(n int) bool { return n == 0 }},
{"words with an empty first or last syllable", `SELECT COUNT(*) FROM words WHERE first = '' OR last = ''`, func(n int) bool { return n == 0 }},
{"aliases pointing at a missing word",
`SELECT COUNT(*) FROM aliases a LEFT JOIN words w ON w.word = a.canonical WHERE w.word IS NULL`,
func(n int) bool { return n == 0 }},
{"aliases that are themselves real words",
`SELECT COUNT(*) FROM aliases a JOIN words w ON w.word = a.variant`,
func(n int) bool { return n == 0 }},
{"words whose first syllable is missing from the syllables table",
`SELECT COUNT(*) FROM words w LEFT JOIN syllables s ON s.syllable = w.first WHERE s.syllable IS NULL`,
func(n int) bool { return n == 0 }},
{"meanings whose word is missing from the words table",
`SELECT COUNT(*) FROM meanings m LEFT JOIN words w ON w.word = m.word WHERE w.word IS NULL`,
func(n int) bool { return n == 0 }},
{"meanings with an empty gloss", `SELECT COUNT(*) FROM meanings WHERE gloss = ''`, func(n int) bool { return n == 0 }},
{"meanings over the length cap", fmt.Sprintf(`SELECT COUNT(*) FROM meanings WHERE LENGTH(gloss) > %d`, maxGlossRunes),
func(n int) bool { return n == 0 }},
{"words with more meanings than the cap",
fmt.Sprintf(`SELECT COUNT(*) FROM (SELECT word FROM meanings GROUP BY word HAVING COUNT(*) > %d)`, maxSenses),
func(n int) bool { return n == 0 }},
}
for _, c := range checks {
var n int
if err := db.QueryRow(c.query).Scan(&n); err != nil {
return fmt.Errorf("check %q: %w", c.desc, err)
}
if !c.want(n) {
return fmt.Errorf("%s: %d", c.desc, n)
}
}
if requireCoverage {
var wordCount, withMeaning int
if err := db.QueryRow(`SELECT COUNT(*) FROM words`).Scan(&wordCount); err != nil {
return err
}
if err := db.QueryRow(`SELECT COUNT(DISTINCT word) FROM meanings`).Scan(&withMeaning); err != nil {
return err
}
if float64(withMeaning) < minMeaningCoverage*float64(wordCount) {
return fmt.Errorf("only %d of %d words have a meaning, expected at least %.0f%% — "+
"the dump's definition markup may have changed", withMeaning, wordCount, minMeaningCoverage*100)
}
}
return nil
}
// entry is one accepted word with the parts the game engine indexes on.
type entry struct {
word string
first string
last string
syllables int
}
// sourceSpec is what the meta table records about where the words came from.
type sourceSpec struct {
// table names the input: "dump:<file>" for the corpus, "wordlist:<file>"
// for a fixture, so the output says which build produced it.
table string
// url is the upstream artifact; empty for fixture builds.
url string
// license and attribution describe the data's licence obligations. The
// server logs the licence at startup, so a build must state its own rather
// than inherit a constant it may not deserve.
license string
attribution string
// extra holds provenance rows specific to one input mode.
extra [][2]string
}
func logRejects(rejects map[rejectReason]int) {
reasons := make([]string, 0, len(rejects))
for r := range rejects {
reasons = append(reasons, string(r))
}
sort.Strings(reasons)
for _, r := range reasons {
log.Printf(" rejected %6d: %s", rejects[rejectReason(r)], r)
}
}
// buildAliases maps alternative spellings onto canonical words. A variant that
// happens to be a real word in its own right is dropped: accepting it would let
// one word be played under two names, or worse, silently rename it.
func buildAliases(words map[string]entry) (map[string]string, int) {
aliases := make(map[string]string)
// poisoned records variants claimed by more than one word. A plain delete
// is not enough: a third claimant would find the key free and reinsert it,
// leaving the winner decided by Go's randomized map iteration order.
poisoned := make(map[string]bool)
collisions := 0
for word := range words {
for _, variant := range variantsFor(word) {
if poisoned[variant] {
continue
}
if _, isRealWord := words[variant]; isRealWord {
collisions++
continue
}
if existing, taken := aliases[variant]; taken && existing != word {
delete(aliases, variant)
poisoned[variant] = true
collisions++
continue
}
aliases[variant] = word
}
}
return aliases, collisions
}
// write builds the database beside the target and renames it into place only
// after the transaction commits. Writing in place would mean that a failure
// partway through -- a full disk, an interrupt -- leaves an empty but
// syntactically valid database where a good one used to be, which the server
// would happily open and find no words in.
func write(path string, words map[string]entry, meanings map[string][]sense, aliases map[string]string, src sourceSpec) error {
tmp := path + ".tmp"
if err := os.Remove(tmp); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove stale temp file: %w", err)
}
// On any error path the half-built file must not survive.
committed := false
defer func() {
if !committed {
_ = os.Remove(tmp)
}
}()
if err := writeTo(tmp, words, meanings, aliases, src); err != nil {
return err
}
// os.Rename replaces the destination atomically on POSIX, so the good
// database is never missing from disk. Only where that fails — Windows
// refuses to rename over an existing file — is the target cleared first,
// accepting the window there rather than opening it everywhere.
if err := os.Rename(tmp, path); err != nil {
if runtime.GOOS != "windows" {
// Anywhere else a failed rename is a real error — a different
// filesystem, a permission — and deleting the good database in
// response would turn it into a lost one.
return fmt.Errorf("move temp database into place: %w", err)
}
if rmErr := os.Remove(path); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) {
return fmt.Errorf("remove existing output: %w", rmErr)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("move temp database into place: %w", err)
}
}
committed = true
return nil
}
func writeTo(path string, words map[string]entry, meanings map[string][]sense, aliases map[string]string, src sourceSpec) error {
db, err := sql.Open("sqlite", dictionary.DSN(path, false))
if err != nil {
return fmt.Errorf("create output: %w", err)
}
defer func() { _ = db.Close() }()
schema := `
CREATE TABLE words (
word TEXT PRIMARY KEY,
first TEXT NOT NULL,
last TEXT NOT NULL,
syllables INTEGER NOT NULL
) WITHOUT ROWID;
CREATE INDEX idx_words_first ON words(first);
CREATE TABLE syllables (
syllable TEXT PRIMARY KEY,
out_degree INTEGER NOT NULL
) WITHOUT ROWID;
CREATE TABLE aliases (
variant TEXT PRIMARY KEY,
canonical TEXT NOT NULL
) WITHOUT ROWID;
-- One row per sense, in page order. pos is the Vietnamese part-of-speech
-- label of the heading the definition sat under, '' when the heading was one
-- the builder does not know. No foreign key pragma: verify() checks the join.
CREATE TABLE meanings (
word TEXT NOT NULL,
ord INTEGER NOT NULL,
pos TEXT NOT NULL,
gloss TEXT NOT NULL,
PRIMARY KEY (word, ord)
) WITHOUT ROWID;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
`
if _, err := db.Exec(schema); err != nil {
return fmt.Errorf("create schema: %w", err)
}
tx, err := db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
insertWord, err := tx.Prepare(`INSERT INTO words (word, first, last, syllables) VALUES (?, ?, ?, ?)`)
if err != nil {
return err
}
defer func() { _ = insertWord.Close() }()
outDegree := make(map[string]int)
for _, e := range words {
if _, err := insertWord.Exec(e.word, e.first, e.last, e.syllables); err != nil {
return fmt.Errorf("insert word %q: %w", e.word, err)
}
outDegree[e.first]++
// A syllable that ends a word but starts none is a dead end; record it
// with zero so the engine can find it without a second query.
if _, seen := outDegree[e.last]; !seen {
outDegree[e.last] = 0
}
}
insertSyllable, err := tx.Prepare(`INSERT INTO syllables (syllable, out_degree) VALUES (?, ?)`)
if err != nil {
return err
}
defer func() { _ = insertSyllable.Close() }()
for syllable, degree := range outDegree {
if _, err := insertSyllable.Exec(syllable, degree); err != nil {
return fmt.Errorf("insert syllable %q: %w", syllable, err)
}
}
insertAlias, err := tx.Prepare(`INSERT INTO aliases (variant, canonical) VALUES (?, ?)`)
if err != nil {
return err
}
defer func() { _ = insertAlias.Close() }()
for variant, canonical := range aliases {
if _, err := insertAlias.Exec(variant, canonical); err != nil {
return fmt.Errorf("insert alias %q: %w", variant, err)
}
}
insertMeaning, err := tx.Prepare(`INSERT INTO meanings (word, ord, pos, gloss) VALUES (?, ?, ?, ?)`)
if err != nil {
return err
}
defer func() { _ = insertMeaning.Close() }()
meaningCount := 0
for word, senses := range meanings {
if _, isWord := words[word]; !isWord {
return fmt.Errorf("meaning for %q, which is not a word", word)
}
for ord, s := range senses {
if s.gloss == "" || utf8.RuneCountInString(s.gloss) > maxGlossRunes {
return fmt.Errorf("meaning %d of %q is empty or over the cap", ord, word)
}
if _, err := insertMeaning.Exec(word, ord, s.pos, s.gloss); err != nil {
return fmt.Errorf("insert meaning %d of %q: %w", ord, word, err)
}
meaningCount++
}
}
insertMeta, err := tx.Prepare(`INSERT INTO meta (key, value) VALUES (?, ?)`)
if err != nil {
return err
}
defer func() { _ = insertMeta.Close() }()
meta := [][2]string{
{"source_url", src.url},
{"source_license", src.license},
{"attribution", src.attribution},
{"built_at", time.Now().UTC().Format(time.RFC3339)},
{"builder_version", builderVer},
{"word_count", fmt.Sprint(len(words))},
{"alias_count", fmt.Sprint(len(aliases))},
{"meaning_count", fmt.Sprint(meaningCount)},
{"words_with_meaning", fmt.Sprint(len(meanings))},
{"source_table", src.table},
}
meta = append(meta, src.extra...)
for _, kv := range meta {
if _, err := insertMeta.Exec(kv[0], kv[1]); err != nil {
return err
}
}
return tx.Commit()
}