File size: 26,019 Bytes
d88243e
 
 
 
f01c104
d88243e
 
f01c104
d88243e
 
 
 
 
 
 
 
 
 
 
 
f01c104
 
d88243e
b0c3cd2
 
 
 
 
 
 
 
 
 
 
 
 
f01c104
b0c3cd2
 
 
 
 
 
 
d88243e
 
ea54b71
 
f01c104
 
 
 
 
 
 
 
 
 
 
 
 
 
ea54b71
 
f01c104
ea54b71
 
 
f01c104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea54b71
 
 
 
 
 
 
f01c104
ea54b71
 
f01c104
 
 
 
 
 
ea54b71
 
 
f01c104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d88243e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f01c104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d88243e
 
 
 
 
 
 
f01c104
d88243e
 
f01c104
d88243e
 
 
 
 
 
 
 
f01c104
 
 
 
d88243e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f01c104
 
d88243e
f01c104
 
 
 
 
 
 
 
d88243e
 
 
f01c104
 
 
 
d88243e
 
f01c104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d88243e
 
 
 
 
 
 
 
 
 
 
 
f01c104
d88243e
 
 
 
 
 
 
 
f01c104
d88243e
 
 
f01c104
d88243e
 
 
f01c104
d88243e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f01c104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d88243e
 
 
f01c104
d88243e
 
 
 
f01c104
 
d88243e
f01c104
 
d88243e
 
 
 
 
 
 
 
 
 
 
 
f01c104
d88243e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f01c104
 
 
 
 
 
d88243e
 
f01c104
d88243e
 
 
 
f01c104
d88243e
f01c104
 
d88243e
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
"""Pass / fail / ungraded counts and themed dashboard HTML for the Stats page."""

from __future__ import annotations

import difflib
import html
import re
from collections import Counter
from dataclasses import dataclass

import pandas as pd
from grades import is_graded_verdict, normalize_grades

DEFAULT_FAIL_REASONS: tuple[str, ...] = (
    "Completeness",
    "Relevance",
    "Clarity",
    "Correctness",
)
OTHER_REASON = "Other"
_TYPO_CUTOFF = 0.8
_MIN_EXTRA_FAIL_COUNT = 3  # more than twice
_ANSWERS_SUFFIXES = ("_answers.parquet", "_answers.csv")
SWIPE_TEMPLATE_COLUMNS: tuple[str, ...] = (
    "term",
    "field_of_law",
    "concept",
    "section",
    "section_index",
    "question_id",
    "group",
    "part",
    "question",
    "question_index",
    "answer",
    "source_fields",
    "source_prompts",
)


def missing_template_columns(columns: object) -> tuple[str, ...]:
    """Return swipe-deck columns absent from a template frame."""
    present = {str(name) for name in columns}
    return tuple(name for name in SWIPE_TEMPLATE_COLUMNS if name not in present)


def is_test_user(record: object) -> bool:
    """True when the users.json record is flagged ``test_user``."""
    return _true_flag(record, "test_user")


def is_admin(record: object) -> bool:
    """True when the users.json record is flagged ``admin``."""
    return _true_flag(record, "admin")


def is_trashed(record: object) -> bool:
    """True when the users.json record is flagged ``trashed``."""
    return _true_flag(record, "trashed")


def _true_flag(record: object, key: str) -> bool:
    if not isinstance(record, dict):
        return False
    return bool(record.get(key))


def production_usernames(users: dict[str, object]) -> set[str]:
    """Registered graders: not test users and not trashed."""
    return {
        name
        for name, record in users.items()
        if not is_test_user(record) and not is_trashed(record)
    }


_SPARSE_TRUE_FLAGS = ("test_user", "admin", "trashed")


def compact_user_record(record: object) -> dict[str, object]:
    """Copy a users.json row, keeping only ``true`` boolean flags."""
    if not isinstance(record, dict):
        return {"password": record}
    row = dict(record)
    for key in _SPARSE_TRUE_FLAGS:
        if key not in row:
            continue
        if row[key]:
            row[key] = True
        else:
            del row[key]
    return row


def compact_users(users: dict[str, object]) -> dict[str, dict[str, object]]:
    """Compact every account record in a users.json mapping."""
    return {name: compact_user_record(record) for name, record in users.items()}


def stamp_test_user_flags(
    users: dict[str, object],
    *,
    production_names: frozenset[str],
) -> dict[str, dict[str, object]]:
    """Copy accounts; set ``test_user`` only for names outside the allowlist."""
    stamped: dict[str, dict[str, object]] = {}
    for name, record in users.items():
        row = compact_user_record(record)
        if name not in production_names:
            row["test_user"] = True
        else:
            row.pop("test_user", None)
        stamped[name] = compact_user_record(row)
    return stamped


def apply_account_edit(
    users: dict[str, object],
    old_name: str,
    *,
    new_username: str = "",
    new_password_hash: str | None = None,
) -> tuple[dict[str, dict[str, object]], str | None, str]:
    """Rename and/or rehash one account. Empty fields are left unchanged."""
    if old_name not in users:
        return compact_users(users), "Username not found", old_name
    wanted = new_username.strip()
    resulting = wanted or old_name
    if wanted and wanted != old_name and wanted in users:
        return compact_users(users), "Username already exists", old_name
    updated = compact_users(users)
    row = dict(updated.pop(old_name))
    if new_password_hash:
        row["password"] = new_password_hash
    if not wanted and not new_password_hash:
        return compact_users(users), "Nothing to save", old_name
    updated[resulting] = compact_user_record(row)
    return updated, None, resulting


def new_user_record(
    password_hash: str, *, test_user: bool = False
) -> dict[str, object]:
    """Build a sparse users.json row. ``test_user`` is omitted when false."""
    row: dict[str, object] = {"password": password_hash}
    if test_user:
        row["test_user"] = True
    return compact_user_record(row)


def account_edit_ready(new_username: str, new_password: str) -> bool:
    """True when Save may run: at least one edit field has text."""
    return bool(str(new_username or "").strip() or str(new_password or ""))


def set_account_trashed(
    users: dict[str, object],
    name: str,
    *,
    trashed: bool = True,
) -> tuple[dict[str, dict[str, object]], str | None]:
    """Toggle the sparse ``trashed`` flag on one account."""
    if name not in users:
        return compact_users(users), "Username not found"
    updated = compact_users(users)
    row = dict(updated[name])
    if trashed:
        row["trashed"] = True
    else:
        row.pop("trashed", None)
    updated[name] = compact_user_record(row)
    return updated, None


def delete_account(
    users: dict[str, object], name: str
) -> tuple[dict[str, dict[str, object]], str | None]:
    """Remove one account from users.json."""
    if name not in users:
        return compact_users(users), "Username not found"
    updated = compact_users(users)
    updated.pop(name, None)
    return updated, None


def partition_console_users(
    users: dict[str, object],
    *,
    omit: str | None = None,
) -> tuple[list[str], list[str]]:
    """Split registered names into graders vs test users (trashed stay listed).

    ``omit`` hides one account (the signed-in admin) without hiding other admins.
    """
    graders: list[str] = []
    testers: list[str] = []
    skipped = omit or ""
    for name in sorted(users):
        if name == skipped:
            continue
        if is_test_user(users[name]):
            testers.append(name)
        else:
            graders.append(name)
    return graders, testers


def can_persist_grades(view_as: str | None) -> bool:
    """False while an admin is in read-only View as."""
    return not bool(view_as)


def display_username(session_username: str, view_as: str | None) -> str:
    """Name whose deck/stats to show. Login identity stays ``session_username``."""
    return str(view_as) if view_as else session_username


def ensure_admin_account(
    users: dict[str, object],
    *,
    password_hash: str,
) -> dict[str, dict[str, object]]:
    """Insert or refresh the ``admin`` account (admin + test_user, not trashed)."""
    updated = compact_users(users)
    row = dict(updated.get("admin") or {})
    row["password"] = password_hash
    row["admin"] = True
    row["test_user"] = True
    row.pop("trashed", None)
    updated["admin"] = compact_user_record(row)
    return updated


@dataclass(frozen=True)
class GradeCounts:
    """One grader's (or a rolled-up group's) Pass / fail / ungraded split."""

    passed: int
    failed: int
    ungraded: int
    fail_reasons: dict[str, int]

    @property
    def graded(self) -> int:
        return self.passed + self.failed

    @property
    def total(self) -> int:
        return self.passed + self.failed + self.ungraded

    def add(self, other: GradeCounts) -> GradeCounts:
        reasons = dict(self.fail_reasons)
        for name, count in other.fail_reasons.items():
            reasons[name] = reasons.get(name, 0) + count
        return GradeCounts(
            passed=self.passed + other.passed,
            failed=self.failed + other.failed,
            ungraded=self.ungraded + other.ungraded,
            fail_reasons=reasons,
        )


def empty_counts(fail_reasons: tuple[str, ...] = DEFAULT_FAIL_REASONS) -> GradeCounts:
    """Zeroed counts with a slot for each named fail reason plus Other."""
    return GradeCounts(
        passed=0,
        failed=0,
        ungraded=0,
        fail_reasons=_empty_reasons(fail_reasons),
    )


def sum_counts(*items: GradeCounts | None) -> GradeCounts:
    """Add counts; skip None. Empty input yields zeros with default reasons."""
    acc: GradeCounts | None = None
    for item in items:
        if item is None:
            continue
        acc = item if acc is None else acc.add(item)
    return acc if acc is not None else empty_counts()


def percent(part: int, whole: int) -> float:
    """Return ``part`` as a percentage of ``whole``, or 0 when whole is 0."""
    if whole <= 0:
        return 0.0
    return 100.0 * part / whole


def segment_widths(parts: tuple[int, ...]) -> tuple[float, ...]:
    """Percent widths that sum to 100 when any part is positive."""
    total = sum(parts)
    if total <= 0:
        return tuple(0.0 for _ in parts)
    rounded = [round(100.0 * n / total, 4) for n in parts]
    drift = round(100.0 - sum(rounded), 4)
    for index in range(len(rounded) - 1, -1, -1):
        if parts[index] > 0:
            rounded[index] = round(rounded[index] + drift, 4)
            break
    return tuple(rounded)


def username_from_answers_filename(name: str) -> str | None:
    """Parse ``alice`` from ``alice_answers.parquet`` or ``…/alice_answers.csv``."""
    base = name.rsplit("/", 1)[-1]
    for suffix in _ANSWERS_SUFFIXES:
        if base.endswith(suffix):
            user = base[: -len(suffix)]
            return user or None
    return None


def grades_to_verdicts(
    df: pd.DataFrame | None,
) -> dict[tuple[object, object, object], object]:
    """Map sparse or template-shaped grade rows to ``(term, fol, qid) → verdict``."""
    graded = normalize_grades(df)
    out: dict[tuple[object, object, object], object] = {}
    if graded.empty:
        return out
    for term, field, question_id, verdict in zip(
        graded["term"],
        graded["field_of_law"],
        graded["question_id"],
        graded["verdict"],
        strict=True,
    ):
        out[(term, field, question_id)] = verdict
    return out


def overlay_verdicts(
    stored: dict[tuple[object, object, object], object],
    session: dict[object, object],
) -> dict[tuple[object, object, object], object]:
    """Copy ``stored``, then apply in-session Pass/fail (and ignore empty skip)."""
    merged = dict(stored)
    for key, verdict in session.items():
        if not is_graded_verdict(verdict):
            continue
        if not isinstance(key, tuple) or len(key) != 3:
            continue
        merged[(key[0], key[1], key[2])] = verdict
    return merged


def normalize_fail_label(raw: object) -> str:
    """Strip, collapse whitespace, and casefold a fail verdict for clustering."""
    text = re.sub(r"\s+", " ", str(raw).strip())
    return text.casefold()


def match_canonical_fail(
    label: str,
    fail_reasons: tuple[str, ...] = DEFAULT_FAIL_REASONS,
    *,
    cutoff: float = _TYPO_CUTOFF,
) -> str | None:
    """Return a button fail reason when ``label`` matches exactly or as a typo."""
    folded = normalize_fail_label(label)
    if not folded:
        return None
    by_fold = {normalize_fail_label(name): name for name in fail_reasons}
    if folded in by_fold:
        return by_fold[folded]
    matches = difflib.get_close_matches(
        folded, list(by_fold.keys()), n=1, cutoff=cutoff
    )
    if not matches:
        return None
    return by_fold[matches[0]]


def cluster_fail_labels(
    labels: list[str],
    fail_reasons: tuple[str, ...] = DEFAULT_FAIL_REASONS,
    *,
    cutoff: float = _TYPO_CUTOFF,
) -> dict[str, int]:
    """Map display labels to counts, folding case and typos onto button reasons."""
    counts: dict[str, int] = {name: 0 for name in fail_reasons}
    free_raw: list[str] = []
    for raw in labels:
        text = re.sub(r"\s+", " ", str(raw).strip())
        if not text:
            continue
        canonical = match_canonical_fail(text, fail_reasons, cutoff=cutoff)
        if canonical is not None:
            counts[canonical] = counts.get(canonical, 0) + 1
            continue
        free_raw.append(text)

    # Cluster remaining free-text: casefold first, then typo-fold onto cluster keys.
    fold_to_display: dict[str, str] = {}
    fold_counts: Counter[str] = Counter()
    display_votes: dict[str, Counter[str]] = {}
    for text in free_raw:
        folded = normalize_fail_label(text)
        if folded in fold_to_display:
            key = folded
        else:
            matches = difflib.get_close_matches(
                folded, list(fold_to_display.keys()), n=1, cutoff=cutoff
            )
            key = matches[0] if matches else folded
            if key not in fold_to_display:
                fold_to_display[key] = text
        fold_counts[key] += 1
        display_votes.setdefault(key, Counter())[text] += 1

    for key, n in fold_counts.items():
        display = display_votes[key].most_common(1)[0][0]
        counts[display] = counts.get(display, 0) + n
    return counts


def fail_type_rows(
    catalog_counts: dict[str, int],
    fail_reasons: tuple[str, ...] = DEFAULT_FAIL_REASONS,
    *,
    min_extra_count: int = _MIN_EXTRA_FAIL_COUNT,
) -> tuple[str, ...]:
    """Button reasons, then free-text clusters with count > 2, then Other if needed."""
    rows = list(fail_reasons)
    extras = [
        (name, n)
        for name, n in catalog_counts.items()
        if name not in fail_reasons and name != OTHER_REASON and n >= min_extra_count
    ]
    extras.sort(key=lambda item: (-item[1], item[0].casefold()))
    rows.extend(name for name, _ in extras)
    leftover = sum(
        n
        for name, n in catalog_counts.items()
        if name not in fail_reasons and name != OTHER_REASON and n < min_extra_count
    )
    leftover += catalog_counts.get(OTHER_REASON, 0)
    if leftover > 0:
        rows.append(OTHER_REASON)
    return tuple(rows)


def project_fail_reasons(
    counts: GradeCounts,
    row_names: tuple[str, ...],
    fail_reasons: tuple[str, ...] = DEFAULT_FAIL_REASONS,
) -> dict[str, int]:
    """Project a grader's fail_reasons onto the shared Fail by type row catalog."""
    projected = {name: 0 for name in row_names}
    named = set(row_names)
    for name, n in counts.fail_reasons.items():
        if name in named and name != OTHER_REASON:
            projected[name] = projected.get(name, 0) + n
        elif OTHER_REASON in named:
            projected[OTHER_REASON] = projected.get(OTHER_REASON, 0) + n
    # Ensure button reasons exist even when absent from the grader's dict.
    for name in fail_reasons:
        if name in projected:
            projected.setdefault(name, 0)
    return projected


def toggle_open_grader(open_names: list[str], name: str) -> list[str]:
    """Append ``name`` if absent; remove it if present. Preserve click order."""
    if name in open_names:
        return [item for item in open_names if item != name]
    return [*open_names, name]


def prune_open_graders(
    open_names: list[str], available: set[str] | frozenset[str]
) -> list[str]:
    """Drop open graders that are no longer in the Other graders list."""
    return [name for name in open_names if name in available]


def counts_from_verdicts(
    verdicts: dict[object, object],
    total_cards: int,
    *,
    pass_label: str = "Pass",
    fail_reasons: tuple[str, ...] = DEFAULT_FAIL_REASONS,
) -> GradeCounts:
    """Split verdicts into Pass, fail (by clustered reason), and ungraded cards."""
    passed = 0
    failed = 0
    fail_labels: list[str] = []
    for verdict in verdicts.values():
        if not is_graded_verdict(verdict):
            continue
        label = str(verdict).strip()
        if label == pass_label:
            passed += 1
            continue
        failed += 1
        fail_labels.append(label)
    reasons = cluster_fail_labels(fail_labels, fail_reasons)
    # Preserve Other slot for callers that expect it when nothing free-texted.
    reasons.setdefault(OTHER_REASON, 0)
    ungraded = max(0, int(total_cards) - passed - failed)
    return GradeCounts(
        passed=passed,
        failed=failed,
        ungraded=ungraded,
        fail_reasons=reasons,
    )


def dashboard_html(
    *,
    everyone: GradeCounts,
    you: GradeCounts,
    you_name: str,
    others: list[tuple[str, GradeCounts]],
    jurisdictions: list[tuple[str, str, GradeCounts]],
    fail_reasons: tuple[str, ...] = DEFAULT_FAIL_REASONS,
    you_is_test_user: bool = False,
    open_others: list[tuple[str, GradeCounts]] | None = None,
) -> str:
    """Return the Stats page body (hero, aggregated, you, other graders)."""
    open_others = open_others or []
    catalog = dict(everyone.fail_reasons)
    if you_is_test_user:
        for name, n in you.fail_reasons.items():
            catalog[name] = catalog.get(name, 0) + n
    row_names = fail_type_rows(catalog, fail_reasons)

    styles: list[str] = []
    everyone_bar = _stacked_bar_html("stats-bar-everyone", everyone, styles)
    reason_block = _fail_reasons_html(
        "stats-reason-everyone",
        everyone,
        row_names,
        styles,
    )
    juri_block = _jurisdiction_block_html(jurisdictions, styles)
    open_names = {name for name, _ in open_others}
    others_block = _others_grid_html(others, styles, open_names=open_names)
    you_role = "Test user (excluded from aggregation)" if you_is_test_user else "Grader"
    you_panel = _grader_panel_html(
        title=you_name,
        role=you_role,
        counts=you,
        team_graded=everyone.graded,
        bar_id="stats-bar-you",
        reason_prefix="stats-reason-you",
        row_names=row_names,
        styles=styles,
        caption_self=True,
    )
    open_panels = []
    for index, (name, counts) in enumerate(open_others):
        open_panels.append(
            _grader_panel_html(
                title=name,
                role="Grader",
                counts=counts,
                team_graded=everyone.graded,
                bar_id=f"stats-bar-open-{_css_id(name)}-{index}",
                reason_prefix=f"stats-reason-open-{_css_id(name)}-{index}",
                row_names=row_names,
                styles=styles,
                caption_self=False,
            )
        )
    style_tag = f"<style>{''.join(styles)}</style>" if styles else ""
    return (
        f"{style_tag}"
        '<div class="stats-page">'
        '<div class="stats-hero">'
        '<h1 class="stats-hero-title">Grading stats</h1>'
        '<p class="stats-hero-kicker">'
        "Pass, fail, and ungraded cards across every jurisdiction with a template."
        "</p>"
        "</div>"
        '<section class="stats-panel">'
        '<div class="stats-panel-head">'
        "<h2>Aggregated results</h2>"
        '<p class="stats-muted">All graders · all jurisdictions</p>'
        "</div>"
        f"{_kpi_row_html(everyone)}"
        f"{everyone_bar}"
        f"{_legend_html()}"
        f"{reason_block}"
        f"{juri_block}"
        "</section>"
        f"{you_panel}"
        '<section class="stats-panel">'
        '<div class="stats-panel-head">'
        "<h2>Other graders</h2>"
        '<p class="stats-muted">Click on graders to see their stats</p>'
        "</div>"
        f"{others_block}"
        "</section>"
        f"{''.join(open_panels)}"
        "</div>"
    )


def _empty_reasons(fail_reasons: tuple[str, ...]) -> dict[str, int]:
    return {name: 0 for name in (*fail_reasons, OTHER_REASON)}


def _fmt(value: int) -> str:
    return f"{value:,}"


def _fmt_pct(value: float) -> str:
    if value == 0:
        return "0%"
    if value < 0.1:
        return f"{value:.2f}%"
    if value < 10:
        return f"{value:.1f}%"
    return f"{value:.0f}%"


def _css_id(label: str) -> str:
    slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", label).strip("-")
    return slug or "x"


def _kpi_row_html(counts: GradeCounts) -> str:
    total = counts.total
    tiles = (
        ("pass", "Pass", counts.passed),
        ("fail", "Fail", counts.failed),
        ("ungraded", "Ungraded", counts.ungraded),
    )
    parts = []
    for kind, label, value in tiles:
        parts.append(
            f'<div class="stats-kpi stats-kpi-{kind}">'
            f'<div class="stats-kpi-label">{label}</div>'
            f'<div class="stats-kpi-value">{_fmt(value)}</div>'
            f'<div class="stats-kpi-pct">{_fmt_pct(percent(value, total))} of deck</div>'
            "</div>"
        )
    return f'<div class="stats-kpi-row">{"".join(parts)}</div>'


def _stacked_bar_html(bar_id: str, counts: GradeCounts, styles: list[str]) -> str:
    widths = segment_widths((counts.passed, counts.failed, counts.ungraded))
    styles.append(
        f"#{bar_id} .seg-pass{{width:{widths[0]:.4f}%!important}}"
        f"#{bar_id} .seg-fail{{width:{widths[1]:.4f}%!important}}"
        f"#{bar_id} .seg-ungraded{{width:{widths[2]:.4f}%!important}}"
    )
    label = (
        f"{_fmt(counts.passed)} pass, {_fmt(counts.failed)} fail, "
        f"{_fmt(counts.ungraded)} ungraded"
    )
    return (
        f'<div class="stats-bar" id="{bar_id}" role="img" '
        f'aria-label="{html.escape(label)}">'
        '<span class="seg seg-pass"></span>'
        '<span class="seg seg-fail"></span>'
        '<span class="seg seg-ungraded"></span>'
        "</div>"
    )


def _legend_html() -> str:
    chips = (
        ("pass", "Pass"),
        ("fail", "Fail"),
        ("ungraded", "Ungraded"),
    )
    parts = [
        f'<span class="stats-legend-chip stats-legend-{kind}">'
        f'<span class="stats-legend-swatch"></span>{label}</span>'
        for kind, label in chips
    ]
    return f'<div class="stats-legend">{"".join(parts)}</div>'


def _grader_panel_html(
    *,
    title: str,
    role: str,
    counts: GradeCounts,
    team_graded: int,
    bar_id: str,
    reason_prefix: str,
    row_names: tuple[str, ...],
    styles: list[str],
    caption_self: bool,
) -> str:
    """You-style panel: KPIs, bar, legend, Fail by type, share caption."""
    bar = _stacked_bar_html(bar_id, counts, styles)
    reasons = _fail_reasons_html(reason_prefix, counts, row_names, styles)
    share = percent(counts.graded, team_graded)
    who = "You have" if caption_self else f"{html.escape(title)} has"
    possessive = "your" if caption_self else "their"
    return (
        '<section class="stats-panel stats-panel-you">'
        '<div class="stats-panel-head">'
        f"<h2>{html.escape(title)}</h2>"
        f'<p class="stats-muted">{html.escape(role)}</p>'
        "</div>"
        f"{_kpi_row_html(counts)}"
        f"{bar}"
        f"{_legend_html()}"
        f"{reasons}"
        '<p class="stats-caption">'
        f"{who} graded {_fmt(counts.graded)} of {_fmt(counts.total)} cards "
        f"({_fmt_pct(percent(counts.graded, counts.total))} of {possessive} deck). "
        f"That is {_fmt_pct(share)} of every card graded by the team."
        "</p>"
        "</section>"
    )


def _fail_reasons_html(
    prefix: str,
    counts: GradeCounts,
    row_names: tuple[str, ...],
    styles: list[str],
) -> str:
    if counts.failed <= 0:
        return '<p class="stats-caption">No fails recorded yet.</p>'
    projected = project_fail_reasons(counts, row_names)
    peak = max((projected.get(name, 0) for name in row_names), default=0) or 1
    rows = []
    for name in row_names:
        n = projected.get(name, 0)
        row_id = f"{prefix}-{_css_id(name)}"
        width = round(100.0 * n / peak, 4)
        styles.append(f"#{row_id} .stats-reason-fill{{width:{width:.4f}%!important}}")
        rows.append(
            f'<div class="stats-reason-row" id="{row_id}">'
            f'<div class="stats-reason-name">{html.escape(name)}</div>'
            '<div class="stats-reason-track"><div class="stats-reason-fill"></div></div>'
            f'<div class="stats-reason-n">{_fmt(n)}</div>'
            "</div>"
        )
    return (
        '<div class="stats-reasons">'
        '<div class="stats-subhead">Fail by type</div>'
        f"{''.join(rows)}"
        "</div>"
    )


def _jurisdiction_block_html(
    jurisdictions: list[tuple[str, str, GradeCounts]],
    styles: list[str],
) -> str:
    if len(jurisdictions) < 2:
        return ""
    rows = []
    for code, label, counts in jurisdictions:
        bar_id = f"stats-bar-j-{_css_id(code)}"
        bar = _stacked_bar_html(bar_id, counts, styles)
        rows.append(
            '<div class="stats-juri-row">'
            f'<div class="stats-juri-name">{html.escape(label)}</div>'
            f"{bar}"
            f'<div class="stats-juri-nums">{_fmt(counts.passed)} · '
            f"{_fmt(counts.failed)} · {_fmt(counts.ungraded)}</div>"
            "</div>"
        )
    return (
        '<div class="stats-juris">'
        '<div class="stats-subhead">By jurisdiction</div>'
        f"{''.join(rows)}"
        "</div>"
    )


def _others_grid_html(
    others: list[tuple[str, GradeCounts]],
    styles: list[str],
    *,
    open_names: set[str] | frozenset[str] | None = None,
) -> str:
    if not others:
        return '<p class="stats-caption">No other graders yet.</p>'
    open_names = open_names or set()
    cards = []
    for name, counts in others:
        bar_id = f"stats-bar-user-{_css_id(name)}"
        bar = _stacked_bar_html(bar_id, counts, styles)
        open_cls = " stats-user-card-open" if name in open_names else ""
        cards.append(
            f'<div class="stats-user-card{open_cls}" role="button" tabindex="0" '
            f'data-grader="{html.escape(name, quote=True)}">'
            f'<div class="stats-user-name">{html.escape(name)}</div>'
            f"{bar}"
            f'<div class="stats-user-nums">'
            f'<span class="stats-num-pass">{_fmt(counts.passed)} pass</span>'
            f'<span class="stats-num-fail">{_fmt(counts.failed)} fail</span>'
            f'<span class="stats-num-ungraded">{_fmt(counts.ungraded)} left</span>'
            "</div>"
            "</div>"
        )
    return f'<div class="stats-users">{"".join(cards)}</div>'