Merge pull request #35 from benjosaur/debug-modal-consistency
Browse files- .env.example +0 -3
- README.md +5 -7
- judge.py +23 -34
- levels/_base.py +0 -3
- levels/animal.py +0 -6
- levels/emotion.py +0 -5
- modal_judge.py +6 -2
- scripts/check_modal.py +2 -2
- tests/test_judge.py +5 -7
.env.example
CHANGED
|
@@ -4,6 +4,3 @@
|
|
| 4 |
MODAL_JUDGE_URL=https://<your-workspace>--semantique-judge-judge-score.modal.run
|
| 5 |
MODAL_KEY=wk-...
|
| 6 |
MODAL_SECRET=ws-...
|
| 7 |
-
|
| 8 |
-
# Offline alternative — no GPU, no network (stub judge):
|
| 9 |
-
# JUDGE_FAKE=1
|
|
|
|
| 4 |
MODAL_JUDGE_URL=https://<your-workspace>--semantique-judge-judge-score.modal.run
|
| 5 |
MODAL_KEY=wk-...
|
| 6 |
MODAL_SECRET=ws-...
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -24,11 +24,11 @@ and uses up one hop. Reach `⏎` and a small open LLM
|
|
| 24 |
judges what emotion your sentence expresses. Check off every target emotion
|
| 25 |
on the list to collect them all.
|
| 26 |
|
| 27 |
-
The judge uses **structured output via exact logprob filtering**:
|
| 28 |
-
|
| 29 |
-
self-hosted open model scores how likely each candidate
|
| 30 |
-
The next-token distribution is masked to the board's labels
|
| 31 |
-
softmax-renormalized — constrained decoding made visible as the verdict card's
|
| 32 |
probability bars.
|
| 33 |
|
| 34 |
> Try it: for **happy**, `start → not → sad → ⏎` works.
|
|
@@ -74,8 +74,6 @@ cp .env.example .env # fill in MODAL_JUDGE_URL + MODAL_KEY/MODAL_SECRE
|
|
| 74 |
python app.py # http://127.0.0.1:7860
|
| 75 |
```
|
| 76 |
|
| 77 |
-
No GPU/Modal handy? `JUDGE_FAKE=1 python app.py` runs an offline stub judge.
|
| 78 |
-
|
| 79 |
Tests: `pytest -m "not slow"` (pure logic) · `pytest -m slow` (live eval against
|
| 80 |
the Modal judge on every board-attainable sentence; skipped if `MODAL_JUDGE_URL`
|
| 81 |
is unset).
|
|
|
|
| 24 |
judges what emotion your sentence expresses. Check off every target emotion
|
| 25 |
on the list to collect them all.
|
| 26 |
|
| 27 |
+
The judge uses **structured output via exact logprob filtering**: a system prompt
|
| 28 |
+
names the board's targets and asks for the one most similar to your sentence, and a
|
| 29 |
+
single forward pass on a self-hosted open model scores how likely each candidate
|
| 30 |
+
label is as the answer. The next-token distribution is masked to the board's labels
|
| 31 |
+
and softmax-renormalized — constrained decoding made visible as the verdict card's
|
| 32 |
probability bars.
|
| 33 |
|
| 34 |
> Try it: for **happy**, `start → not → sad → ⏎` works.
|
|
|
|
| 74 |
python app.py # http://127.0.0.1:7860
|
| 75 |
```
|
| 76 |
|
|
|
|
|
|
|
| 77 |
Tests: `pytest -m "not slow"` (pure logic) · `pytest -m slow` (live eval against
|
| 78 |
the Modal judge on every board-attainable sentence; skipped if `MODAL_JUDGE_URL`
|
| 79 |
is unset).
|
judge.py
CHANGED
|
@@ -1,9 +1,8 @@
|
|
| 1 |
"""The judge: structured output via exact logprob filtering over a self-hosted LLM.
|
| 2 |
|
| 3 |
-
A system message
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
modal_judge.py) scores each candidate label's whole token sequence in one forward
|
| 7 |
pass; we get the *exact* probability of each label, not just whatever lands in a
|
| 8 |
hosted API's top-k. Those per-label logprobs are masked to the level's candidate
|
| 9 |
labels and renormalized with a softmax — constrained decoding, enforced
|
|
@@ -11,7 +10,6 @@ client-side. The candidate set is what makes the answer an emotion on one board
|
|
| 11 |
and an animal on another.
|
| 12 |
|
| 13 |
`score_labels()` is the only seam to the model: it POSTs to the Modal endpoint.
|
| 14 |
-
Set JUDGE_FAKE=1 for an offline stub (no GPU, no network).
|
| 15 |
"""
|
| 16 |
|
| 17 |
import math
|
|
@@ -44,20 +42,17 @@ def assemble_sentence(words: list[str]) -> str:
|
|
| 44 |
return out
|
| 45 |
|
| 46 |
|
| 47 |
-
def build_messages(
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
messages.append({"role": "assistant", "content": ex_label})
|
| 59 |
-
messages.append({"role": "user", "content": f"{sentence} is the same as"})
|
| 60 |
-
return messages
|
| 61 |
|
| 62 |
|
| 63 |
def renormalize(label_logprobs: dict[str, float]) -> dict[str, float]:
|
|
@@ -72,21 +67,20 @@ def score_labels(
|
|
| 72 |
sentence: str,
|
| 73 |
labels: list[str],
|
| 74 |
targets: list[str],
|
| 75 |
-
examples: tuple[tuple[str, str], ...] = (),
|
| 76 |
) -> dict[str, float]:
|
| 77 |
"""One call to the Modal GPU endpoint -> exact per-label logprobs.
|
| 78 |
|
| 79 |
-
The prompt (built from `targets`
|
| 80 |
-
|
| 81 |
"""
|
| 82 |
if not MODAL_JUDGE_URL:
|
| 83 |
-
raise RuntimeError("MODAL_JUDGE_URL is not set (deploy modal_judge.py
|
| 84 |
headers = {}
|
| 85 |
if MODAL_KEY and MODAL_SECRET: # Modal proxy auth
|
| 86 |
headers = {"Modal-Key": MODAL_KEY, "Modal-Secret": MODAL_SECRET}
|
| 87 |
resp = requests.post(
|
| 88 |
MODAL_JUDGE_URL,
|
| 89 |
-
json={"messages": build_messages(sentence, targets
|
| 90 |
headers=headers,
|
| 91 |
timeout=REQUEST_TIMEOUT,
|
| 92 |
)
|
|
@@ -103,7 +97,7 @@ def judge(payload: dict) -> dict:
|
|
| 103 |
round-trip through the client.
|
| 104 |
"""
|
| 105 |
words = payload["words"]
|
| 106 |
-
|
| 107 |
level = get_level(payload["level_id"])
|
| 108 |
labels = level.labels
|
| 109 |
if not words:
|
|
@@ -116,14 +110,9 @@ def judge(payload: dict) -> dict:
|
|
| 116 |
}
|
| 117 |
sentence = assemble_sentence(words)
|
| 118 |
try:
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
fake = {label: -6.0 - i for i, label in enumerate(labels)}
|
| 123 |
-
fake[targets[0] if targets else labels[0]] = -0.3
|
| 124 |
-
probs = renormalize(fake)
|
| 125 |
-
else:
|
| 126 |
-
probs = renormalize(score_labels(sentence, labels, targets, level.examples))
|
| 127 |
except Exception as e:
|
| 128 |
return {"ok": False, "error": str(e)}
|
| 129 |
winner = max(probs, key=probs.get)
|
|
@@ -132,5 +121,5 @@ def judge(payload: dict) -> dict:
|
|
| 132 |
"sentence": sentence,
|
| 133 |
"probs": probs,
|
| 134 |
"winner": winner,
|
| 135 |
-
"verdict": "win" if winner in
|
| 136 |
}
|
|
|
|
| 1 |
"""The judge: structured output via exact logprob filtering over a self-hosted LLM.
|
| 2 |
|
| 3 |
+
A system message names the level's targets and asks for the one most similar to the
|
| 4 |
+
player's message; the user message is the assembled sentence. The model (on a Modal
|
| 5 |
+
GPU — see modal_judge.py) scores each candidate label's whole token sequence in one forward
|
|
|
|
| 6 |
pass; we get the *exact* probability of each label, not just whatever lands in a
|
| 7 |
hosted API's top-k. Those per-label logprobs are masked to the level's candidate
|
| 8 |
labels and renormalized with a softmax — constrained decoding, enforced
|
|
|
|
| 10 |
and an animal on another.
|
| 11 |
|
| 12 |
`score_labels()` is the only seam to the model: it POSTs to the Modal endpoint.
|
|
|
|
| 13 |
"""
|
| 14 |
|
| 15 |
import math
|
|
|
|
| 42 |
return out
|
| 43 |
|
| 44 |
|
| 45 |
+
def build_messages(sentence: str, targets: list[str]) -> list[dict]:
|
| 46 |
+
"""The judge prompt: a system message naming `targets` and asking for the one
|
| 47 |
+
closest to the player's message, then the assembled sentence as the user turn."""
|
| 48 |
+
return [
|
| 49 |
+
{
|
| 50 |
+
"role": "system",
|
| 51 |
+
"content": f"The targets are: {', '.join(targets)}. "
|
| 52 |
+
"Output the one most similar to the user's message.",
|
| 53 |
+
},
|
| 54 |
+
{"role": "user", "content": sentence},
|
| 55 |
+
]
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
def renormalize(label_logprobs: dict[str, float]) -> dict[str, float]:
|
|
|
|
| 67 |
sentence: str,
|
| 68 |
labels: list[str],
|
| 69 |
targets: list[str],
|
|
|
|
| 70 |
) -> dict[str, float]:
|
| 71 |
"""One call to the Modal GPU endpoint -> exact per-label logprobs.
|
| 72 |
|
| 73 |
+
The prompt (built from `targets`) is sent as chat messages; the candidate
|
| 74 |
+
`labels` are what the endpoint scores and constrains the answer to.
|
| 75 |
"""
|
| 76 |
if not MODAL_JUDGE_URL:
|
| 77 |
+
raise RuntimeError("MODAL_JUDGE_URL is not set (deploy modal_judge.py — see README)")
|
| 78 |
headers = {}
|
| 79 |
if MODAL_KEY and MODAL_SECRET: # Modal proxy auth
|
| 80 |
headers = {"Modal-Key": MODAL_KEY, "Modal-Secret": MODAL_SECRET}
|
| 81 |
resp = requests.post(
|
| 82 |
MODAL_JUDGE_URL,
|
| 83 |
+
json={"messages": build_messages(sentence, targets), "labels": labels},
|
| 84 |
headers=headers,
|
| 85 |
timeout=REQUEST_TIMEOUT,
|
| 86 |
)
|
|
|
|
| 97 |
round-trip through the client.
|
| 98 |
"""
|
| 99 |
words = payload["words"]
|
| 100 |
+
remaining = payload["targets"] # still-unchecked labels; only the win check (+ stub) use these
|
| 101 |
level = get_level(payload["level_id"])
|
| 102 |
labels = level.labels
|
| 103 |
if not words:
|
|
|
|
| 110 |
}
|
| 111 |
sentence = assemble_sentence(words)
|
| 112 |
try:
|
| 113 |
+
# Condition on the full target set so the prompt is stable across a session;
|
| 114 |
+
# the win check below, not the prompt, is what tracks remaining targets.
|
| 115 |
+
probs = renormalize(score_labels(sentence, labels, level.targets))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
except Exception as e:
|
| 117 |
return {"ok": False, "error": str(e)}
|
| 118 |
winner = max(probs, key=probs.get)
|
|
|
|
| 121 |
"sentence": sentence,
|
| 122 |
"probs": probs,
|
| 123 |
"winner": winner,
|
| 124 |
+
"verdict": "win" if winner in remaining else "lose",
|
| 125 |
}
|
levels/_base.py
CHANGED
|
@@ -24,9 +24,6 @@ class Level:
|
|
| 24 |
targets: list[str] # labels to collect (a win checks one off)
|
| 25 |
labels: list[str] # full candidate set the judge scores among
|
| 26 |
budget: int # hops before the doodle falls off the board
|
| 27 |
-
# Few-shot (sentence, label) pairs prepended to the judge prompt to calibrate
|
| 28 |
-
# its read of this board. Judge-only — never shipped to the client (no spoilers).
|
| 29 |
-
examples: tuple[tuple[str, str], ...] = ()
|
| 30 |
order: int = 100 # play order across boards (lower comes first)
|
| 31 |
home: bool = False # the board the app boots into
|
| 32 |
|
|
|
|
| 24 |
targets: list[str] # labels to collect (a win checks one off)
|
| 25 |
labels: list[str] # full candidate set the judge scores among
|
| 26 |
budget: int # hops before the doodle falls off the board
|
|
|
|
|
|
|
|
|
|
| 27 |
order: int = 100 # play order across boards (lower comes first)
|
| 28 |
home: bool = False # the board the app boots into
|
| 29 |
|
levels/animal.py
CHANGED
|
@@ -21,12 +21,6 @@ LEVEL = Level(
|
|
| 21 |
start=(2, 2), # the centre tile
|
| 22 |
targets=["whale", "dolphin", "otter", "cat", "dog", "bear", "owl", "mouse", "lion", "cow"],
|
| 23 |
labels=["whale", "dolphin", "otter", "cat", "dog", "bear", "owl", "mouse", "lion", "cow"],
|
| 24 |
-
examples=( # few-shot calibration for the judge (board words hinting animals)
|
| 25 |
-
("sea big", "whale"),
|
| 26 |
-
("puppy", "dog"),
|
| 27 |
-
("flying quiet", "owl"),
|
| 28 |
-
("grass big", "cow"),
|
| 29 |
-
),
|
| 30 |
budget=12,
|
| 31 |
order=1,
|
| 32 |
)
|
|
|
|
| 21 |
start=(2, 2), # the centre tile
|
| 22 |
targets=["whale", "dolphin", "otter", "cat", "dog", "bear", "owl", "mouse", "lion", "cow"],
|
| 23 |
labels=["whale", "dolphin", "otter", "cat", "dog", "bear", "owl", "mouse", "lion", "cow"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
budget=12,
|
| 25 |
order=1,
|
| 26 |
)
|
levels/emotion.py
CHANGED
|
@@ -20,11 +20,6 @@ LEVEL = Level(
|
|
| 20 |
start=(1, 1), # row, col of the start tile
|
| 21 |
targets=["happy", "betrayed", "surprised"], # collect them all
|
| 22 |
labels=["happy", "betrayed", "surprised"],
|
| 23 |
-
examples=( # few-shot calibration for the judge (board words, all three labels)
|
| 24 |
-
("great!", "happy"),
|
| 25 |
-
("hurt", "betrayed"),
|
| 26 |
-
("yikes", "surprised"),
|
| 27 |
-
),
|
| 28 |
budget=10,
|
| 29 |
order=0,
|
| 30 |
home=True,
|
|
|
|
| 20 |
start=(1, 1), # row, col of the start tile
|
| 21 |
targets=["happy", "betrayed", "surprised"], # collect them all
|
| 22 |
labels=["happy", "betrayed", "surprised"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
budget=10,
|
| 24 |
order=0,
|
| 25 |
home=True,
|
modal_judge.py
CHANGED
|
@@ -167,8 +167,12 @@ def main(sentence: str = "not sad"):
|
|
| 167 |
"""Smoke test against the deployed/served class: `modal run modal_judge.py`."""
|
| 168 |
labels = ["happy", "betrayed", "surprised"]
|
| 169 |
messages = [
|
| 170 |
-
{
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
]
|
| 173 |
result = Judge().score_remote.remote(messages, labels)
|
| 174 |
print(f"{sentence!r}")
|
|
|
|
| 167 |
"""Smoke test against the deployed/served class: `modal run modal_judge.py`."""
|
| 168 |
labels = ["happy", "betrayed", "surprised"]
|
| 169 |
messages = [
|
| 170 |
+
{
|
| 171 |
+
"role": "system",
|
| 172 |
+
"content": f"The targets are: {', '.join(labels)}. "
|
| 173 |
+
"Output the one most similar to the user's message.",
|
| 174 |
+
},
|
| 175 |
+
{"role": "user", "content": sentence},
|
| 176 |
]
|
| 177 |
result = Judge().score_remote.remote(messages, labels)
|
| 178 |
print(f"{sentence!r}")
|
scripts/check_modal.py
CHANGED
|
@@ -36,8 +36,8 @@ SENTENCES = ["not sad", "great!", "very great!", "hurt", "yikes", "great sad"]
|
|
| 36 |
|
| 37 |
|
| 38 |
def call(sentence: str) -> dict:
|
| 39 |
-
# Mirror the in-game call: every label is a target
|
| 40 |
-
messages = build_messages(sentence, LEVEL.labels
|
| 41 |
resp = requests.post(
|
| 42 |
URL,
|
| 43 |
json={"messages": messages, "labels": LEVEL.labels, "debug": True},
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
def call(sentence: str) -> dict:
|
| 39 |
+
# Mirror the in-game call: every label is a target.
|
| 40 |
+
messages = build_messages(sentence, LEVEL.labels)
|
| 41 |
resp = requests.post(
|
| 42 |
URL,
|
| 43 |
json={"messages": messages, "labels": LEVEL.labels, "debug": True},
|
tests/test_judge.py
CHANGED
|
@@ -46,13 +46,11 @@ def test_live_judge_on_board_sentences(sentence, target, should_win):
|
|
| 46 |
from judge import score_labels
|
| 47 |
from levels import get_level
|
| 48 |
|
| 49 |
-
# Mirror the in-game judge() call: every label is passed as a target
|
| 50 |
-
# system prompt
|
| 51 |
-
#
|
| 52 |
-
#
|
| 53 |
level = get_level("emotion")
|
| 54 |
-
probs = renormalize(
|
| 55 |
-
score_labels(sentence, level.labels, targets=level.labels, examples=level.examples)
|
| 56 |
-
)
|
| 57 |
winner = max(probs, key=probs.get)
|
| 58 |
assert (winner == target) == should_win, f"{sentence!r} -> {probs}"
|
|
|
|
| 46 |
from judge import score_labels
|
| 47 |
from levels import get_level
|
| 48 |
|
| 49 |
+
# Mirror the in-game judge() call: every label is passed as a target, so the
|
| 50 |
+
# system prompt names them all and asks for the one closest to the sentence.
|
| 51 |
+
# This measures the discrimination players actually get, which is what makes
|
| 52 |
+
# labels like "betrayed"/"surprised" reachable at all.
|
| 53 |
level = get_level("emotion")
|
| 54 |
+
probs = renormalize(score_labels(sentence, level.labels, targets=level.labels))
|
|
|
|
|
|
|
| 55 |
winner = max(probs, key=probs.get)
|
| 56 |
assert (winner == target) == should_win, f"{sentence!r} -> {probs}"
|