draftomen 0.3.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.
Files changed (59) hide show
  1. draftomen/__init__.py +17 -0
  2. draftomen/assets/draftomen.icns +0 -0
  3. draftomen/assets/draftomen.ico +0 -0
  4. draftomen/assets/draftomen_logo.png +0 -0
  5. draftomen/audit.py +606 -0
  6. draftomen/backtest.py +449 -0
  7. draftomen/benchmark.py +834 -0
  8. draftomen/carddb.py +1869 -0
  9. draftomen/cardimages.py +313 -0
  10. draftomen/cli.py +891 -0
  11. draftomen/config.py +133 -0
  12. draftomen/deckbuilder.py +2723 -0
  13. draftomen/events.py +772 -0
  14. draftomen/logfollow.py +451 -0
  15. draftomen/mock_session.py +745 -0
  16. draftomen/paths.py +120 -0
  17. draftomen/pickengine.py +1117 -0
  18. draftomen/pool.py +1259 -0
  19. draftomen/preferences.py +289 -0
  20. draftomen/qml/AboutDialog.qml +156 -0
  21. draftomen/qml/AppBar.qml +120 -0
  22. draftomen/qml/BacktestView.qml +316 -0
  23. draftomen/qml/BuildView.qml +1151 -0
  24. draftomen/qml/CardPreview.qml +434 -0
  25. draftomen/qml/DimensionalButton.qml +42 -0
  26. draftomen/qml/DimensionalComboBox.qml +182 -0
  27. draftomen/qml/DimensionalSurface.qml +118 -0
  28. draftomen/qml/DimensionalTabButton.qml +41 -0
  29. draftomen/qml/LiveDraftView.qml +468 -0
  30. draftomen/qml/Main.qml +175 -0
  31. draftomen/qml/NavigationRail.qml +226 -0
  32. draftomen/qml/PoolSummaryPanel.qml +322 -0
  33. draftomen/qml/PrivacyDialog.qml +96 -0
  34. draftomen/qml/RecentPickThumbnail.qml +83 -0
  35. draftomen/qml/RecentPicksGallery.qml +333 -0
  36. draftomen/qml/RecommendationRow.qml +404 -0
  37. draftomen/qml/SettingsSwitch.qml +141 -0
  38. draftomen/qml/SettingsView.qml +531 -0
  39. draftomen/qml/StateBanner.qml +189 -0
  40. draftomen/qml/StatusStrip.qml +84 -0
  41. draftomen/qml/Theme.qml +71 -0
  42. draftomen/qml/qmldir +23 -0
  43. draftomen/qt_adapter.py +884 -0
  44. draftomen/qt_gui.py +338 -0
  45. draftomen/qt_mock.py +63 -0
  46. draftomen/ranking.py +126 -0
  47. draftomen/replay.py +480 -0
  48. draftomen/session.py +3668 -0
  49. draftomen/setinfo.py +26 -0
  50. draftomen/seventeen.py +2766 -0
  51. draftomen/splash.py +617 -0
  52. draftomen/tui.py +4007 -0
  53. draftomen/watch.py +336 -0
  54. draftomen-0.3.0.dist-info/METADATA +156 -0
  55. draftomen-0.3.0.dist-info/RECORD +59 -0
  56. draftomen-0.3.0.dist-info/WHEEL +5 -0
  57. draftomen-0.3.0.dist-info/entry_points.txt +6 -0
  58. draftomen-0.3.0.dist-info/licenses/LICENSE +21 -0
  59. draftomen-0.3.0.dist-info/top_level.txt +1 -0
