clinescope 1.0.1__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.
- clinescope/__init__.py +9 -0
- clinescope/__main__.py +152 -0
- clinescope/_datafiles.py +111 -0
- clinescope/advice.py +120 -0
- clinescope/agreement.py +270 -0
- clinescope/agreement_multi.py +207 -0
- clinescope/apply_recovery.py +514 -0
- clinescope/compare.py +365 -0
- clinescope/corpus.py +497 -0
- clinescope/diff_coherence.py +352 -0
- clinescope/diff_minimality.py +323 -0
- clinescope/gate.py +317 -0
- clinescope/gold.py +341 -0
- clinescope/judge.py +351 -0
- clinescope/judge_multidraw.py +444 -0
- clinescope/judge_run.py +631 -0
- clinescope/label_gold.py +263 -0
- clinescope/labels.py +271 -0
- clinescope/report.py +433 -0
- clinescope/tool_selection.py +91 -0
- clinescope/tool_vocab.py +69 -0
- clinescope/world_a.py +210 -0
- clinescope-1.0.1.dist-info/METADATA +150 -0
- clinescope-1.0.1.dist-info/RECORD +95 -0
- clinescope-1.0.1.dist-info/WHEEL +4 -0
- clinescope-1.0.1.dist-info/entry_points.txt +4 -0
- clinescope-1.0.1.dist-info/licenses/LICENSE +201 -0
- examples/apply-patch-trace.json +62 -0
- examples/apply-recovery-trace.json +97 -0
- examples/corpus/README.md +66 -0
- examples/corpus/corpus.json +98 -0
- examples/corpus/live-gpt-oss-add-file.json +187 -0
- examples/corpus/live-gpt-oss-apply-fail.json +135 -0
- examples/corpus/live-gpt-oss-trace.json +137 -0
- examples/corpus/live-gpt-oss-update-2hunk.json +135 -0
- examples/corpus/llama-code-dump.json +41 -0
- examples/corpus/qwen-missing-tools.json +41 -0
- examples/gate-regression-badpatch.json +35 -0
- examples/gold/dm-hardcase-01-py-retype-timeout-pair.json +63 -0
- examples/gold/dm-hardcase-02-js-retype-header-pair.json +63 -0
- examples/gold/dm-hardcase-03-go-retype-struct-fields.json +63 -0
- examples/gold/dm-hardcase-04-yaml-retype-resource-limits.json +63 -0
- examples/gold/dm-hardcase-05-py-settings-interleaved-retype.json +63 -0
- examples/gold/dm-hardcase-06-go-handler-interleaved-fields.json +63 -0
- examples/gold/dm-hardcase-07-yaml-ci-interleaved-steps.json +63 -0
- examples/gold/dm-hardcase-08-fix-timeout-constant.json +63 -0
- examples/gold/dm-hardcase-09-add-py-typed-marker.json +39 -0
- examples/gold/dm-hardcase-10-rename-util-module.json +63 -0
- examples/gold/dm-hardcase-11-add-nil-guard.json +63 -0
- examples/gold/dm-hardcase-12-bump-replicas-two-line.json +63 -0
- examples/gold/dm-hardcase-13-blind-retype-normalize-fn.json +63 -0
- examples/gold/dm-hardcase-14-blind-retype-js-retry-config.json +63 -0
- examples/gold/dm-hardcase-15-blind-retype-go-limits-mixed.json +63 -0
- examples/gold/dm-hardcase-16-blind-retype-yaml-ci-matrix.json +63 -0
- examples/gold/dm-hardcase-17-retype-normalize-body.json +63 -0
- examples/gold/dm-hardcase-18-retype-retry-config.json +63 -0
- examples/gold/dm-hardcase-19-retype-parse-args-go.json +63 -0
- examples/gold/dm-hardcase-20-retype-reducer-js.json +63 -0
- examples/gold/dm-hardcase-21-python-http-timeout-default.json +63 -0
- examples/gold/dm-hardcase-22-go-server-const-poolsize.json +63 -0
- examples/gold/dm-hardcase-23-yaml-deploy-image-tag.json +63 -0
- examples/gold/dm-hardcase-24-js-retry-max-attempts.json +63 -0
- examples/gold/dm-hardcase-25-py-retype-two-thresholds.json +63 -0
- examples/gold/dm-hardcase-26-ts-retype-two-flags.json +63 -0
- examples/gold/dm-hardcase-27-py-interleaved-defaults.json +63 -0
- examples/gold/dm-hardcase-28-go-interleaved-consts.json +63 -0
- examples/gold/dm-hardcase-29-fix-single-return.json +63 -0
- examples/gold/dm-hardcase-30-add-one-import.json +63 -0
- examples/gold/dm-hardcase-31-bump-single-version.json +63 -0
- examples/gold/dm-hardcase-32-add-newline-eof.json +63 -0
- examples/gold/dm-hardcase-33-py-timeout-in-big-block.json +63 -0
- examples/gold/dm-hardcase-34-yaml-image-in-pod.json +63 -0
- examples/gold/dm-hardcase-35-go-one-field-in-struct.json +63 -0
- examples/gold/dm-hardcase-36-js-one-option-in-object.json +63 -0
- examples/gold/dm-hardcase-37-py-split-compute.json +63 -0
- examples/gold/dm-hardcase-38-go-split-parse.json +63 -0
- examples/gold/dm-hardcase-39-js-split-reducer.json +63 -0
- examples/gold/dm-hardcase-40-py-split-validate.json +63 -0
- examples/gold/dm-hardcase-41-blind-retype-py-config-block.json +63 -0
- examples/gold/dm-hardcase-42-blind-retype-go-struct-literal.json +63 -0
- examples/gold/dm-hardcase-43-blind-retype-yaml-service.json +63 -0
- examples/gold/dm-hardcase-44-blind-retype-js-handlers.json +63 -0
- examples/gold/dm-hardcase-45-py-retype-pair-limits.json +63 -0
- examples/gold/dm-hardcase-46-go-interleaved-two.json +63 -0
- examples/gold/dm-hardcase-47-context-drag-py-single.json +63 -0
- examples/gold/dm-hardcase-48-tight-add-guard-js.json +63 -0
- examples/live-gpt-oss-add-file.json +187 -0
- examples/live-gpt-oss-apply-fail.json +135 -0
- examples/live-gpt-oss-trace.json +137 -0
- examples/live-gpt-oss-update-2hunk.json +135 -0
- examples/multi-op-trace.json +65 -0
- examples/sample-trace.json +59 -0
- gold/README.md +149 -0
- gold/diff_minimality.gold.jsonl +50 -0
- gold/diff_minimality.judge.jsonl +50 -0
clinescope/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""clinescope — a Cline-native agentic-coding-trace eval harness.
|
|
2
|
+
|
|
3
|
+
Package root. The thin path is load a World-A trace -> score tool selection ->
|
|
4
|
+
render a report; see :mod:`clinescope.world_a`,
|
|
5
|
+
:mod:`clinescope.tool_selection`, and :mod:`clinescope.report`,
|
|
6
|
+
with :mod:`clinescope.__main__` as the CLI entry point.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "1.0.1"
|
clinescope/__main__.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Thin CLI for the walking skeleton: load -> score -> EMIT.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python -m clinescope <trace.json> --expected read_files [write_file ...] [--verbose]
|
|
5
|
+
|
|
6
|
+
Loads a Cline World-A trace, scores tool selection against the expected tool
|
|
7
|
+
names, renders the report, and prints it. The default is a one-line-per-scorer
|
|
8
|
+
summary; ``--verbose`` emits the full per-scorer debug dump instead. The heavy
|
|
9
|
+
lifting lives in
|
|
10
|
+
:func:`clinescope.report.render_report` (a pure ``str``-returning
|
|
11
|
+
function) so the report is testable WITHOUT a subprocess; this module is only
|
|
12
|
+
argument parsing plus glue.
|
|
13
|
+
|
|
14
|
+
The trace ``sessionId`` is not modelled on
|
|
15
|
+
:class:`clinescope.world_a.Trace` (the loader discards it), so it is
|
|
16
|
+
lifted here with one cheap read and passed through to the emitter.
|
|
17
|
+
|
|
18
|
+
A trace that cannot be loaded (missing path, unsupported version, malformed or
|
|
19
|
+
non-object JSON) prints a single ``error: ...`` line to stderr and exits 1 --
|
|
20
|
+
never a raw Python traceback.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from clinescope.apply_recovery import score_apply_recovery
|
|
31
|
+
from clinescope.diff_coherence import score_diff_coherence
|
|
32
|
+
from clinescope.diff_minimality import score_diff_minimality
|
|
33
|
+
from clinescope.report import render_report
|
|
34
|
+
from clinescope.tool_selection import score_tool_selection
|
|
35
|
+
from clinescope.tool_vocab import CLINE_WORLD_A_TOOLS, tool_vocab_check
|
|
36
|
+
from clinescope.world_a import load_trace
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _ListToolsAction(argparse.Action):
|
|
40
|
+
"""--list-tools: print the known Cline tool vocabulary and exit 0.
|
|
41
|
+
|
|
42
|
+
A user should never have to GUESS what goes after --expected. This prints the
|
|
43
|
+
same pinned vocabulary --expected is validated against, and short-circuits
|
|
44
|
+
before ``trace`` is required (so ``clinescope --list-tools`` needs no trace).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __call__(self, parser, namespace, values, option_string=None): # type: ignore[no-untyped-def]
|
|
48
|
+
for name in sorted(CLINE_WORLD_A_TOOLS):
|
|
49
|
+
print(name)
|
|
50
|
+
parser.exit(0)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _read_session_id(path: Path) -> str | None:
|
|
54
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
55
|
+
session_id = raw.get("sessionId")
|
|
56
|
+
return session_id if isinstance(session_id, str) else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
60
|
+
parser = argparse.ArgumentParser(prog="clinescope")
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"trace", type=Path, help="Path to a Cline World-A messages.json trace"
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--expected",
|
|
66
|
+
nargs="+",
|
|
67
|
+
default=None,
|
|
68
|
+
metavar="TOOL",
|
|
69
|
+
help=(
|
|
70
|
+
"Expected tool name(s), space-separated (e.g. --expected read_files "
|
|
71
|
+
"apply_patch). Omit to skip tool-selection scoring; run --list-tools "
|
|
72
|
+
"to see valid names."
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--list-tools",
|
|
77
|
+
action=_ListToolsAction,
|
|
78
|
+
nargs=0,
|
|
79
|
+
help="Print the known Cline tool names (for --expected) and exit",
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--advice",
|
|
83
|
+
action="store_true",
|
|
84
|
+
help="Append per-failing-scorer coaching (what to change) to the summary",
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--verbose",
|
|
88
|
+
action="store_true",
|
|
89
|
+
help="Emit the full per-scorer debug dump instead of the one-line summary",
|
|
90
|
+
)
|
|
91
|
+
return parser.parse_args(argv)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _warn_unknown_expected(expected: list[str]) -> None:
|
|
95
|
+
# A misspelled --expected name would silently score as a MISSING tool -- a
|
|
96
|
+
# false negative that blames the agent for the user's typo. Warn (don't abort:
|
|
97
|
+
# a genuinely custom tool is legal) with a nearest-match suggestion.
|
|
98
|
+
for name, suggestion in tool_vocab_check(expected):
|
|
99
|
+
hint = f" - did you mean '{suggestion}'?" if suggestion else ""
|
|
100
|
+
print(f"warning: unknown tool '{name}'{hint}", file=sys.stderr)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main(argv: list[str] | None = None) -> int:
|
|
104
|
+
args = _parse_args(argv)
|
|
105
|
+
# Opt-in tool_selection: distinguish "--expected was omitted" (None -> skip
|
|
106
|
+
# scoring it, show n/a) from "--expected with no names" so an omitted flag no
|
|
107
|
+
# longer prints a vacuous 100/100 PASS. Validate the given names for typos.
|
|
108
|
+
expected_provided = args.expected is not None
|
|
109
|
+
expected = args.expected if expected_provided else []
|
|
110
|
+
if expected_provided:
|
|
111
|
+
_warn_unknown_expected(expected)
|
|
112
|
+
|
|
113
|
+
# Load boundary: a bad path / bad version / malformed or non-object JSON must
|
|
114
|
+
# print a clean one-line error, NOT a raw Python traceback. The loader raises
|
|
115
|
+
# a whole family of failures beyond its own WorldATraceError: OSError from a
|
|
116
|
+
# missing/unreadable file, json.JSONDecodeError from bad JSON, and even an
|
|
117
|
+
# AttributeError/TypeError when JSON-parseable-but-structurally-invalid input
|
|
118
|
+
# trips the parser. Catch broadly here so every load failure normalizes to the
|
|
119
|
+
# same clean stderr line + exit 1 -- this is the deliberate load boundary (the
|
|
120
|
+
# sibling `clinescope.gate` CLI does the same), not swallowed application logic.
|
|
121
|
+
try:
|
|
122
|
+
trace = load_trace(args.trace)
|
|
123
|
+
session_id = _read_session_id(args.trace)
|
|
124
|
+
except Exception as err: # noqa: BLE001 -- deliberate load-failure boundary (see above)
|
|
125
|
+
print(
|
|
126
|
+
f"error: could not load trace {args.trace}: {type(err).__name__}: {err}",
|
|
127
|
+
file=sys.stderr,
|
|
128
|
+
)
|
|
129
|
+
return 1
|
|
130
|
+
|
|
131
|
+
score = score_tool_selection(trace, set(expected))
|
|
132
|
+
diff_score = score_diff_coherence(trace)
|
|
133
|
+
minimality_score = score_diff_minimality(trace)
|
|
134
|
+
recovery_score = score_apply_recovery(trace)
|
|
135
|
+
print(
|
|
136
|
+
render_report(
|
|
137
|
+
trace,
|
|
138
|
+
score,
|
|
139
|
+
session_id=session_id,
|
|
140
|
+
diff_coherence=diff_score,
|
|
141
|
+
diff_minimality=minimality_score,
|
|
142
|
+
apply_recovery=recovery_score,
|
|
143
|
+
expected_provided=expected_provided,
|
|
144
|
+
advice=args.advice,
|
|
145
|
+
verbose=args.verbose,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
sys.exit(main())
|
clinescope/_datafiles.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Locate clinescope's bundled data (``examples/`` + ``gold/``) from anywhere.
|
|
2
|
+
|
|
3
|
+
The default-data features -- the real-trace corpus (:mod:`clinescope.corpus`), the gold
|
|
4
|
+
set + judge cache (:mod:`clinescope.gold`, :mod:`clinescope.judge_run`) -- read committed
|
|
5
|
+
files by a repo-relative path. From a source checkout that is the repo root (cwd). But a
|
|
6
|
+
``pip install`` user has no repo: the files ship inside the wheel (via
|
|
7
|
+
``[tool.hatch.build.targets.wheel.force-include]``), so a cwd-relative default resolves to
|
|
8
|
+
nothing and the feature crashes.
|
|
9
|
+
|
|
10
|
+
This module resolves the DATA ROOT -- the directory that contains ``examples/`` and
|
|
11
|
+
``gold/`` -- independent of the current working directory, so those defaults work from a
|
|
12
|
+
source checkout AND from an installed wheel. It does NOT change the public
|
|
13
|
+
``repo_root=`` parameters (a caller may still point at any tree); it only supplies the
|
|
14
|
+
DEFAULT base when the caller does not.
|
|
15
|
+
|
|
16
|
+
**How it resolves (first hit wins):**
|
|
17
|
+
|
|
18
|
+
1. The current working directory, if it already contains ``examples/`` + ``gold/`` --
|
|
19
|
+
preserves the historical "run from the repo root" behaviour byte-for-byte.
|
|
20
|
+
2. The directory that ships alongside the installed package. With force-include the data
|
|
21
|
+
lands next to the ``clinescope`` package dir (``site-packages/examples``,
|
|
22
|
+
``site-packages/gold``); in the src-layout dev tree it is two levels above the package
|
|
23
|
+
(``<repo>/examples``). Both candidates are probed via ``importlib.resources`` on the
|
|
24
|
+
package's anchor, falling back to ``__file__`` walking.
|
|
25
|
+
|
|
26
|
+
If none contains the data, the resolver raises a clear :class:`DataFilesNotFound` naming
|
|
27
|
+
what it looked for -- never a bare ``FileNotFoundError`` deep inside a loader.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from importlib import resources
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
# The two directories that together mark a valid clinescope data root.
|
|
36
|
+
_DATA_MARKERS = ("examples", "gold")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DataFilesNotFound(RuntimeError):
|
|
40
|
+
"""The bundled ``examples/`` + ``gold/`` data root could not be located."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def datafiles_root() -> Path:
|
|
44
|
+
"""Return the directory that contains clinescope's ``examples/`` + ``gold/`` data.
|
|
45
|
+
|
|
46
|
+
Tries the current working directory first (a source checkout), then the location the
|
|
47
|
+
data ships at alongside the installed package. Raises :class:`DataFilesNotFound` if
|
|
48
|
+
neither has the data.
|
|
49
|
+
"""
|
|
50
|
+
for candidate in _datafiles_candidate_roots():
|
|
51
|
+
if _datafiles_is_root(candidate):
|
|
52
|
+
return candidate
|
|
53
|
+
tried = ", ".join(str(c) for c in _datafiles_candidate_roots())
|
|
54
|
+
raise DataFilesNotFound(
|
|
55
|
+
"could not locate clinescope's bundled data (an 'examples/' and 'gold/' "
|
|
56
|
+
f"directory). Looked in: {tried}. Run from a source checkout, or pass an "
|
|
57
|
+
"explicit path (e.g. `clinescope-corpus <manifest>`)."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def datafiles_path(*parts: str) -> Path:
|
|
62
|
+
"""Resolve a data-relative path (e.g. ``"gold", "diff_minimality.gold.jsonl"``).
|
|
63
|
+
|
|
64
|
+
Joins ``parts`` onto :func:`datafiles_root`. The returned path is NOT required to
|
|
65
|
+
exist (a caller may build a default that a loader then checks); it is only anchored to
|
|
66
|
+
the located data root.
|
|
67
|
+
"""
|
|
68
|
+
return datafiles_root().joinpath(*parts)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _datafiles_candidate_roots() -> tuple[Path, ...]:
|
|
72
|
+
"""Ordered candidate data roots: cwd first, then package-adjacent locations."""
|
|
73
|
+
candidates: list[Path] = [Path.cwd()]
|
|
74
|
+
candidates.extend(_datafiles_package_adjacent_roots())
|
|
75
|
+
# De-dupe while preserving order (cwd may coincide with a package root in dev).
|
|
76
|
+
seen: set[Path] = set()
|
|
77
|
+
unique: list[Path] = []
|
|
78
|
+
for candidate in candidates:
|
|
79
|
+
resolved = candidate.resolve()
|
|
80
|
+
if resolved not in seen:
|
|
81
|
+
seen.add(resolved)
|
|
82
|
+
unique.append(resolved)
|
|
83
|
+
return tuple(unique)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _datafiles_package_adjacent_roots() -> list[Path]:
|
|
87
|
+
"""Candidate roots derived from where the ``clinescope`` package is installed.
|
|
88
|
+
|
|
89
|
+
force-include lands the data at ``site-packages/`` (one level ABOVE the package dir);
|
|
90
|
+
the src-layout dev tree keeps it two levels above (``<repo>/examples`` vs
|
|
91
|
+
``<repo>/src/clinescope``). Probe both, via ``importlib.resources`` then ``__file__``.
|
|
92
|
+
"""
|
|
93
|
+
roots: list[Path] = []
|
|
94
|
+
try:
|
|
95
|
+
anchor = resources.files("clinescope")
|
|
96
|
+
pkg_dir = Path(str(anchor))
|
|
97
|
+
except (
|
|
98
|
+
ModuleNotFoundError,
|
|
99
|
+
TypeError,
|
|
100
|
+
): # pragma: no cover - clinescope is importable
|
|
101
|
+
pkg_dir = Path(__file__).resolve().parent
|
|
102
|
+
# site-packages/clinescope -> site-packages (wheel force-include target)
|
|
103
|
+
roots.append(pkg_dir.parent)
|
|
104
|
+
# <repo>/src/clinescope -> <repo> (src-layout dev checkout)
|
|
105
|
+
roots.append(pkg_dir.parent.parent)
|
|
106
|
+
return roots
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _datafiles_is_root(candidate: Path) -> bool:
|
|
110
|
+
"""True iff ``candidate`` contains every data marker directory."""
|
|
111
|
+
return all((candidate / marker).is_dir() for marker in _DATA_MARKERS)
|
clinescope/advice.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Rule-based advice/coach layer (deterministic, zero-LLM).
|
|
2
|
+
|
|
3
|
+
Clinescope scores a run, then leaves the developer with bare numbers -- a real
|
|
4
|
+
user's reaction was "it scored... now what?". This module turns a FAILING scorer
|
|
5
|
+
into (1) a short failure-taxonomy label and (2) concrete "what to do" guidance
|
|
6
|
+
(usually a prompt fix), keyed to that scorer's EXISTING evidence fields. It
|
|
7
|
+
RECOMPUTES nothing and changes no score: every advice string reads fields the
|
|
8
|
+
scorers already surfaced (missing tools, gate violations, blind-rewrite counts,
|
|
9
|
+
unrecovered files). Off by default; the CLI renders it only under ``--advice``.
|
|
10
|
+
|
|
11
|
+
The taxonomy labels are the minimal first slice of the charter's roadmap metric
|
|
12
|
+
#7 (failure taxonomy): a fixed enum, one label selected per failing scorer from
|
|
13
|
+
its evidence -- NOT a learned classifier and NOT an LLM. A scorer that PASSED or
|
|
14
|
+
ABSTAINED yields no advice (``None``), so a clean run stays quiet.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from enum import Enum
|
|
21
|
+
|
|
22
|
+
from clinescope.apply_recovery import ApplyRecoveryScore
|
|
23
|
+
from clinescope.diff_coherence import DiffCoherenceScore
|
|
24
|
+
from clinescope.diff_minimality import DiffMinimalityScore
|
|
25
|
+
from clinescope.tool_selection import ToolSelectionScore
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FailureLabel(Enum):
|
|
29
|
+
"""A fixed, pre-defined failure category per scorer (roadmap metric #7, v1)."""
|
|
30
|
+
|
|
31
|
+
MISSING_TOOLS = "missing_tools"
|
|
32
|
+
MALFORMED_PATCH = "malformed_patch"
|
|
33
|
+
BLIND_REWRITE = "blind_rewrite"
|
|
34
|
+
NO_APPLY_RECOVERY = "no_apply_recovery"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class ScorerAdvice:
|
|
39
|
+
"""One failing scorer's taxonomy label + human-readable guidance lines.
|
|
40
|
+
|
|
41
|
+
``lines`` quote the scorer's OWN evidence (a missing tool name, a violation
|
|
42
|
+
string, a count, a file path) -- never a recomputed value.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
label: FailureLabel
|
|
46
|
+
lines: tuple[str, ...]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def advice_for_tool_selection(score: ToolSelectionScore) -> ScorerAdvice | None:
|
|
50
|
+
"""Advise when expected tools were not used; ``None`` when all were."""
|
|
51
|
+
if not score.missing:
|
|
52
|
+
return None
|
|
53
|
+
tools = ", ".join(sorted(score.missing))
|
|
54
|
+
return ScorerAdvice(
|
|
55
|
+
label=FailureLabel.MISSING_TOOLS,
|
|
56
|
+
lines=(
|
|
57
|
+
f"The agent never called: {tools}.",
|
|
58
|
+
"Add to your prompt an instruction to use the right tool for the task "
|
|
59
|
+
"(e.g. 'Always read a file with read_files before you patch it').",
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def advice_for_diff_coherence(score: DiffCoherenceScore) -> ScorerAdvice | None:
|
|
65
|
+
"""Advise when the apply_patch grammar is malformed; ``None`` at a perfect score.
|
|
66
|
+
|
|
67
|
+
diff_coherence never abstains (no apply_patch is a hard 0.0), so any score below
|
|
68
|
+
1.0 is a real malformed-patch signal worth coaching.
|
|
69
|
+
"""
|
|
70
|
+
if score.score == 1.0:
|
|
71
|
+
return None
|
|
72
|
+
reason = score.violations[0] if score.violations else "malformed apply_patch"
|
|
73
|
+
return ScorerAdvice(
|
|
74
|
+
label=FailureLabel.MALFORMED_PATCH,
|
|
75
|
+
lines=(
|
|
76
|
+
f"The patch is malformed: {reason}.",
|
|
77
|
+
"The model is emitting invalid apply_patch grammar. Add a few-shot "
|
|
78
|
+
"example of a correct '*** Begin Patch' block to your prompt, or try a "
|
|
79
|
+
"stronger model.",
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def advice_for_diff_minimality(score: DiffMinimalityScore) -> ScorerAdvice | None:
|
|
85
|
+
"""Advise when hunks are blind whole-block rewrites; ``None`` when clean/abstaining.
|
|
86
|
+
|
|
87
|
+
Abstains (``applicable=False``) and perfect scores yield no advice. A hard-zero
|
|
88
|
+
(mis-shaped patch) is a coherence problem, not a minimality one, so it is left to
|
|
89
|
+
diff_coherence; here we only coach the genuine blind-rewrite signal.
|
|
90
|
+
"""
|
|
91
|
+
if not score.applicable or score.score == 1.0:
|
|
92
|
+
return None
|
|
93
|
+
if score.blind_rewrite_hunks == 0:
|
|
94
|
+
return None
|
|
95
|
+
return ScorerAdvice(
|
|
96
|
+
label=FailureLabel.BLIND_REWRITE,
|
|
97
|
+
lines=(
|
|
98
|
+
f"{score.blind_rewrite_hunks} of {score.hunks_with_body} edited hunks "
|
|
99
|
+
"are blind whole-block rewrites (delete the whole block, retype it).",
|
|
100
|
+
"Prompt the agent to change only the lines that must change and keep "
|
|
101
|
+
"the surrounding lines as context.",
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def advice_for_apply_recovery(score: ApplyRecoveryScore) -> ScorerAdvice | None:
|
|
107
|
+
"""Advise when a failed patch was never recovered; ``None`` when clean/abstaining."""
|
|
108
|
+
if not score.applicable or score.score == 1.0:
|
|
109
|
+
return None
|
|
110
|
+
files = ", ".join(score.failed_target_paths) if score.failed_target_paths else "-"
|
|
111
|
+
return ScorerAdvice(
|
|
112
|
+
label=FailureLabel.NO_APPLY_RECOVERY,
|
|
113
|
+
lines=(
|
|
114
|
+
f"The agent failed a patch and did not recover it "
|
|
115
|
+
f"({score.confirmed_recovered_pairs}/{score.total_failed_pairs} "
|
|
116
|
+
f"recovered; unrecovered files: {files}).",
|
|
117
|
+
"Add a retry instruction: after a failed apply_patch, re-read the file "
|
|
118
|
+
"and try a corrected patch instead of giving up.",
|
|
119
|
+
),
|
|
120
|
+
)
|
clinescope/agreement.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Cohen's-kappa agreement harness -- judge-validation, segment 1 (zero-LLM stats).
|
|
2
|
+
|
|
3
|
+
The credibility layer for the charter's criterion 3: once an LLM judge and a small
|
|
4
|
+
HUMAN gold set label the same items, this reports their chance-corrected agreement
|
|
5
|
+
as Cohen's kappa WITH a confidence interval -- the "prove the evaluator is correct"
|
|
6
|
+
number an eval reader looks for. This module is the pure statistics ONLY: it takes
|
|
7
|
+
two aligned label lists and returns kappa + a CI. The judge, the gold set, and the
|
|
8
|
+
end-to-end wiring are LATER segments; nothing here calls an LLM, reads a file, or
|
|
9
|
+
imports a scorer.
|
|
10
|
+
|
|
11
|
+
kappa = (p_o - p_e) / (1 - p_e)
|
|
12
|
+
|
|
13
|
+
where ``p_o`` is observed agreement (fraction of items both raters labeled the same)
|
|
14
|
+
and ``p_e`` is expected/chance agreement (sum over each category ``k`` of
|
|
15
|
+
``P(rater_a = k) * P(rater_b = k)``, i.e. the product of the two marginals). Source:
|
|
16
|
+
Wikipedia "Cohen's kappa"; scikit-learn ``cohen_kappa_score`` (identical definition).
|
|
17
|
+
|
|
18
|
+
**Deliberate decisions (each a stated choice, not undefined behaviour):**
|
|
19
|
+
|
|
20
|
+
* Two raters, one nominal label per item (binary is the segment-1 case, but the
|
|
21
|
+
kappa itself is category-agnostic: it works for any hashable label set). Weighted
|
|
22
|
+
kappa / Krippendorff's alpha (for ORDINAL labels) are a LATER segment -- not built
|
|
23
|
+
for a label scale that does not exist yet (two-implementation rule).
|
|
24
|
+
* Degenerate ``p_e == 1`` (both raters put every item in ONE category, so chance
|
|
25
|
+
agreement is already total) makes ``kappa = 0/0`` mathematically UNDEFINED. We
|
|
26
|
+
follow scikit-learn: return ``kappa = NaN`` with ``defined = False`` (and NaN CI
|
|
27
|
+
bounds), NEVER a silent ``0.0`` or ``1.0`` and never a crash. "Perfect raw
|
|
28
|
+
agreement but kappa undefined" is a real signal (degenerate marginals -- the
|
|
29
|
+
base-rate paradox), so it is surfaced honestly, not laundered into a number. This
|
|
30
|
+
IS the current clinescope corpus (every minimality score is 1.0 -> one class).
|
|
31
|
+
* The CI is a BOOTSTRAP PERCENTILE interval, NOT the asymptotic (Wald) ``kappa +/-
|
|
32
|
+
1.96*SE``. For the small gold sets this targets (N ~ 20-50), the sampling
|
|
33
|
+
distribution of kappa is skewed and the symmetric Wald interval is a poor fit that
|
|
34
|
+
can even spill past +/-1 (Fleiss & Cicchetti 1978); the percentile bootstrap
|
|
35
|
+
respects the ``[-1, 1]`` bounds and the asymmetry. Honest caveat: at small N the
|
|
36
|
+
interval is WIDE (McHugh 2012: N < 30 gives an interval so wide "no agreement" can
|
|
37
|
+
fall inside it) -- that width is a feature, it tells the reader not to over-trust
|
|
38
|
+
a point estimate from few items.
|
|
39
|
+
* The bootstrap is SEEDED (``seed`` param, default fixed) and uses stdlib
|
|
40
|
+
``random.Random`` so the CI is fully DETERMINISTIC and reproducible: same inputs +
|
|
41
|
+
same seed -> byte-identical bounds, which every clinescope test and the whole
|
|
42
|
+
repo's determinism posture rely on. ``n_boot`` (resample count) is also a param.
|
|
43
|
+
* ZERO runtime dependency (the project invariant): kappa is a handful of counts and
|
|
44
|
+
the bootstrap is stdlib ``random`` + a percentile pick -- no numpy/scipy/sklearn.
|
|
45
|
+
|
|
46
|
+
Primary sources: Wikipedia "Cohen's kappa" (formula, the 0.4 worked example, the
|
|
47
|
+
p_e=1 undefined case); McHugh ML (2012) Biochem Med 22(3):276-282, PMC3900052 (SE/CI
|
|
48
|
+
worked example, N>=30 guidance); scikit-learn ``cohen_kappa_score`` (NaN convention).
|
|
49
|
+
|
|
50
|
+
The functions are pure: no I/O, no LLM, deterministic under a fixed seed.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import math
|
|
56
|
+
import random
|
|
57
|
+
from collections import Counter
|
|
58
|
+
from collections.abc import Sequence
|
|
59
|
+
from dataclasses import dataclass
|
|
60
|
+
from typing import Hashable
|
|
61
|
+
|
|
62
|
+
# Percentile bounds for the 95% bootstrap CI (2.5th and 97.5th).
|
|
63
|
+
_CI_LOW_PCT = 2.5
|
|
64
|
+
_CI_HIGH_PCT = 97.5
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class CohenKappaResult:
|
|
69
|
+
"""Result of :func:`cohen_kappa`.
|
|
70
|
+
|
|
71
|
+
Invariants:
|
|
72
|
+
|
|
73
|
+
* ``n`` is the number of paired items (``len(labels_a) == len(labels_b)``).
|
|
74
|
+
* ``p_observed`` is the fraction of items both raters labeled identically;
|
|
75
|
+
``p_expected`` is the chance-agreement sum of marginal products. Both in
|
|
76
|
+
``[0.0, 1.0]``.
|
|
77
|
+
* ``defined`` is ``False`` IFF ``p_expected == 1.0`` (the degenerate one-class
|
|
78
|
+
case, ``kappa = 0/0``). When ``defined`` is ``False``, ``kappa``, ``ci_low``
|
|
79
|
+
and ``ci_high`` are all ``NaN`` -- honestly undefined, never a silent number.
|
|
80
|
+
* When ``defined`` is ``True``, ``kappa = (p_observed - p_expected) /
|
|
81
|
+
(1 - p_expected)`` in ``[-1.0, 1.0]`` (``1.0`` = perfect, ``0.0`` = chance,
|
|
82
|
+
``< 0`` = worse than chance).
|
|
83
|
+
* ``ci_low <= kappa <= ci_high`` is the 95% BOOTSTRAP PERCENTILE interval
|
|
84
|
+
(``_CI_LOW_PCT`` / ``_CI_HIGH_PCT``), computed by resampling the ``n`` paired
|
|
85
|
+
items with replacement ``n_boot`` times under ``seed``. Deterministic for a
|
|
86
|
+
fixed ``(inputs, seed)``. Wider at smaller ``n`` (it reflects sample size).
|
|
87
|
+
A bootstrap resample that is itself degenerate contributes a ``NaN`` kappa and
|
|
88
|
+
is dropped from the percentile pool (only defined resamples form the interval).
|
|
89
|
+
* ``n_boot`` echoes the requested resample count. ``n_boot_effective`` is how many
|
|
90
|
+
resamples were actually NON-degenerate and thus formed the percentile pool --
|
|
91
|
+
it is ``<= n_boot`` and drops sharply on a near-degenerate (skewed base-rate)
|
|
92
|
+
input, where many resamples collapse to one class. A large gap between the two
|
|
93
|
+
means the interval rests on a THIN, conditionally-biased pool (only resamples
|
|
94
|
+
that happened to retain both classes) -- read it as a "trust the CI less" flag,
|
|
95
|
+
surfaced rather than hidden. ``0`` (with ``defined`` still ``True``) means every
|
|
96
|
+
resample was degenerate and the CI bounds are ``NaN`` despite a defined point
|
|
97
|
+
estimate. Both are ``0`` when the point estimate itself is undefined.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
kappa: float
|
|
101
|
+
defined: bool
|
|
102
|
+
p_observed: float
|
|
103
|
+
p_expected: float
|
|
104
|
+
ci_low: float
|
|
105
|
+
ci_high: float
|
|
106
|
+
n: int
|
|
107
|
+
n_boot: int
|
|
108
|
+
n_boot_effective: int
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cohen_kappa(
|
|
112
|
+
labels_a: Sequence[Hashable],
|
|
113
|
+
labels_b: Sequence[Hashable],
|
|
114
|
+
*,
|
|
115
|
+
n_boot: int = 2000,
|
|
116
|
+
seed: int = 12345,
|
|
117
|
+
) -> CohenKappaResult:
|
|
118
|
+
"""Cohen's kappa + a seeded bootstrap 95% CI for two raters over paired labels.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
labels_a: Rater A's label per item.
|
|
122
|
+
labels_b: Rater B's label per item, aligned 1:1 with ``labels_a``.
|
|
123
|
+
n_boot: Bootstrap resample count for the CI (default 2000).
|
|
124
|
+
seed: Seed for the bootstrap RNG, so the CI is reproducible (default fixed).
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
TypeError: If either argument is a ``str`` -- a bare string would be iterated
|
|
128
|
+
character-by-character as if each char were an item's label and return a
|
|
129
|
+
plausible-but-wrong kappa, so this one silent-wrong-answer case is guarded.
|
|
130
|
+
ValueError: If the two label lists differ in length, or are empty (kappa is
|
|
131
|
+
undefined with no items to agree on).
|
|
132
|
+
"""
|
|
133
|
+
_agreement_guard_inputs(labels_a, labels_b)
|
|
134
|
+
|
|
135
|
+
a = list(labels_a)
|
|
136
|
+
b = list(labels_b)
|
|
137
|
+
n = len(a)
|
|
138
|
+
|
|
139
|
+
p_observed, p_expected, kappa = _agreement_kappa(a, b)
|
|
140
|
+
if math.isnan(kappa):
|
|
141
|
+
nan = math.nan
|
|
142
|
+
return CohenKappaResult(
|
|
143
|
+
kappa=nan,
|
|
144
|
+
defined=False,
|
|
145
|
+
p_observed=p_observed,
|
|
146
|
+
p_expected=p_expected,
|
|
147
|
+
ci_low=nan,
|
|
148
|
+
ci_high=nan,
|
|
149
|
+
n=n,
|
|
150
|
+
n_boot=n_boot,
|
|
151
|
+
n_boot_effective=0,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
ci_low, ci_high, n_boot_effective = _agreement_bootstrap_ci(
|
|
155
|
+
a, b, n_boot=n_boot, seed=seed
|
|
156
|
+
)
|
|
157
|
+
return CohenKappaResult(
|
|
158
|
+
kappa=kappa,
|
|
159
|
+
defined=True,
|
|
160
|
+
p_observed=p_observed,
|
|
161
|
+
p_expected=p_expected,
|
|
162
|
+
ci_low=ci_low,
|
|
163
|
+
ci_high=ci_high,
|
|
164
|
+
n=n,
|
|
165
|
+
n_boot=n_boot,
|
|
166
|
+
n_boot_effective=n_boot_effective,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _agreement_guard_inputs(
|
|
171
|
+
labels_a: Sequence[Hashable], labels_b: Sequence[Hashable]
|
|
172
|
+
) -> None:
|
|
173
|
+
# str AND the bytes-like siblings are all "scalar sequences": iterating them
|
|
174
|
+
# yields per-CHARACTER / per-BYTE items, not per-item labels, so a caller who
|
|
175
|
+
# passes one gets a plausible-but-wrong kappa with no error (e.g. b"YY" vs
|
|
176
|
+
# ["Y","Y"] scores perfect agreement as total DISagreement, because list(b"YY")
|
|
177
|
+
# is [89, 89] ints that never equal the strings). Reject all of them loudly.
|
|
178
|
+
if isinstance(labels_a, (str, bytes, bytearray, memoryview)) or isinstance(
|
|
179
|
+
labels_b, (str, bytes, bytearray, memoryview)
|
|
180
|
+
):
|
|
181
|
+
raise TypeError(
|
|
182
|
+
"labels_a/labels_b must be sequences of per-item labels, not a "
|
|
183
|
+
"str/bytes/bytearray/memoryview; those iterate character/byte-wise and "
|
|
184
|
+
"score wrongly -- pass a list/tuple of one label per item"
|
|
185
|
+
)
|
|
186
|
+
if len(labels_a) != len(labels_b):
|
|
187
|
+
raise ValueError(
|
|
188
|
+
f"labels_a and labels_b must have equal length "
|
|
189
|
+
f"({len(labels_a)} != {len(labels_b)})"
|
|
190
|
+
)
|
|
191
|
+
if len(labels_a) == 0:
|
|
192
|
+
raise ValueError("cohen_kappa needs at least one paired item, got none")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _agreement_kappa(
|
|
196
|
+
a: list[Hashable], b: list[Hashable]
|
|
197
|
+
) -> tuple[float, float, float]:
|
|
198
|
+
"""Return ``(p_observed, p_expected, kappa)`` for aligned label lists.
|
|
199
|
+
|
|
200
|
+
``kappa`` is ``NaN`` when ``p_expected == 1`` (the degenerate one-class case;
|
|
201
|
+
``0/0``). Nominal / category-agnostic: works for any hashable label set.
|
|
202
|
+
"""
|
|
203
|
+
n = len(a)
|
|
204
|
+
agree = sum(1 for la, lb in zip(a, b) if la == lb)
|
|
205
|
+
p_observed = agree / n
|
|
206
|
+
|
|
207
|
+
count_a = Counter(a)
|
|
208
|
+
count_b = Counter(b)
|
|
209
|
+
# sorted(..., key=repr) fixes the summation order so the float p_expected is
|
|
210
|
+
# bit-identical regardless of set-iteration order (the module's whole selling
|
|
211
|
+
# point is determinism); key=repr keeps it total-orderable for any hashable label.
|
|
212
|
+
categories = sorted(set(count_a) | set(count_b), key=repr)
|
|
213
|
+
p_expected = sum((count_a[k] / n) * (count_b[k] / n) for k in categories)
|
|
214
|
+
|
|
215
|
+
if p_expected >= 1.0:
|
|
216
|
+
return p_observed, p_expected, math.nan
|
|
217
|
+
kappa = (p_observed - p_expected) / (1.0 - p_expected)
|
|
218
|
+
return p_observed, p_expected, kappa
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _agreement_bootstrap_ci(
|
|
222
|
+
a: list[Hashable], b: list[Hashable], *, n_boot: int, seed: int
|
|
223
|
+
) -> tuple[float, float, int]:
|
|
224
|
+
"""Seeded percentile bootstrap 95% CI for kappa over the paired items.
|
|
225
|
+
|
|
226
|
+
Resamples the ``n`` item INDICES with replacement ``n_boot`` times, recomputes
|
|
227
|
+
kappa on each resample, drops resamples whose kappa is undefined (a degenerate
|
|
228
|
+
resample -- all-one-class), and returns the 2.5/97.5 percentiles of the rest.
|
|
229
|
+
|
|
230
|
+
Returns ``(ci_low, ci_high, n_boot_effective)`` where ``n_boot_effective`` is the
|
|
231
|
+
number of NON-degenerate resamples that formed the percentile pool (``<= n_boot``);
|
|
232
|
+
it is surfaced so a caller can see when the interval rests on a thin/biased pool.
|
|
233
|
+
A ``0`` effective count (every resample degenerate) yields ``NaN`` bounds.
|
|
234
|
+
"""
|
|
235
|
+
n = len(a)
|
|
236
|
+
rng = random.Random(seed)
|
|
237
|
+
kappas: list[float] = []
|
|
238
|
+
for _ in range(n_boot):
|
|
239
|
+
idx = [rng.randrange(n) for _ in range(n)]
|
|
240
|
+
ra = [a[i] for i in idx]
|
|
241
|
+
rb = [b[i] for i in idx]
|
|
242
|
+
_, _, k = _agreement_kappa(ra, rb)
|
|
243
|
+
if not math.isnan(k):
|
|
244
|
+
kappas.append(k)
|
|
245
|
+
|
|
246
|
+
if not kappas:
|
|
247
|
+
return math.nan, math.nan, 0
|
|
248
|
+
kappas.sort()
|
|
249
|
+
return (
|
|
250
|
+
_agreement_percentile(kappas, _CI_LOW_PCT),
|
|
251
|
+
_agreement_percentile(kappas, _CI_HIGH_PCT),
|
|
252
|
+
len(kappas),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _agreement_percentile(sorted_values: list[float], pct: float) -> float:
|
|
257
|
+
"""Linear-interpolated percentile of an already-sorted list (``pct`` in 0..100).
|
|
258
|
+
|
|
259
|
+
Matches the common ``numpy.percentile`` 'linear' method so the interval is a
|
|
260
|
+
standard percentile bootstrap, implemented in stdlib to keep zero dependencies.
|
|
261
|
+
"""
|
|
262
|
+
if len(sorted_values) == 1:
|
|
263
|
+
return sorted_values[0]
|
|
264
|
+
rank = (pct / 100.0) * (len(sorted_values) - 1)
|
|
265
|
+
lower = math.floor(rank)
|
|
266
|
+
upper = math.ceil(rank)
|
|
267
|
+
if lower == upper:
|
|
268
|
+
return sorted_values[lower]
|
|
269
|
+
frac = rank - lower
|
|
270
|
+
return sorted_values[lower] * (1.0 - frac) + sorted_values[upper] * frac
|