Spaces:
Running
Running
Update app.py
Browse files
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 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = "
|
| 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=
|
| 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
|
|
|
|
| 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
|
| 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=
|
| 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.
|
| 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 |
-
#
|
| 446 |
-
#
|
| 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 |
-
|
| 459 |
-
|
| 460 |
-
|
| 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:
|