File size: 35,690 Bytes
a6b9c57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
PDF Injection Detector - MiMo-7B.

Upload a PDF, and this reads it the way the corpus was read, cuts it into batches of regions that
fit one run of the model, and asks MiMo-7B-RL whether a payload is hidden in the batch you choose.

`app.py` holds the interface, the batching and the aggregation. It contains no detection logic of
its own: the text extraction lives in `corpus_text.py`, the prompt and parser in `mimo.py`, the
document-shape features in `doc_features.py`, the embedding lookup in `neighbours.py` and the
family-naming model in `family_model.py` - each quoted from, or fitted in, the notebook that
measured it.
"""

import re
import traceback
from pathlib import Path

import gradio as gr

import corpus_text
import doc_features
import family_model
import mimo
import neighbours

# What to do about each family, shown under a payload verdict.
#
# One entry per family in `mimo.FAMILIES`, and the keys are asserted against that list at import so
# a renamed family cannot silently lose its advice. Each is written against the generator's own
# object shapes rather than the general security literature, so a reader who opens a flagged file
# finds the thing the text describes.
#
# **This is advice conditional on a name**, and which name it is conditional on changed when the
# family-naming model was added. MiMo alone gets the family right 43% of the time, so most of the
# advice it selected was the remedy for a different attack; `family_model` chooses it now, at P@1
# 0.994 on files carrying a known signature and 0.691 on files carrying none. Neither figure is
# certainty, so the first line of every entry is still a containment step that holds whatever the
# family turns out to be, and the interface still says which model named it and how sure it was.
TREATMENT = {
    "javascript_injection": (
        "Quarantine the file immediately. Do not open in Adobe Reader or any JS-enabled viewer. "
        "Strip the /JS and /JavaScript PDF objects using a PDF sanitiser (e.g. qpdf or mutool). "
        "Disable JavaScript in PDF readers organisation-wide via Group Policy."),
    "cross_site_scripting": (
        "Do not open in a browser-based PDF viewer. Use a sandboxed desktop reader only. "
        "Submit to your security team for content stripping before redistribution."),
    "ssrf": (
        "Block outbound HTTP requests from any server that processes this file. "
        "Do not open on cloud infrastructure without egress filtering - the payload targets "
        "the AWS/GCP metadata endpoint (169.254.169.254). Flag for security review."),
    "object_action_injection": (
        "Open only in read-only sandboxed mode. Strip /Launch, /OpenAction and /AA objects "
        "using a PDF sanitiser. Alert your SOC team - this payload attempts to execute a "
        "program outside the PDF viewer, with the opening user's own permissions."),
    "llm_prompt_injection": (
        "Do not feed this document to any AI/LLM pipeline without human review first. "
        "The payload attempts to override AI instructions. Sanitise or reject the file "
        "before any automated processing."),
    "shellcode_embedded_exe": (
        "Quarantine immediately and isolate the machine. Run through antivirus before any "
        "attachment is extracted, and do not let anyone double-click what comes out. "
        "Report the binary stream hash to your threat intelligence team."),
    "dde_template_injection": (
        "Do not copy any field contents into Excel or Word - the payload activates as a DDE "
        "formula once it reaches Office, not while the PDF is open. Strip /AcroForm field "
        "values and any remote template reference, and block outbound requests to the linked "
        "template host."),
    "polyglot_file": (
        "Treat this as two files, not one. Scan it again as an archive as well as a PDF: "
        "whichever format your scanner did not choose was never inspected at all. Do not let "
        "it through a filter that decides file type from the extension or the leading bytes "
        "alone."),
    "ransomware_simulation": (
        "Isolate the host before anything else, and do not open the file. Confirm backups are "
        "offline and restorable, then hand the sample to incident response. In this corpus the "
        "payload is a harmless RANSIM/EICAR simulation - an unknown file of this shape should "
        "be treated as the real thing until proven otherwise."),
    "steganographic_payload": (
        "Do not forward the file. Extract and inspect the embedded image streams separately: "
        "the payload hides in pixel data and survives filters that look for code or links. "
        "Re-encode or drop the images before any redistribution."),
    "uri_redirect_phishing": (
        "Do not click anywhere in the document - the link annotation covers the entire page, "
        "so any click opens it. Verify the destination host out of band, strip /URI actions "
        "with a PDF sanitiser, and report the URL to your phishing intake."),
    "xfa_acroform_injection": (
        "Strip the XFA form definition before the file is processed further. The payload lives "
        "in an XML document inside the PDF, so scanners that parse only PDF objects never see "
        "it. Do not open in a reader with XFA support enabled."),
}

# A verdict can be `injected=true` with `injection_type="none"` - the model is sure something is
# wrong and will not say what. That still deserves an answer.
TREATMENT_UNNAMED = (
    "This file was flagged but no family was named, so no specific remediation applies. "
    "Treat the file as untrusted: do not open it on a machine that matters, do not forward it, "
    "and pass it to whoever handles security for you.")

assert set(TREATMENT) == set(mimo.FAMILIES), (
    f"TREATMENT does not cover mimo.FAMILIES: "
    f"missing {sorted(set(mimo.FAMILIES) - set(TREATMENT))}, "
    f"unknown {sorted(set(TREATMENT) - set(mimo.FAMILIES))}")

# Measured in Part B on a T4, on the project's own 1,100-document corpus.
PART_B = {"f1": 0.945, "precision": 0.988, "recall": 0.906, "family_acc": 0.433,
          "false_alarm_rate": 0.05, "unparsable": 155, "n": 1100}

# How many regions fit in ONE run is not a taste decision. On ZeroGPU a single grant is capped at
# 300 seconds and the whole scan plus the 4-bit load must fit inside it. That ceiling is what a
# batch is.
MAX_PER_BATCH = mimo.MAX_WINDOWS_GPU
DEFAULT_PER_BATCH = mimo.MAX_WINDOWS_GPU

WINDOW_COLUMNS = ["#", "where in the skeleton", "kind", "markers", "verdict", "family", "evidence"]

# One example per injection family, plus one clean control, taken from the generation repo.
#
# The files on disk are named Example_N and nothing else, and that stays true: the uploader shows
# a neutral filename, and nothing about the file announces its own answer. The *buttons* are
# labelled by family so the demo can be driven deliberately ("show me ransomware"), which costs
# nothing on the model side - a filename never reaches the prompt, only extracted text does.
#
# Labels come from `examples/manifest.json`, written by the same script that copies the PDFs, so
# a button cannot end up pointing at the wrong family. `examples/Example_Key.txt` is the same
# mapping in prose, for whoever is marking this.
EXAMPLE_DIR = Path(__file__).resolve().parent / "examples"


def load_examples():
    """[(label, path)], families first in the model's own order, clean last. Empty if absent."""
    manifest = EXAMPLE_DIR / "manifest.json"
    if not manifest.exists():
        return []
    import json
    mapping = json.loads(manifest.read_text(encoding="utf-8"))
    order = {f: i for i, f in enumerate(mimo.FAMILIES)}
    rows = [(fam, EXAMPLE_DIR / name) for name, fam in mapping.items()
            if (EXAMPLE_DIR / name).exists()]
    # "clean" is not in FAMILIES, so it sorts last - which is where it belongs: it is the control,
    # read after you have seen what a hit looks like.
    return sorted(rows, key=lambda r: order.get(r[0], len(order)))


EXAMPLES = load_examples()


def fmt_eta(n: int) -> str:
    seconds = n * mimo.SECONDS_PER_WINDOW
    if seconds < 90:
        return f"about {max(20, int(seconds))}s"
    return f"{seconds * 0.6 / 60:.0f}-{seconds * 1.5 / 60:.0f} min"


def extract(path, cover_all=True):
    """PDF bytes to ranked candidate regions. No model touched, so this runs on upload."""
    with open(path, "rb") as fh:
        data = fh.read()
    skeleton, truncated, dropped = corpus_text.build_skeleton(data)
    return (data, skeleton, truncated, dropped,
            corpus_text.candidate_windows(skeleton, cover_all=bool(cover_all)))


def document_answer(results):
    """
    One document-level verdict from a batch of per-region ones, for the family model.

    Part B scored MiMo once per document; this app runs it over several regions of one. The first
    flagged region is taken as the document's answer, falling back to the first region read when
    nothing was flagged - the same rule the neighbour lookup uses to pick its query, so the two
    features the family model consumes describe the same piece of text.
    """
    if not results:
        return None, None
    for window, answer in results:
        if answer["pred_injected"]:
            return window, answer
    return results[0][0], results[0][1]


def batches_of(candidates, per_batch):
    """The ranked regions cut into runnable chunks. Batch 1 is the most marker-dense."""
    per_batch = max(1, int(per_batch))
    return [candidates[i:i + per_batch] for i in range(0, len(candidates), per_batch)]


def batch_label(batch, i, n_batches):
    """What one batch is, in a line, so the choice is informed rather than a number."""
    first, last = batch[0], batch[-1]
    if first["is_head"]:
        where = "head of document"
    else:
        where = f"chars {min(w['start'] for w in batch):,}-{max(w['end'] for w in batch):,}"
    fams = sorted({f for w in batch for f in w["families"]})
    n_marker = sum(1 for w in batch if w["source"] == "marker")
    kind = ("marker regions" if n_marker == len(batch) else
            "sweep of the document" if n_marker == 0 else
            f"{n_marker} marker + {len(batch) - n_marker} sweep")
    # "signature:" and not the bare family name. The regex has only seen a token like `/JS (` in
    # the text; plenty of harmless PDFs contain one. Printing "javascript_injection" on its own
    # before the model has read anything reads as a verdict the app has not made.
    tail = f" · signature: {', '.join(fams)}" if fams else ""
    return (f"Batch {i + 1} of {n_batches}{len(batch)} region(s), {kind} · {where}{tail} · "
            f"~{fmt_eta(len(batch))}")


def on_upload(path, per_batch, cover_all):
    """Extract, rank and batch. Fast enough to run on every upload and every slider move."""
    if not path:
        return (None, "Upload a PDF to see what will be read.",
                gr.update(choices=[], value=None, interactive=False))

    try:
        data, skeleton, truncated, dropped, candidates = extract(path, cover_all)
    except Exception as e:
        return (None, f"Could not read that file: `{type(e).__name__}: {e}`",
                gr.update(choices=[], value=None, interactive=False))

    groups = batches_of(candidates, per_batch)

    # Plain label strings, NOT (label, index) pairs. The dropdown allows custom values so the API
    # can name a batch before any PDF has been uploaded, and that turns it into a free-text
    # combobox: a programmatic integer value of 0 is falsy, so the displayed text refused to
    # refresh and the box kept showing the previous split after the slider moved. Strings are
    # never falsy here, and `resolve_batch` reads the index straight back out of "Batch N of M".
    choices = [batch_label(b, i, len(groups)) for i, b in enumerate(groups)]

    lines = [
        f"**{len(data):,} bytes** on disk, rendered to a **{len(skeleton):,}-character skeleton**"
        + (f" (truncated to the {corpus_text.SKELETON_CHAR_BUDGET:,}-character budget)"
           if truncated else "")
        + (f", {dropped} binary stream(s) dropped." if dropped else "."),
    ]
    n_marker = sum(1 for w in candidates if w["source"] == "marker")
    fams = corpus_text.detect_markers(data)

    if n_marker:
        lines.append(
            f"**{n_marker} region(s) carry a marker**, and the remaining "
            f"{len(candidates) - n_marker} cover the rest of the document — "
            f"**{len(candidates)} in total, cut into {len(groups)} batch(es)** of at most "
            f"{int(per_batch)}. Structural signatures in the raw file: "
            f"`{'`, `'.join(fams) or 'none'}`.")
        lines.append(
            "_Ranking decides **reading order only** — batch 1 is the most marker-dense, not the "
            "guilty one. The verdict is MiMo's alone._")
    elif cover_all:
        lines.append(
            f"**No structural marker anywhere in the file.** The triage only recognises the twelve "
            f"families this project generated, so this means either a clean file or a payload "
            f"shaped like none of them — which is why the batches below sweep the **whole** "
            f"skeleton rather than stopping here: **{len(candidates)} region(s) in "
            f"{len(groups)} batch(es)**.")
    else:
        lines.append(
            "**No structural marker anywhere in the file**, and the sweep is switched off — so "
            "only the head of the document will be read, which is exactly what the corpus builder "
            "produced for a *clean* file. Switch the sweep on to look at the rest of it.")

    lines.append("Pick a batch and press **Check this batch**. One batch is one run of the model, "
                 "sized to fit inside a single ZeroGPU grant; run as many batches as you like, one "
                 "at a time.")

    return ((skeleton, candidates), "\n\n".join(lines),
            gr.update(choices=choices, value=choices[0], interactive=True))


def resolve_batch(value, n_batches: int) -> int:
    """
    Whatever the dropdown handed back, as a batch index that exists.

    Because the dropdown allows custom values it can arrive as the integer index, as `None` before
    anything was picked, or as the label string itself. All three mean something, and none of them
    should be an exception in front of a user who just pressed a button.
    """
    if isinstance(value, (int, float)):
        idx = int(value)
    else:
        digits = re.search(r"\d+", str(value or ""))
        idx = int(digits.group()) - 1 if digits else 0     # labels are 1-based, indices are not
    return idx if 0 <= idx < n_batches else 0


def run(path, per_batch, batch_index, want_family, cover_all, progress=gr.Progress()):
    """Score one batch."""
    if not path:
        return "Upload a PDF first.", [], [], [], ""

    progress(0.05, desc="reading the PDF")
    try:
        data, skeleton, truncated, dropped, candidates = extract(path, cover_all)
    except Exception as e:
        return f"Could not read that file: `{type(e).__name__}: {e}`", [], [], [], ""

    groups = batches_of(candidates, per_batch)
    idx = resolve_batch(batch_index, len(groups))
    batch = groups[idx]

    progress(0.15, desc="loading MiMo")
    try:
        answers = mimo.judge_all([w["text"] for w in batch],
                                 progress=lambda m: progress(0.4, desc=m))
    except Exception as e:
        hint = ""
        if "quota" in str(e).lower():
            hint = ("\n\nA free account gets about **five minutes of ZeroGPU per day**, and the "
                    "scheduler reserves a whole run up front. The quota is per visitor, so this "
                    "affects your account rather than the Space. Fewer regions per batch costs "
                    "less of it; otherwise it resets in 24 hours.")
        return (f"## MiMo could not run\n\n`{type(e).__name__}: {e}`{hint}"), [], [], [], \
            traceback.format_exc()

    offset = idx * max(1, int(per_batch))
    rows, log, results = [], [], list(zip(batch, answers))
    for i, (win, r) in enumerate(results):
        where = "head of document" if win["is_head"] else f"chars {win['start']:,}-{win['end']:,}"
        verdict = ("PAYLOAD" if r["pred_injected"] else "clean") + (
            "" if r["parse_ok"] else " (unreadable answer)")
        rows.append([offset + i + 1, where, win["source"], ", ".join(win["families"]) or "-",
                     verdict, r["pred_family"], (r["evidence"] or "-")[:160]])
        log.append(f"--- region {offset + i + 1} ({where}, prompt via {r['prompt_route']}) ---\n"
                   f"{r['raw']}")

    # Stage 4. Both the family model and the neighbour table embed a region with Part A's model, so
    # they are enabled together - the 550 MB download is the cost of either one alone.
    family, family_rows, nb_rows, family_error = None, [], [], None
    query_window, doc_answer = document_answer(results)

    if want_family and results:
        progress(0.9, desc="embedding the region and naming the family")
        try:
            neighbours.check_provenance()
            stats = doc_features.describe(path, data, skeleton, truncated, dropped)
            family = family_model.predict(skeleton, doc_answer, stats, query_window["text"])
            family_rows = family_model.rows(family)
            nb_rows = neighbours.neighbour_rows(query_window["text"], k=5)
        except Exception as e:
            family_error = f"{type(e).__name__}: {e}"
            log.append(traceback.format_exc())

    report = build_report(results, idx, groups, len(candidates), family, family_error,
                          doc_answer, bool(want_family))

    if nb_rows:
        flagged = doc_answer and doc_answer["pred_injected"]
        basis = ("the first flagged region" if flagged else
                 "the first region in this batch (nothing was flagged)")
        report += (f"\n\n### Nearest files in the corpus\n\nEmbedded from **{basis}** with Part A's "
                   f"winning configuration — the same vector the family model's neighbour-vote "
                   f"feature is built from. Precision@5 on this index is **35.6%** against a 6.8% "
                   f"random baseline: fewer than 2 of the 5 listed are the same kind of attack. "
                   f"Read it as *resemblance*, not identification.")

    return report, rows, family_rows, nb_rows, "\n\n".join(log)


def begin(per_batch):
    """
    Lock the button and say so, before the slow part starts.

    A second press while MiMo is mid-scan is worse here than in most apps: on ZeroGPU it queues a
    second grant against a daily quota that only affords one or two, so the cost of a stray click
    is the rest of the day. The button is disabled for the duration and `mimo._lock` serialises
    the model itself, so neither the UI nor the server can be made to run two scans at once.

    Stale results are cleared at the same time - leaving the previous batch's verdict on screen
    under a "loading" banner is how someone reads the wrong answer for the wrong file.
    """
    n = max(1, int(per_batch or 1))
    return (gr.update(interactive=False, value="Checking…"),
            f"### Loading…\n\nMiMo is reading up to **{n} region(s)** — roughly {fmt_eta(n)} once "
            f"the model is in memory. The first scan after a restart also downloads the weights, "
            f"which takes several minutes longer.\n\n"
            f"_Leave this tab open; the report replaces this message when it is done._",
            [], [], [], "")


def finish():
    """Give the button back. Chained with `.then()`, so it runs even if the scan raised."""
    return gr.update(interactive=True, value="Check this batch")


def family_section(family, family_error, mimo_fams, want_family, mimo_flagged) -> tuple:
    """
    The family model's shortlist, and which family the treatment below should be written for.

    Returns `(markdown, [families to treat], source)`. The model is allowed to disagree with MiMo
    and usually should - naming the family is the job it was fitted for and the job MiMo is worst
    at - but a disagreement is printed rather than resolved silently.
    """
    if family is None:
        if not want_family:
            note = ("\n\n### Attack family\n\nThe family-naming model is switched off, so the "
                    "family below is **MiMo's own guess — right about 43% of the time**. Switch "
                    "*Name the attack family* on for the model's ranked answer instead.")
        else:
            note = (f"\n\n### Attack family\n\nThe family-naming model could not run "
                    f"(`{family_error}`), so the family below is **MiMo's own guess — right about "
                    f"43% of the time**. Everything else in this report is unaffected.")
        return note, mimo_fams, "MiMo"

    ranked = family["ranked"]
    top, confidence = ranked[0]
    shortlist = " · ".join(f"**{name}** {prob:.0%}" if i == 0 else f"{name} {prob:.0%}"
                           for i, (name, prob) in enumerate(ranked))

    lines = ["\n\n### Attack family", ""]
    if top == "none":
        stance = ("**against MiMo's flag**" if mimo_flagged else
                  "which **agrees with MiMo**, from a different four signals")
        lines.append(
            f"The family-naming model puts **{confidence:.0%}** of its confidence on this file "
            f"carrying **no injection at all** — {stance}. Ranked: {shortlist}.")
    else:
        lines.append(f"The family-naming model names this **{top}** at **{confidence:.0%}** "
                     f"confidence. Ranked: {shortlist}.")

    lines.append(
        f"\nIt fuses four signals — the structural signatures found in the file, MiMo's own verdict "
        f"and guess, seventeen document-shape measurements, and a weighted vote of the 20 nearest "
        f"corpus files in Part A's embedding index. Out of fold it reaches **P@1 "
        f"{family['p_at_1']:.3f}** in this regime ({family['regime']}).")

    disagreement = [f for f in mimo_fams if f != top]
    if disagreement and top != "none":
        lines.append(
            f"\n_MiMo said **{', '.join(disagreement)}**; the model says **{top}**. The model is "
            f"the one the advice below follows, because naming the family is the task it was "
            f"fitted for and the one MiMo is measurably worst at (43%)._")

    return "\n".join(lines), ([top] if top != "none" else []), "the family-naming model"


def build_report(results, idx, groups, n_candidates, family, family_error, doc_answer,
                 want_family) -> str:
    """The verdict for this batch, and an explicit account of what is still unread."""
    if not results:
        return "Nothing was read."

    hits = [(w, r) for w, r in results if r["pred_injected"]]
    unparsed = sum(1 for _, r in results if not r["parse_ok"])
    read = len(results)
    unread = n_candidates - read
    others = [i for i in range(len(groups)) if i != idx]

    mimo_fams = sorted({r["pred_family"] for _, r in hits if r["pred_family"] != "none"})
    fam_md, treat_fams, source = family_section(family, family_error, mimo_fams, want_family,
                                                bool(hits))

    if hits:
        head = (f"## Payload found in batch {idx + 1}\n\n"
                f"> ### ⚠️ Instructions: do not open the file. Erase immediately!\n\n"
                f"MiMo flagged **{len(hits)} of the {read} region(s)** in this batch — the verdict "
                f"it scores F1 0.945 on.")
        head += fam_md

        # Treatment goes directly under the family, because it is the only part of this report
        # anyone acts on. A flagged file with no name still gets the generic containment advice
        # rather than silence.
        head += "\n\n### What to do about it\n"
        if treat_fams:
            for name in treat_fams:
                head += f"\n**{name}**\n\n{TREATMENT[name]}\n"
            certainty = (f"{family['confidence']:.0%} confident, P@1 {family['p_at_1']:.3f} in "
                         f"this regime" if family else "right about 43% of the time")
            head += (f"\n_Chosen by **{source}** ({certainty}) — so this advice can still be the "
                     f"remedy for a different attack. The containment step in the first sentence "
                     f"holds either way._")
        elif family is not None and family["top"] == "none":
            head += (f"\n{TREATMENT_UNNAMED}\n\n_MiMo flagged a region; the family-naming model "
                     f"disagrees and puts the file in the **clean** class. Two models disagreeing "
                     f"is a reason to look at the file yourself, not a reason to trust either._")
        else:
            head += f"\n{TREATMENT_UNNAMED}\n"
    else:
        head = (f"## Nothing found in batch {idx + 1}\n\nMiMo read **{read} region(s)** in this "
                f"batch and flagged none of them.")
        # A clean verdict is the harder half of the claim, so the model's opinion is worth as much
        # here as it is over a hit - it either corroborates MiMo or contradicts it.
        if family is not None:
            head += fam_md

    caveats = []
    if unread > 0:
        caveats.append(
            f"**This is 1 of {len(groups)} batches.** {unread} region(s) across "
            f"{len(others)} other batch(es) have not been read. Whatever this batch says, it says "
            f"it about {read} of the file's {n_candidates} candidate regions — nothing more.")
    if unparsed:
        sweep_bad = sum(1 for w, r in results if not r["parse_ok"] and w["source"] == "sweep")
        note = (f"{unparsed} answer(s) could not be parsed and count as *not injected*, exactly as "
                f"Part B scored them (155 of 1,100 there).")
        if sweep_bad:
            note += (
                f" **{sweep_bad} of those were sweep regions**, and that is expected rather than "
                f"surprising: Part B only ever showed MiMo marker-centred windows or the head of a "
                f"document, never arbitrary mid-file content streams. Given a page of font "
                f"positioning operators it tends to carry on copying the input instead of "
                f"answering. Sweep regions buy coverage of text that would otherwise never be "
                f"looked at; they do not inherit Part B's accuracy, and a *clean* verdict on one "
                f"is close to no evidence at all.")
        caveats.append(note)

    body = head
    if caveats:
        body += "\n\n" + "\n\n".join("- " + c for c in caveats)
    body += (f"\n\n---\n\n**On the corpus Part B measured**, MiMo scored F1 {PART_B['f1']}, "
             f"precision {PART_B['precision']}, recall {PART_B['recall']} on {PART_B['n']:,} "
             f"documents that were 82% injected — where a detector that flags everything without "
             f"reading it scores F1 0.900. Read 0.945 against 0.900, not against zero. The "
             f"family-naming model's own baseline is the same corpus's 18.2% clean share: always "
             f"answering *none* scores P@1 0.182.\n\n"
             f"_{mimo.RUNTIME_NOTE}_")
    return body


INTRO = f"""
# PDF Injection Detector — MiMo-7B

Upload a PDF. It is rendered to text with the extractor that built the project corpus, the regions
carrying structural markers are ranked and cut into **batches that fit one run of the model**,
**MiMo-7B-RL** reads the batch you choose and says whether a payload is hidden there, and a
**family-naming model** then decides *which kind* of attack it is.

Batching is what keeps a long document inside one ZeroGPU grant: one batch is one run, and you
decide how many runs to spend. The report always states how much of the file is still unread.

**Two models, because they are good at different questions.** MiMo answers *is there a payload
here?* — F1 0.945 on the corpus it was measured against — and quotes the substring that convinced
it. It is poor at *which kind?*, naming the family correctly only **43%** of the time, which
mattered because the remediation advice is chosen by family. So the family is named instead by a
gradient-boosted model fitted on four cheap signals (structural signatures, MiMo's own opinion,
seventeen document-shape measurements, and a vote of the nearest corpus files): **P@1 0.994** on
files carrying one of the twelve known signatures, **0.691** on files carrying none. The report
says which of those two regimes your file fell into rather than quoting one number for both.

**None of it is guaranteed correct** — not the verdict, not the family, not the treatment. A clean
verdict is not proof of a clean file. Read every output as a prompt to look closer yourself, never
as a decision that has already been made.

This is a coursework artefact built on a synthetic corpus of 1,100 PDFs carrying harmless
EICAR/AMTSO/WICAR/RANSIM test markers. **It is not a general malware scanner**, and real malware
does not announce itself the way these samples do.

**Why MiMo and not Gemma?** Part B's winner was Gemma-2-9B at F1 0.969, against MiMo's 0.945. But
Gemma is gated behind a licence and a token, and it is 2.6× slower per window (10.95 s vs 4.18 s).
On free ZeroGPU — one grant capped at 300 s, and roughly five minutes of GPU per day — that is the
difference between a working demo and one that refuses strangers at the door and then runs out of
quota. The cost of the swap is 0.024 F1, and family naming is no longer MiMo's job anyway.

Running MiMo in 4-bit NF4 — Part B's own configuration — at about {mimo.SECONDS_PER_WINDOW:g}s per
region. The family-naming model is 915 KB and runs on the CPU in under a second.
"""


