shmulc commited on
Commit
07abc97
ยท
verified ยท
1 Parent(s): 5f7ce52

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +46 -22
app.py CHANGED
@@ -48,9 +48,14 @@ SECOND_OPINION = os.environ.get("SECOND_OPINION", "1").lower() not in ("0", "fal
48
  # how hard to avoid enemy/neutral/assassin words (lam_*). Cautious only plays rock-solid
49
  # clues (and refuses more); bold reaches for more words and tolerates a tighter enemy.
50
  RISK_PROFILES = {
51
- "cautious": dict(m=2, lam_a=3.0, lam_opp=1.5, lam_neu=0.5, keep=0.68, safe_margin=0.05),
52
- "balanced": dict(m=3, lam_a=2.0, lam_opp=1.0, lam_neu=0.3, keep=0.55, safe_margin=0.02),
53
- "bold": dict(m=4, lam_a=1.5, lam_opp=0.7, lam_neu=0.2, keep=0.45, safe_margin=0.0),
 
 
 
 
 
54
  }
55
  # Which keys parameterise candidate generation vs. the count-trim threshold. safe_margin is the
56
  # real risk dial: how far a team word must outrank every enemy word to count toward a clue.
@@ -125,7 +130,7 @@ MODELS = [
125
  {"id": probe.LLM_BIG, "label": "12B (ืื™ื›ื•ืชื™)"},
126
  ]
127
  ENCODER_KEYS = list(probe.ENCODERS.keys())
128
- GEO_ENC = "fasttext" # the principled Hebrew geometry (see project memory)
129
  XENC = "neodictabert" # cross-engine second opinion for the operative (no LLM)
130
 
131
  _llms: dict = {}
@@ -177,7 +182,7 @@ def geo_assets():
177
  # essentially any common, legal Hebrew noun/adjective is a candidate โ€” not a small list.
178
  # The POS + frequency floor are quality guards (they keep junk/function words from
179
  # winning the geometry); legality (board word/shoresh) and the blocklist are the rest.
180
- vocab, counts = probe.clue_vocab_band(20000, lo=300, hi=80000,
181
  pos={"NOUN", "ADJ"}, source_n=30000)
182
  freq = probe.freq_scores(counts, lo=1500, hi=40000)
183
  block = _load_blocklist() # drop offensive terms from the clue pool
@@ -339,7 +344,8 @@ def space():
339
  # --------------------------------------------------------------------------- #
340
 
341
  def _analyze_clue(board: probe.Board, word: str, targets, count, score,
342
- focus, reason: str = "", keep_rel: float = 0.66) -> dict:
 
343
  """Full operative-eye analysis of one candidate clue: how the board reads, the *safe run*
344
  (team words a guesser reaches before any enemy), what it leaks, assassin proximity, a
345
  geometry rationale, and an honest no-clue verdict. Each entry in the spymaster `options`
@@ -347,7 +353,7 @@ def _analyze_clue(board: probe.Board, word: str, targets, count, score,
347
 
348
  `targets` are the words the candidate was optimised for (focus / best-m); leak & risk are
349
  judged against them. The *recommended* number and the lit-up words, though, are the full
350
- safe run โ€” so a clue chosen for 2 words that safely covers 5 is reported as 5."""
351
  read = _read_clue(board, word)
352
  target_sims = [r["sim"] for r in read if r["word"] in targets]
353
  floor = min(target_sims) if target_sims else -1.0
@@ -390,9 +396,14 @@ def _analyze_clue(board: probe.Board, word: str, targets, count, score,
390
  focusset = set(focus or [])
391
  disp_intended = []
392
  if not no_clue:
 
 
 
393
  disp_intended = probe.served_count(read, keep_rel=keep_rel, pin=focusset,
394
- enc=get_enc(GEO_ENC), cohesion_floor=COH_FLOOR,
395
- cohesion_mode=COH_MODE)
 
 
396
  disp_count = len(disp_intended)
397
  reason = reason or _geo_reason(disp_intended or targets, board, read)
398
  return {"word": word, "count": disp_count, "intended": disp_intended, "score": score,
@@ -425,12 +436,24 @@ def coach_spymaster():
425
  if not clue or probe.llm_root_conflicts(get_llm(mid), [clue.word], board.words):
426
  return jsonify(error="DictaLM ืœื ื”ืฆืœื™ื— ืœื”ื—ื–ื™ืจ ืจืžื– ื—ื•ืงื™, ื ืกื” ืฉื•ื‘ ืื• ืขื‘ื•ืจ ืœื’ืื•ืžื˜ืจื™ื”")
427
  options = [_analyze_clue(board, clue.word, clue.intended, clue.count, clue.margin,
428
- focus, reason=clue.reason, keep_rel=keep_rel)]
 
429
  else:
430
  vocab, emb, lems, freq = geo_assets()
431
  cands = probe.encoder_clue_candidates(get_enc(GEO_ENC), board, vocab, emb,
432
- vocab_lemmas=lems, vocab_freq=freq, lam_f=0.05,
433
  n=10, targets=focus, **cand_kw)
 
 
 
 
 
 
 
 
 
 
 
434
  if engine == "hybrid": # shoresh/derivative gate (DictaLM); geometry stays LLM-free
435
  bad = probe.llm_root_conflicts(get_llm(mid), [c["word"] for c in cands], board.words)
436
  cands = [c for c in cands if c["word"] not in bad] or cands # keep >=1
@@ -439,26 +462,27 @@ def coach_spymaster():
439
  chosen = probe.llm_pick_clue(get_llm(mid), board, cands)
440
  picked = next((i for i, c in enumerate(cands) if c["word"] == chosen.word), 0)
441
  options = [_analyze_clue(board, c["word"], c["intended"], c["count"], c["score"], focus,
442
- keep_rel=keep_rel)
443
  for c in cands]
444
  if engine == "geometry":
445
- # order bestโ†’worst so the first clue shown is the strongest. Refusals always last,
446
- # and a lonely 1-word clue (weak/trivial in Codenames) is pushed below any clue that
447
- # genuinely covers 2+ words. Safety-first modes (cautious/balanced) then push risky
448
- # clues down and lead with the longest safe run; bold leads with *coverage* (most
449
- # words the clue claims) without burying a risky clue โ€” matching what the dial promises.
450
  if risk == "bold":
451
  order = sorted(range(len(options)), key=lambda i: (
452
  1 if options[i]["no_clue"] else 0,
453
- -options[i]["count"], -options[i]["score"]))
 
 
 
 
 
454
  else:
455
  order = sorted(range(len(options)), key=lambda i: (
456
  1 if options[i]["no_clue"] else 0,
457
  1 if options[i]["risky"] else 0,
458
- 1 if options[i]["count"] <= 1 else 0,
459
- -options[i]["safe"], -options[i]["score"]))
460
- options = [options[i] for i in order]
461
- shortlist = [shortlist[i] for i in order]
462
  picked = 0
463
 
464
  if not options:
 
48
  # how hard to avoid enemy/neutral/assassin words (lam_*). Cautious only plays rock-solid
49
  # clues (and refuses more); bold reaches for more words and tolerates a tighter enemy.
50
  RISK_PROFILES = {
51
+ # Scoring weights validated on bench_clue.py (real serving path + validated fasttext+qwen
52
+ # guesser); a param search did not beat them on held-out boards. The count-trim `keep`
53
+ # (keep_rel) was tightened for balanced (0.55โ†’0.66) and bold (0.45โ†’0.55): across two
54
+ # held-out seeds this drops the shaky last word, lifting safe-turn rate ~+0.10 and recovery
55
+ # ~+0.03 and cutting over-claim ~0.15, for a small coverage cost โ€” the "weak third word" fix.
56
+ "cautious": dict(m=2, lam_a=3.0, lam_opp=1.3, lam_neu=0.7, keep=0.68, safe_margin=0.05),
57
+ "balanced": dict(m=3, lam_a=2.5, lam_opp=0.9, lam_neu=0.6, keep=0.66, safe_margin=0.02),
58
+ "bold": dict(m=4, lam_a=1.8, lam_opp=0.7, lam_neu=0.4, keep=0.55, safe_margin=0.0),
59
  }
60
  # Which keys parameterise candidate generation vs. the count-trim threshold. safe_margin is the
61
  # real risk dial: how far a team word must outrank every enemy word to count toward a clue.
 
130
  {"id": probe.LLM_BIG, "label": "12B (ืื™ื›ื•ืชื™)"},
131
  ]
132
  ENCODER_KEYS = list(probe.ENCODERS.keys())
133
+ GEO_ENC = "blend_0.7_0.3" # Concatenated L2-normalized fastText + Numberbatch blend
134
  XENC = "neodictabert" # cross-engine second opinion for the operative (no LLM)
135
 
136
  _llms: dict = {}
 
182
  # essentially any common, legal Hebrew noun/adjective is a candidate โ€” not a small list.
183
  # The POS + frequency floor are quality guards (they keep junk/function words from
184
  # winning the geometry); legality (board word/shoresh) and the blocklist are the rest.
185
+ vocab, counts = probe.clue_vocab_band(20000, lo=1000, hi=80000,
186
  pos={"NOUN", "ADJ"}, source_n=30000)
187
  freq = probe.freq_scores(counts, lo=1500, hi=40000)
188
  block = _load_blocklist() # drop offensive terms from the clue pool
 
344
  # --------------------------------------------------------------------------- #
345
 
346
  def _analyze_clue(board: probe.Board, word: str, targets, count, score,
347
+ focus, reason: str = "", keep_rel: float = 0.66,
348
+ max_count: int | None = None) -> dict:
349
  """Full operative-eye analysis of one candidate clue: how the board reads, the *safe run*
350
  (team words a guesser reaches before any enemy), what it leaks, assassin proximity, a
351
  geometry rationale, and an honest no-clue verdict. Each entry in the spymaster `options`
 
353
 
354
  `targets` are the words the candidate was optimised for (focus / best-m); leak & risk are
355
  judged against them. The *recommended* number and the lit-up words, though, are the full
356
+ safe run, capped by `max_count` when a risk profile sets a maximum claim."""
357
  read = _read_clue(board, word)
358
  target_sims = [r["sim"] for r in read if r["word"] in targets]
359
  floor = min(target_sims) if target_sims else -1.0
 
396
  focusset = set(focus or [])
397
  disp_intended = []
398
  if not no_clue:
399
+ encoder = get_enc(GEO_ENC)
400
+ cliff_factor = 0.4 if getattr(encoder, "model_id", "").startswith("blend_") else 0.5
401
+ coh_floor = 0.15 if getattr(encoder, "model_id", "").startswith("blend_") else COH_FLOOR
402
  disp_intended = probe.served_count(read, keep_rel=keep_rel, pin=focusset,
403
+ enc=encoder, cohesion_floor=coh_floor,
404
+ cohesion_mode=COH_MODE, cliff=cliff_factor)
405
+ if max_count is not None:
406
+ disp_intended = disp_intended[:max_count]
407
  disp_count = len(disp_intended)
408
  reason = reason or _geo_reason(disp_intended or targets, board, read)
409
  return {"word": word, "count": disp_count, "intended": disp_intended, "score": score,
 
436
  if not clue or probe.llm_root_conflicts(get_llm(mid), [clue.word], board.words):
437
  return jsonify(error="DictaLM ืœื ื”ืฆืœื™ื— ืœื”ื—ื–ื™ืจ ืจืžื– ื—ื•ืงื™, ื ืกื” ืฉื•ื‘ ืื• ืขื‘ื•ืจ ืœื’ืื•ืžื˜ืจื™ื”")
438
  options = [_analyze_clue(board, clue.word, clue.intended, clue.count, clue.margin,
439
+ focus, reason=clue.reason, keep_rel=keep_rel,
440
+ max_count=prof["m"])]
441
  else:
442
  vocab, emb, lems, freq = geo_assets()
443
  cands = probe.encoder_clue_candidates(get_enc(GEO_ENC), board, vocab, emb,
444
+ vocab_lemmas=lems, vocab_freq=freq, lam_f=0.14,
445
  n=10, targets=focus, **cand_kw)
446
+ if risk == "bold":
447
+ # A bold player should not be offered less coverage merely because the bolder
448
+ # scoring surface missed a strong balanced cluster. Merge that independent
449
+ # shortlist, then let the bold ordering choose coverage first.
450
+ balanced = RISK_PROFILES["balanced"]
451
+ balanced_kw = {key: balanced[key] for key in _CAND_KEYS}
452
+ fallback = probe.encoder_clue_candidates(
453
+ get_enc(GEO_ENC), board, vocab, emb, vocab_lemmas=lems,
454
+ vocab_freq=freq, lam_f=0.14, n=10, targets=focus, **balanced_kw)
455
+ seen = {candidate["word"] for candidate in cands}
456
+ cands.extend(candidate for candidate in fallback if candidate["word"] not in seen)
457
  if engine == "hybrid": # shoresh/derivative gate (DictaLM); geometry stays LLM-free
458
  bad = probe.llm_root_conflicts(get_llm(mid), [c["word"] for c in cands], board.words)
459
  cands = [c for c in cands if c["word"] not in bad] or cands # keep >=1
 
462
  chosen = probe.llm_pick_clue(get_llm(mid), board, cands)
463
  picked = next((i for i, c in enumerate(cands) if c["word"] == chosen.word), 0)
464
  options = [_analyze_clue(board, c["word"], c["intended"], c["count"], c["score"], focus,
465
+ keep_rel=keep_rel, max_count=prof["m"])
466
  for c in cands]
467
  if engine == "geometry":
468
+ # The risk dial is policy, not just a scoring weight: cautious maximises safety;
469
+ # balanced maximises safe coverage; bold maximises coverage while retaining refusals.
 
 
 
470
  if risk == "bold":
471
  order = sorted(range(len(options)), key=lambda i: (
472
  1 if options[i]["no_clue"] else 0,
473
+ -options[i]["count"], -options[i]["safe"], -options[i]["score"]))
474
+ elif risk == "balanced":
475
+ order = sorted(range(len(options)), key=lambda i: (
476
+ 1 if options[i]["no_clue"] else 0,
477
+ 1 if options[i]["risky"] else 0,
478
+ -options[i]["safe"], -options[i]["count"], -options[i]["score"]))
479
  else:
480
  order = sorted(range(len(options)), key=lambda i: (
481
  1 if options[i]["no_clue"] else 0,
482
  1 if options[i]["risky"] else 0,
483
+ -options[i]["safe"], -options[i]["count"], -options[i]["score"]))
484
+ options = [options[i] for i in order][:10]
485
+ shortlist = [shortlist[i] for i in order][:10]
 
486
  picked = 0
487
 
488
  if not options: