diff --git a/.dev.vars.example b/.dev.vars.example index 3d04513..78cc06d 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -2,7 +2,3 @@ # Copy to .dev.vars (gitignored) and fill in real values. TELEGRAM_BOT_TOKEN= TELEGRAM_WEBHOOK_SECRET= - -# Optional: override the word2sim base URL for local/self-hosted instances -# (semantle module). Default is https://word2sim.sg.miti99.com. -# WORD2SIM_API_URL=http://localhost:8000 diff --git a/src/modules/semantle/README.md b/src/modules/semantle/README.md index df875d8..c6a758e 100644 --- a/src/modules/semantle/README.md +++ b/src/modules/semantle/README.md @@ -1,9 +1,9 @@ # Semantle Module -Word2vec similarity guessing game. A secret word is picked from our hosted -[word2sim](https://github.com/tiennm99/word2sim) instance; each guess is -scored by cosine similarity against the target. Unlimited guesses per round -— you play until you get the exact word (case-insensitive). +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). ## Commands @@ -20,28 +20,38 @@ ignored (no cost, no stat inflation). ## Data source -Target words and similarity scores come from **[word2sim](https://word2sim.sg.miti99.com)**, -our hosted FastAPI service over the GoogleNews pretrained word2vec model -(3M tokens × 300 dims). Two endpoints are used: +**[ConceptNet 5](https://api.conceptnet.io/)** — free public API, no auth, +~300k English concepts including multi-word phrases. Two endpoints: -- `GET /random` — round-start target pick, filtered to game-friendly words. -- `GET /similarity?a&b` — per-guess cosine similarity. +- `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. -No local model — every guess is a network round-trip. Typical latency -~200–400ms; `api-client.js` enforces a 5s timeout and surfaces a -"Upstream hiccup" message on failure. +Because ConceptNet has no random-word endpoint, the target pool ships in +`wordlist.js` (~250 curated English words, 4–10 letters, all alphabetic). +Each new round picks locally, verifies via the concept endpoint, and falls +back to an unverified pick after a few misses. + +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. ## Architecture -- `api-client.js` — word2sim HTTP wrapper (`randomWord`, `similarity`) plus - `Word2SimError` with `{status, body, cause}` metadata. +- `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. +- `wordlist.js` — curated local target pool and `pickFromPool()`. - `state.js` — KV persistence for game + stats. Target stored lowercased. - `lookup.js` — guess normalization and shape validation. - `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.
 - `handlers.js` — subject resolution (user in DMs, chat in groups) + the
-  four command entry points.
+  three command entry points.
 
 Subject resolution: private chats track per-user games; groups track
 per-chat shared games. Mirrors `loldle`/`wordle`.
@@ -60,12 +70,9 @@ form is lowercased on write so the solve check is a single string compare.
 
 ## Config
 
-| Env var | Default | Meaning |
-|---------|---------|---------|
-| `WORD2SIM_API_URL` | `https://word2sim.sg.miti99.com` | word2sim base URL; override for local word2sim or self-hosted |
-
-Set in `wrangler.toml` `[vars]`. For local `wrangler dev`, optionally add
-to `.dev.vars` (gitignored).
+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.
 
 ## Why unlimited guesses?
 
@@ -76,6 +83,5 @@ across all rounds.
 
 ## Credits
 
