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.
Files changed (95) hide show
  1. clinescope/__init__.py +9 -0
  2. clinescope/__main__.py +152 -0
  3. clinescope/_datafiles.py +111 -0
  4. clinescope/advice.py +120 -0
  5. clinescope/agreement.py +270 -0
  6. clinescope/agreement_multi.py +207 -0
  7. clinescope/apply_recovery.py +514 -0
  8. clinescope/compare.py +365 -0
  9. clinescope/corpus.py +497 -0
  10. clinescope/diff_coherence.py +352 -0
  11. clinescope/diff_minimality.py +323 -0
  12. clinescope/gate.py +317 -0
  13. clinescope/gold.py +341 -0
  14. clinescope/judge.py +351 -0
  15. clinescope/judge_multidraw.py +444 -0
  16. clinescope/judge_run.py +631 -0
  17. clinescope/label_gold.py +263 -0
  18. clinescope/labels.py +271 -0
  19. clinescope/report.py +433 -0
  20. clinescope/tool_selection.py +91 -0
  21. clinescope/tool_vocab.py +69 -0
  22. clinescope/world_a.py +210 -0
  23. clinescope-1.0.1.dist-info/METADATA +150 -0
  24. clinescope-1.0.1.dist-info/RECORD +95 -0
  25. clinescope-1.0.1.dist-info/WHEEL +4 -0
  26. clinescope-1.0.1.dist-info/entry_points.txt +4 -0
  27. clinescope-1.0.1.dist-info/licenses/LICENSE +201 -0
  28. examples/apply-patch-trace.json +62 -0
  29. examples/apply-recovery-trace.json +97 -0
  30. examples/corpus/README.md +66 -0
  31. examples/corpus/corpus.json +98 -0
  32. examples/corpus/live-gpt-oss-add-file.json +187 -0
  33. examples/corpus/live-gpt-oss-apply-fail.json +135 -0
  34. examples/corpus/live-gpt-oss-trace.json +137 -0
  35. examples/corpus/live-gpt-oss-update-2hunk.json +135 -0
  36. examples/corpus/llama-code-dump.json +41 -0
  37. examples/corpus/qwen-missing-tools.json +41 -0
  38. examples/gate-regression-badpatch.json +35 -0
  39. examples/gold/dm-hardcase-01-py-retype-timeout-pair.json +63 -0
  40. examples/gold/dm-hardcase-02-js-retype-header-pair.json +63 -0
  41. examples/gold/dm-hardcase-03-go-retype-struct-fields.json +63 -0
  42. examples/gold/dm-hardcase-04-yaml-retype-resource-limits.json +63 -0
  43. examples/gold/dm-hardcase-05-py-settings-interleaved-retype.json +63 -0
  44. examples/gold/dm-hardcase-06-go-handler-interleaved-fields.json +63 -0
  45. examples/gold/dm-hardcase-07-yaml-ci-interleaved-steps.json +63 -0
  46. examples/gold/dm-hardcase-08-fix-timeout-constant.json +63 -0
  47. examples/gold/dm-hardcase-09-add-py-typed-marker.json +39 -0
  48. examples/gold/dm-hardcase-10-rename-util-module.json +63 -0
  49. examples/gold/dm-hardcase-11-add-nil-guard.json +63 -0
  50. examples/gold/dm-hardcase-12-bump-replicas-two-line.json +63 -0
  51. examples/gold/dm-hardcase-13-blind-retype-normalize-fn.json +63 -0
  52. examples/gold/dm-hardcase-14-blind-retype-js-retry-config.json +63 -0
  53. examples/gold/dm-hardcase-15-blind-retype-go-limits-mixed.json +63 -0
  54. examples/gold/dm-hardcase-16-blind-retype-yaml-ci-matrix.json +63 -0
  55. examples/gold/dm-hardcase-17-retype-normalize-body.json +63 -0
  56. examples/gold/dm-hardcase-18-retype-retry-config.json +63 -0
  57. examples/gold/dm-hardcase-19-retype-parse-args-go.json +63 -0
  58. examples/gold/dm-hardcase-20-retype-reducer-js.json +63 -0
  59. examples/gold/dm-hardcase-21-python-http-timeout-default.json +63 -0
  60. examples/gold/dm-hardcase-22-go-server-const-poolsize.json +63 -0
  61. examples/gold/dm-hardcase-23-yaml-deploy-image-tag.json +63 -0
  62. examples/gold/dm-hardcase-24-js-retry-max-attempts.json +63 -0
  63. examples/gold/dm-hardcase-25-py-retype-two-thresholds.json +63 -0
  64. examples/gold/dm-hardcase-26-ts-retype-two-flags.json +63 -0
  65. examples/gold/dm-hardcase-27-py-interleaved-defaults.json +63 -0
  66. examples/gold/dm-hardcase-28-go-interleaved-consts.json +63 -0
  67. examples/gold/dm-hardcase-29-fix-single-return.json +63 -0
  68. examples/gold/dm-hardcase-30-add-one-import.json +63 -0
  69. examples/gold/dm-hardcase-31-bump-single-version.json +63 -0
  70. examples/gold/dm-hardcase-32-add-newline-eof.json +63 -0
  71. examples/gold/dm-hardcase-33-py-timeout-in-big-block.json +63 -0
  72. examples/gold/dm-hardcase-34-yaml-image-in-pod.json +63 -0
  73. examples/gold/dm-hardcase-35-go-one-field-in-struct.json +63 -0
  74. examples/gold/dm-hardcase-36-js-one-option-in-object.json +63 -0
  75. examples/gold/dm-hardcase-37-py-split-compute.json +63 -0
  76. examples/gold/dm-hardcase-38-go-split-parse.json +63 -0
  77. examples/gold/dm-hardcase-39-js-split-reducer.json +63 -0
  78. examples/gold/dm-hardcase-40-py-split-validate.json +63 -0
  79. examples/gold/dm-hardcase-41-blind-retype-py-config-block.json +63 -0
  80. examples/gold/dm-hardcase-42-blind-retype-go-struct-literal.json +63 -0
  81. examples/gold/dm-hardcase-43-blind-retype-yaml-service.json +63 -0
  82. examples/gold/dm-hardcase-44-blind-retype-js-handlers.json +63 -0
  83. examples/gold/dm-hardcase-45-py-retype-pair-limits.json +63 -0
  84. examples/gold/dm-hardcase-46-go-interleaved-two.json +63 -0
  85. examples/gold/dm-hardcase-47-context-drag-py-single.json +63 -0
  86. examples/gold/dm-hardcase-48-tight-add-guard-js.json +63 -0
  87. examples/live-gpt-oss-add-file.json +187 -0
  88. examples/live-gpt-oss-apply-fail.json +135 -0
  89. examples/live-gpt-oss-trace.json +137 -0
  90. examples/live-gpt-oss-update-2hunk.json +135 -0
  91. examples/multi-op-trace.json +65 -0
  92. examples/sample-trace.json +59 -0
  93. gold/README.md +149 -0
  94. gold/diff_minimality.gold.jsonl +50 -0
  95. 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())
@@ -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
+ )
@@ -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