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.
- jitcatch/__init__.py +2 -0
- jitcatch/__main__.py +5 -0
- jitcatch/adapters/__init__.py +36 -0
- jitcatch/adapters/base.py +137 -0
- jitcatch/adapters/golang.py +88 -0
- jitcatch/adapters/java.py +136 -0
- jitcatch/adapters/javascript.py +165 -0
- jitcatch/adapters/python.py +187 -0
- jitcatch/assessors/__init__.py +94 -0
- jitcatch/assessors/failure_parse.py +126 -0
- jitcatch/assessors/llm_judge.py +75 -0
- jitcatch/assessors/rules.py +343 -0
- jitcatch/cli.py +338 -0
- jitcatch/config.py +259 -0
- jitcatch/engine.py +521 -0
- jitcatch/github.py +90 -0
- jitcatch/gitutil.py +231 -0
- jitcatch/hook.py +98 -0
- jitcatch/jsonutil.py +87 -0
- jitcatch/llm/__init__.py +21 -0
- jitcatch/llm/base.py +100 -0
- jitcatch/llm/providers.py +127 -0
- jitcatch/models.py +150 -0
- jitcatch/prompts.py +191 -0
- jitcatch/report.py +163 -0
- jitcatch/targeting.py +44 -0
- jitcatch/templates.py +76 -0
- jitcatch/userconfig.py +188 -0
- jitcatch-0.2.0.dist-info/METADATA +164 -0
- jitcatch-0.2.0.dist-info/RECORD +34 -0
- jitcatch-0.2.0.dist-info/WHEEL +5 -0
- jitcatch-0.2.0.dist-info/entry_points.txt +2 -0
- jitcatch-0.2.0.dist-info/licenses/LICENSE +21 -0
- jitcatch-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
})
|