-- Embedding model: Google's pretrained word2vec (3M tokens, 300 dims, trained on Google News).
-- Hosting layer: [tiennm99/word2sim](https://github.com/tiennm99/word2sim).
+- 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/semantle/api-client.js b/src/modules/semantle/api-client.js
index a19c8f5..32c6daf 100644
--- a/src/modules/semantle/api-client.js
+++ b/src/modules/semantle/api-client.js
@@ -1,29 +1,38 @@
 /**
- * @file word2sim HTTP API client.
+ * @file ConceptNet API client for the semantle module.
  *
- * Wraps two endpoints:
- *   GET /random      → pick a secret word at round start
- *   GET /similarity  → cosine similarity between target and guess per turn
+ * ConceptNet endpoints:
+ *   GET /relatedness?node1=/c/en/X&node2=/c/en/Y  → { value: number ∈ [-1, 1] }
+ *   GET /c/en/{term}                              → { edges: [...] }   (empty ⇒ OOV)
  *
- * Stateless. No caching layer — word2sim itself is cheap enough, and caching
- * per-pair scores in KV would pollute the namespace without measurable gain.
+ * There is no official random-word endpoint, so the client picks a candidate
+ * from our local `TARGET_POOL` and verifies it has a ConceptNet entry with
+ * edges. After a few failed attempts it falls back to an unverified pick —
+ * the curated pool is trusted enough that this should be rare.
+ *
+ * The returned `similarity(a, b)` shape mirrors the earlier word2sim contract
+ * so handlers/render/state don't have to change.
  */
 
+import { pickFromPool } from "./wordlist.js";
+
+const DEFAULT_API_BASE = "https://api.conceptnet.io";
 const DEFAULT_TIMEOUT_MS = 5000;
 const USER_AGENT = "miti99bot/semantle";
+const MAX_RANDOM_ATTEMPTS = 5;
 
-export class Word2SimError extends Error {
+export class UpstreamError extends Error {
   /** @param {string} message @param {{status?: number, body?: string, cause?: unknown}} [meta] */
   constructor(message, meta = {}) {
     super(message);
-    this.name = "Word2SimError";
+    this.name = "UpstreamError";
     this.status = meta.status;
     this.body = meta.body;
     if (meta.cause !== undefined) this.cause = meta.cause;
   }
 }
 
-function buildUrl(base, path, params) {
+function buildUrl(base, path, params = {}) {
   const normalized = String(base).replace(/\/+$/, "");
   const url = new URL(`${normalized}${path}`);
   for (const [k, v] of Object.entries(params)) {
@@ -44,12 +53,12 @@ async function fetchJson(url, timeoutMs) {
     });
   } catch (err) {
     clearTimeout(timer);
-    throw new Word2SimError("word2sim fetch failed", { cause: err });
+    throw new UpstreamError("conceptnet fetch failed", { cause: err });
   }
   clearTimeout(timer);
   const text = await res.text();
   if (!res.ok) {
-    throw new Word2SimError(`word2sim HTTP ${res.status}`, {
+    throw new UpstreamError(`conceptnet HTTP ${res.status}`, {
       status: res.status,
       body: text.slice(0, 500),
     });
@@ -57,37 +66,89 @@ async function fetchJson(url, timeoutMs) {
   try {
     return JSON.parse(text);
   } catch (err) {
-    throw new Word2SimError("word2sim non-JSON response", { cause: err });
+    throw new UpstreamError("conceptnet non-JSON response", { cause: err });
   }
 }
 
+function hasEdges(concept) {
+  return Array.isArray(concept?.edges) && concept.edges.length > 0;
+}
+
 /**
- * @param {string} apiBase — e.g. "https://word2sim.sg.miti99.com"
+ * @param {string} [apiBase] — override for mirrors/tests (default api.conceptnet.io)
  * @param {{ timeoutMs?: number }} [opts]
  */
-export function createClient(apiBase, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
+export function createClient(apiBase = DEFAULT_API_BASE, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
+  /** @param {string} term */
+  function concept(term) {
+    return fetchJson(buildUrl(apiBase, `/c/en/${encodeURIComponent(term)}`), timeoutMs);
+  }
+
+  /** @param {string} a @param {string} b */
+  function relatedness(a, b) {
+    return fetchJson(
+      buildUrl(apiBase, "/relatedness", {
+        node1: `/c/en/${a}`,
+        node2: `/c/en/${b}`,
+      }),
+      timeoutMs,
+    );
+  }
+
   return {
+    concept,
+    relatedness,
+
     /**
-     * Pick a random vocab word matching filters.
-     * @param {Record} [filters]
-     * @returns {Promise<{ word: string, rank: number }>}
+     * Pick a target word from the local pool. Verifies each candidate has a
+     * ConceptNet entry; falls back to an unverified pick after a few tries.
+     * Shape matches the old word2sim `/random` response for handler reuse.
+     * @returns {Promise<{ word: string, verified: boolean }>}
      */
-    randomWord(filters = {}) {
-      return fetchJson(buildUrl(apiBase, "/random", filters), timeoutMs);
+    async randomWord() {
+      for (let i = 0; i < MAX_RANDOM_ATTEMPTS; i++) {
+        const candidate = pickFromPool();
+        try {
+          const c = await concept(candidate);
+          if (hasEdges(c)) return { word: candidate, verified: true };
+        } catch {
+          // swallow — try the next candidate
+        }
+      }
+      return { word: pickFromPool(), verified: false };
     },
+
     /**
-     * Cosine similarity between two words.
+     * Cosine-like similarity between `a` (target) and `b` (guess). Runs the
+     * edge-check for `b` in parallel with the relatedness call so OOV guesses
+     * are identified on the same round-trip.
+     *
+     * Shape deliberately mirrors the old word2sim response.
      * @param {string} a
      * @param {string} b
      * @returns {Promise<{
      *   a: string, b: string,
-     *   canonical_a: string|null, canonical_b: string|null,
+     *   canonical_a: string, canonical_b: string,
      *   in_vocab_a: boolean, in_vocab_b: boolean,
-     *   similarity: number|null
+     *   similarity: number | null,
      * }>}
      */
-    similarity(a, b) {
-      return fetchJson(buildUrl(apiBase, "/similarity", { a, b }), timeoutMs);
+    async similarity(a, b) {
+      const [conceptB, rel] = await Promise.all([concept(b), relatedness(a, b)]);
+      const inVocabB = hasEdges(conceptB);
+      const value = typeof rel?.value === "number" ? rel.value : null;
+      return {
+        a,
+        b,
+        canonical_a: a,
+        canonical_b: b,
+        in_vocab_a: true, // target was verified at round start
+        in_vocab_b: inVocabB,
+        similarity: inVocabB ? value : null,
+      };
     },
   };
 }
+
+// Backwards-compat alias — older imports referenced `Word2SimError`.
+export { UpstreamError as Word2SimError };
diff --git a/src/modules/semantle/handlers.js b/src/modules/semantle/handlers.js
index 26f7a53..ad5cf14 100644
--- a/src/modules/semantle/handlers.js
+++ b/src/modules/semantle/handlers.js
@@ -13,19 +13,12 @@
  */
 
 import { escapeHtml } from "../../util/escape-html.js";
-import { Word2SimError } from "./api-client.js";
+import { UpstreamError } from "./api-client.js";
 import { isValidShape, normalize } from "./lookup.js";
 import { renderBoard, renderGuess } from "./render.js";
 import { clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js";
 
 const UPSTREAM_FAIL = "⚠️ Upstream hiccup — try again in a few seconds.";
-const RANDOM_FILTERS = {
-  min_rank: 500,
-  max_rank: 20000,
-  alpha_only: true,
-  min_len: 4,
-  max_len: 10,
-};
 
 function getSubject(ctx) {
   const type = ctx.chat?.type;
@@ -44,15 +37,15 @@ function logFail(stage, err) {
     JSON.stringify({
       msg: "semantle_upstream_fail",
       stage,
-      err: err instanceof Word2SimError ? { status: err.status, body: err.body } : String(err),
+      err: err instanceof UpstreamError ? { status: err.status, body: err.body } : String(err),
     }),
   );
 }
 
 async function startFreshGame(db, client, subject) {
-  const picked = await client.randomWord(RANDOM_FILTERS);
+  const picked = await client.randomWord();
   const target = String(picked?.word ?? "").toLowerCase();
-  if (!target) throw new Word2SimError("empty target from /random");
+  if (!target) throw new UpstreamError("empty target from randomWord");
   const fresh = { target, startedAt: null, solved: false, guesses: [] };
   await saveGame(db, subject, fresh);
   return fresh;
diff --git a/src/modules/semantle/index.js b/src/modules/semantle/index.js
index 0a9dbbc..731c71d 100644
--- a/src/modules/semantle/index.js
+++ b/src/modules/semantle/index.js
@@ -1,16 +1,15 @@
 /**
- * @file Semantle module — word2vec similarity guessing game.
+ * @file Semantle module — similarity guessing game backed by ConceptNet.
  *
- * Target words come from our own hosted word2sim instance
- * (default: https://word2sim.sg.miti99.com). Override via env var
- * `WORD2SIM_API_URL` for local dev or self-hosting.
+ * Targets come from a curated local wordlist (ConceptNet has no /random).
+ * Similarity scores come from `api.conceptnet.io/relatedness`. The ConceptNet
+ * base URL is hardcoded in the client; tests can still override via
+ * `createClient(url)` if needed.
  */
 
 import { createClient } from "./api-client.js";
 import { handleGiveup, handleSemantle, handleStats } from "./handlers.js";
 
-const DEFAULT_API_URL = "https://word2sim.sg.miti99.com";
-
 /** @type {import("../../db/kv-store-interface.js").KVStore | null} */
 let db = null;
 /** @type {ReturnType | null} */
@@ -19,10 +18,9 @@ let client = null;
 /** @type {import("../registry.js").BotModule} */
 const semantleModule = {
   name: "semantle",
-  init: async ({ db: store, env }) => {
+  init: async ({ db: store }) => {
     db = store;
-    const base = env?.WORD2SIM_API_URL || DEFAULT_API_URL;
-    client = createClient(base);
+    client = createClient();
   },
   commands: [
     {
diff --git a/src/modules/semantle/wordlist.js b/src/modules/semantle/wordlist.js
new file mode 100644
index 0000000..8680478
--- /dev/null
+++ b/src/modules/semantle/wordlist.js
@@ -0,0 +1,75 @@
+/**
+ * @file Curated target-word pool for the semantle module.
+ *
+ * ConceptNet has no random-word endpoint, so we ship a hand-picked list of
+ * common, game-friendly English nouns/verbs/adjectives (4–10 ASCII letters).
+ * The list is small on purpose — every entry is a reasonable Semantle target,
+ * which matters more than raw size. Expand freely as the game matures.
+ *
+ * Entries are lowercase and alphabetic only (the similarity endpoint accepts
+ * these directly as `/c/en/` concept IDs).
+ */
+
+// biome-ignore format: keep the list compact and grep-friendly
+export const TARGET_POOL = [
+  // nature / geography
+  "ocean", "mountain", "forest", "desert", "river", "valley", "garden", "island",
+  "beach", "cave", "meadow", "glacier", "volcano", "canyon", "jungle", "lake",
+  "hill", "plateau", "cliff", "harbor", "coast", "swamp", "prairie", "tundra",
+  "delta", "creek", "stream", "pebble", "boulder", "horizon", "iceberg", "dune",
+  // weather / time
+  "winter", "summer", "autumn", "spring", "morning", "evening", "midnight",
+  "sunrise", "sunset", "thunder", "rainbow", "blizzard", "breeze", "drought",
+  "storm", "shadow", "twilight", "decade",
+  // people / relations
+  "friend", "family", "mother", "father", "brother", "sister", "child",
+  "stranger", "neighbor", "partner", "sibling", "elder", "infant",
+  // arts
+  "music", "dance", "poem", "story", "painting", "theater", "cinema", "novel",
+  "symphony", "sculpture", "sketch", "ballet", "opera", "concert",
+  // objects
+  "computer", "phone", "camera", "robot", "engine", "wheel", "pencil", "hammer",
+  "mirror", "bicycle", "umbrella", "lantern", "compass", "anchor", "blanket",
+  "candle", "cushion", "kettle", "ladder", "needle", "paper", "pillow",
+  "scissors", "telescope", "throne", "vase", "window", "zipper", "bottle",
+  "basket", "bridge", "tower",
+  // animals
+  "eagle", "tiger", "dolphin", "rabbit", "snake", "salmon", "wolf", "horse",
+  "butterfly", "elephant", "panda", "falcon", "sparrow", "penguin", "octopus",
+  "beetle", "crow", "dragon", "hawk", "jaguar", "kangaroo", "lion", "monkey",
+  "otter", "parrot", "raccoon", "squirrel", "turtle", "whale", "bear", "fox",
+  "shark",
+  // food
+  "apple", "bread", "cheese", "coffee", "sugar", "pepper", "potato", "orange",
+  "honey", "chocolate", "cinnamon", "almond", "berry", "butter", "grape",
+  "lemon", "olive", "tomato", "walnut", "yogurt", "ginger",
+  // emotions / abstract
+  "love", "anger", "fear", "courage", "sorrow", "wonder", "dream", "memory",
+  "silence", "laughter", "hope", "delight", "regret", "trust", "justice",
+  "freedom", "honor", "peace", "victory", "promise", "secret", "truth",
+  "wisdom", "mystery", "destiny", "patience", "loyalty",
+  // professions
+  "teacher", "doctor", "artist", "farmer", "pilot", "soldier", "writer",
+  "sailor", "hunter", "engineer", "chef", "dentist", "nurse", "judge",
+  "scientist",
+  // body
+  "shoulder", "finger", "elbow", "throat", "pulse", "tongue", "ankle", "spine",
+  "muscle", "nerve", "eyelid", "beard", "tooth", "thumb", "knee",
+  // verbs / actions
+  "gather", "wonder", "linger", "stumble", "whisper", "shimmer", "wander",
+  "rescue", "gallop", "imagine", "pursue", "retreat", "thrive", "squeeze",
+  "shatter", "tremble", "travel", "borrow", "descend", "inherit", "vanish",
+  "forget", "invite", "resist", "settle",
+  // adjectives
+  "ancient", "gentle", "fragile", "massive", "glowing", "rugged", "silent",
+  "frozen", "steady", "brittle", "clever", "gloomy", "cheerful", "noisy",
+  "silver", "golden", "radiant", "distant", "graceful", "humble", "vivid",
+  "quiet", "sacred", "sudden", "tender", "wealthy", "generous", "majestic",
+];
+
+/**
+ * @returns {string} — a random lowercase word from the pool.
+ */
+export function pickFromPool() {
+  return TARGET_POOL[Math.floor(Math.random() * TARGET_POOL.length)];
+}
diff --git a/tests/modules/semantle/api-client.test.js b/tests/modules/semantle/api-client.test.js
index 72c7272..69a3ed0 100644
--- a/tests/modules/semantle/api-client.test.js
+++ b/tests/modules/semantle/api-client.test.js
@@ -1,76 +1,142 @@
 import { afterEach, describe, expect, it, vi } from "vitest";
-import { Word2SimError, createClient } from "../../../src/modules/semantle/api-client.js";
+import {
+  UpstreamError,
+  Word2SimError,
+  createClient,
+} from "../../../src/modules/semantle/api-client.js";
+
+/**
+ * ConceptNet stubs — minimal shape the client cares about.
+ */
+function conceptResp(edgeCount = 5) {
+  return {
+    ok: true,
+    text: () =>
+      Promise.resolve(
+        JSON.stringify({
+          edges: Array.from({ length: edgeCount }, (_, i) => ({ id: `e${i}` })),
+        }),
+      ),
+  };
+}
+
+function relatednessResp(value) {
+  return {
+    ok: true,
+    text: () => Promise.resolve(JSON.stringify({ value })),
+  };
+}
 
 describe("semantle/api-client", () => {
   afterEach(() => {
     vi.restoreAllMocks();
   });
 
-  describe("Word2SimError", () => {
+  describe("UpstreamError", () => {
     it("stores status and body metadata", () => {
-      const err = new Word2SimError("test", { status: 404, body: "not found" });
+      const err = new UpstreamError("test", { status: 404, body: "not found" });
       expect(err.message).toBe("test");
       expect(err.status).toBe(404);
       expect(err.body).toBe("not found");
-      expect(err.name).toBe("Word2SimError");
+      expect(err.name).toBe("UpstreamError");
     });
 
     it("stores cause when provided", () => {
       const cause = new Error("underlying");
-      const err = new Word2SimError("wrapper", { cause });
+      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", () => {
-    it("randomWord builds correct URL with filters", async () => {
+    it("similarity runs concept + relatedness in parallel", async () => {
       const client = createClient("https://api.test", { timeoutMs: 100 });
+      const calls = [];
       global.fetch = vi.fn((url) => {
-        expect(url).toContain("/random");
-        expect(url).toContain("min_rank=5");
-        expect(url).toContain("alpha=true");
-        return Promise.resolve({
-          ok: true,
-          text: () => Promise.resolve('{"word":"apple","rank":1234}'),
-        });
-      });
-      const res = await client.randomWord({ min_rank: 5, alpha: true });
-      expect(res.word).toBe("apple");
-      expect(res.rank).toBe(1234);
-    });
-
-    it("similarity builds URL with both words", async () => {
-      const client = createClient("https://api.test", { timeoutMs: 100 });
-      global.fetch = vi.fn((url) => {
-        expect(url).toContain("/similarity");
-        expect(url).toContain("a=apple");
-        expect(url).toContain("b=orange");
-        return Promise.resolve({
-          ok: true,
-          text: () =>
-            Promise.resolve(
-              '{"a":"apple","b":"orange","in_vocab_a":true,"in_vocab_b":true,"similarity":0.45}',
-            ),
-        });
+        calls.push(String(url));
+        if (url.includes("/relatedness")) return Promise.resolve(relatednessResp(0.45));
+        return Promise.resolve(conceptResp(3));
       });
       const res = await client.similarity("apple", "orange");
       expect(res.similarity).toBe(0.45);
+      expect(res.in_vocab_b).toBe(true);
+      expect(res.canonical_b).toBe("orange");
+      expect(global.fetch).toHaveBeenCalledTimes(2);
+      expect(calls.some((u) => u.includes("/c/en/orange"))).toBe(true);
+      expect(calls.some((u) => u.includes("node1=%2Fc%2Fen%2Fapple"))).toBe(true);
+      expect(calls.some((u) => u.includes("node2=%2Fc%2Fen%2Forange"))).toBe(true);
     });
 
-    it("URL-encodes special characters in params", async () => {
+    it("similarity flags OOV when the concept endpoint returns no edges", async () => {
       const client = createClient("https://api.test", { timeoutMs: 100 });
       global.fetch = vi.fn((url) => {
-        expect(url).toMatch(/search=hello/);
-        return Promise.resolve({
-          ok: true,
-          text: () => Promise.resolve('{"word":"test"}'),
-        });
+        if (url.includes("/relatedness")) return Promise.resolve(relatednessResp(0.02));
+        return Promise.resolve(conceptResp(0));
       });
-      await client.randomWord({ search: "hello world" });
-      expect(global.fetch).toHaveBeenCalled();
+      const res = await client.similarity("apple", "zzzfoo");
+      expect(res.in_vocab_b).toBe(false);
+      expect(res.similarity).toBe(null);
     });
 
-    it("throws Word2SimError on non-2xx response", async () => {
+    it("similarity returns null when relatedness payload lacks a numeric value", async () => {
+      const client = createClient("https://api.test", { timeoutMs: 100 });
+      global.fetch = vi.fn((url) => {
+        if (url.includes("/relatedness")) {
+          return Promise.resolve({ ok: true, text: () => Promise.resolve("{}") });
+        }
+        return Promise.resolve(conceptResp(5));
+      });
+      const res = await client.similarity("apple", "orange");
+      expect(res.similarity).toBe(null);
+    });
+
+    it("similarity distinguishes 0 from null", async () => {
+      const client = createClient("https://api.test", { timeoutMs: 100 });
+      global.fetch = vi.fn((url) => {
+        if (url.includes("/relatedness")) return Promise.resolve(relatednessResp(0));
+        return Promise.resolve(conceptResp(5));
+      });
+      const res = await client.similarity("apple", "orange");
+      expect(res.similarity).toBe(0);
+      expect(res.in_vocab_b).toBe(true);
+    });
+
+    it("randomWord returns a verified pick when edges present", async () => {
+      const client = createClient("https://api.test", { timeoutMs: 100 });
+      global.fetch = vi.fn(() => Promise.resolve(conceptResp(5)));
+      const res = await client.randomWord();
+      expect(typeof res.word).toBe("string");
+      expect(res.word.length).toBeGreaterThan(0);
+      expect(res.verified).toBe(true);
+    });
+
+    it("randomWord falls back to unverified pick after max attempts", async () => {
+      const client = createClient("https://api.test", { timeoutMs: 100 });
+      // Every concept lookup returns zero edges → exhausts retries.
+      global.fetch = vi.fn(() => Promise.resolve(conceptResp(0)));
+      const res = await client.randomWord();
+      expect(res.verified).toBe(false);
+      expect(typeof res.word).toBe("string");
+    });
+
+    it("randomWord swallows transient fetch errors during verification", async () => {
+      const client = createClient("https://api.test", { timeoutMs: 100 });
+      let n = 0;
+      global.fetch = vi.fn(() => {
+        n += 1;
+        // Error for the first few attempts, then succeed.
+        if (n <= 2) return Promise.reject(new Error("transient"));
+        return Promise.resolve(conceptResp(3));
+      });
+      const res = await client.randomWord();
+      expect(res.verified).toBe(true);
+    });
+
+    it("concept throws UpstreamError on non-2xx response", async () => {
       const client = createClient("https://api.test", { timeoutMs: 100 });
       global.fetch = vi.fn(() =>
         Promise.resolve({
@@ -79,95 +145,75 @@ describe("semantle/api-client", () => {
           text: () => Promise.resolve("Internal Server Error"),
         }),
       );
-      await expect(client.randomWord()).rejects.toMatchObject({
-        name: "Word2SimError",
+      await expect(client.concept("apple")).rejects.toMatchObject({
+        name: "UpstreamError",
         status: 500,
         body: "Internal Server Error",
       });
     });
 
-    it("throws Word2SimError when response is not valid JSON", async () => {
+    it("concept throws UpstreamError when response is not valid JSON", async () => {
       const client = createClient("https://api.test", { timeoutMs: 100 });
       global.fetch = vi.fn(() =>
-        Promise.resolve({
-          ok: true,
-          text: () => Promise.resolve("not json at all"),
-        }),
+        Promise.resolve({ ok: true, text: () => Promise.resolve("not json") }),
       );
-      await expect(client.randomWord()).rejects.toMatchObject({
-        name: "Word2SimError",
-      });
+      await expect(client.concept("apple")).rejects.toMatchObject({ name: "UpstreamError" });
     });
 
-    it("throws Word2SimError on fetch failure", async () => {
+    it("concept throws UpstreamError on fetch failure", async () => {
       const client = createClient("https://api.test", { timeoutMs: 100 });
       global.fetch = vi.fn(() => Promise.reject(new Error("network error")));
-      await expect(client.randomWord()).rejects.toThrow("word2sim fetch failed");
+      await expect(client.concept("apple")).rejects.toThrow("conceptnet fetch failed");
     });
 
-    it("uses custom timeout and truncates response body to 500 chars", async () => {
+    it("truncates response body to 500 chars in UpstreamError", async () => {
       const client = createClient("https://api.test", { timeoutMs: 50 });
       const longBody = "x".repeat(600);
       global.fetch = vi.fn(() =>
-        Promise.resolve({
-          ok: false,
-          status: 400,
-          text: () => Promise.resolve(longBody),
-        }),
+        Promise.resolve({ ok: false, status: 400, text: () => Promise.resolve(longBody) }),
       );
       try {
-        await client.randomWord();
+        await client.concept("apple");
       } catch (err) {
         expect(err.body.length).toBe(500);
       }
     });
 
-    it("includes User-Agent header", async () => {
+    it("sends User-Agent and Accept headers", async () => {
       const client = createClient("https://api.test", { timeoutMs: 100 });
       global.fetch = vi.fn((_, opts) => {
         expect(opts.headers["User-Agent"]).toContain("miti99bot");
-        return Promise.resolve({
-          ok: true,
-          text: () => Promise.resolve('{"word":"test"}'),
-        });
-      });
-      await client.randomWord();
-    });
-
-    it("includes Accept header", async () => {
-      const client = createClient("https://api.test", { timeoutMs: 100 });
-      global.fetch = vi.fn((_, opts) => {
         expect(opts.headers.Accept).toBe("application/json");
-        return Promise.resolve({
-          ok: true,
-          text: () => Promise.resolve('{"word":"test"}'),
-        });
+        return Promise.resolve(conceptResp(1));
       });
-      await client.randomWord();
+      await client.concept("apple");
     });
 
-    it("handles trailing slashes in API base URL", async () => {
+    it("strips trailing slashes from the API base URL", async () => {
       const client = createClient("https://api.test///", { timeoutMs: 100 });
       global.fetch = vi.fn((url) => {
-        expect(url.startsWith("https://api.test/")).toBe(true);
-        return Promise.resolve({
-          ok: true,
-          text: () => Promise.resolve('{"word":"test"}'),
-        });
+        expect(url.startsWith("https://api.test/c/en/")).toBe(true);
+        return Promise.resolve(conceptResp(1));
       });
-      await client.randomWord();
+      await client.concept("apple");
     });
 
-    it("filters out undefined/null params", async () => {
+    it("URL-encodes the term path segment", async () => {
       const client = createClient("https://api.test", { timeoutMs: 100 });
       global.fetch = vi.fn((url) => {
-        expect(url).not.toContain("min_rank=");
-        return Promise.resolve({
-          ok: true,
-          text: () => Promise.resolve('{"word":"test"}'),
-        });
+        expect(url).toContain("/c/en/hello%20world");
+        return Promise.resolve(conceptResp(1));
       });
-      await client.randomWord({ min_rank: undefined, max_rank: null });
+      await client.concept("hello world");
+    });
+
+    it("defaults to the public ConceptNet base URL when none provided", async () => {
+      const client = createClient();
+      global.fetch = vi.fn((url) => {
+        expect(url.startsWith("https://api.conceptnet.io/")).toBe(true);
+        return Promise.resolve(conceptResp(1));
+      });
+      await client.concept("apple");
     });
   });
 });
diff --git a/wrangler.toml b/wrangler.toml
index 590f77a..7893261 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -6,8 +6,6 @@ compatibility_date = "2025-10-01"
 # Also duplicate this value into .env.deploy so scripts/register.js derives the same public command list.
 [vars]
 MODULES = "util,wordle,loldle,misc,trading,lolschedule,semantle"
-# Base URL for the hosted word2sim similarity API (semantle module).
-WORD2SIM_API_URL = "https://word2sim.sg.miti99.com"
 
 # KV namespace holding all module state. Each module auto-prefixes its keys via createStore().
 # Production-only — no preview namespace. Create with: