bitexoft-verify 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """bitexoft-verify: the platform's only definition of "correct"."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Checks, one module per delivery."""
@@ -0,0 +1,396 @@
1
+ """Delivery A1 — the domain: one instant of the match.
2
+
3
+ What is checked is the *shape of the boundary*, never the implementation. How the
4
+ Lyapunov maths or the centroid is written is the author's business; that an invalid
5
+ position cannot come into existence is not.
6
+
7
+ Two checks here are worth more than all the rest together:
8
+
9
+ * `boundary` the domain imports no I/O. It is what keeps every later delivery cheap.
10
+ * `invariants` impossible objects are refused, with an error of the author's own.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import subprocess
16
+ from pathlib import Path
17
+
18
+ from ..report import Check, Gate
19
+ from .common import (
20
+ Context,
21
+ check_determinism,
22
+ check_domain_has_no_io,
23
+ check_manifest_keys,
24
+ check_symbol,
25
+ check_tests_cover_invariants,
26
+ check_tests_pass,
27
+ import_symbol,
28
+ )
29
+
30
+ PITCH_LENGTH_M = 105.0
31
+ PITCH_WIDTH_M = 68.0
32
+
33
+
34
+ def fixture(name: str) -> Path:
35
+ return Path(__file__).resolve().parent.parent / "data" / name
36
+
37
+
38
+ def _domain(ctx: Context, reference: str):
39
+ return import_symbol(ctx.manifest.resolve(reference))
40
+
41
+
42
+ def _position(ctx: Context):
43
+ return _domain(ctx, "<layers.domain>.models:Position")
44
+
45
+
46
+ def _frame(ctx: Context):
47
+ return _domain(ctx, "<layers.domain>.models:Frame")
48
+
49
+
50
+ def _domain_error(ctx: Context):
51
+ return _domain(ctx, "<layers.domain>.errors:DomainError")
52
+
53
+
54
+ def check_position_rejects_impossible(ctx: Context) -> Check:
55
+ title = "Position refuses coordinates off the pitch"
56
+ try:
57
+ Position = _position(ctx)
58
+ DomainError = _domain_error(ctx)
59
+ except Exception as exc: # noqa: BLE001
60
+ return Check("position_invariants", title, "skip", f"symbols not available yet: {exc}")
61
+
62
+ impossible = [
63
+ ("x=-3", {"x": -3.0, "y": 10.0}),
64
+ (f"x={PITCH_LENGTH_M + 50}", {"x": PITCH_LENGTH_M + 50, "y": 10.0}),
65
+ ("y=-1", {"x": 10.0, "y": -1.0}),
66
+ (f"y={PITCH_WIDTH_M + 20}", {"x": 10.0, "y": PITCH_WIDTH_M + 20}),
67
+ ]
68
+ accepted: list[str] = []
69
+ wrong_error: list[str] = []
70
+ for label, kwargs in impossible:
71
+ try:
72
+ Position(**kwargs)
73
+ except Exception as exc: # noqa: BLE001
74
+ if not isinstance(exc, DomainError):
75
+ wrong_error.append(f"{label} raised {type(exc).__name__} instead of a DomainError")
76
+ continue
77
+ accepted.append(label)
78
+
79
+ problems = []
80
+ if accepted:
81
+ problems.append(f"accepted impossible positions: {', '.join(accepted)}")
82
+ if wrong_error:
83
+ problems.append("; ".join(wrong_error))
84
+
85
+ # The corners of the pitch are valid: an off-by-one here is a real bug.
86
+ for label, kwargs in [("0,0", {"x": 0.0, "y": 0.0}), ("105,68", {"x": PITCH_LENGTH_M, "y": PITCH_WIDTH_M})]:
87
+ try:
88
+ Position(**kwargs)
89
+ except Exception as exc: # noqa: BLE001
90
+ problems.append(f"rejected the valid corner {label} ({type(exc).__name__})")
91
+
92
+ if problems:
93
+ return Check("position_invariants", title, "fail", " | ".join(problems))
94
+ return Check("position_invariants", title, "pass")
95
+
96
+
97
+ def check_frame_rejects_impossible(ctx: Context) -> Check:
98
+ title = "Frame refuses impossible instants"
99
+ try:
100
+ Position = _position(ctx)
101
+ Frame = _frame(ctx)
102
+ DomainError = _domain_error(ctx)
103
+ except Exception as exc: # noqa: BLE001
104
+ return Check("frame_invariants", title, "skip", f"symbols not available yet: {exc}")
105
+
106
+ players = (Position(x=10.0, y=10.0), Position(x=20.0, y=20.0))
107
+ ball = Position(x=52.5, y=34.0)
108
+ problems: list[str] = []
109
+
110
+ for label, kwargs in [
111
+ ("negative time", {"t": -1.0, "players": players, "ball": ball}),
112
+ ("a single player", {"t": 0.0, "players": (players[0],), "ball": ball}),
113
+ ("no players", {"t": 0.0, "players": (), "ball": ball}),
114
+ ]:
115
+ try:
116
+ Frame(**kwargs)
117
+ except Exception as exc: # noqa: BLE001
118
+ if not isinstance(exc, DomainError):
119
+ problems.append(f"{label} raised {type(exc).__name__} instead of a DomainError")
120
+ continue
121
+ problems.append(f"accepted a frame with {label}")
122
+
123
+ if problems:
124
+ return Check("frame_invariants", title, "fail", " | ".join(problems))
125
+ return Check("frame_invariants", title, "pass")
126
+
127
+
128
+ def check_value_semantics(ctx: Context) -> Check:
129
+ title = "Compared by value, hashable and immutable"
130
+ try:
131
+ Position = _position(ctx)
132
+ Frame = _frame(ctx)
133
+ except Exception as exc: # noqa: BLE001
134
+ return Check("value_semantics", title, "skip", f"symbols not available yet: {exc}")
135
+
136
+ problems: list[str] = []
137
+ one, same, other = Position(x=10.0, y=20.0), Position(x=10.0, y=20.0), Position(x=11.0, y=20.0)
138
+
139
+ if one != same:
140
+ problems.append("two positions with the same coordinates are not equal")
141
+ if one == other:
142
+ problems.append("two different positions compare equal")
143
+ try:
144
+ if len({one, same, other}) != 2:
145
+ problems.append("hashing does not match equality")
146
+ except TypeError:
147
+ problems.append("Position is not hashable: it cannot go into a set or a dict key")
148
+
149
+ try:
150
+ object.__setattr__ # noqa: B018 - documents intent; the real attempt is below
151
+ one.x = 99.0 # type: ignore[misc]
152
+ problems.append("a position can be modified after construction")
153
+ except Exception: # noqa: BLE001 - FrozenInstanceError or AttributeError, both fine
154
+ pass
155
+
156
+ players = (Position(x=10.0, y=10.0), Position(x=20.0, y=20.0))
157
+ ball = Position(x=52.5, y=34.0)
158
+ first, second = Frame(t=1.0, players=players, ball=ball), Frame(t=1.0, players=players, ball=ball)
159
+ if first != second:
160
+ problems.append("two frames with the same data are not equal")
161
+ try:
162
+ first.t = 5.0 # type: ignore[misc]
163
+ problems.append("a frame can be modified after construction")
164
+ except Exception: # noqa: BLE001
165
+ pass
166
+
167
+ if problems:
168
+ return Check("value_semantics", title, "fail", " | ".join(problems))
169
+ return Check("value_semantics", title, "pass")
170
+
171
+
172
+ def _square_frame(ctx: Context, half: float):
173
+ """A square centred on the pitch: centroid and spread are known without computing."""
174
+ Position = _position(ctx)
175
+ Frame = _frame(ctx)
176
+ cx, cy = PITCH_LENGTH_M / 2, PITCH_WIDTH_M / 2
177
+ corners = tuple(
178
+ Position(x=cx + dx * half, y=cy + dy * half) for dx, dy in ((-1, -1), (-1, 1), (1, -1), (1, 1))
179
+ )
180
+ return Frame(t=0.0, players=corners, ball=Position(x=cx, y=cy))
181
+
182
+
183
+ def _line_frame(ctx: Context, step: float):
184
+ """Five players in a line through the centre.
185
+
186
+ A square cannot tell a mean apart from a maximum — every corner sits at the same
187
+ distance from the centre — so it would pass an implementation that returns the
188
+ furthest player instead of the average. This shape separates them: the mean
189
+ distance is 1.2 * step while the maximum is 2 * step.
190
+ """
191
+ Position = _position(ctx)
192
+ Frame = _frame(ctx)
193
+ cx, cy = PITCH_LENGTH_M / 2, PITCH_WIDTH_M / 2
194
+ players = tuple(Position(x=cx + offset * step, y=cy) for offset in (-2, -1, 0, 1, 2))
195
+ return Frame(t=0.0, players=players, ball=Position(x=cx, y=cy))
196
+
197
+
198
+ def check_geometry(ctx: Context) -> Check:
199
+ title = "The centroid and the spread are right"
200
+ try:
201
+ square = _square_frame(ctx, half=10.0)
202
+ line = _line_frame(ctx, step=10.0)
203
+ except Exception as exc: # noqa: BLE001
204
+ return Check("geometry", title, "skip", f"cannot build a frame yet: {exc}")
205
+
206
+ problems: list[str] = []
207
+ centre = getattr(square, "centroid", None)
208
+ if centre is None:
209
+ problems.append("Frame has no centroid")
210
+ elif abs(centre.x - PITCH_LENGTH_M / 2) > 1e-6 or abs(centre.y - PITCH_WIDTH_M / 2) > 1e-6:
211
+ problems.append(f"the centroid of a centred square is not the centre ({centre.x}, {centre.y})")
212
+
213
+ for frame, expected, shape in (
214
+ (square, (2 * 10.0**2) ** 0.5, "a square of half-side 10 m"),
215
+ (line, 12.0, "five players 10 m apart in a line"),
216
+ ):
217
+ try:
218
+ spread = frame.spread()
219
+ except Exception as exc: # noqa: BLE001
220
+ problems.append(f"spread() raised {type(exc).__name__}: {exc}")
221
+ break
222
+ if abs(spread - expected) > 1e-6:
223
+ problems.append(
224
+ f"for {shape} the mean distance to the centroid is {expected:.4f} and spread() returned {spread}"
225
+ )
226
+
227
+ if problems:
228
+ return Check("geometry", title, "fail", " | ".join(problems))
229
+ return Check("geometry", title, "pass")
230
+
231
+
232
+ def check_classification(ctx: Context) -> Check:
233
+ title = "Classification is monotonic and reaches all three states"
234
+ try:
235
+ BlockState = _domain(ctx, "<layers.domain>.models:BlockState")
236
+ Compactness = _domain(ctx, "<layers.domain>.analysis:BlockCompactness")
237
+ policy = Compactness()
238
+ except Exception as exc: # noqa: BLE001
239
+ return Check("classification", title, "skip", f"symbols not available yet: {exc}")
240
+
241
+ order = {BlockState.COMPACT: 0, BlockState.STRETCHED: 1, BlockState.BROKEN: 2}
242
+ seen = []
243
+ problems: list[str] = []
244
+ for half in (2.0, 6.0, 10.0, 14.0, 18.0, 22.0, 26.0, 30.0):
245
+ try:
246
+ state = policy.classify(_square_frame(ctx, half=half))
247
+ except Exception as exc: # noqa: BLE001
248
+ return Check("classification", title, "fail", f"classify() raised {type(exc).__name__}: {exc}")
249
+ if state not in order:
250
+ return Check("classification", title, "fail", f"classify() returned {state!r}, which is not a BlockState")
251
+ seen.append((half, state))
252
+
253
+ ranks = [order[state] for _, state in seen]
254
+ if ranks != sorted(ranks):
255
+ detail = ", ".join(f"{half:g}m->{state.name}" for half, state in seen)
256
+ problems.append(f"a tighter block is classified worse than a looser one: {detail}")
257
+ if len(set(ranks)) < 3:
258
+ problems.append(
259
+ "not all three states are reachable with the default thresholds: "
260
+ + ", ".join(f"{half:g}m->{state.name}" for half, state in seen)
261
+ )
262
+
263
+ if problems:
264
+ return Check("classification", title, "fail", " | ".join(problems))
265
+ return Check("classification", title, "pass")
266
+
267
+
268
+ def check_thresholds_are_validated(ctx: Context) -> Check:
269
+ title = "BlockCompactness refuses thresholds that make no sense"
270
+ try:
271
+ Compactness = _domain(ctx, "<layers.domain>.analysis:BlockCompactness")
272
+ except Exception as exc: # noqa: BLE001
273
+ return Check("thresholds", title, "skip", f"symbol not available yet: {exc}")
274
+ try:
275
+ Compactness(stretched_above=30.0, broken_above=20.0)
276
+ except Exception: # noqa: BLE001 - any refusal is fine; the point is that it refuses
277
+ return Check("thresholds", title, "pass")
278
+ return Check(
279
+ "thresholds",
280
+ title,
281
+ "fail",
282
+ "it accepted stretched_above above broken_above: the policy would never return STRETCHED",
283
+ )
284
+
285
+
286
+ def check_cli_analyses_fixture(ctx: Context) -> Check:
287
+ title = "The program analyses the sample file"
288
+ try:
289
+ result = ctx.cli(["analyze", str(fixture("a1_match_sample.csv"))], timeout=120)
290
+ except subprocess.TimeoutExpired:
291
+ return Check("cli_analyze", title, "fail", "it did not finish within 120s")
292
+ except Exception as exc: # noqa: BLE001
293
+ return Check("cli_analyze", title, "fail", f"could not run the entry point: {exc}")
294
+ if result.returncode != 0:
295
+ tail = (result.stderr or result.stdout).strip().splitlines()[-3:]
296
+ return Check("cli_analyze", title, "fail", " / ".join(tail) or f"exit code {result.returncode}")
297
+ lines = [line for line in result.stdout.splitlines() if line.strip()]
298
+ if len(lines) < 10:
299
+ return Check("cli_analyze", title, "fail", f"it printed {len(lines)} lines: where is the table?")
300
+ states = {"COMPACT", "STRETCHED", "BROKEN", "compact", "stretched", "broken"}
301
+ if not any(state in result.stdout for state in states):
302
+ return Check("cli_analyze", title, "fail", "the output names no block state")
303
+ return Check("cli_analyze", title, "pass")
304
+
305
+
306
+ def check_cli_fails_cleanly(ctx: Context) -> Check:
307
+ title = "With impossible data it fails cleanly, without a traceback"
308
+ try:
309
+ result = ctx.cli(["analyze", str(fixture("a1_impossible_sample.csv"))], timeout=120)
310
+ except subprocess.TimeoutExpired:
311
+ return Check("cli_bad_input", title, "fail", "it did not finish within 120s")
312
+ except Exception as exc: # noqa: BLE001
313
+ return Check("cli_bad_input", title, "fail", f"could not run the entry point: {exc}")
314
+
315
+ if "Traceback (most recent call last)" in (result.stderr or ""):
316
+ return Check(
317
+ "cli_bad_input",
318
+ title,
319
+ "fail",
320
+ "it printed a traceback: catch your DomainError at the boundary and explain the problem",
321
+ )
322
+ if result.returncode == 0:
323
+ return Check("cli_bad_input", title, "fail", "it exited with 0 on data that cannot be valid")
324
+ if result.returncode != 2:
325
+ return Check(
326
+ "cli_bad_input",
327
+ title,
328
+ "fail",
329
+ f"it exited with {result.returncode}; the contract asks for 2 so a script can tell bad data from a crash",
330
+ )
331
+ if not (result.stderr or result.stdout).strip():
332
+ return Check("cli_bad_input", title, "fail", "it failed silently: say which row and why")
333
+ return Check("cli_bad_input", title, "pass")
334
+
335
+
336
+ def gates(ctx: Context) -> list[Gate]:
337
+ acceptance = Gate(
338
+ "acceptance",
339
+ [
340
+ check_manifest_keys(ctx, ["project.id", "project.package", "layers.domain", "cli.module"]),
341
+ check_symbol(
342
+ ctx,
343
+ "<layers.domain>.models:Position",
344
+ shape="frozen-dataclass",
345
+ members=["x", "y", "distance_to"],
346
+ check_id="position",
347
+ ),
348
+ check_symbol(
349
+ ctx,
350
+ "<layers.domain>.models:Frame",
351
+ shape="frozen-dataclass",
352
+ members=["t", "players", "ball", "centroid", "spread"],
353
+ check_id="frame",
354
+ ),
355
+ check_symbol(
356
+ ctx,
357
+ "<layers.domain>.models:BlockState",
358
+ shape="enum",
359
+ members=["COMPACT", "STRETCHED", "BROKEN"],
360
+ check_id="block_state",
361
+ ),
362
+ check_symbol(ctx, "<layers.domain>.errors:DomainError", shape="exception", check_id="domain_error"),
363
+ check_symbol(
364
+ ctx, "<layers.domain>.analysis:BlockCompactness", members=["classify"], check_id="compactness"
365
+ ),
366
+ check_position_rejects_impossible(ctx),
367
+ check_frame_rejects_impossible(ctx),
368
+ check_value_semantics(ctx),
369
+ check_geometry(ctx),
370
+ check_classification(ctx),
371
+ check_thresholds_are_validated(ctx),
372
+ check_cli_analyses_fixture(ctx),
373
+ check_cli_fails_cleanly(ctx),
374
+ check_determinism(ctx, ["analyze", str(fixture("a1_match_sample.csv"))]),
375
+ ],
376
+ )
377
+ design = Gate(
378
+ "design",
379
+ [
380
+ check_domain_has_no_io(ctx),
381
+ check_tests_pass(ctx),
382
+ check_tests_cover_invariants(ctx, ["InvalidPosition", "InvalidFrame", "DomainError"]),
383
+ ],
384
+ )
385
+ evidence = Gate(
386
+ "evidence",
387
+ [
388
+ Check(
389
+ "written",
390
+ "Your written explanation",
391
+ "skip",
392
+ "this gate is yours to close on the platform: write the why and compare it against the rubric",
393
+ )
394
+ ],
395
+ )
396
+ return [acceptance, design, evidence]