ridi-check 0.3.2__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.
- ridi_check/__init__.py +3 -0
- ridi_check/__main__.py +6 -0
- ridi_check/adapters.py +257 -0
- ridi_check/analysis.py +190 -0
- ridi_check/cli.py +337 -0
- ridi_check/contract.py +222 -0
- ridi_check/demo/questions.csv +31 -0
- ridi_check/demo/ridi-check.json +11 -0
- ridi_check/demo/toy_assistant.py +154 -0
- ridi_check/gate.py +76 -0
- ridi_check/report.py +412 -0
- ridi_check/report_template.html +321 -0
- ridi_check/runner.py +501 -0
- ridi_check/scoring.py +21 -0
- ridi_check/starter/ASK_YOUR_AI.md +36 -0
- ridi_check/starter/my_assistant.py +37 -0
- ridi_check/starter/questions.csv +4 -0
- ridi_check/starter/results_template.csv +4 -0
- ridi_check/starter/ridi-check.json +10 -0
- ridi_check/strings.py +242 -0
- ridi_check/util.py +70 -0
- ridi_check-0.3.2.dist-info/METADATA +224 -0
- ridi_check-0.3.2.dist-info/RECORD +27 -0
- ridi_check-0.3.2.dist-info/WHEEL +5 -0
- ridi_check-0.3.2.dist-info/entry_points.txt +2 -0
- ridi_check-0.3.2.dist-info/licenses/LICENSE +21 -0
- ridi_check-0.3.2.dist-info/top_level.txt +1 -0
ridi_check/__init__.py
ADDED
ridi_check/__main__.py
ADDED
ridi_check/adapters.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Adapters: how ridi-check calls the assistant under test.
|
|
2
|
+
|
|
3
|
+
Every adapter is one function, answer(question) -> {"answer": str, "context": list[str] | None}.
|
|
4
|
+
No model provider is built in. The owner's assistant is the system under test.
|
|
5
|
+
|
|
6
|
+
A system definition is one of:
|
|
7
|
+
python:module:function an importable Python function (a bare module:function also works)
|
|
8
|
+
command:<shell command> reads the question on stdin, writes JSON or plain text on stdout
|
|
9
|
+
http://... or https://... POST {"question": ...}, reads "answer" and "context" from the reply
|
|
10
|
+
path/to/system.json the same three types with every option spelled out
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import inspect
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
from .util import load_callable, sha256_file
|
|
25
|
+
|
|
26
|
+
DEFAULT_TIMEOUT = 120.0
|
|
27
|
+
ENV_REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
28
|
+
SECRET_HEADER = re.compile(r"auth|key|token|secret|cookie", re.I)
|
|
29
|
+
BARE_PYTHON = re.compile(r"^[A-Za-z_][\w.]*:[A-Za-z_][\w.]*$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AdapterError(Exception):
|
|
33
|
+
"""A call to the system under test failed or returned the wrong shape."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DefinitionError(ValueError):
|
|
37
|
+
"""A system definition could not be understood."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def normalise_output(out: Any) -> dict:
|
|
41
|
+
"""Coerce an adapter result to {"answer": str, "context": list[str] | None}."""
|
|
42
|
+
if isinstance(out, str):
|
|
43
|
+
return {"answer": out, "context": None}
|
|
44
|
+
if not isinstance(out, dict) or "answer" not in out:
|
|
45
|
+
raise AdapterError('the system must return {"answer": str, "context": list[str] | None} or a string')
|
|
46
|
+
answer = out["answer"]
|
|
47
|
+
if not isinstance(answer, str):
|
|
48
|
+
raise AdapterError(f'"answer" must be a string, got {type(answer).__name__}')
|
|
49
|
+
context = out.get("context")
|
|
50
|
+
if context is None:
|
|
51
|
+
return {"answer": answer, "context": None}
|
|
52
|
+
if isinstance(context, str):
|
|
53
|
+
context = [context]
|
|
54
|
+
if not isinstance(context, (list, tuple)):
|
|
55
|
+
raise AdapterError(f'"context" must be a list of strings or null, got {type(context).__name__}')
|
|
56
|
+
return {
|
|
57
|
+
"answer": answer,
|
|
58
|
+
"context": [c if isinstance(c, str) else json.dumps(c, ensure_ascii=False) for c in context],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def json_path(obj: Any, path: str) -> Any:
|
|
63
|
+
"""Follow a dotted path. Integers index lists; '*' maps over a list."""
|
|
64
|
+
parts = [p for p in path.split(".") if p != ""]
|
|
65
|
+
|
|
66
|
+
def walk(node: Any, i: int) -> Any:
|
|
67
|
+
if i == len(parts):
|
|
68
|
+
return node
|
|
69
|
+
part = parts[i]
|
|
70
|
+
if part == "*":
|
|
71
|
+
if not isinstance(node, list):
|
|
72
|
+
raise KeyError(path)
|
|
73
|
+
return [walk(item, i + 1) for item in node]
|
|
74
|
+
if isinstance(node, list):
|
|
75
|
+
try:
|
|
76
|
+
return walk(node[int(part)], i + 1)
|
|
77
|
+
except (ValueError, IndexError):
|
|
78
|
+
raise KeyError(path) from None
|
|
79
|
+
if isinstance(node, dict) and part in node:
|
|
80
|
+
return walk(node[part], i + 1)
|
|
81
|
+
raise KeyError(path)
|
|
82
|
+
|
|
83
|
+
return walk(obj, 0)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fill(template: Any, question: str) -> Any:
|
|
87
|
+
if isinstance(template, str):
|
|
88
|
+
return template.replace("{question}", question)
|
|
89
|
+
if isinstance(template, list):
|
|
90
|
+
return [_fill(item, question) for item in template]
|
|
91
|
+
if isinstance(template, dict):
|
|
92
|
+
return {k: _fill(v, question) for k, v in template.items()}
|
|
93
|
+
return template
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _expand_env(value: str, what: str) -> str:
|
|
97
|
+
def replace(match: re.Match) -> str:
|
|
98
|
+
name = match.group(1)
|
|
99
|
+
if name not in os.environ:
|
|
100
|
+
raise DefinitionError(f"{what}: environment variable {name} is not set")
|
|
101
|
+
return os.environ[name]
|
|
102
|
+
|
|
103
|
+
return ENV_REFERENCE.sub(replace, value)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def python_adapter(target: str) -> tuple[Callable[[str, dict], dict], str | None]:
|
|
107
|
+
"""Returns (answer, SHA-256 of the file the function lives in, when it can be found)."""
|
|
108
|
+
try:
|
|
109
|
+
function = load_callable(target, "python adapter")
|
|
110
|
+
except ValueError as exc:
|
|
111
|
+
raise DefinitionError(str(exc)) from exc
|
|
112
|
+
try:
|
|
113
|
+
source = sha256_file(inspect.getsourcefile(function))
|
|
114
|
+
except (TypeError, OSError):
|
|
115
|
+
source = None
|
|
116
|
+
return (lambda question, meta: normalise_output(function(question))), source
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def command_adapter(command: str, timeout: float) -> Callable[[str, dict], dict]:
|
|
120
|
+
if not command.strip():
|
|
121
|
+
raise DefinitionError("command adapter: the command is empty")
|
|
122
|
+
|
|
123
|
+
def answer(question: str, meta: dict) -> dict:
|
|
124
|
+
env = dict(os.environ)
|
|
125
|
+
env.update({f"RIDI_CHECK_{k.upper()}": str(v) for k, v in meta.items()})
|
|
126
|
+
# The question goes out as UTF-8 and the reply is read as UTF-8, on every platform. A Python child
|
|
127
|
+
# on Windows would otherwise use the local code page and garble any non-Latin question or answer.
|
|
128
|
+
env["PYTHONUTF8"] = "1"
|
|
129
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
130
|
+
try:
|
|
131
|
+
done = subprocess.run(
|
|
132
|
+
command, shell=True, input=question, capture_output=True, text=True,
|
|
133
|
+
encoding="utf-8", errors="replace", timeout=timeout, env=env,
|
|
134
|
+
)
|
|
135
|
+
except subprocess.TimeoutExpired:
|
|
136
|
+
raise AdapterError(f"command timed out after {timeout:g}s") from None
|
|
137
|
+
if done.returncode != 0:
|
|
138
|
+
tail = done.stderr.strip()[-300:]
|
|
139
|
+
raise AdapterError(f"command exited with status {done.returncode}" + (f": {tail}" if tail else ""))
|
|
140
|
+
text = done.stdout.strip()
|
|
141
|
+
try:
|
|
142
|
+
parsed = json.loads(text)
|
|
143
|
+
except ValueError:
|
|
144
|
+
parsed = None
|
|
145
|
+
if isinstance(parsed, dict) and "answer" in parsed:
|
|
146
|
+
return normalise_output(parsed)
|
|
147
|
+
return {"answer": text, "context": None}
|
|
148
|
+
|
|
149
|
+
return answer
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def http_adapter(config: dict, timeout: float) -> Callable[[str, dict], dict]:
|
|
153
|
+
url = config.get("url")
|
|
154
|
+
if not isinstance(url, str):
|
|
155
|
+
raise DefinitionError('http adapter: needs "url"')
|
|
156
|
+
url = _expand_env(url, "http adapter url") # the address may come from the environment
|
|
157
|
+
if not url.startswith(("http://", "https://")):
|
|
158
|
+
raise DefinitionError("http adapter: \"url\" must start with http:// or https://")
|
|
159
|
+
template = config.get("request", {"question": "{question}"})
|
|
160
|
+
answer_path = config.get("answer_path", "answer")
|
|
161
|
+
context_path = config.get("context_path", "context")
|
|
162
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
163
|
+
for name, value in (config.get("headers") or {}).items():
|
|
164
|
+
headers[name] = _expand_env(str(value), f"http adapter header {name}")
|
|
165
|
+
|
|
166
|
+
def answer(question: str, meta: dict) -> dict:
|
|
167
|
+
body = json.dumps(_fill(template, question), ensure_ascii=False).encode("utf-8")
|
|
168
|
+
request = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
169
|
+
try:
|
|
170
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
171
|
+
raw = response.read().decode("utf-8", errors="replace")
|
|
172
|
+
except urllib.error.HTTPError as exc:
|
|
173
|
+
detail = exc.read().decode("utf-8", errors="replace").strip()[:300]
|
|
174
|
+
raise AdapterError(f"HTTP {exc.code}" + (f": {detail}" if detail else "")) from None
|
|
175
|
+
except urllib.error.URLError as exc:
|
|
176
|
+
raise AdapterError(f"request failed: {exc.reason}") from None
|
|
177
|
+
except TimeoutError:
|
|
178
|
+
raise AdapterError(f"request timed out after {timeout:g}s") from None
|
|
179
|
+
try:
|
|
180
|
+
reply = json.loads(raw)
|
|
181
|
+
except ValueError:
|
|
182
|
+
raise AdapterError(f"the reply is not JSON: {raw[:200]!r}") from None
|
|
183
|
+
try:
|
|
184
|
+
found = json_path(reply, answer_path)
|
|
185
|
+
except KeyError:
|
|
186
|
+
raise AdapterError(f"the reply has no value at answer_path {answer_path!r}") from None
|
|
187
|
+
context = None
|
|
188
|
+
if context_path:
|
|
189
|
+
try:
|
|
190
|
+
context = json_path(reply, context_path)
|
|
191
|
+
except KeyError:
|
|
192
|
+
context = None
|
|
193
|
+
return normalise_output({"answer": found, "context": context})
|
|
194
|
+
|
|
195
|
+
return answer
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def parse_definition(text: str) -> dict:
|
|
199
|
+
"""Turn a definition string into a config dict with a "type"."""
|
|
200
|
+
s = text.strip()
|
|
201
|
+
if s.startswith("python:"):
|
|
202
|
+
return {"type": "python", "target": s[len("python:"):].strip()}
|
|
203
|
+
if s.startswith("command:"):
|
|
204
|
+
return {"type": "command", "command": s[len("command:"):].strip()}
|
|
205
|
+
if s.startswith(("http://", "https://")):
|
|
206
|
+
return {"type": "http", "url": s}
|
|
207
|
+
if os.path.isfile(s):
|
|
208
|
+
try:
|
|
209
|
+
with open(s, encoding="utf-8") as f:
|
|
210
|
+
config = json.load(f)
|
|
211
|
+
except (OSError, ValueError) as exc:
|
|
212
|
+
raise DefinitionError(f"{s}: not a readable JSON system definition: {exc}") from exc
|
|
213
|
+
if not isinstance(config, dict) or config.get("type") not in ("python", "command", "http"):
|
|
214
|
+
raise DefinitionError(f'{s}: needs "type": "python", "command" or "http"')
|
|
215
|
+
return config
|
|
216
|
+
if BARE_PYTHON.match(s):
|
|
217
|
+
return {"type": "python", "target": s}
|
|
218
|
+
raise DefinitionError(
|
|
219
|
+
f"cannot read system definition {text!r}: use python:module:function, command:<shell command>, "
|
|
220
|
+
"an http(s) URL, or the path of a JSON definition file"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def build_adapter(definition: str, timeout: float | None = None) -> tuple[Callable[[str, dict], dict], dict]:
|
|
225
|
+
"""Returns (answer(question, meta), description for the manifest)."""
|
|
226
|
+
config = parse_definition(definition)
|
|
227
|
+
limit = float(config.get("timeout", timeout if timeout is not None else DEFAULT_TIMEOUT))
|
|
228
|
+
kind = config["type"]
|
|
229
|
+
description: dict = {"definition": definition, "type": kind}
|
|
230
|
+
if os.path.isfile(definition.strip()):
|
|
231
|
+
description["definition_file_sha256"] = sha256_file(definition.strip())
|
|
232
|
+
description["definition_file_content"] = config
|
|
233
|
+
if kind == "python":
|
|
234
|
+
if not isinstance(config.get("target"), str):
|
|
235
|
+
raise DefinitionError('python adapter: needs "target": "module:function"')
|
|
236
|
+
answer, source = python_adapter(config["target"])
|
|
237
|
+
if source:
|
|
238
|
+
description["python_source_sha256"] = source
|
|
239
|
+
return answer, description
|
|
240
|
+
if kind == "command":
|
|
241
|
+
if not isinstance(config.get("command"), str):
|
|
242
|
+
raise DefinitionError('command adapter: needs "command"')
|
|
243
|
+
return command_adapter(config["command"], limit), description
|
|
244
|
+
return http_adapter(config, limit), description
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def literal_secret_headers(definition: str) -> list[str]:
|
|
248
|
+
"""Header names that look like credentials and hold a literal value (not ${ENV_VAR})."""
|
|
249
|
+
try:
|
|
250
|
+
config = parse_definition(definition)
|
|
251
|
+
except DefinitionError:
|
|
252
|
+
return []
|
|
253
|
+
headers = config.get("headers") or {}
|
|
254
|
+
return [
|
|
255
|
+
name for name, value in headers.items()
|
|
256
|
+
if SECRET_HEADER.search(name) and not ENV_REFERENCE.search(str(value))
|
|
257
|
+
]
|
ridi_check/analysis.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Case states and first-run rates. Same rules as the browser prototype.
|
|
2
|
+
|
|
3
|
+
Per case, with the runs of each version as a list:
|
|
4
|
+
1. any run of a version differs from that version's first run -> unstable
|
|
5
|
+
2. else the first runs of the two versions are equal -> correct_both / wrong_both / same
|
|
6
|
+
3. else stable_regression / stable_improvement / stable_change, or the *_unconfirmed label
|
|
7
|
+
when either version has a single run. "Stable" needs at least two runs of both versions.
|
|
8
|
+
|
|
9
|
+
Three things are added to the prototype's rules, none of which changes its states or counts:
|
|
10
|
+
- `incomplete`, for a case whose first run of a version failed in `ridi-check run`. It is not
|
|
11
|
+
compared and is left out of every rate.
|
|
12
|
+
- Under a word-overlap threshold, two answers that differ in a negation word or in a number are
|
|
13
|
+
never counted as the same. With the default (exact match) this has no effect.
|
|
14
|
+
- In answer-key mode, a case that is wrong in both versions is also checked for a changed answer
|
|
15
|
+
(`answer_change`). Its state stays `wrong_both`; correctness counting alone does not see it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import unicodedata
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Callable
|
|
23
|
+
|
|
24
|
+
from .contract import KEY, Case, Run
|
|
25
|
+
|
|
26
|
+
STABLE_REGRESSION = "stable_regression"
|
|
27
|
+
STABLE_IMPROVEMENT = "stable_improvement"
|
|
28
|
+
STABLE_CHANGE = "stable_change"
|
|
29
|
+
REGRESSION_UNCONFIRMED = "regression_unconfirmed"
|
|
30
|
+
IMPROVEMENT_UNCONFIRMED = "improvement_unconfirmed"
|
|
31
|
+
CHANGE_UNCONFIRMED = "change_unconfirmed"
|
|
32
|
+
UNSTABLE = "unstable"
|
|
33
|
+
WRONG_BOTH = "wrong_both"
|
|
34
|
+
CORRECT_BOTH = "correct_both"
|
|
35
|
+
SAME = "same"
|
|
36
|
+
INCOMPLETE = "incomplete"
|
|
37
|
+
|
|
38
|
+
KEY_STATES = [
|
|
39
|
+
STABLE_REGRESSION, STABLE_IMPROVEMENT, REGRESSION_UNCONFIRMED, IMPROVEMENT_UNCONFIRMED,
|
|
40
|
+
UNSTABLE, WRONG_BOTH, CORRECT_BOTH, INCOMPLETE,
|
|
41
|
+
]
|
|
42
|
+
NOKEY_STATES = [STABLE_CHANGE, CHANGE_UNCONFIRMED, UNSTABLE, SAME, INCOMPLETE]
|
|
43
|
+
UNCHANGED_STATES = {CORRECT_BOTH, SAME}
|
|
44
|
+
|
|
45
|
+
# "t" is what n't leaves behind once punctuation is removed (isn't -> isn t).
|
|
46
|
+
NEGATIONS = frozenset(
|
|
47
|
+
"not no never neither nor without none cannot t "
|
|
48
|
+
"لا غير لم لن بدون ليس ليست".split()
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def normalise(text: str) -> str:
|
|
53
|
+
"""Lower-case, punctuation to spaces, whitespace collapsed (the prototype's `norm`)."""
|
|
54
|
+
kept = []
|
|
55
|
+
for ch in str(text).lower():
|
|
56
|
+
if ch.isspace() or ch == "\ufeff" or unicodedata.category(ch)[0] in "LN":
|
|
57
|
+
kept.append(ch)
|
|
58
|
+
else:
|
|
59
|
+
kept.append(" ")
|
|
60
|
+
return " ".join("".join(kept).replace("\ufeff", " ").split())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def same_answer(a: str, b: str, threshold: float = 1.0) -> bool:
|
|
64
|
+
"""Equal after normalisation, or word overlap (Jaccard) at or above the threshold."""
|
|
65
|
+
x, y = normalise(a), normalise(b)
|
|
66
|
+
if x == y:
|
|
67
|
+
return True
|
|
68
|
+
if threshold >= 1:
|
|
69
|
+
return False
|
|
70
|
+
words_x, words_y = set(x.split(" ")), set(y.split(" "))
|
|
71
|
+
if bool(words_x & NEGATIONS) != bool(words_y & NEGATIONS):
|
|
72
|
+
return False # "is effective" and "is not effective" share most of their words
|
|
73
|
+
if {w for w in words_x if w.isdigit()} != {w for w in words_y if w.isdigit()}:
|
|
74
|
+
return False # "within 30 days" and "within 45 days" do too
|
|
75
|
+
shared = len(words_x & words_y)
|
|
76
|
+
union = len(words_x) + len(words_y) - shared
|
|
77
|
+
return union > 0 and shared / union >= threshold
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def make_eq(mode: str, threshold: float = 1.0) -> Callable[[Run, Run], bool]:
|
|
81
|
+
if mode == KEY:
|
|
82
|
+
return lambda r, s: r.correct == s.correct
|
|
83
|
+
return lambda r, s: same_answer(r.answer, s.answer, threshold)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def classify(case: Case, mode: str, threshold: float = 1.0) -> str:
|
|
87
|
+
if case.incomplete:
|
|
88
|
+
return INCOMPLETE
|
|
89
|
+
eq = make_eq(mode, threshold)
|
|
90
|
+
b, u = case.baseline, case.updated
|
|
91
|
+
stable_b = all(eq(r, b[0]) for r in b)
|
|
92
|
+
stable_u = all(eq(r, u[0]) for r in u)
|
|
93
|
+
if not stable_b or not stable_u:
|
|
94
|
+
return UNSTABLE
|
|
95
|
+
key = mode == KEY
|
|
96
|
+
if eq(b[0], u[0]):
|
|
97
|
+
if not key:
|
|
98
|
+
return SAME
|
|
99
|
+
return CORRECT_BOTH if b[0].correct else WRONG_BOTH
|
|
100
|
+
confirmed = len(b) > 1 and len(u) > 1
|
|
101
|
+
if not key:
|
|
102
|
+
return STABLE_CHANGE if confirmed else CHANGE_UNCONFIRMED
|
|
103
|
+
if b[0].correct:
|
|
104
|
+
return STABLE_REGRESSION if confirmed else REGRESSION_UNCONFIRMED
|
|
105
|
+
return STABLE_IMPROVEMENT if confirmed else IMPROVEMENT_UNCONFIRMED
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class Analysis:
|
|
110
|
+
mode: str
|
|
111
|
+
threshold: float
|
|
112
|
+
items: list[tuple[Case, str]] = field(default_factory=list)
|
|
113
|
+
counts: dict[str, int] = field(default_factory=dict)
|
|
114
|
+
answer_change: dict[str, str] = field(default_factory=dict) # wrong_both case id -> stable | unconfirmed
|
|
115
|
+
n: int = 0 # rows in the file
|
|
116
|
+
n_compared: int = 0 # rows with a first run of both versions
|
|
117
|
+
changed: int = 0 # first runs differ
|
|
118
|
+
rerun_b: int = 0 # run 1 and run 2 of the current version differ
|
|
119
|
+
rerun_b_n: int = 0 # cases with at least two runs of the current version
|
|
120
|
+
rerun_u: int = 0
|
|
121
|
+
rerun_u_n: int = 0
|
|
122
|
+
any_run_b: int = 0 # any run of the current version differs from its first run
|
|
123
|
+
any_run_u: int = 0
|
|
124
|
+
confirmable: int = 0 # cases with at least two runs of both versions
|
|
125
|
+
max_runs_b: int = 0
|
|
126
|
+
max_runs_u: int = 0
|
|
127
|
+
correct_b: int = 0 # key mode, first run
|
|
128
|
+
correct_u: int = 0
|
|
129
|
+
regressions: int = 0 # key mode, first run, 1 -> 0
|
|
130
|
+
improvements: int = 0 # key mode, first run, 0 -> 1
|
|
131
|
+
|
|
132
|
+
def ids(self, state: str) -> list[str]:
|
|
133
|
+
return [case.case_id for case, s in self.items if s == state]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def analyse(cases: list[Case], mode: str, threshold: float = 1.0) -> Analysis:
|
|
137
|
+
key = mode == KEY
|
|
138
|
+
eq = make_eq(mode, threshold)
|
|
139
|
+
a = Analysis(mode=mode, threshold=threshold, n=len(cases))
|
|
140
|
+
a.counts = {state: 0 for state in (KEY_STATES if key else NOKEY_STATES)}
|
|
141
|
+
for case in cases:
|
|
142
|
+
state = classify(case, mode, threshold)
|
|
143
|
+
a.counts[state] += 1
|
|
144
|
+
a.items.append((case, state))
|
|
145
|
+
if state == INCOMPLETE:
|
|
146
|
+
continue
|
|
147
|
+
b, u = case.baseline, case.updated
|
|
148
|
+
a.n_compared += 1
|
|
149
|
+
a.max_runs_b = max(a.max_runs_b, len(b))
|
|
150
|
+
a.max_runs_u = max(a.max_runs_u, len(u))
|
|
151
|
+
if not eq(b[0], u[0]):
|
|
152
|
+
a.changed += 1
|
|
153
|
+
if len(b) > 1:
|
|
154
|
+
a.rerun_b_n += 1
|
|
155
|
+
a.rerun_b += 0 if eq(b[0], b[1]) else 1
|
|
156
|
+
a.any_run_b += 0 if all(eq(r, b[0]) for r in b) else 1
|
|
157
|
+
if len(u) > 1:
|
|
158
|
+
a.rerun_u_n += 1
|
|
159
|
+
a.rerun_u += 0 if eq(u[0], u[1]) else 1
|
|
160
|
+
a.any_run_u += 0 if all(eq(r, u[0]) for r in u) else 1
|
|
161
|
+
if state == WRONG_BOTH and all(r.answer for r in b + u):
|
|
162
|
+
by_answer = classify(case, "nokey", threshold)
|
|
163
|
+
if by_answer == STABLE_CHANGE:
|
|
164
|
+
a.answer_change[case.case_id] = "stable"
|
|
165
|
+
elif by_answer == CHANGE_UNCONFIRMED:
|
|
166
|
+
a.answer_change[case.case_id] = "unconfirmed"
|
|
167
|
+
if len(b) > 1 and len(u) > 1:
|
|
168
|
+
a.confirmable += 1
|
|
169
|
+
if key:
|
|
170
|
+
a.correct_b += b[0].correct
|
|
171
|
+
a.correct_u += u[0].correct
|
|
172
|
+
a.regressions += 1 if b[0].correct and not u[0].correct else 0
|
|
173
|
+
a.improvements += 1 if not b[0].correct and u[0].correct else 0
|
|
174
|
+
return a
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def duplicate_questions(pairs: list[tuple[str, str]]) -> list[list[str]]:
|
|
178
|
+
"""Groups of case ids whose question text is the same after normalisation."""
|
|
179
|
+
groups: dict[str, list[str]] = {}
|
|
180
|
+
for case_id, question in pairs:
|
|
181
|
+
text = normalise(question)
|
|
182
|
+
if text:
|
|
183
|
+
groups.setdefault(text, []).append(case_id)
|
|
184
|
+
return [ids for ids in groups.values() if len(ids) > 1]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def duplicate_note(groups: list[list[str]]) -> str:
|
|
188
|
+
shown = "; ".join(" = ".join(ids) for ids in groups[:5]) + (f"; and {len(groups) - 5} more" if len(groups) > 5 else "")
|
|
189
|
+
return (f"{len(groups)} question(s) appear under more than one case_id ({shown}). "
|
|
190
|
+
"Each row still counts as one case, so a repeated question counts more than once in every rate.")
|