diff --git a/scripts/build-semantle-words.js b/scripts/build-semantle-words.js index fed1cbf..135a2eb 100644 --- a/scripts/build-semantle-words.js +++ b/scripts/build-semantle-words.js @@ -31,7 +31,7 @@ if (!res.ok) throw new Error(`fetch failed: ${res.status} ${res.statusText}`); const text = await res.text(); // Normalize only: trim whitespace, lowercase, drop blanks, dedupe. -// Preserve original frequency order so `getLine(n)` stays a frequency rank. +// Preserve original frequency order — source is ranked by Google Ngram. const words = Array.from( new Set( text diff --git a/src/modules/doantu/README.md b/src/modules/doantu/README.md index 96e6dad..ecf8c19 100644 --- a/src/modules/doantu/README.md +++ b/src/modules/doantu/README.md @@ -2,8 +2,8 @@ Vietnamese "đoán từ" (guess-the-word) — same core mechanic as `semantle`, but targets come from a Vietnamese wordlist and similarity is computed -against ConceptNet's `/c/vi/` concept URIs. Unlimited guesses per -round; solve on exact match. +with a multilingual embedding model. Unlimited guesses per round; solve +on exact match (case-insensitive, diacritic-sensitive). **Visibility: `protected`** — commands appear in `/help` but are hidden from Telegram's native `/` autocomplete menu while the module is still @@ -18,40 +18,43 @@ experimental. | `/doantu_stats` | protected | Show per-subject stats | Submit with `/doantu ` (e.g. `/doantu con chó`). Multi-syllable words -with single spaces between them are accepted. Matching is case-insensitive -and diacritic-sensitive — `cá` and `ca` are different targets. +with single spaces between them are accepted. `cá` and `ca` are different +targets. ## Data source -**Target pool:** [duyet/vietnamese-wordlist](https://github.com/duyet/vietnamese-wordlist)'s -Viet22K list (~22k entries), sorted alphabetically. The raw list is -normalized (lowercase + deduped) but otherwise used verbatim — ConceptNet's -verify-and-fallback at round start rejects any pick that has no concept -edges. License: GPL-2.0 (Ho Ngoc Duc). +**Target + vocabulary:** [duyet/vietnamese-wordlist](https://github.com/duyet/vietnamese-wordlist)'s +Viet22K list (~22k entries), lowercased and deduped. The same list is +both the target pool and the vocabulary — OOV detection is `Set.has()` +with no upstream call. License: GPL-2.0 (Ho Ngoc Duc). +Regenerate with `node scripts/build-doantu-words.js`. -**Similarity + vocabulary:** [ConceptNet 5](https://conceptnet.io). -Multi-word Vietnamese terms are converted to underscore form (`con chó` -→ `/c/vi/con_chó`) only when building URIs — the board keeps the -space-separated display. +**Similarity:** `@cf/baai/bge-m3` multilingual text embeddings via the +`env.AI` binding. Chosen over the English-only `bge-small-en-v1.5` +because that model's tokenizer shreds Vietnamese diacritics into noisy +byte-level subwords. Each in-vocab guess runs one inference call +batching target + guess (1024-dim vectors); the module scores them with +local cosine similarity. ## Architecture -- `api-client.js` — ConceptNet wrapper (`randomWord`, `similarity`, and - lower-level `concept` / `relatedness`). Hardcoded `LANG = "vi"`. -- `wordlist.js` — three-function API (`LINE_COUNT`, `randomLine`, `getLine`) - over `words-data.js`. -- `words-data.js` — auto-generated (regenerate via `npm run build:doantu-words`). +- `api-client.js` — Workers AI wrapper: `randomWord()` picks from the + local pool, `similarity(a, b)` calls `env.AI.run()` and returns + `{ in_vocab_b, similarity }`. `UpstreamError` on inference failure. +- `words-data.js` — auto-generated Viet22K dictionary. +- `wordlist.js` — one-function module exposing `randomLine()`. - `state.js` — KV persistence for game + stats. Same shape as semantle. - `lookup.js` — guess normalization + shape validation. Accepts Unicode letters + combining marks + single internal spaces. -- `format.js` — warmth-percent and emoji-bucket formatters (unchanged). +- `format.js` — warmth-percent and emoji-bucket formatters (identical + to semantle/format.js — score display is language-agnostic). - `render.js` — Telegram HTML `
` monospace board with a 🇻🇳 header.
 - `handlers.js` — subject resolution + the three command entry points.
 
 Near-clone of the semantle sibling — kept separate per the repo's
-existing one-module-per-game convention rather than factoring out a
-shared base. Diff your changes against `../semantle/` when fixing bugs
-that apply to both.
+one-module-per-game convention rather than factoring out a shared base.
+Diff your changes against `../semantle/` when fixing bugs that apply to
+both.
 
 ## Storage
 
@@ -64,11 +67,11 @@ KV namespace prefix: `doantu:`
 
 ## Config
 
-No env vars. ConceptNet base (`https://api.conceptnet.io`) is hardcoded;
-pass an override to `createClient(url)` if you need a mirror or test double.
+No env vars. Model defaults to `@cf/baai/bge-m3`; override with
+`createClient(env.AI, { model: "..." })` in a test or alternative deploy.
 
 ## Credits
 
