Ben Blaker commited on
Commit
c8fda64
·
unverified ·
2 Parent(s): 17c3e5f491911f

Merge pull request #35 from benjosaur/debug-modal-consistency

Browse files
.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**: the prompt is
28
- your sentence followed by `is the same as`, and a single forward pass on a
29
- self-hosted open model scores how likely each candidate label is as the answer.
30
- The next-token distribution is masked to the board's labels and
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 lists the level's still-needed targets, optionally followed by a
4
- few (sentence, label) examples as few-shot turns; the final user message is the
5
- assembled sentence followed by " is the same as". The model (on a Modal GPU — see
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
- sentence: str,
49
- targets: list[str],
50
- examples: tuple[tuple[str, str], ...] = (),
51
- ) -> list[dict]:
52
- """The judge prompt: a system message listing `targets`, then each (sentence,
53
- label) in `examples` as a few-shot turn in the "<sentence> is the same as" ->
54
- "<label>" shape, then the final "<sentence> is the same as"."""
55
- messages = [{"role": "system", "content": f"The targets are: {', '.join(targets)}."}]
56
- for ex_sentence, ex_label in examples:
57
- messages.append({"role": "user", "content": f"{ex_sentence} is the same as"})
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`/`examples`) is sent as chat messages; the
80
- candidate `labels` are what the endpoint scores and constrains the answer to.
81
  """
82
  if not MODAL_JUDGE_URL:
83
- raise RuntimeError("MODAL_JUDGE_URL is not set (deploy modal_judge.py, or use JUDGE_FAKE=1)")
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, examples), "labels": labels},
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
- targets = payload["targets"] # still-unchecked labels; any of them wins
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
- if os.environ.get("JUDGE_FAKE"): # offline dev mode: no GPU, no network
120
- # Favour the first still-needed target so a collect-them-all run
121
- # is playable without a token.
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 targets else "lose",
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
- {"role": "system", "content": f"The targets are: {', '.join(labels)}."},
171
- {"role": "user", "content": f"{sentence} is the same as"},
 
 
 
 
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, board examples calibrate.
40
- messages = build_messages(sentence, LEVEL.labels, LEVEL.examples)
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 (so the
50
- # system prompt hints no single answer) and the board's own few-shot examples
51
- # calibrate the read. This measures the discrimination players actually get,
52
- # which is what makes labels like "betrayed"/"surprised" reachable at all.
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}"