jitcatch 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ """pytest adapter."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ import shutil
7
+ import sys
8
+ from pathlib import Path, PurePosixPath
9
+
10
+ from ..models import Outcome, TestRun
11
+ from .base import Adapter, TestPlacement, make_run, run_cmd
12
+
13
+ log = logging.getLogger("jitcatch")
14
+
15
+
16
+ class PythonAdapter(Adapter):
17
+ name = "python"
18
+ fence_langs = ("python", "py")
19
+ source_exts = (".py",)
20
+ _resolved: str | None = None
21
+
22
+ def __init__(self, settings, repo):
23
+ super().__init__(settings, repo)
24
+ self._pytest_path = _UNSET
25
+
26
+ def is_test_file(self, rel_path: str) -> bool:
27
+ p = PurePosixPath(rel_path)
28
+ n = p.name
29
+ return (
30
+ n.startswith("test_") or n.endswith("_test.py") or n == "conftest.py"
31
+ or any(part in ("tests", "test") for part in p.parts[:-1])
32
+ )
33
+
34
+ def handles(self, rel_path: str) -> bool:
35
+ if not super().handles(rel_path):
36
+ return False
37
+ n = PurePosixPath(rel_path).name
38
+ return n not in ("setup.py", "noxfile.py", "manage.py") and "/migrations/" not in f"/{rel_path}"
39
+
40
+ def _project_envs(self) -> list[str]:
41
+ """The project's own interpreters (they have its dependencies): repo venvs, then the active venv."""
42
+ envs: list[str] = []
43
+ for rel in (".venv/bin/python", "venv/bin/python", ".venv/Scripts/python.exe"):
44
+ p = self.repo / rel
45
+ if p.exists():
46
+ envs.append(str(p))
47
+ venv = os.environ.get("VIRTUAL_ENV")
48
+ if venv and (Path(venv) / "bin" / "python").exists():
49
+ envs.append(str(Path(venv) / "bin" / "python"))
50
+ return list(dict.fromkeys(envs))
51
+
52
+ def _candidates(self) -> list[str]:
53
+ cands = [c for c in (shutil.which("python3"), shutil.which("python"), sys.executable) if c]
54
+ return list(dict.fromkeys(cands))
55
+
56
+ def python_exe(self) -> str:
57
+ """Interpreter used to run tests: the configured one, else the project's venv (it has the project's
58
+ dependencies; pytest is supplied by jitcatch if missing), else the first PATH interpreter with pytest."""
59
+ if self.settings.get("python"):
60
+ return self.settings["python"]
61
+ if self._resolved is None:
62
+ envs = self._project_envs()
63
+ if envs:
64
+ self._resolved = envs[0]
65
+ else:
66
+ cands = self._candidates()
67
+ self._resolved = next((c for c in cands if _has_pytest(c, self.repo)), cands[0])
68
+ return self._resolved
69
+
70
+ def pytest_path(self) -> str | None:
71
+ """Extra PYTHONPATH entry that provides pytest: "" if the interpreter already has it, a jitcatch cache
72
+ directory if jitcatch installed it there (the project is never modified), None if unavailable."""
73
+ if self._pytest_path is _UNSET:
74
+ exe = self.python_exe()
75
+ self._pytest_path = "" if _has_pytest(exe, self.repo) else _provision_pytest(exe, self.repo)
76
+ return self._pytest_path
77
+
78
+ def available(self) -> tuple[bool, str]:
79
+ exe = self.python_exe()
80
+ if self.pytest_path() is not None:
81
+ return True, ""
82
+ return False, (f"pytest is not available for {exe} and jitcatch could not install its own copy "
83
+ "(needs uv or pip and network once); install pytest there or set "
84
+ "[adapters.python] python = ...")
85
+
86
+ def module_name(self, source_rel: str) -> str:
87
+ parts = list(PurePosixPath(source_rel).with_suffix("").parts)
88
+ if parts and parts[0] == "src":
89
+ parts = parts[1:]
90
+ if parts and parts[-1] == "__init__":
91
+ parts = parts[:-1]
92
+ return ".".join(parts)
93
+
94
+ def placement(self, source_rel: str, test_id: str, source_text: str) -> TestPlacement:
95
+ related = self.related_test(source_rel)
96
+ test_dir = PurePosixPath(related).parent if related else PurePosixPath(source_rel).parent
97
+ rel = str(test_dir / f"test_jitcatch_{test_id}.py")
98
+ instr = (
99
+ "Framework: pytest. Write a self-contained pytest module (plain `def test_...` functions; "
100
+ "fixtures/monkeypatch allowed). The test runs from the repository root with the root and `src/` "
101
+ f"on sys.path, so import the module under test as `{self.module_name(source_rel)}` "
102
+ "(mirror the import style of the example test if one is shown). Do not use network, sleeps, "
103
+ "randomness or the current time. Do not access private names (leading underscore) of the module "
104
+ "under test unless there is no public alternative."
105
+ )
106
+ return TestPlacement(rel_path=rel, instructions=instr)
107
+
108
+ def _env(self, worktree: Path) -> dict[str, str]:
109
+ paths = [str(worktree)]
110
+ if (worktree / "src").is_dir():
111
+ paths.append(str(worktree / "src"))
112
+ if self.pytest_path():
113
+ paths.append(self.pytest_path())
114
+ return {"PYTHONPATH": os.pathsep.join(paths)}
115
+
116
+ def run_test(self, worktree: Path, test_rel: str, placement: TestPlacement, timeout: float) -> TestRun:
117
+ cmd = [self.python_exe(), "-m", "pytest", test_rel, "-q", "-p", "no:cacheprovider",
118
+ "--rootdir", str(worktree), "-o", "addopts=", "--tb=short", *self.settings.get("extra_args", [])]
119
+ code, out, secs, timed_out = run_cmd(cmd, worktree, timeout, self._env(worktree))
120
+ return make_run(self._classify(code, out, timed_out), code, out, secs, cmd)
121
+
122
+ def run_existing_tests(self, worktree: Path, source_rel: str, timeout: float) -> TestRun | None:
123
+ cmd = [self.python_exe(), "-m", "pytest", "-q", "-x", "-p", "no:cacheprovider", "--tb=line",
124
+ "--ignore-glob=**/test_jitcatch_*.py"]
125
+ code, out, secs, timed_out = run_cmd(cmd, worktree, timeout, self._env(worktree))
126
+ outcome = Outcome.PASS if code in (0, 5) else self._classify(code, out, timed_out)
127
+ return make_run(outcome, code, out, secs, cmd)
128
+
129
+ @staticmethod
130
+ def _classify(code: int, out: str, timed_out: bool) -> Outcome:
131
+ if timed_out or code in (-2, 3, 4):
132
+ return Outcome.INFRA
133
+ if code == 0:
134
+ return Outcome.PASS
135
+ if code == 5:
136
+ return Outcome.NO_TESTS
137
+ if code == 2: # interrupted: collection / import errors
138
+ return Outcome.ERROR
139
+ if code == 1:
140
+ return Outcome.FAIL
141
+ return Outcome.INFRA
142
+
143
+
144
+ def _has_pytest(exe: str, cwd: Path, extra_path: str = "") -> bool:
145
+ code, _, _, _ = run_cmd([exe, "-c", "import pytest"], cwd, 60, {"PYTHONPATH": extra_path} if extra_path else None)
146
+ return code == 0
147
+
148
+
149
+ _UNSET = object()
150
+
151
+
152
+ def _py_version(exe: str, cwd: Path) -> str | None:
153
+ code, out, _, _ = run_cmd([exe, "-c", "import sys; print('%d.%d' % sys.version_info[:2])"], cwd, 60)
154
+ v = out.strip().splitlines()[-1] if code == 0 and out.strip() else ""
155
+ return v if v.count(".") == 1 else None
156
+
157
+
158
+ def _provision_pytest(exe: str, cwd: Path) -> str | None:
159
+ """Install pytest into jitcatch's cache (keyed by Python version) and return that directory.
160
+ It is only added to PYTHONPATH of the test processes; the project's environment is left untouched."""
161
+ from ..userconfig import cache_home
162
+ ver = _py_version(exe, cwd)
163
+ if ver is None:
164
+ return None
165
+ target = cache_home() / f"pytest-py{ver}"
166
+ if (target / "pytest").exists() and _has_pytest(exe, cwd, str(target)):
167
+ return str(target)
168
+ log.info("pytest is not installed for %s; installing a private copy into %s (your project is not changed)…",
169
+ exe, target)
170
+ tmp = target.with_name(target.name + ".tmp")
171
+ shutil.rmtree(tmp, ignore_errors=True)
172
+ attempts = []
173
+ if shutil.which("uv"):
174
+ attempts.append(["uv", "pip", "install", "--quiet", "--target", str(tmp), "--python", exe, "pytest"])
175
+ attempts.append([exe, "-m", "pip", "install", "--quiet", "--disable-pip-version-check",
176
+ "--target", str(tmp), "pytest"])
177
+ attempts.append([sys.executable, "-m", "pip", "install", "--quiet", "--disable-pip-version-check",
178
+ "--target", str(tmp), "--python-version", ver, "--only-binary=:all:", "pytest"])
179
+ for cmd in attempts:
180
+ code, out, _, _ = run_cmd(cmd, cwd, 600)
181
+ if code == 0 and _has_pytest(exe, cwd, str(tmp)):
182
+ shutil.rmtree(target, ignore_errors=True)
183
+ tmp.rename(target)
184
+ return str(target)
185
+ shutil.rmtree(tmp, ignore_errors=True)
186
+ log.warning("could not install pytest for %s", exe)
187
+ return None
@@ -0,0 +1,94 @@
1
+ """Combine the assessors into a single true/false-positive score per weak catch."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ from ..llm import LLMClient
7
+ from ..models import Assessment, AssessorScore, ChangeContext, GeneratedTest, Intent
8
+ from .. import prompts
9
+ from . import failure_parse, llm_judge, rules
10
+
11
+
12
+ def assess_weak_catch(
13
+ test: GeneratedTest,
14
+ ctx: ChangeContext,
15
+ intent: Intent | None,
16
+ language: str,
17
+ fence: str,
18
+ judges: list[LLMClient],
19
+ logprob_judge: LLMClient | None,
20
+ settings: dict[str, Any],
21
+ max_patch_chars: int,
22
+ ) -> Assessment:
23
+ assert test.head_run is not None
24
+ file_patch = next((f.patch for f in ctx.files if f.path == test.target_file), "") or ctx.full_patch
25
+ rin = rules.RuleInput(test=test, output=test.head_run.output, patch=file_patch, intent=intent,
26
+ title=ctx.title, language=language)
27
+ rule_score = rules.assess(rin, settings.get("rule_overrides") or {})
28
+ scores: list[AssessorScore] = [rule_score]
29
+
30
+ if rule_score.details.get("high_fp") and settings.get("discard_on_high_fp_rule", True):
31
+ names = ", ".join(h["rule"] for h in rule_score.details["false_positive_patterns"] if h["level"] == "high")
32
+ return Assessment(test_id=test.id, scores=scores, combined=-1.0, discarded=True,
33
+ discard_reason=f"rule-based false-positive pattern: {names}",
34
+ behavior_change=_fallback_behavior(test))
35
+
36
+ user = prompts.JUDGE_USER.format(
37
+ intent=(f"{intent.summary} (kind: {intent.change_kind})" if intent else "(not inferred)"),
38
+ title=ctx.title or "(none)", description=(ctx.description or "")[:4000],
39
+ patch=_clip(file_patch, max_patch_chars), test_path=test.test_path, lang=fence,
40
+ test_code=test.code, outcome=test.head_run.outcome.value, output=_clip(test.head_run.output, 8000),
41
+ )
42
+ bucket, rationales = llm_judge.bucket_ensemble(judges, user)
43
+ scores.append(bucket)
44
+ lp_user = prompts.LOGPROB_USER.format(
45
+ intent=(f"{intent.summary} (kind: {intent.change_kind})" if intent else "(not inferred)"),
46
+ title=ctx.title or "(none)", description=(ctx.description or "")[:4000],
47
+ patch=_clip(file_patch, max_patch_chars), test_path=test.test_path, lang=fence,
48
+ test_code=test.code, outcome=test.head_run.outcome.value, output=_clip(test.head_run.output, 8000),
49
+ )
50
+ scores.append(llm_judge.logprob_score(logprob_judge, lp_user))
51
+
52
+ weights = settings.get("weights") or {}
53
+ num = den = 0.0
54
+ for s in scores:
55
+ if s.score is None:
56
+ continue
57
+ w = float(weights.get(s.name, 1.0))
58
+ num += w * s.score
59
+ den += w
60
+ combined = round(num / den, 4) if den else 0.0
61
+
62
+ behavior, question = "", ""
63
+ valid = [r for r in rationales if "value" in r]
64
+ if valid and bucket.score is not None:
65
+ pick = min(valid, key=lambda r: abs(r["value"] - bucket.score))
66
+ behavior, question = pick.get("behavior_change", ""), pick.get("question", "")
67
+ if not behavior:
68
+ behavior = _fallback_behavior(test)
69
+ if not question and behavior:
70
+ question = f"{behavior} Is this expected?"
71
+ return Assessment(
72
+ test_id=test.id, scores=scores, combined=combined, discarded=False,
73
+ behavior_change=behavior, question=question,
74
+ rationales=[{k: str(v) for k, v in r.items() if k in ("judge", "model", "likelihood", "rationale", "error")}
75
+ for r in rationales],
76
+ )
77
+
78
+
79
+ def _fallback_behavior(test: GeneratedTest) -> str:
80
+ if test.head_run is None:
81
+ return ""
82
+ comps = failure_parse.comparisons(test.head_run.output)
83
+ if comps:
84
+ c = comps[0]
85
+ return f"A value that was `{c.expected[:120]}` before the change is now `{c.actual[:120]}`."
86
+ exc = failure_parse.exceptions(test.head_run.output)
87
+ if exc:
88
+ name, msg = exc[0]
89
+ return f"Code that ran successfully before the change now raises `{name}{': ' + msg[:120] if msg else ''}`."
90
+ return ""
91
+
92
+
93
+ def _clip(s: str, n: int) -> str:
94
+ return s if len(s) <= n else s[: n // 2] + "\n... [truncated] ...\n" + s[-n // 2 :]
@@ -0,0 +1,126 @@
1
+ """Extract structured facts from test-runner output across pytest, Jest/Vitest, JUnit and go test."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class Comparison:
10
+ actual: str
11
+ expected: str
12
+ source: str # which pattern produced it
13
+
14
+
15
+ _PYTEST_ASSERT = re.compile(r"^E\s+(?:AssertionError: )?assert (.+?) (==|!=|is|is not) (.+?)\s*$", re.M)
16
+ _PYTEST_BARE = re.compile(r"^E\s+(?:AssertionError: )?assert (not )?(True|False|None|0|''|\"\"|\[\]|\{\})\s*$", re.M)
17
+ _JEST = re.compile(r"Expected(?: value)?:\s*(.+?)\s*\n\s*Received(?: value)?:\s*(.+?)\s*$", re.M)
18
+ _JUNIT = re.compile(r"expected:? ?<(.*?)> but was:? ?<(.*?)>", re.S)
19
+ _JUNIT5_ALT = re.compile(r"==> expected: <(.*?)> but was: <(.*?)>", re.S)
20
+ _TESTIFY = re.compile(r"expected:\s*(.+?)\s*\n\s*actual\s*:\s*(.+?)\s*$", re.M)
21
+ _GOT_WANT = re.compile(r"got:?\s*(.+?)[,;]?\s+(?:but\s+)?want(?:ed)?:?\s*([^\n]+)", re.I)
22
+ _WANT_GOT = re.compile(r"want(?:ed)?:?\s*(.+?)[,;]?\s+(?:but\s+)?got:?\s*([^\n]+)", re.I)
23
+
24
+
25
+ def comparisons(output: str) -> list[Comparison]:
26
+ out: list[Comparison] = []
27
+ for m in _PYTEST_ASSERT.finditer(output):
28
+ out.append(Comparison(actual=m.group(1).strip(), expected=m.group(3).strip(), source="pytest"))
29
+ for m in _PYTEST_BARE.finditer(output):
30
+ negated, val = bool(m.group(1)), m.group(2)
31
+ if val in ("True", "False"):
32
+ # `assert False` -> the asserted expression evaluated to False; True was expected.
33
+ # `assert not True` -> the expression evaluated to True; False was expected.
34
+ opposite = "False" if val == "True" else "True"
35
+ out.append(Comparison(actual=val, expected=opposite, source="pytest-bare"))
36
+ elif negated:
37
+ continue
38
+ else:
39
+ out.append(Comparison(actual=val, expected="<truthy>", source="pytest-bare"))
40
+ for m in _JEST.finditer(output):
41
+ out.append(Comparison(actual=m.group(2).strip(), expected=m.group(1).strip(), source="jest"))
42
+ for rx in (_JUNIT5_ALT, _JUNIT):
43
+ for m in rx.finditer(output):
44
+ out.append(Comparison(actual=m.group(2).strip(), expected=m.group(1).strip(), source="junit"))
45
+ for m in _TESTIFY.finditer(output):
46
+ out.append(Comparison(actual=m.group(2).strip(), expected=m.group(1).strip(), source="testify"))
47
+ for m in _GOT_WANT.finditer(output):
48
+ out.append(Comparison(actual=m.group(1).strip(), expected=m.group(2).strip(), source="got-want"))
49
+ for m in _WANT_GOT.finditer(output):
50
+ out.append(Comparison(actual=m.group(2).strip(), expected=m.group(1).strip(), source="want-got"))
51
+ # de-duplicate
52
+ seen, uniq = set(), []
53
+ for c in out:
54
+ k = (c.actual, c.expected)
55
+ if k not in seen:
56
+ seen.add(k)
57
+ uniq.append(c)
58
+ return uniq
59
+
60
+
61
+ _BOOL = {"true": True, "false": False}
62
+
63
+
64
+ def as_bool(s: str) -> bool | None:
65
+ return _BOOL.get(s.strip().strip("<>").lower())
66
+
67
+
68
+ _NULLS = {"none", "null", "undefined", "nil", "<nil>"}
69
+
70
+
71
+ def is_null(s: str) -> bool:
72
+ return s.strip().strip("<>").lower() in _NULLS
73
+
74
+
75
+ _EMPTY = {"[]", "{}", "()", "set()", "''", '""', "array []", "object {}", "map[]", "[]{}", "0"}
76
+
77
+
78
+ def is_empty_container(s: str) -> bool:
79
+ v = s.strip()
80
+ return v in _EMPTY or bool(re.fullmatch(r"(Array|Object|Map|Set)\s*(\(0\))?\s*(\[\]|\{\})", v))
81
+
82
+
83
+ def same_elements_different_order(a: str, b: str) -> bool:
84
+ """True when two rendered collections contain the same items in a different order."""
85
+ if a == b:
86
+ return False
87
+ ta = _tokens(a)
88
+ tb = _tokens(b)
89
+ return len(ta) >= 2 and sorted(ta) == sorted(tb) and ta != tb
90
+
91
+
92
+ def _tokens(s: str) -> list[str]:
93
+ inner = s.strip()
94
+ if len(inner) >= 2 and inner[0] in "[{(" and inner[-1] in "]})":
95
+ inner = inner[1:-1]
96
+ return [t.strip() for t in re.split(r",\s*", inner) if t.strip()]
97
+
98
+
99
+ # -- exceptions --------------------------------------------------------------------------
100
+
101
+ _EXC_PATTERNS = [
102
+ re.compile(r"^E\s+([A-Za-z_][\w.]*(?:Error|Exception|Exit|Interrupt))(?::\s*(.*))?$", re.M), # pytest
103
+ re.compile(r"^\s*([A-Z][\w.]*(?:Error|Exception))(?::\s*(.*))?$", re.M), # JS / Java / traceback tail
104
+ re.compile(r"^panic:\s*(runtime error):?\s*(.*)$", re.M), # Go
105
+ ]
106
+
107
+
108
+ def exceptions(output: str) -> list[tuple[str, str]]:
109
+ found: list[tuple[str, str]] = []
110
+ for rx in _EXC_PATTERNS:
111
+ for m in rx.finditer(output):
112
+ name = m.group(1).split(".")[-1]
113
+ msg = (m.group(2) or "").strip()
114
+ if (name, msg) not in found:
115
+ found.append((name, msg))
116
+ return found
117
+
118
+
119
+ def changed_lines(patch: str) -> list[str]:
120
+ lines = []
121
+ for ln in patch.splitlines():
122
+ if ln.startswith(("+++", "---", "@@", "diff ", "index ")):
123
+ continue
124
+ if ln.startswith(("+", "-")):
125
+ lines.append(ln[1:])
126
+ return lines
@@ -0,0 +1,75 @@
1
+ """LLM-as-judge ensemble (paper §6.1).
2
+
3
+ * Ensemble categorical likelihood ("Bucket Med"): each judge answers High/Medium/Low, mapped to
4
+ 1 / 0 / -1, aggregated with the median (ordinal data).
5
+ * Normalised token-probability score ("TP Prob"): a judge with token log-probabilities answers
6
+ Yes/No; P(Yes) and P(No) for the first answer token give a score in [-1, 1].
7
+ * Rationales from every judge are kept individually (not merged), as in the paper.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import statistics
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from typing import Any
15
+
16
+ from ..jsonutil import LLMFormatError, extract_json
17
+ from ..llm import LLMClient, LLMError
18
+ from ..models import AssessorScore
19
+ from .. import prompts
20
+
21
+ BUCKETS = {"high": 1.0, "medium": 0.0, "low": -1.0}
22
+
23
+
24
+ def _one_judge(client: LLMClient, user: str) -> dict[str, Any]:
25
+ try:
26
+ resp = client.complete(prompts.JUDGE_SYSTEM, user, max_tokens=1500)
27
+ data = extract_json(resp.text)
28
+ if not isinstance(data, dict):
29
+ raise LLMFormatError("judge output is not a JSON object")
30
+ level = str(data.get("likelihood", "")).strip().lower()
31
+ if level not in BUCKETS:
32
+ raise LLMFormatError(f"invalid likelihood {data.get('likelihood')!r}")
33
+ return {
34
+ "judge": client.name, "model": resp.model, "likelihood": level.capitalize(),
35
+ "value": BUCKETS[level],
36
+ "behavior_change": str(data.get("behavior_change", "")).strip(),
37
+ "question": str(data.get("question", "")).strip(),
38
+ "rationale": str(data.get("rationale", "")).strip(),
39
+ }
40
+ except (LLMError, LLMFormatError) as e:
41
+ return {"judge": client.name, "error": str(e)[:500]}
42
+
43
+
44
+ def bucket_ensemble(judges: list[LLMClient], user: str) -> tuple[AssessorScore, list[dict[str, Any]]]:
45
+ if not judges:
46
+ return AssessorScore("llm_bucket", None, {"note": "no judges configured"}), []
47
+ with ThreadPoolExecutor(max_workers=min(8, len(judges))) as ex:
48
+ results = list(ex.map(lambda c: _one_judge(c, user), judges))
49
+ values = [r["value"] for r in results if "value" in r]
50
+ score = statistics.median(values) if values else None
51
+ return AssessorScore("llm_bucket", score, {"judges": results, "n_valid": len(values)}), results
52
+
53
+
54
+ def logprob_score(client: LLMClient | None, user: str) -> AssessorScore:
55
+ if client is None:
56
+ return AssessorScore("llm_logprob", None, {"note": "no logprob judge configured"})
57
+ try:
58
+ resp = client.complete(prompts.JUDGE_SYSTEM, user, max_tokens=5, want_logprobs=True)
59
+ except LLMError as e:
60
+ return AssessorScore("llm_logprob", None, {"error": str(e)[:500]})
61
+ if not resp.logprobs:
62
+ return AssessorScore("llm_logprob", None, {"error": "server returned no logprobs", "text": resp.text[:50]})
63
+ first = next((t for t in resp.logprobs if t.token.strip()), None)
64
+ if first is None:
65
+ return AssessorScore("llm_logprob", None, {"error": "empty answer"})
66
+ candidates = dict(first.top)
67
+ candidates.setdefault(first.token, first.logprob)
68
+ p_yes = sum(math.exp(lp) for tok, lp in candidates.items() if tok.strip().lower().startswith("yes"))
69
+ p_no = sum(math.exp(lp) for tok, lp in candidates.items() if tok.strip().lower().startswith("no"))
70
+ if p_yes + p_no == 0:
71
+ return AssessorScore("llm_logprob", None, {"error": f"first token {first.token!r} is neither Yes nor No"})
72
+ score = (p_yes - p_no) / (p_yes + p_no)
73
+ return AssessorScore("llm_logprob", round(score, 4), {
74
+ "answer": first.token.strip(), "p_yes": round(p_yes, 4), "p_no": round(p_no, 4), "judge": client.name,
75
+ })