+- Embeddings: [`@cf/baai/bge-m3`](https://developers.cloudflare.com/workers-ai/models/bge-m3/) on Cloudflare Workers AI (multilingual).
 - Wordlist: [duyet/vietnamese-wordlist](https://github.com/duyet/vietnamese-wordlist) by Ho Ngoc Duc (GPL-2.0).
-- Similarity + vocabulary: [ConceptNet 5](https://conceptnet.io) by Robyn Speer et al.
 - Game concept: [Semantle](https://semantle.com/) by David Turner.
diff --git a/src/modules/doantu/lookup.js b/src/modules/doantu/lookup.js
index 685123b..f60a479 100644
--- a/src/modules/doantu/lookup.js
+++ b/src/modules/doantu/lookup.js
@@ -4,8 +4,7 @@
  * Allows Unicode letters (including diacritics via combining marks) plus
  * single spaces between syllables for compound words (`con chó`,
  * `máy bay`). Rejects digits, punctuation, and underscores so the board
- * stays clean; the api-client handles the space→underscore conversion
- * internally when building ConceptNet URIs.
+ * stays clean.
  */
 
 /** @param {string} raw */
diff --git a/src/modules/doantu/wordlist.js b/src/modules/doantu/wordlist.js
index 2f5c707..223ac1d 100644
--- a/src/modules/doantu/wordlist.js
+++ b/src/modules/doantu/wordlist.js
@@ -4,23 +4,11 @@
  * Data lives in the sibling `words-data.js` (auto-generated by
  * `scripts/build-doantu-words.js` from duyet/vietnamese-wordlist's
  * Viet22K list, lowercased + deduped).
- *
- * API mirrors wordle/loldle and semantle's english sibling:
- *   `LINE_COUNT`  — total entries
- *   `randomLine()` — uniform random pick
- *   `getLine(n)`  — read the nth entry (alphabetical rank in the source list)
  */
 
 import LINES from "./words-data.js";
 
-export const LINE_COUNT = LINES.length;
-
-/** @param {number} n @returns {string | undefined} */
-export function getLine(n) {
-  return LINES[n];
-}
-
 /** @returns {string} */
 export function randomLine() {
-  return LINES[Math.floor(Math.random() * LINE_COUNT)];
+  return LINES[Math.floor(Math.random() * LINES.length)];
 }
diff --git a/src/modules/semantle/README.md b/src/modules/semantle/README.md
index ff182fd..9755494 100644
--- a/src/modules/semantle/README.md
+++ b/src/modules/semantle/README.md
@@ -1,9 +1,9 @@
 # Semantle Module
 
 Semantic-similarity guessing game. A secret word is picked from a local
-curated pool and validated against ConceptNet; each guess is scored by
-ConceptNet's relatedness API against the target. Unlimited guesses per
-round — you play until you get the exact word (case-insensitive).
+curated pool and each guess is scored by cosine similarity between
+embedding vectors produced by Cloudflare Workers AI. Unlimited guesses
+per round — you play until you get the exact word (case-insensitive).
 
 ## Commands
 
@@ -20,47 +20,34 @@ ignored (no cost, no stat inflation).
 
 ## Data source
 
-**[ConceptNet 5](https://api.conceptnet.io/)** — free public API, no auth,
-~300k English concepts including multi-word phrases. Two endpoints:
-
-- `GET /relatedness?node1=/c/en/X&node2=/c/en/Y` — per-guess similarity,
-  returns `{ value: number ∈ [-1, 1] }`.
-- `GET /c/en/{term}` — vocabulary check: term is in vocab iff the response
-  carries at least one edge.
-
-Because ConceptNet has no random-word endpoint, the target pool ships in
-`words-data.js` — the full
-[google-10000-english-no-swears](https://github.com/first20hours/google-10000-english)
+**Target + vocabulary:** `words-data.js` ships the full
+[google-10000-english](https://github.com/first20hours/google-10000-english)
 list (~9.9k entries), ordered by Google Ngram frequency, normalized to
-lowercase and deduped but otherwise unfiltered.
+lowercase and deduped but otherwise unfiltered. The **same list is both
+the target pool and the vocabulary** — so every legal guess could itself
+have been the answer, and OOV detection is an O(1) `Set.has()` with no
+upstream round-trip. Regenerate with `node scripts/build-semantle-words.js`.
 
-`wordlist.js` exposes three accessors over the imported array:
-- `LINE_COUNT` — total entries
-- `randomLine()` — uniform random pick
-- `getLine(n)` — read the nth entry (n is the frequency rank)
+**Similarity:** `@cf/baai/bge-small-en-v1.5` text embeddings via the
+`env.AI` binding. Each in-vocab guess runs one inference call batching
+target + guess (384-dim vectors) and the module scores them with local
+cosine similarity. At ~0.0037 Neurons per guess, the Workers Free plan
+cap of 10k Neurons/day covers ~2.7M guesses/day.
 
-Each new round picks via `randomLine()`, verifies the candidate via
-ConceptNet's concept endpoint, and falls back to an unverified pick after
-a few misses (see `api-client.js`).
-
-Regenerate with `npm run build:semantle-words` (chained into the main
-`npm run build` that `npm run deploy` invokes).
-
-Every guess costs **two** ConceptNet calls (concept edges + relatedness)
-issued in parallel. Typical latency ~300–600ms round-trip from Cloudflare
-Workers; `api-client.js` enforces a 5s timeout and surfaces a "Upstream
-hiccup" message on failure.
+OOV guesses short-circuit before inference — the player sees
+"isn't in the vocabulary" instead of a noisy subword-based score.
 
 ## Architecture
 
-- `api-client.js` — ConceptNet HTTP wrapper (`randomWord`, `similarity`,
-  plus lower-level `concept` / `relatedness`) with `UpstreamError` metadata.
-  Preserves the earlier word2sim response shape so the rest of the module
-  didn't need rewriting.
-- `words-data.js` — auto-generated dictionary (regenerate via `npm run build:semantle-words`).
-- `wordlist.js` — thin wrapper exposing `TARGET_POOL` and `pickFromPool()` over the dictionary.
+- `api-client.js` — Workers AI wrapper: `randomWord()` picks from the
+  local pool, `similarity(a, b)` runs `env.AI.run()` and returns
+  `{ in_vocab_b, similarity }` along with canonical forms.
+  `UpstreamError` carries status/body metadata when inference fails.
+- `words-data.js` — auto-generated dictionary (~9.9k entries).
+- `wordlist.js` — one-function module exposing `randomLine()`.
 - `state.js` — KV persistence for game + stats. Target stored lowercased.
-- `lookup.js` — guess normalization and shape validation.
+- `lookup.js` — guess normalization (`trim + lowercase + collapse spaces`)
+  and shape validation (`/^[a-z]+$/`, max 64 chars).
 - `format.js` — warmth-percent and emoji-bucket formatters.
 - `render.js` — Telegram HTML `
` monospace board, sorted by similarity
   desc, capped at top 15 rows to stay under Telegram's message-length limit.
@@ -79,24 +66,22 @@ KV namespace prefix: `semantle:`
 | `game:` | `{ target, startedAt, solved, guesses[] }` — active round (TTL 7 days). `target` stored lowercased. |
 | `stats:` | `{ played, solved, totalGuesses, bestGuessCount, lastResultAt }` |
 
-Each `guesses[]` entry is `{ word, canonical, similarity }`. The canonical
-form is lowercased on write so the solve check is a single string compare.
+Each `guesses[]` entry is `{ word, canonical, similarity }`.
 
 ## Config
 
-No env vars. ConceptNet's public API base (`https://api.conceptnet.io`) is
-hardcoded in `api-client.js`; pass an override to `createClient(url)` if you
-need to point at a mirror or test double.
+No env vars. Model defaults to `@cf/baai/bge-small-en-v1.5`; override with
+`createClient(env.AI, { model: "@cf/baai/bge-base-en-v1.5" })` in a test
+or alternative deploy.
 
 ## Why unlimited guesses?
 
 Classic Semantle offers up to 100s of guesses per day, and the fun is in
-the hunt — not the timer. We keep rounds open indefinitely (TTL 7 days on
-KV) and measure skill via `bestGuessCount`, the fewest guesses to solve
-across all rounds.
+the hunt — not the timer. Rounds stay open (TTL 7 days on KV) and skill is
+tracked via `bestGuessCount` — fewest guesses to solve across all rounds.
 
 ## Credits
 
-- Similarity + vocabulary: [ConceptNet 5](https://conceptnet.io) by Robyn Speer et al.
+- Embeddings: [`@cf/baai/bge-small-en-v1.5`](https://developers.cloudflare.com/workers-ai/models/bge-small-en-v1.5/) on Cloudflare Workers AI.
 - Target dictionary: [google-10000-english](https://github.com/first20hours/google-10000-english) by Josh Kaufman, derived from Peter Norvig's Google Ngram analysis.
 - Game concept: [Semantle](https://semantle.com/) by David Turner.
diff --git a/src/modules/semantle/api-client.js b/src/modules/semantle/api-client.js
index 44ecf94..32cc80b 100644
--- a/src/modules/semantle/api-client.js
+++ b/src/modules/semantle/api-client.js
@@ -8,12 +8,9 @@
  * Vocabulary: the curated `words-data.js` list (google-10k) doubles as our
  * in/out-of-vocabulary set — anything outside it is treated as OOV so players
  * get the "not in the vocabulary" reply instead of a noisy embedding score.
- *
- * The returned `similarity(a, b)` shape is kept identical to the prior
- * ConceptNet/word2sim contract so handlers/render/state stay untouched.
  */
 
-import { pickFromPool } from "./wordlist.js";
+import { randomLine } from "./wordlist.js";
 import WORDS from "./words-data.js";
 
 const DEFAULT_MODEL = "@cf/baai/bge-small-en-v1.5";
@@ -74,11 +71,10 @@ export function createClient(ai, { model = DEFAULT_MODEL } = {}) {
     /**
      * Pick a target word from the local pool. The pool IS our vocabulary,
      * so every pick is trivially verified — no upstream check needed.
-     * Shape matches the old word2sim `/random` response for handler reuse.
      * @returns {Promise<{ word: string, verified: boolean }>}
      */
     async randomWord() {
-      return { word: pickFromPool(), verified: true };
+      return { word: randomLine(), verified: true };
     },
 
     /**
@@ -106,6 +102,3 @@ export function createClient(ai, { model = DEFAULT_MODEL } = {}) {
     },
   };
 }
-
-// Backwards-compat alias — older imports referenced `Word2SimError`.
-export { UpstreamError as Word2SimError };
diff --git a/src/modules/semantle/index.js b/src/modules/semantle/index.js
index bebc7be..c0508fb 100644
--- a/src/modules/semantle/index.js
+++ b/src/modules/semantle/index.js
@@ -4,7 +4,7 @@
  * Targets come from a curated local wordlist (same list doubles as the
  * vocabulary for OOV detection, so no upstream check is needed to pick or
  * validate a word). Similarity scores come from cosine distance between
- * `@cf/baai/bge-base-en-v1.5` embeddings produced by the `env.AI` binding.
+ * `@cf/baai/bge-small-en-v1.5` embeddings produced by the `env.AI` binding.
  */
 
 import { createClient } from "./api-client.js";
diff --git a/src/modules/semantle/lookup.js b/src/modules/semantle/lookup.js
index bb0da0b..c676c01 100644
--- a/src/modules/semantle/lookup.js
+++ b/src/modules/semantle/lookup.js
@@ -1,9 +1,9 @@
 /**
  * @file Guess normalization + shape validation.
  *
- * Keeps obviously-bad input from hitting the API. The /random endpoint
- * already filters its output to ASCII letters only, so any guess outside
- * that shape can never equal the target — fail fast.
+ * Keeps obviously-bad input out of the VOCAB lookup and the embedding call.
+ * The wordlist is ASCII-letter-only at build time, so any guess outside
+ * that shape is guaranteed OOV — fail fast.
  */
 
 /** @param {string} raw */
diff --git a/src/modules/semantle/state.js b/src/modules/semantle/state.js
index 83e422d..8b5607f 100644
--- a/src/modules/semantle/state.js
+++ b/src/modules/semantle/state.js
@@ -4,7 +4,7 @@
  *
  * Target is stored lowercased so the case-insensitive equality check
  * is a single compare. Unlimited guesses — no MAX cap; rounds end only
- * on solve, giveup, or `/semantle_new`.
+ * on solve or giveup.
  *
  * Key layout (inside the module-prefixed store):
  *   game:   -> { target, startedAt, solved, guesses[] }
diff --git a/src/modules/semantle/wordlist.js b/src/modules/semantle/wordlist.js
index c86b559..c170dc3 100644
--- a/src/modules/semantle/wordlist.js
+++ b/src/modules/semantle/wordlist.js
@@ -4,26 +4,11 @@
  * Data lives in the sibling `words-data.js` (auto-generated by
  * `scripts/build-semantle-words.js` from google-10000-english, normalized
  * to lowercase + deduped but otherwise unfiltered).
- *
- * API mirrors the "count, random index, read line" access pattern:
- *   `LINE_COUNT`  — how many entries are in the dictionary
- *   `randomLine()` — pick a uniformly-random entry
- *   `getLine(n)`  — read the nth entry (n is the frequency rank)
  */
 
 import LINES from "./words-data.js";
 
-export const LINE_COUNT = LINES.length;
-
-/** @param {number} n @returns {string | undefined} */
-export function getLine(n) {
-  return LINES[n];
-}
-
 /** @returns {string} */
 export function randomLine() {
-  return LINES[Math.floor(Math.random() * LINE_COUNT)];
+  return LINES[Math.floor(Math.random() * LINES.length)];
 }
-
-// Backwards-compat alias for earlier callers.
-export { randomLine as pickFromPool };
diff --git a/tests/modules/semantle/api-client.test.js b/tests/modules/semantle/api-client.test.js
index e8940a1..9acc464 100644
--- a/tests/modules/semantle/api-client.test.js
+++ b/tests/modules/semantle/api-client.test.js
@@ -1,15 +1,11 @@
 import { describe, expect, it, vi } from "vitest";
-import {
-  UpstreamError,
-  Word2SimError,
-  createClient,
-} from "../../../src/modules/semantle/api-client.js";
+import { UpstreamError, createClient } from "../../../src/modules/semantle/api-client.js";
 
 /**
- * Build a deterministic 768-dim vector from a seed so cosine scores are
- * reproducible in tests without hardcoding 768 floats.
+ * Build a deterministic 384-dim vector (bge-small output size) from a seed
+ * so cosine scores are reproducible without hardcoding 384 floats.
  */
-function fakeVector(seed, dim = 768) {
+function fakeVector(seed, dim = 384) {
   const out = new Array(dim);
   for (let i = 0; i < dim; i++) out[i] = Math.sin(seed * (i + 1));
   return out;
@@ -38,10 +34,6 @@ describe("semantle/api-client", () => {
       const err = new UpstreamError("wrapper", { cause });
       expect(err.cause).toBe(cause);
     });
-
-    it("is re-exported as Word2SimError alias for legacy callers", () => {
-      expect(Word2SimError).toBe(UpstreamError);
-    });
   });
 
   describe("createClient", () => {
@@ -53,7 +45,7 @@ describe("semantle/api-client", () => {
 
     it("similarity batches target + guess in a single run() call", async () => {
       const ai = fakeAi(async (_model, { text }) => ({
-        shape: [text.length, 768],
+        shape: [text.length, 384],
         data: text.map((_, i) => fakeVector(i + 1)),
       }));
       const client = createClient(ai);
@@ -115,7 +107,7 @@ describe("semantle/api-client", () => {
     });
 
     it("similarity returns null score when a vector norm is zero", async () => {
-      const zero = new Array(768).fill(0);
+      const zero = new Array(384).fill(0);
       const ai = fakeAi(async () => ({ data: [zero, fakeVector(1)] }));
       const client = createClient(ai);
       const res = await client.similarity("apple", "orange");
diff --git a/tests/modules/semantle/handlers.test.js b/tests/modules/semantle/handlers.test.js
index 8802126..f6bda24 100644
--- a/tests/modules/semantle/handlers.test.js
+++ b/tests/modules/semantle/handlers.test.js
@@ -1,6 +1,6 @@
 import { beforeEach, describe, expect, it, vi } from "vitest";
 import { createStore } from "../../../src/db/create-store.js";
-import { Word2SimError } from "../../../src/modules/semantle/api-client.js";
+import { UpstreamError } from "../../../src/modules/semantle/api-client.js";
 import {
   handleGiveup,
   handleSemantle,
@@ -302,7 +302,7 @@ describe("semantle/handlers", () => {
     });
 
     it("replies with UPSTREAM_FAIL on randomWord error", async () => {
-      client.randomWord.mockRejectedValue(new Word2SimError("timeout", { status: 504 }));
+      client.randomWord.mockRejectedValue(new UpstreamError("timeout", { status: 504 }));
 
       const ctx = makeCtx(1, "private", "/semantle");
       await handleSemantle(ctx, { db, client });
@@ -312,7 +312,7 @@ describe("semantle/handlers", () => {
 
     it("replies with UPSTREAM_FAIL on similarity error", async () => {
       client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
-      client.similarity.mockRejectedValue(new Word2SimError("network error"));
+      client.similarity.mockRejectedValue(new UpstreamError("network error"));
 
       const ctx = makeCtx(1, "private", "/semantle guess");
       await handleSemantle(ctx, { db, client });