with gr.Blocks(title="PDF Injection Detector") as demo:
    gr.Markdown(INTRO)
    state = gr.State()

    with gr.Row():
        # The example rail, down the left edge. Plain buttons rather than `gr.Examples`: the
        # built-in renders a horizontal table of filenames, and what is wanted here is one
        # labelled tab per attack type that loads its document on click. Buttons also sidestep
        # `gr.Examples`' caching, which would run a full scan of all thirteen files at startup
        # and spend the whole day's ZeroGPU quota before anyone opened the page.
        with gr.Column(scale=1, min_width=170):
            if EXAMPLES:
                gr.Markdown("### Examples\nOne document per attack type.")
                example_buttons = [(gr.Button(label.replace("_", " "), size="sm"), path)
                                   for label, path in EXAMPLES]
            else:
                example_buttons = []

        with gr.Column(scale=2):
            pdf = gr.File(label="PDF", file_types=[".pdf"], type="filepath")
            per_batch = gr.Slider(1, MAX_PER_BATCH, value=DEFAULT_PER_BATCH, step=1,
                                  label="Regions per batch",
                                  info=(f"How many regions one run of the model reads — it just "
                                        f"re-cuts the same list, so fewer per batch means more "
                                        f"batches. A run reserves the same GPU time whatever this "
                                        f"is set to, so lowering it inspects less of the file for "
                                        f"the same quota. Leave it at {MAX_PER_BATCH} unless you "
                                        f"want a faster single run."))
            # allow_custom_value: the choices are empty until a PDF is uploaded, and without this
            # Gradio validates any incoming value against that empty list and rejects it - which
            # makes the batch un-selectable over the API even though the UI had populated it.
            # `resolve_batch` below is what actually decides the index, from the file itself.
            batch_pick = gr.Dropdown(label="Batch to check", choices=[], interactive=False,
                                     allow_custom_value=True,
                                     info="Each batch is a separate run — spend as many as you "
                                          "like.")
            sweep = gr.Checkbox(
                value=True, label="Sweep the rest of the document too",
                info="Off = marker regions only, which is the shape Part B measured. On = the "
                     "batches cover the whole file, at the cost of regions MiMo often will not "
                     "answer about.")
            # One control for both, because they are the same download: the family model's
            # neighbour-vote feature and the neighbour table are two readings of the same query
            # vector, so paying 550 MB for one and not the other would be strange.
            want_family = gr.Checkbox(
                value=True, label="Name the attack family (and show nearest corpus files)",
                info=("Runs the family-naming model and Part A's lookup. Adds a one-off 550 MB "
                      "embedding-model download. Off, the family shown is MiMo's own guess."),
                interactive=family_model.AVAILABLE)
            go = gr.Button("Check this batch", variant="primary")
            plan = gr.Markdown("Upload a PDF to see what will be read.")

        with gr.Column(scale=4):
            with gr.Tab("Report"):
                report = gr.Markdown()
            with gr.Tab("Regions read"):
                window_table = gr.Dataframe(headers=WINDOW_COLUMNS, wrap=True, interactive=False)
            with gr.Tab("Family shortlist"):
                family_table = gr.Dataframe(headers=family_model.ROW_COLUMNS, interactive=False)
            with gr.Tab("Nearest corpus files"):
                nb_table = gr.Dataframe(headers=neighbours.NEIGHBOUR_COLUMNS, interactive=False)
            with gr.Tab("What MiMo actually said"):
                # No `show_copy_button`: gradio 6 removed it, and this Space should survive an
                # sdk_version bump rather than crash at startup on a cosmetic argument.
                raw = gr.Textbox(lines=22, interactive=False,
                                 label="The prompt route and untouched generation per region")

    # Each example button just drops its path into the file component. That fires `pdf.change`
    # below, so an example goes through exactly the same triage as a real upload - there is no
    # second code path for demo files, and nothing about an example is pre-computed.
    for button, path in example_buttons:
        button.click(lambda p=str(path): p, None, pdf)

    inputs = [pdf, per_batch, sweep]
    for ev in (pdf.change, per_batch.change, sweep.change):
        ev(on_upload, inputs, [state, plan, batch_pick])
    # Three chained steps: lock and show "Loading…", scan, unlock. `.then()` rather than
    # `.success()` for the last one, because the button must come back even when the scan raised -
    # a quota refusal that left the app permanently disabled would look like a crash.
    # concurrency_limit=1 is the server-side half of the same guarantee.
    scan = go.click(begin, [per_batch],
                    [go, report, window_table, family_table, nb_table, raw], queue=False)
    scan = scan.then(run, [pdf, per_batch, batch_pick, want_family, sweep],
                     [report, window_table, family_table, nb_table, raw], concurrency_limit=1)
    scan.then(finish, None, go, queue=False)

if __name__ == "__main__":
    demo.queue(max_size=8).launch()