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 ADDED
@@ -0,0 +1,3 @@
1
+ """ridi-check: what did an update change, and which changes hold on re-run."""
2
+
3
+ __version__ = "0.3.2"
ridi_check/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
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.")