draftomen/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Package metadata for Draftomen.
2
+ Keep shared version and disclaimer text in one place.
3
+ """
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try:
8
+ __version__ = version(distribution_name="draftomen")
9
+ except PackageNotFoundError:
10
+ __version__ = "0.0.0+unknown"
11
+
12
+ DISCLAIMER = (
13
+ "Draft Omen is unofficial Fan Content permitted under the Fan Content Policy. "
14
+ "Not approved/endorsed by Wizards. Portions of the materials used are property "
15
+ "of Wizards of the Coast. ©Wizards of the Coast LLC. Card data from 17Lands "
16
+ "(17lands.com); 17Lands does not endorse this tool."
17
+ )
Binary file
Binary file
Binary file
draftomen/audit.py ADDED
@@ -0,0 +1,606 @@
1
+ """Independent append-only audit logging for live draft decisions.
2
+ Records preserve scoring inputs, recommendations, and eventual choices.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import json
9
+ import os
10
+ from collections.abc import Callable, Mapping
11
+ from dataclasses import asdict
12
+ from datetime import UTC, datetime
13
+ from os import PathLike
14
+ from pathlib import Path
15
+ from typing import Any, TypeAlias
16
+
17
+ from draftomen import __version__
18
+ from draftomen.config import PickEngineConfig
19
+ from draftomen.events import (
20
+ DraftCompletedEvent,
21
+ PackOfferedEvent,
22
+ PickMadeEvent,
23
+ )
24
+ from draftomen.pickengine import ScoredCard, ScoredPack
25
+ from draftomen.pool import DraftState
26
+ from draftomen.ranking import RANKING_MODES, rank_scored_cards, validate_ranking_mode
27
+ from draftomen.seventeen import SeventeenLandsData
28
+
29
+ PathInput: TypeAlias = str | PathLike[str]
30
+ Clock: TypeAlias = Callable[[], datetime]
31
+ AuditRecord: TypeAlias = dict[str, Any]
32
+
33
+ AUDIT_DIRECTORY_NAME = "audit"
34
+ DRAFT_AUDIT_DIRECTORY_NAME = "drafts"
35
+ DRAFT_AUDIT_SCHEMA_VERSION = 1
36
+
37
+
38
+ class DraftAuditError(RuntimeError):
39
+ """Raised when a draft audit record cannot be read or persisted.
40
+ Live callers surface the failure instead of silently losing evidence.
41
+ """
42
+
43
+
44
+ def draft_audit_path(
45
+ *,
46
+ account_id: str,
47
+ draft_id: str,
48
+ app_dir: PathInput,
49
+ ) -> Path:
50
+ """Return the append-only JSONL path for one account and draft.
51
+ Path segments are validated before being used on disk.
52
+ """
53
+
54
+ safe_draft_id = _path_segment(value=draft_id, field_name="draft_id")
55
+ return (
56
+ Path(app_dir)
57
+ / AUDIT_DIRECTORY_NAME
58
+ / DRAFT_AUDIT_DIRECTORY_NAME
59
+ / _path_segment(value=account_id, field_name="account_id")
60
+ / f"{safe_draft_id}.jsonl"
61
+ )
62
+
63
+
64
+ def load_draft_audit_records(
65
+ *,
66
+ account_id: str,
67
+ draft_id: str,
68
+ app_dir: PathInput,
69
+ ) -> tuple[AuditRecord, ...]:
70
+ """Load and validate every audit record for one draft.
71
+ Malformed lines fail with their one-based line number.
72
+ """
73
+
74
+ path = draft_audit_path(
75
+ account_id=account_id,
76
+ draft_id=draft_id,
77
+ app_dir=app_dir,
78
+ )
79
+ if not path.exists():
80
+ return ()
81
+
82
+ try:
83
+ lines = path.read_text(encoding="utf-8").splitlines()
84
+ except OSError as error:
85
+ raise DraftAuditError(f"Could not read draft audit log {path}: {error}.") from error
86
+
87
+ records: list[AuditRecord] = []
88
+ for line_number, line in enumerate(lines, start=1):
89
+ try:
90
+ value = json.loads(line)
91
+ except json.JSONDecodeError as error:
92
+ raise DraftAuditError(
93
+ f"Malformed draft audit log {path} at line {line_number}: {error}."
94
+ ) from error
95
+
96
+ if not isinstance(value, dict):
97
+ raise DraftAuditError(
98
+ f"Malformed draft audit log {path} at line {line_number}: "
99
+ "expected an object."
100
+ )
101
+
102
+ schema_version = value.get("schema_version")
103
+ if schema_version != DRAFT_AUDIT_SCHEMA_VERSION:
104
+ raise DraftAuditError(
105
+ f"Unsupported draft audit schema {schema_version!r} in {path} "
106
+ f"at line {line_number}; expected {DRAFT_AUDIT_SCHEMA_VERSION}."
107
+ )
108
+
109
+ record_id = value.get("record_id")
110
+ if not isinstance(record_id, str) or not record_id:
111
+ raise DraftAuditError(
112
+ f"Malformed draft audit log {path} at line {line_number}: "
113
+ "missing record_id."
114
+ )
115
+
116
+ records.append(value)
117
+
118
+ return tuple(records)
119
+
120
+
121
+ class DraftAuditStore:
122
+ """Persist stable, independently analyzable records for live drafts.
123
+ Duplicate log scans reuse record ids and leave prior evidence unchanged.
124
+ """
125
+
126
+ def __init__(
127
+ self,
128
+ *,
129
+ app_dir: PathInput,
130
+ clock: Clock | None = None,
131
+ app_version: str = __version__,
132
+ ) -> None:
133
+ self.app_dir = app_dir
134
+ self._clock = _system_clock if clock is None else clock
135
+ self._app_version = app_version
136
+ self._indexed_paths: set[Path] = set()
137
+ self._known_record_ids: dict[Path, set[str]] = {}
138
+ self._closed_decision_ids: dict[Path, set[str]] = {}
139
+ self._latest_evaluations: dict[tuple[Path, str], AuditRecord] = {}
140
+
141
+ def record_draft_started(self, *, state: DraftState) -> Path:
142
+ """Record one draft's durable identity and starting metadata.
143
+ Repeated course snapshots resolve to the same stable record.
144
+ """
145
+
146
+ payload: AuditRecord = {
147
+ "course_id": state.course_id,
148
+ "started_at": state.started_at,
149
+ }
150
+ record_id = _record_id(
151
+ prefix="draft-started",
152
+ value={
153
+ "account_id": state.account_id,
154
+ "draft_id": state.draft_id,
155
+ **payload,
156
+ },
157
+ )
158
+ return self._append(
159
+ state=state,
160
+ record_type="draft_started",
161
+ record_id=record_id,
162
+ payload=payload,
163
+ )
164
+
165
+ def record_decision(
166
+ self,
167
+ *,
168
+ state: DraftState,
169
+ event: PackOfferedEvent,
170
+ scored_pack: ScoredPack,
171
+ config: PickEngineConfig,
172
+ ratings_data: SeventeenLandsData | None,
173
+ ) -> Path:
174
+ """Record one distinct scoring evaluation for an offered pack.
175
+ Evaluations after a recorded choice are ignored as retrospective rescans.
176
+ """
177
+
178
+ path = self.path_for(state=state)
179
+ self._ensure_index(path=path)
180
+ decision_id = _decision_id(
181
+ state=state,
182
+ pack_number=event.pack_number,
183
+ pick_number=event.pick_number,
184
+ )
185
+ if decision_id in self._closed_decision_ids[path]:
186
+ return path
187
+
188
+ evaluation = _decision_payload(
189
+ state=state,
190
+ event=event,
191
+ scored_pack=scored_pack,
192
+ config=config,
193
+ ratings_data=ratings_data,
194
+ app_version=self._app_version,
195
+ decision_id=decision_id,
196
+ )
197
+ evaluation_id = _record_id(prefix="evaluation", value=evaluation)
198
+ payload = {
199
+ **evaluation,
200
+ "evaluation_id": evaluation_id,
201
+ }
202
+ return self._append(
203
+ state=state,
204
+ record_type="decision_evaluated",
205
+ record_id=evaluation_id,
206
+ payload=payload,
207
+ )
208
+
209
+ def record_choice(
210
+ self,
211
+ *,
212
+ state: DraftState,
213
+ event: PickMadeEvent,
214
+ ranking_mode: str,
215
+ ) -> Path:
216
+ """Record the actual Arena pick and recommendation visible at that time.
217
+ The choice links to the most recent persisted evaluation when available.
218
+ """
219
+
220
+ mode = validate_ranking_mode(ranking_mode=ranking_mode)
221
+ path = self.path_for(state=state)
222
+ self._ensure_index(path=path)
223
+ decision_id = _decision_id(
224
+ state=state,
225
+ pack_number=event.pack_number,
226
+ pick_number=event.pick_number,
227
+ )
228
+ evaluation = self._latest_evaluations.get((path, decision_id))
229
+ recommended_grp_id = _recommended_grp_id(
230
+ evaluation=evaluation,
231
+ ranking_mode=mode,
232
+ )
233
+ payload: AuditRecord = {
234
+ "decision_id": decision_id,
235
+ "evaluation_id": (
236
+ None if evaluation is None else evaluation.get("evaluation_id")
237
+ ),
238
+ "pack_number": event.pack_number,
239
+ "pick_number": event.pick_number,
240
+ "chosen_grp_id": event.chosen_grp_id,
241
+ "ranking_mode": mode,
242
+ "recommended_grp_id": recommended_grp_id,
243
+ "recommendation_followed": (
244
+ None
245
+ if recommended_grp_id is None
246
+ else recommended_grp_id == event.chosen_grp_id
247
+ ),
248
+ }
249
+ record_id = _record_id(
250
+ prefix="choice",
251
+ value={
252
+ "decision_id": decision_id,
253
+ "chosen_grp_id": event.chosen_grp_id,
254
+ },
255
+ )
256
+ return self._append(
257
+ state=state,
258
+ record_type="choice_made",
259
+ record_id=record_id,
260
+ payload=payload,
261
+ )
262
+
263
+ def record_draft_completed(
264
+ self,
265
+ *,
266
+ state: DraftState,
267
+ event: DraftCompletedEvent,
268
+ ) -> Path:
269
+ """Record final pool contents and the completion signal type.
270
+ Repeated completion payloads share one stable record.
271
+ """
272
+
273
+ payload: AuditRecord = {
274
+ "pack_number": event.pack_number,
275
+ "pick_number": event.pick_number,
276
+ "picked_grp_ids": list(event.picked_grp_ids),
277
+ "pick_count": len(event.picked_grp_ids),
278
+ "inferred": event.inferred,
279
+ "completed_at": state.completed_at,
280
+ }
281
+ record_id = _record_id(
282
+ prefix="draft-completed",
283
+ value={
284
+ "account_id": state.account_id,
285
+ "draft_id": state.draft_id,
286
+ **payload,
287
+ },
288
+ )
289
+ return self._append(
290
+ state=state,
291
+ record_type="draft_completed",
292
+ record_id=record_id,
293
+ payload=payload,
294
+ )
295
+
296
+ def path_for(self, *, state: DraftState) -> Path:
297
+ """Return the audit path associated with one persisted draft state.
298
+ The path is resolved without creating files or directories.
299
+ """
300
+
301
+ return draft_audit_path(
302
+ account_id=state.account_id,
303
+ draft_id=state.draft_id,
304
+ app_dir=self.app_dir,
305
+ )
306
+
307
+ def _append(
308
+ self,
309
+ *,
310
+ state: DraftState,
311
+ record_type: str,
312
+ record_id: str,
313
+ payload: Mapping[str, Any],
314
+ ) -> Path:
315
+ path = self.path_for(state=state)
316
+ self._ensure_index(path=path)
317
+ if record_id in self._known_record_ids[path]:
318
+ return path
319
+
320
+ record: AuditRecord = {
321
+ "schema_version": DRAFT_AUDIT_SCHEMA_VERSION,
322
+ "record_id": record_id,
323
+ "record_type": record_type,
324
+ "recorded_at": self._now_iso(),
325
+ "app_version": self._app_version,
326
+ "account_id": state.account_id,
327
+ "draft_id": state.draft_id,
328
+ "event_name": state.event_name,
329
+ "set_code": state.set_code,
330
+ **dict(payload),
331
+ }
332
+ encoded = (
333
+ json.dumps(
334
+ record,
335
+ ensure_ascii=True,
336
+ separators=(",", ":"),
337
+ sort_keys=True,
338
+ )
339
+ + "\n"
340
+ ).encode("utf-8")
341
+
342
+ try:
343
+ path.parent.mkdir(parents=True, exist_ok=True)
344
+ descriptor = os.open(
345
+ path,
346
+ os.O_APPEND | os.O_CREAT | os.O_WRONLY,
347
+ 0o600,
348
+ )
349
+ try:
350
+ _write_all(descriptor=descriptor, data=encoded)
351
+ os.fsync(descriptor)
352
+ finally:
353
+ os.close(descriptor)
354
+ except OSError as error:
355
+ raise DraftAuditError(
356
+ f"Could not append draft audit record to {path}: {error}."
357
+ ) from error
358
+
359
+ self._remember_record(path=path, record=record)
360
+ return path
361
+
362
+ def _ensure_index(self, *, path: Path) -> None:
363
+ if path in self._indexed_paths:
364
+ return
365
+
366
+ self._known_record_ids[path] = set()
367
+ self._closed_decision_ids[path] = set()
368
+ account_id = path.parent.name
369
+ draft_id = path.stem
370
+ for record in load_draft_audit_records(
371
+ account_id=account_id,
372
+ draft_id=draft_id,
373
+ app_dir=self.app_dir,
374
+ ):
375
+ self._remember_record(path=path, record=record)
376
+
377
+ self._indexed_paths.add(path)
378
+
379
+ def _remember_record(self, *, path: Path, record: AuditRecord) -> None:
380
+ record_id = record["record_id"]
381
+ self._known_record_ids[path].add(record_id)
382
+ record_type = record.get("record_type")
383
+ decision_id = record.get("decision_id")
384
+ if record_type == "decision_evaluated" and isinstance(decision_id, str):
385
+ self._latest_evaluations[(path, decision_id)] = record
386
+ elif record_type == "choice_made" and isinstance(decision_id, str):
387
+ self._closed_decision_ids[path].add(decision_id)
388
+
389
+ def _now_iso(self) -> str:
390
+ return self._clock().astimezone(UTC).isoformat()
391
+
392
+
393
+ def _decision_payload(
394
+ *,
395
+ state: DraftState,
396
+ event: PackOfferedEvent,
397
+ scored_pack: ScoredPack,
398
+ config: PickEngineConfig,
399
+ ratings_data: SeventeenLandsData | None,
400
+ app_version: str,
401
+ decision_id: str,
402
+ ) -> AuditRecord:
403
+ rankings = {
404
+ mode: [
405
+ card.card.grp_id
406
+ for card in rank_scored_cards(cards=scored_pack.cards, ranking_mode=mode)
407
+ ]
408
+ for mode in RANKING_MODES
409
+ }
410
+ return {
411
+ "decision_id": decision_id,
412
+ "pack_number": event.pack_number,
413
+ "pick_number": event.pick_number,
414
+ "pick_index": scored_pack.commitment.pick_index,
415
+ "offered_grp_ids": list(event.offered_grp_ids),
416
+ "pool_before_pick": list(event.pool_grp_ids),
417
+ "source_summary": scored_pack.source_summary,
418
+ "algorithm": {
419
+ "name": "draftomen.pickengine.PickEngine",
420
+ "app_version": app_version,
421
+ "config": asdict(config),
422
+ "features": {
423
+ "splash_enabled": scored_pack.splash_state.enabled,
424
+ },
425
+ },
426
+ "ratings_snapshot": _ratings_snapshot(ratings_data=ratings_data),
427
+ "normalization": {
428
+ "lower_rating": scored_pack.normalization.lower_rating,
429
+ "upper_rating": scored_pack.normalization.upper_rating,
430
+ "neutral_rating": scored_pack.normalization.neutral_rating,
431
+ },
432
+ "commitment": {
433
+ "pick_index": scored_pack.commitment.pick_index,
434
+ "pool_size": scored_pack.commitment.pool_size,
435
+ "color_weights": dict(scored_pack.commitment.color_weights),
436
+ "inferred_pair": scored_pack.commitment.inferred_pair,
437
+ "level": scored_pack.commitment.level,
438
+ "phase": scored_pack.commitment.phase,
439
+ "locked": scored_pack.commitment.locked,
440
+ },
441
+ "splash_state": asdict(scored_pack.splash_state),
442
+ "rankings": rankings,
443
+ "recommended_grp_id": rankings["score"][0] if rankings["score"] else None,
444
+ "candidates": [
445
+ _candidate_payload(scored_card=card, rank=rank)
446
+ for rank, card in enumerate(scored_pack.cards, start=1)
447
+ ],
448
+ }
449
+
450
+
451
+ def _candidate_payload(*, scored_card: ScoredCard, rank: int) -> AuditRecord:
452
+ rating = scored_card.rating
453
+ return {
454
+ "rank": rank,
455
+ "grp_id": scored_card.card.grp_id,
456
+ "name": scored_card.card.name,
457
+ "colors": list(scored_card.card.colors),
458
+ "mana_value": scored_card.card.mana_value,
459
+ "rarity": scored_card.card.rarity,
460
+ "types": list(scored_card.card.types),
461
+ "unknown": scored_card.card.unknown,
462
+ "offered_index": scored_card.original_index,
463
+ "rating": {
464
+ "grp_id": rating.grp_id,
465
+ "name": rating.name,
466
+ "color": rating.color,
467
+ "rarity": rating.rarity,
468
+ "average_last_seen_at": rating.average_last_seen_at,
469
+ "gih_win_rate": rating.gih_win_rate,
470
+ "opening_hand_win_rate": rating.opening_hand_win_rate,
471
+ "drawn_improvement_win_rate": rating.drawn_improvement_win_rate,
472
+ "sample_counts": rating.sample_counts.to_json(),
473
+ "letter_grade": rating.letter_grade,
474
+ "neutral_prior_score": rating.neutral_prior_score,
475
+ "neutral_prior": rating.neutral_prior,
476
+ "source": {
477
+ "requested_format": rating.metadata.requested_format,
478
+ "source": rating.metadata.source,
479
+ "source_format": rating.metadata.source_format,
480
+ "fallback_reason": rating.metadata.fallback_reason,
481
+ },
482
+ },
483
+ "scoring": {
484
+ "base_rating": scored_card.base_rating,
485
+ "base_score": scored_card.base_score,
486
+ "color_fit": scored_card.color_fit,
487
+ "color_factor": scored_card.color_factor,
488
+ "adjusted_rating": scored_card.adjusted_rating,
489
+ "raw_score": scored_card.raw_score,
490
+ "score": scored_card.score,
491
+ "source_label": scored_card.source_label,
492
+ "pair_tiebreaker_pair": scored_card.pair_tiebreaker_pair,
493
+ "pair_tiebreaker_win_rate": scored_card.pair_tiebreaker_win_rate,
494
+ "pair_tiebreaker_weight": scored_card.pair_tiebreaker_weight,
495
+ "score_sort_index": scored_card.score_sort_index,
496
+ },
497
+ "splash": asdict(scored_card.splash),
498
+ }
499
+
500
+
501
+ def _ratings_snapshot(*, ratings_data: SeventeenLandsData | None) -> AuditRecord | None:
502
+ if ratings_data is None:
503
+ return None
504
+
505
+ pair_datasets = {
506
+ pair: {
507
+ "event_format": dataset.event_format,
508
+ "fetched_at": dataset.fetched_at.astimezone(UTC).isoformat(),
509
+ }
510
+ for pair, dataset in sorted(ratings_data.pair_card_ratings.items())
511
+ }
512
+ return {
513
+ "set_code": ratings_data.set_code,
514
+ "requested_format": ratings_data.requested_format,
515
+ "primary": {
516
+ "event_format": ratings_data.primary.event_format,
517
+ "fetched_at": ratings_data.primary.fetched_at.astimezone(UTC).isoformat(),
518
+ },
519
+ "fallback": (
520
+ None
521
+ if ratings_data.fallback is None
522
+ else {
523
+ "event_format": ratings_data.fallback.event_format,
524
+ "fetched_at": ratings_data.fallback.fetched_at.astimezone(UTC).isoformat(),
525
+ }
526
+ ),
527
+ "pair_card_ratings": pair_datasets,
528
+ "pair_win_rates": {
529
+ pair: record.to_json()
530
+ for pair, record in sorted(ratings_data.pair_win_rates.items())
531
+ },
532
+ }
533
+
534
+
535
+ def _decision_id(
536
+ *,
537
+ state: DraftState,
538
+ pack_number: int,
539
+ pick_number: int,
540
+ ) -> str:
541
+ return _record_id(
542
+ prefix="decision",
543
+ value={
544
+ "account_id": state.account_id,
545
+ "draft_id": state.draft_id,
546
+ "pack_number": pack_number,
547
+ "pick_number": pick_number,
548
+ },
549
+ )
550
+
551
+
552
+ def _recommended_grp_id(
553
+ *,
554
+ evaluation: AuditRecord | None,
555
+ ranking_mode: str,
556
+ ) -> int | None:
557
+ if evaluation is None:
558
+ return None
559
+
560
+ rankings = evaluation.get("rankings")
561
+ if not isinstance(rankings, dict):
562
+ return None
563
+
564
+ ranked_grp_ids = rankings.get(ranking_mode)
565
+ if not isinstance(ranked_grp_ids, list) or not ranked_grp_ids:
566
+ return None
567
+
568
+ grp_id = ranked_grp_ids[0]
569
+ return grp_id if isinstance(grp_id, int) else None
570
+
571
+
572
+ def _record_id(*, prefix: str, value: Mapping[str, Any]) -> str:
573
+ canonical = json.dumps(
574
+ value,
575
+ ensure_ascii=True,
576
+ separators=(",", ":"),
577
+ sort_keys=True,
578
+ )
579
+ digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
580
+ return f"{prefix}:{digest}"
581
+
582
+
583
+ def _write_all(*, descriptor: int, data: bytes) -> None:
584
+ view = memoryview(data)
585
+ while view:
586
+ written = os.write(descriptor, view)
587
+ if written <= 0:
588
+ raise OSError("append returned no bytes")
589
+ view = view[written:]
590
+
591
+
592
+ def _path_segment(*, value: str, field_name: str) -> str:
593
+ if (
594
+ not value
595
+ or value in {".", ".."}
596
+ or "/" in value
597
+ or "\\" in value
598
+ or "\x00" in value
599
+ ):
600
+ raise DraftAuditError(f"Invalid {field_name}; cannot be used as a path segment.")
601
+
602
+ return value
603
+
604
+
605
+ def _system_clock() -> datetime:
606
+ return datetime.now(tz=UTC)