Remove the Mosaics tool; def card stops rebuilding on submode clicks

The two-word generator goes (the /api/word phonetics card stays);
the definition card now belongs to the word — switching Rhymes/
Synonyms only swaps the list, and a dismissed card stays dismissed
until a new word is looked up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 04:24:11 -04:00
parent e45fbf6eb3
commit e1b47e3af7
3 changed files with 10 additions and 106 deletions
-77
View File
@@ -32,7 +32,6 @@ async def lifespan(app: FastAPI):
except Exception:
pass
get_slant_index()
get_mosaic_indexes()
yield
@@ -1094,79 +1093,6 @@ def _ranked(words, exclude: set[str], limit: int) -> list[dict]:
return out
_phones_index: dict[str, list[str]] | None = None
_rime_index: dict[str, list[str]] | None = None
SQUEEZABLE = {"AH", "IH", "EH", "UH", "ER"} # vowels that reduce unstressed
def _squeeze(phones: str) -> str:
return " ".join("x" if p in SQUEEZABLE else p for p in phones.split())
def get_mosaic_indexes():
"""full-phones -> words (exact and vowel-squeezed) and rime -> words,
normalized, built once."""
global _phones_index, _rime_index
if _phones_index is None:
pronouncing.init_cmu()
pidx: dict[str, list[str]] = defaultdict(list)
ridx: dict[str, list[str]] = defaultdict(list)
for w, phones in pronouncing.pronunciations:
if not w.isalpha() or zipf_frequency(w, "en") < 3.0:
continue
ph = _norm_r(phones)
bare = DIGITS.sub("", ph)
pidx[bare].append(w)
squeezed = _squeeze(bare)
if squeezed != bare:
pidx[squeezed].append(w)
ridx[DIGITS.sub("", pronouncing.rhyming_part(ph))].append(w)
_phones_index, _rime_index = pidx, ridx
return _phones_index, _rime_index
def mosaics_for(w: str, limit: int) -> list[dict]:
"""Two-word phrases that rhyme with w perfectly: split its rime at
every consonant boundary and search both halves (placement ->
race + meant)."""
phones = phones_for(w)
if not phones:
return []
# anchor at the FIRST vowel: tonight (AH N AY T) splits into a + night
pl = DIGITS.sub("", phones).split()
vi = next((i for i, p in enumerate(pl) if p in ARPA_VOWELS), None)
if vi is None:
return []
rime = pl[vi:]
pidx, ridx = get_mosaic_indexes()
pairs: dict[str, float] = {}
for i in range(1, len(rime)):
left, right = " ".join(rime[:i]), " ".join(rime[i:])
if not any(p in ARPA_VOWELS for p in rime[:i]):
continue
if not any(p in ARPA_VOWELS for p in rime[i:]):
continue
# exact tail match, plus squeezed: where the target carries a
# reducible vowel, the tail word's own vowel may squeeze to fit
# (placement -> place + meant, the EH collapsing to a schwa)
tails = {right: 2.0, _squeeze(right): 0.0}
for a in ridx.get(left, []):
for tail_key, bonus in tails.items():
for b in pidx.get(tail_key, []):
if a == w or b == w:
continue
phrase = f"{a} {b}"
score = (zipf_frequency(a, "en")
+ zipf_frequency(b, "en") + bonus
- (1.5 if len(a) < 2 else 0))
if score > pairs.get(phrase, 0):
pairs[phrase] = score
ranked = sorted(pairs.items(), key=lambda kv: (-kv[1], kv[0]))
return [{"word": p, "syl": None} for p, _ in ranked[:limit]]
@app.get("/api/word")
def word_info(word: str):
"""Phonetic anatomy of a word: phones, syllables, stress, rime."""
@@ -1192,9 +1118,6 @@ def lookup(word: str, mode: str = "rhyme", limit: int = 60):
sections = synonyms_for(w, limit)
return {"word": w, "mode": mode, "known": bool(sections),
"sections": sections}
if mode == "mosaic":
words = mosaics_for(w, limit)
return {"word": w, "mode": mode, "known": bool(words), "words": words}
phones = phones_for(w)
if not phones:
return {"word": w, "mode": mode, "known": False, "words": []}
+10 -19
View File
@@ -298,7 +298,6 @@ Double-click any word to look it up on the right."></textarea>
<div class="seg" id="modeSeg">
<button class="active" data-mode="rhyme">Rhymes</button>
<button data-mode="syn">Synonyms</button>
<button data-mode="mosaic" title="Two-word phrases that rhyme with it">Mosaics</button>
</div>
<div id="defBox"></div>
<div id="lookupResults">
@@ -648,18 +647,18 @@ async function doLookup(){
const word = document.getElementById('lookupInput').value.trim();
if(!word) return;
document.getElementById('tab-lookup').scrollTop = 0;
showDefinition(word); // the definition always pins on top
// the card belongs to the word, not the submode: rebuild only when
// the word changes, and respect a dismissal until then
if(word.toLowerCase() !== defWord){
defWord = word.toLowerCase();
defDismissed = false;
showDefinition(word);
}
resultsBox.innerHTML = '<p class="muted">Searching…</p>';
try{
const r = await fetch(`/api/lookup?word=${encodeURIComponent(word)}&mode=${mode}`);
const data = await r.json();
if(mode === 'mosaic'){
if(!data.known){
resultsBox.innerHTML = `<p class="muted">No clean two-word split for “${esc(word)}” — mosaics need a sound the dictionary can rebuild from two words (try placement, creation, tonight).</p>`;
return;
}
renderMosaics(word, data.words);
}else if(mode === 'syn'){
if(mode === 'syn'){
if(!data.known){
resultsBox.innerHTML = `<p class="muted">No synonyms found for “${esc(word)}”.</p>`;
return;
@@ -694,6 +693,7 @@ function wireChips(){
/* ---------- definition card (free dictionary API) ---------- */
const defBox = document.getElementById('defBox');
let defWord = null, defDismissed = false;
function defCard(word, inner){
defBox.innerHTML = `
<div class="defcard">
@@ -705,7 +705,7 @@ function defCard(word, inner){
</div>
</div>`;
defBox.querySelector('#defInsert').addEventListener('click', ()=> insertAtCursor(word));
defBox.querySelector('.defx').addEventListener('click', ()=>{ defBox.innerHTML=''; });
defBox.querySelector('.defx').addEventListener('click', ()=>{ defBox.innerHTML=''; defDismissed = true; });
}
function draftMates(word){
if(!analysis) return [];
@@ -759,15 +759,6 @@ function renderChips(label, words){
resultsBox.innerHTML = `<div class="res-label">${label}</div>` + chipHtml(words.slice(0,50));
wireChips();
}
function renderMosaics(word, items){
let h = `<div class="res-label">two-word rhymes for “${esc(word)}” — click to insert</div>`;
h += chipHtml(items.map(d=>d.word));
resultsBox.innerHTML = h;
resultsBox.querySelectorAll('.chip').forEach(c=>{
c.addEventListener('click', ()=> insertAtCursor(c.dataset.w));
});
}
function renderSections(word, sections){
if(!sections.length){ resultsBox.innerHTML = `<p class="muted">No synonyms for “${esc(word)}”.</p>`; return; }
let h = '';
-10
View File
@@ -519,16 +519,6 @@ def test_secondary_pronunciation_stays_local():
# -------------------------------------------------------------- new tools
def test_mosaic_generator():
from app import mosaics_for
words = {m["word"] for m in mosaics_for("placement", 20)}
assert "place meant" in words
creation = {m["word"] for m in mosaics_for("creation", 20)}
assert "way shun" in creation
tonight = {m["word"] for m in mosaics_for("tonight", 20)}
assert "a night" in tonight
def test_word_info():
from app import word_info
info = word_info(word="tonight")