BentoUniAcc commited on
Commit
d113a7f
Β·
verified Β·
1 Parent(s): 474abee

label marker vs sweep regions; explain sweep parse failures; sweep toggle

Browse files
Files changed (2) hide show
  1. README.md +20 -4
  2. app.py +329 -306
README.md CHANGED
@@ -66,10 +66,26 @@ window with the identical Β±1,500-character shape, overlapping ones are merged,
66
  marker-dense go to the model first. **The ranking decides reading order, never the verdict.** The
67
  report always says how many marked regions were left unread, so "clean" never overstates itself.
68
 
69
- A file with no marker anywhere yields exactly one candidate β€” the head of the document β€” which is
70
- byte-identical to what the corpus builder produced for a *clean* file. The triage only knows the
71
- twelve families this project generated, so a payload shaped like none of them is triaged as if the
72
- file were clean and MiMo sees the head of the file. That is a real hole, and the interface says so.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
  **Nothing is downloaded until it is needed.** The page comes up first; the 4.7 GB GGUF, the
75
  550 MB embedding model and the 3 MB index arrive on the first scan and are cached after that.
 
66
  marker-dense go to the model first. **The ranking decides reading order, never the verdict.** The
67
  report always says how many marked regions were left unread, so "clean" never overstates itself.
68
 
69
+ A file with no marker anywhere yields exactly one marker candidate β€” the head of the document β€”
70
+ which is byte-identical to what the corpus builder produced for a *clean* file.
71
+
72
+ ### Batches, and the sweep
73
+
74
+ Marker regions alone leave most of a file unread: the triage only knows the twelve families this
75
+ project generated, so a payload shaped like none of them produces no marker and would sit in text
76
+ the model never saw while the report said "clean". So after the marker regions, the rest of the
77
+ skeleton is tiled into windows of the same size, and the whole list is cut into **batches sized to
78
+ fit one run of the model** β€” one ZeroGPU grant, or a tolerable wait on CPU. You pick which batch to
79
+ spend a run on, and the report always states how much is still unread.
80
+
81
+ **The sweep regions do not inherit Part B's accuracy, and the app says so.** Part B only ever
82
+ showed MiMo marker-centred windows or the head of a document. Handed an arbitrary mid-file content
83
+ stream β€” a page of font-positioning operators β€” MiMo frequently does not answer at all: it carries
84
+ on copying the input after the prefill, and the answer parses as unrecoverable, which scores as
85
+ *not injected*. Those regions buy coverage of text that would otherwise never be looked at; a
86
+ *clean* verdict on one is close to no evidence. The regions table labels every row `marker` or
87
+ `sweep`, the report counts the sweep parse failures separately and explains them, and the sweep can
88
+ be switched off to keep the app strictly inside the shape Part B measured.
89
 
90
  **Nothing is downloaded until it is needed.** The page comes up first; the 4.7 GB GGUF, the
91
  550 MB embedding model and the 3 MB index arrive on the first scan and are cached after that.
app.py CHANGED
@@ -1,306 +1,329 @@
1
- """
2
- PDF Injection Detector - MiMo-7B.
3
-
4
- Upload a PDF, and this reads it the way the corpus was read, cuts it into batches of regions that
5
- fit one run of the model, and asks MiMo-7B-RL whether a payload is hidden in the batch you choose.
6
-
7
- `app.py` holds the interface, the batching and the aggregation. It contains no detection logic of
8
- its own: the text extraction lives in `corpus_text.py`, the prompt and parser in `mimo.py`, the
9
- embedding lookup in `neighbours.py`, and each is quoted from the notebook that measured it.
10
- """
11
-
12
- import re
13
- import traceback
14
-
15
- import gradio as gr
16
-
17
- import corpus_text
18
- import mimo
19
- import neighbours
20
-
21
- # Measured in Part B on a T4, on the project's own 1,100-document corpus.
22
- PART_B = {"f1": 0.945, "precision": 0.988, "recall": 0.906, "family_acc": 0.433,
23
- "false_alarm_rate": 0.05, "unparsable": 155, "n": 1100}
24
-
25
- GPU = mimo.BACKEND == "gpu"
26
-
27
- # How many regions fit in ONE run is not a taste decision. On ZeroGPU a single grant is capped at
28
- # 300 seconds and the whole scan plus the 4-bit load must fit inside it; on 2 vCPUs a region costs
29
- # two minutes and a browser will not wait for many. That ceiling is what a batch is.
30
- MAX_PER_BATCH = mimo.MAX_WINDOWS_GPU if GPU else 6
31
- DEFAULT_PER_BATCH = 8 if GPU else 3
32
-
33
- WINDOW_COLUMNS = ["#", "where in the skeleton", "markers", "verdict", "family", "evidence"]
34
-
35
-
36
- def fmt_eta(n: int) -> str:
37
- seconds = n * mimo.SECONDS_PER_WINDOW
38
- if seconds < 90:
39
- return f"about {max(20, int(seconds))}s"
40
- return f"{seconds * 0.6 / 60:.0f}-{seconds * 1.5 / 60:.0f} min"
41
-
42
-
43
- def extract(path):
44
- """PDF bytes to ranked candidate regions. No model touched, so this runs on upload."""
45
- with open(path, "rb") as fh:
46
- data = fh.read()
47
- skeleton, truncated, dropped = corpus_text.build_skeleton(data)
48
- return data, skeleton, truncated, dropped, corpus_text.candidate_windows(skeleton)
49
-
50
-
51
- def batches_of(candidates, per_batch):
52
- """The ranked regions cut into runnable chunks. Batch 1 is the most marker-dense."""
53
- per_batch = max(1, int(per_batch))
54
- return [candidates[i:i + per_batch] for i in range(0, len(candidates), per_batch)]
55
-
56
-
57
- def batch_label(batch, i, n_batches):
58
- """What one batch is, in a line, so the choice is informed rather than a number."""
59
- first, last = batch[0], batch[-1]
60
- if first["is_head"]:
61
- where = "head of document"
62
- else:
63
- where = f"chars {min(w['start'] for w in batch):,}-{max(w['end'] for w in batch):,}"
64
- fams = sorted({f for w in batch for f in w["families"]})
65
- n_marker = sum(1 for w in batch if w["source"] == "marker")
66
- kind = ("marker regions" if n_marker == len(batch) else
67
- "sweep of the document" if n_marker == 0 else
68
- f"{n_marker} marker + {len(batch) - n_marker} sweep")
69
- tail = f" Β· {', '.join(fams)}" if fams else ""
70
- return (f"Batch {i + 1} of {n_batches} β€” {len(batch)} region(s), {kind} Β· {where}{tail} Β· "
71
- f"~{fmt_eta(len(batch))}")
72
-
73
-
74
- def on_upload(path, per_batch):
75
- """Extract, rank and batch. Fast enough to run on every upload and every slider move."""
76
- if not path:
77
- return (None, "Upload a PDF to see what will be read.",
78
- gr.update(choices=[], value=None, interactive=False))
79
-
80
- try:
81
- data, skeleton, truncated, dropped, candidates = extract(path)
82
- except Exception as e:
83
- return (None, f"Could not read that file: `{type(e).__name__}: {e}`",
84
- gr.update(choices=[], value=None, interactive=False))
85
-
86
- groups = batches_of(candidates, per_batch)
87
- choices = [(batch_label(b, i, len(groups)), i) for i, b in enumerate(groups)]
88
-
89
- lines = [
90
- f"**{len(data):,} bytes** on disk, rendered to a **{len(skeleton):,}-character skeleton**"
91
- + (f" (truncated to the {corpus_text.SKELETON_CHAR_BUDGET:,}-character budget)"
92
- if truncated else "")
93
- + (f", {dropped} binary stream(s) dropped." if dropped else "."),
94
- ]
95
- n_marker = sum(1 for w in candidates if w["source"] == "marker")
96
- fams = corpus_text.detect_markers(data)
97
-
98
- if n_marker:
99
- lines.append(
100
- f"**{n_marker} region(s) carry a marker**, and the remaining "
101
- f"{len(candidates) - n_marker} cover the rest of the document β€” "
102
- f"**{len(candidates)} in total, cut into {len(groups)} batch(es)** of at most "
103
- f"{int(per_batch)}. Structural signatures in the raw file: "
104
- f"`{'`, `'.join(fams) or 'none'}`.")
105
- lines.append(
106
- "_Ranking decides **reading order only** β€” batch 1 is the most marker-dense, not the "
107
- "guilty one. The verdict is MiMo's alone._")
108
- else:
109
- lines.append(
110
- f"**No structural marker anywhere in the file.** The triage only recognises the twelve "
111
- f"families this project generated, so this means either a clean file or a payload "
112
- f"shaped like none of them β€” which is why the batches below sweep the **whole** "
113
- f"skeleton rather than stopping here: **{len(candidates)} region(s) in "
114
- f"{len(groups)} batch(es)**.")
115
-
116
- lines.append(f"Pick a batch and press **Check this batch**. One batch is one run of the model, "
117
- f"sized to fit the **{mimo.BACKEND.upper()}** runtime's limit; run as many "
118
- f"batches as you like, one at a time.")
119
-
120
- return ((skeleton, candidates), "\n\n".join(lines),
121
- gr.update(choices=choices, value=0, interactive=True))
122
-
123
-
124
- def resolve_batch(value, n_batches: int) -> int:
125
- """
126
- Whatever the dropdown handed back, as a batch index that exists.
127
-
128
- Because the dropdown allows custom values it can arrive as the integer index, as `None` before
129
- anything was picked, or as the label string itself. All three mean something, and none of them
130
- should be an exception in front of a user who just pressed a button.
131
- """
132
- if isinstance(value, (int, float)):
133
- idx = int(value)
134
- else:
135
- digits = re.search(r"\d+", str(value or ""))
136
- idx = int(digits.group()) - 1 if digits else 0 # labels are 1-based, indices are not
137
- return idx if 0 <= idx < n_batches else 0
138
-
139
-
140
- def run(path, per_batch, batch_index, want_neighbours, progress=gr.Progress()):
141
- """Score one batch."""
142
- if not path:
143
- return "Upload a PDF first.", [], [], ""
144
-
145
- progress(0.05, desc="reading the PDF")
146
- try:
147
- _, _, _, _, candidates = extract(path)
148
- except Exception as e:
149
- return f"Could not read that file: `{type(e).__name__}: {e}`", [], [], ""
150
-
151
- groups = batches_of(candidates, per_batch)
152
- idx = resolve_batch(batch_index, len(groups))
153
- batch = groups[idx]
154
-
155
- progress(0.15, desc=f"loading MiMo ({mimo.BACKEND})")
156
- try:
157
- answers = mimo.judge_all([w["text"] for w in batch],
158
- progress=lambda m: progress(0.4, desc=m))
159
- except Exception as e:
160
- return (f"## MiMo could not run\n\n`{type(e).__name__}: {e}`\n\nRuntime selected: "
161
- f"**{mimo.BACKEND}**."), [], [], traceback.format_exc()
162
-
163
- offset = idx * max(1, int(per_batch))
164
- rows, log, results = [], [], list(zip(batch, answers))
165
- for i, (win, r) in enumerate(results):
166
- where = "head of document" if win["is_head"] else f"chars {win['start']:,}-{win['end']:,}"
167
- verdict = ("PAYLOAD" if r["pred_injected"] else "clean") + (
168
- "" if r["parse_ok"] else " (unreadable answer)")
169
- rows.append([offset + i + 1, where, ", ".join(win["families"]) or "-", verdict,
170
- r["pred_family"], (r["evidence"] or "-")[:160]])
171
- log.append(f"--- region {offset + i + 1} ({where}, prompt via {r['prompt_route']}) ---\n"
172
- f"{r['raw']}")
173
-
174
- report = build_report(results, idx, groups, len(candidates))
175
-
176
- nb_rows = []
177
- if want_neighbours and results:
178
- progress(0.95, desc="embedding and looking up the corpus")
179
- flagged = next((w for w, r in results if r["pred_injected"]), None)
180
- query = flagged or batch[0]
181
- try:
182
- neighbours.check_provenance()
183
- nb_rows = neighbours.neighbour_rows(query["text"], k=5)
184
- basis = ("the first flagged region" if flagged else
185
- "the first region in this batch (nothing was flagged)")
186
- report += (f"\n\n### Nearest files in the corpus\n\nEmbedded from **{basis}** with "
187
- f"Part A's winning configuration. Precision@5 on this index is **35.6%** "
188
- f"against a 6.8% random baseline: fewer than 2 of the 5 listed are the same "
189
- f"kind of attack. Read it as *resemblance*, not identification.")
190
- except Exception as e:
191
- report += (f"\n\n### Nearest files in the corpus\n\nUnavailable: "
192
- f"`{type(e).__name__}: {e}`")
193
- log.append(traceback.format_exc())
194
-
195
- return report, rows, nb_rows, "\n\n".join(log)
196
-
197
-
198
- def build_report(results, idx, groups, n_candidates) -> str:
199
- """The verdict for this batch, and an explicit account of what is still unread."""
200
- if not results:
201
- return "Nothing was read."
202
-
203
- hits = [(w, r) for w, r in results if r["pred_injected"]]
204
- unparsed = sum(1 for _, r in results if not r["parse_ok"])
205
- read = len(results)
206
- unread = n_candidates - read
207
- others = [i for i in range(len(groups)) if i != idx]
208
-
209
- if hits:
210
- fams = sorted({r["pred_family"] for _, r in hits if r["pred_family"] != "none"})
211
- head = (f"## Payload found in batch {idx + 1}\n\nMiMo flagged **{len(hits)} of the {read} "
212
- f"region(s)** in this batch.")
213
- head += (f" It named the family as **{', '.join(fams)}** β€” correct 43% of the time in "
214
- f"Part B, so treat it as a suggestion." if fams
215
- else " It did not commit to a family.")
216
- else:
217
- head = (f"## Nothing found in batch {idx + 1}\n\nMiMo read **{read} region(s)** in this "
218
- f"batch and flagged none of them.")
219
-
220
- caveats = []
221
- if unread > 0:
222
- caveats.append(
223
- f"**This is 1 of {len(groups)} batches.** {unread} region(s) across "
224
- f"{len(others)} other batch(es) have not been read. Whatever this batch says, it says "
225
- f"it about {read} of the file's {n_candidates} candidate regions β€” nothing more.")
226
- if unparsed:
227
- caveats.append(f"{unparsed} answer(s) could not be parsed and count as *not injected*, "
228
- f"exactly as Part B scored them (155 of 1,100 there).")
229
-
230
- body = head
231
- if caveats:
232
- body += "\n\n" + "\n\n".join("- " + c for c in caveats)
233
- body += (f"\n\n---\n\n**On the corpus Part B measured**, MiMo scored F1 {PART_B['f1']}, "
234
- f"precision {PART_B['precision']}, recall {PART_B['recall']} on {PART_B['n']:,} "
235
- f"documents that were 82% injected β€” where a detector that flags everything without "
236
- f"reading it scores F1 0.900. Read 0.945 against 0.900, not against zero.\n\n"
237
- f"_Runtime: **{mimo.BACKEND}**. {mimo.RUNTIME_CAVEAT}_")
238
- return body
239
-
240
-
241
- INTRO = f"""
242
- # PDF Injection Detector β€” MiMo-7B
243
-
244
- Upload a PDF. It is rendered to text with the extractor that built the project corpus, the regions
245
- carrying structural markers are ranked and cut into **batches that fit one run of the model**, and
246
- **MiMo-7B-RL** reads the batch you choose β€” reporting whether a payload is hidden there, and the
247
- substring that convinced it.
248
-
249
- Batching is what keeps a long document inside the runtime's limit: one batch is one run, and you
250
- decide how many runs to spend. The report always states how much of the file is still unread.
251
-
252
- This is a coursework artefact built on a synthetic corpus of 1,100 PDFs carrying harmless
253
- EICAR/AMTSO/WICAR/RANSIM test markers. **It is not a general malware scanner**, and real malware
254
- does not announce itself the way these samples do.
255
-
256
- Running on the **{mimo.BACKEND.upper()}** runtime
257
- ({'4-bit NF4 β€” Part B’s own configuration' if GPU else 'Q4_K_M GGUF via llama.cpp'}),
258
- about {mimo.SECONDS_PER_WINDOW:g}s per region.
259
- """
260
-
261
-
262
- with gr.Blocks(title="PDF Injection Detector") as demo:
263
- gr.Markdown(INTRO)
264
- state = gr.State()
265
-
266
- with gr.Row():
267
- with gr.Column(scale=1):
268
- pdf = gr.File(label="PDF", file_types=[".pdf"], type="filepath")
269
- per_batch = gr.Slider(1, MAX_PER_BATCH, value=DEFAULT_PER_BATCH, step=1,
270
- label="Regions per batch",
271
- info=f"One batch is one run of the model. The "
272
- f"{mimo.BACKEND.upper()} runtime tops out at "
273
- f"{MAX_PER_BATCH}.")
274
- # allow_custom_value: the choices are empty until a PDF is uploaded, and without this
275
- # Gradio validates any incoming value against that empty list and rejects it - which
276
- # makes the batch un-selectable over the API even though the UI had populated it.
277
- # `resolve_batch` below is what actually decides the index, from the file itself.
278
- batch_pick = gr.Dropdown(label="Batch to check", choices=[], interactive=False,
279
- allow_custom_value=True,
280
- info="Each batch is a separate run β€” spend as many as you "
281
- "like.")
282
- want_nb = gr.Checkbox(value=True, label="Also show the nearest files in the corpus",
283
- info="Adds a one-off 550 MB embedding-model download.")
284
- go = gr.Button("Check this batch", variant="primary")
285
- plan = gr.Markdown("Upload a PDF to see what will be read.")
286
-
287
- with gr.Column(scale=2):
288
- with gr.Tab("Report"):
289
- report = gr.Markdown()
290
- with gr.Tab("Regions read"):
291
- window_table = gr.Dataframe(headers=WINDOW_COLUMNS, wrap=True, interactive=False)
292
- with gr.Tab("Nearest corpus files"):
293
- nb_table = gr.Dataframe(headers=neighbours.NEIGHBOUR_COLUMNS, interactive=False)
294
- with gr.Tab("What MiMo actually said"):
295
- # No `show_copy_button`: gradio 6 removed it, and this Space should survive an
296
- # sdk_version bump rather than crash at startup on a cosmetic argument.
297
- raw = gr.Textbox(lines=22, interactive=False,
298
- label="The prompt route and untouched generation per region")
299
-
300
- for ev in (pdf.change, per_batch.change):
301
- ev(on_upload, [pdf, per_batch], [state, plan, batch_pick])
302
- go.click(run, [pdf, per_batch, batch_pick, want_nb],
303
- [report, window_table, nb_table, raw])
304
-
305
- if __name__ == "__main__":
306
- demo.queue(max_size=8).launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PDF Injection Detector - MiMo-7B.
3
+
4
+ Upload a PDF, and this reads it the way the corpus was read, cuts it into batches of regions that
5
+ fit one run of the model, and asks MiMo-7B-RL whether a payload is hidden in the batch you choose.
6
+
7
+ `app.py` holds the interface, the batching and the aggregation. It contains no detection logic of
8
+ its own: the text extraction lives in `corpus_text.py`, the prompt and parser in `mimo.py`, the
9
+ embedding lookup in `neighbours.py`, and each is quoted from the notebook that measured it.
10
+ """
11
+
12
+ import re
13
+ import traceback
14
+
15
+ import gradio as gr
16
+
17
+ import corpus_text
18
+ import mimo
19
+ import neighbours
20
+
21
+ # Measured in Part B on a T4, on the project's own 1,100-document corpus.
22
+ PART_B = {"f1": 0.945, "precision": 0.988, "recall": 0.906, "family_acc": 0.433,
23
+ "false_alarm_rate": 0.05, "unparsable": 155, "n": 1100}
24
+
25
+ GPU = mimo.BACKEND == "gpu"
26
+
27
+ # How many regions fit in ONE run is not a taste decision. On ZeroGPU a single grant is capped at
28
+ # 300 seconds and the whole scan plus the 4-bit load must fit inside it; on 2 vCPUs a region costs
29
+ # two minutes and a browser will not wait for many. That ceiling is what a batch is.
30
+ MAX_PER_BATCH = mimo.MAX_WINDOWS_GPU if GPU else 6
31
+ DEFAULT_PER_BATCH = 8 if GPU else 3
32
+
33
+ WINDOW_COLUMNS = ["#", "where in the skeleton", "kind", "markers", "verdict", "family", "evidence"]
34
+
35
+
36
+ def fmt_eta(n: int) -> str:
37
+ seconds = n * mimo.SECONDS_PER_WINDOW
38
+ if seconds < 90:
39
+ return f"about {max(20, int(seconds))}s"
40
+ return f"{seconds * 0.6 / 60:.0f}-{seconds * 1.5 / 60:.0f} min"
41
+
42
+
43
+ def extract(path, cover_all=True):
44
+ """PDF bytes to ranked candidate regions. No model touched, so this runs on upload."""
45
+ with open(path, "rb") as fh:
46
+ data = fh.read()
47
+ skeleton, truncated, dropped = corpus_text.build_skeleton(data)
48
+ return (data, skeleton, truncated, dropped,
49
+ corpus_text.candidate_windows(skeleton, cover_all=bool(cover_all)))
50
+
51
+
52
+ def batches_of(candidates, per_batch):
53
+ """The ranked regions cut into runnable chunks. Batch 1 is the most marker-dense."""
54
+ per_batch = max(1, int(per_batch))
55
+ return [candidates[i:i + per_batch] for i in range(0, len(candidates), per_batch)]
56
+
57
+
58
+ def batch_label(batch, i, n_batches):
59
+ """What one batch is, in a line, so the choice is informed rather than a number."""
60
+ first, last = batch[0], batch[-1]
61
+ if first["is_head"]:
62
+ where = "head of document"
63
+ else:
64
+ where = f"chars {min(w['start'] for w in batch):,}-{max(w['end'] for w in batch):,}"
65
+ fams = sorted({f for w in batch for f in w["families"]})
66
+ n_marker = sum(1 for w in batch if w["source"] == "marker")
67
+ kind = ("marker regions" if n_marker == len(batch) else
68
+ "sweep of the document" if n_marker == 0 else
69
+ f"{n_marker} marker + {len(batch) - n_marker} sweep")
70
+ tail = f" Β· {', '.join(fams)}" if fams else ""
71
+ return (f"Batch {i + 1} of {n_batches} β€” {len(batch)} region(s), {kind} Β· {where}{tail} Β· "
72
+ f"~{fmt_eta(len(batch))}")
73
+
74
+
75
+ def on_upload(path, per_batch, cover_all):
76
+ """Extract, rank and batch. Fast enough to run on every upload and every slider move."""
77
+ if not path:
78
+ return (None, "Upload a PDF to see what will be read.",
79
+ gr.update(choices=[], value=None, interactive=False))
80
+
81
+ try:
82
+ data, skeleton, truncated, dropped, candidates = extract(path, cover_all)
83
+ except Exception as e:
84
+ return (None, f"Could not read that file: `{type(e).__name__}: {e}`",
85
+ gr.update(choices=[], value=None, interactive=False))
86
+
87
+ groups = batches_of(candidates, per_batch)
88
+ choices = [(batch_label(b, i, len(groups)), i) for i, b in enumerate(groups)]
89
+
90
+ lines = [
91
+ f"**{len(data):,} bytes** on disk, rendered to a **{len(skeleton):,}-character skeleton**"
92
+ + (f" (truncated to the {corpus_text.SKELETON_CHAR_BUDGET:,}-character budget)"
93
+ if truncated else "")
94
+ + (f", {dropped} binary stream(s) dropped." if dropped else "."),
95
+ ]
96
+ n_marker = sum(1 for w in candidates if w["source"] == "marker")
97
+ fams = corpus_text.detect_markers(data)
98
+
99
+ if n_marker:
100
+ lines.append(
101
+ f"**{n_marker} region(s) carry a marker**, and the remaining "
102
+ f"{len(candidates) - n_marker} cover the rest of the document β€” "
103
+ f"**{len(candidates)} in total, cut into {len(groups)} batch(es)** of at most "
104
+ f"{int(per_batch)}. Structural signatures in the raw file: "
105
+ f"`{'`, `'.join(fams) or 'none'}`.")
106
+ lines.append(
107
+ "_Ranking decides **reading order only** β€” batch 1 is the most marker-dense, not the "
108
+ "guilty one. The verdict is MiMo's alone._")
109
+ elif cover_all:
110
+ lines.append(
111
+ f"**No structural marker anywhere in the file.** The triage only recognises the twelve "
112
+ f"families this project generated, so this means either a clean file or a payload "
113
+ f"shaped like none of them β€” which is why the batches below sweep the **whole** "
114
+ f"skeleton rather than stopping here: **{len(candidates)} region(s) in "
115
+ f"{len(groups)} batch(es)**.")
116
+ else:
117
+ lines.append(
118
+ "**No structural marker anywhere in the file**, and the sweep is switched off β€” so "
119
+ "only the head of the document will be read, which is exactly what the corpus builder "
120
+ "produced for a *clean* file. Switch the sweep on to look at the rest of it.")
121
+
122
+ lines.append(f"Pick a batch and press **Check this batch**. One batch is one run of the model, "
123
+ f"sized to fit the **{mimo.BACKEND.upper()}** runtime's limit; run as many "
124
+ f"batches as you like, one at a time.")
125
+
126
+ return ((skeleton, candidates), "\n\n".join(lines),
127
+ gr.update(choices=choices, value=0, interactive=True))
128
+
129
+
130
+ def resolve_batch(value, n_batches: int) -> int:
131
+ """
132
+ Whatever the dropdown handed back, as a batch index that exists.
133
+
134
+ Because the dropdown allows custom values it can arrive as the integer index, as `None` before
135
+ anything was picked, or as the label string itself. All three mean something, and none of them
136
+ should be an exception in front of a user who just pressed a button.
137
+ """
138
+ if isinstance(value, (int, float)):
139
+ idx = int(value)
140
+ else:
141
+ digits = re.search(r"\d+", str(value or ""))
142
+ idx = int(digits.group()) - 1 if digits else 0 # labels are 1-based, indices are not
143
+ return idx if 0 <= idx < n_batches else 0
144
+
145
+
146
+ def run(path, per_batch, batch_index, want_neighbours, cover_all, progress=gr.Progress()):
147
+ """Score one batch."""
148
+ if not path:
149
+ return "Upload a PDF first.", [], [], ""
150
+
151
+ progress(0.05, desc="reading the PDF")
152
+ try:
153
+ _, _, _, _, candidates = extract(path, cover_all)
154
+ except Exception as e:
155
+ return f"Could not read that file: `{type(e).__name__}: {e}`", [], [], ""
156
+
157
+ groups = batches_of(candidates, per_batch)
158
+ idx = resolve_batch(batch_index, len(groups))
159
+ batch = groups[idx]
160
+
161
+ progress(0.15, desc=f"loading MiMo ({mimo.BACKEND})")
162
+ try:
163
+ answers = mimo.judge_all([w["text"] for w in batch],
164
+ progress=lambda m: progress(0.4, desc=m))
165
+ except Exception as e:
166
+ return (f"## MiMo could not run\n\n`{type(e).__name__}: {e}`\n\nRuntime selected: "
167
+ f"**{mimo.BACKEND}**."), [], [], traceback.format_exc()
168
+
169
+ offset = idx * max(1, int(per_batch))
170
+ rows, log, results = [], [], list(zip(batch, answers))
171
+ for i, (win, r) in enumerate(results):
172
+ where = "head of document" if win["is_head"] else f"chars {win['start']:,}-{win['end']:,}"
173
+ verdict = ("PAYLOAD" if r["pred_injected"] else "clean") + (
174
+ "" if r["parse_ok"] else " (unreadable answer)")
175
+ rows.append([offset + i + 1, where, win["source"], ", ".join(win["families"]) or "-",
176
+ verdict, r["pred_family"], (r["evidence"] or "-")[:160]])
177
+ log.append(f"--- region {offset + i + 1} ({where}, prompt via {r['prompt_route']}) ---\n"
178
+ f"{r['raw']}")
179
+
180
+ report = build_report(results, idx, groups, len(candidates))
181
+
182
+ nb_rows = []
183
+ if want_neighbours and results:
184
+ progress(0.95, desc="embedding and looking up the corpus")
185
+ flagged = next((w for w, r in results if r["pred_injected"]), None)
186
+ query = flagged or batch[0]
187
+ try:
188
+ neighbours.check_provenance()
189
+ nb_rows = neighbours.neighbour_rows(query["text"], k=5)
190
+ basis = ("the first flagged region" if flagged else
191
+ "the first region in this batch (nothing was flagged)")
192
+ report += (f"\n\n### Nearest files in the corpus\n\nEmbedded from **{basis}** with "
193
+ f"Part A's winning configuration. Precision@5 on this index is **35.6%** "
194
+ f"against a 6.8% random baseline: fewer than 2 of the 5 listed are the same "
195
+ f"kind of attack. Read it as *resemblance*, not identification.")
196
+ except Exception as e:
197
+ report += (f"\n\n### Nearest files in the corpus\n\nUnavailable: "
198
+ f"`{type(e).__name__}: {e}`")
199
+ log.append(traceback.format_exc())
200
+
201
+ return report, rows, nb_rows, "\n\n".join(log)
202
+
203
+
204
+ def build_report(results, idx, groups, n_candidates) -> str:
205
+ """The verdict for this batch, and an explicit account of what is still unread."""
206
+ if not results:
207
+ return "Nothing was read."
208
+
209
+ hits = [(w, r) for w, r in results if r["pred_injected"]]
210
+ unparsed = sum(1 for _, r in results if not r["parse_ok"])
211
+ read = len(results)
212
+ unread = n_candidates - read
213
+ others = [i for i in range(len(groups)) if i != idx]
214
+
215
+ if hits:
216
+ fams = sorted({r["pred_family"] for _, r in hits if r["pred_family"] != "none"})
217
+ head = (f"## Payload found in batch {idx + 1}\n\nMiMo flagged **{len(hits)} of the {read} "
218
+ f"region(s)** in this batch.")
219
+ head += (f" It named the family as **{', '.join(fams)}** β€” correct 43% of the time in "
220
+ f"Part B, so treat it as a suggestion." if fams
221
+ else " It did not commit to a family.")
222
+ else:
223
+ head = (f"## Nothing found in batch {idx + 1}\n\nMiMo read **{read} region(s)** in this "
224
+ f"batch and flagged none of them.")
225
+
226
+ caveats = []
227
+ if unread > 0:
228
+ caveats.append(
229
+ f"**This is 1 of {len(groups)} batches.** {unread} region(s) across "
230
+ f"{len(others)} other batch(es) have not been read. Whatever this batch says, it says "
231
+ f"it about {read} of the file's {n_candidates} candidate regions β€” nothing more.")
232
+ if unparsed:
233
+ sweep_bad = sum(1 for w, r in results if not r["parse_ok"] and w["source"] == "sweep")
234
+ note = (f"{unparsed} answer(s) could not be parsed and count as *not injected*, exactly as "
235
+ f"Part B scored them (155 of 1,100 there).")
236
+ if sweep_bad:
237
+ note += (
238
+ f" **{sweep_bad} of those were sweep regions**, and that is expected rather than "
239
+ f"surprising: Part B only ever showed MiMo marker-centred windows or the head of a "
240
+ f"document, never arbitrary mid-file content streams. Given a page of font "
241
+ f"positioning operators it tends to carry on copying the input instead of "
242
+ f"answering. Sweep regions buy coverage of text that would otherwise never be "
243
+ f"looked at; they do not inherit Part B's accuracy, and a *clean* verdict on one "
244
+ f"is close to no evidence at all.")
245
+ caveats.append(note)
246
+
247
+ body = head
248
+ if caveats:
249
+ body += "\n\n" + "\n\n".join("- " + c for c in caveats)
250
+ body += (f"\n\n---\n\n**On the corpus Part B measured**, MiMo scored F1 {PART_B['f1']}, "
251
+ f"precision {PART_B['precision']}, recall {PART_B['recall']} on {PART_B['n']:,} "
252
+ f"documents that were 82% injected β€” where a detector that flags everything without "
253
+ f"reading it scores F1 0.900. Read 0.945 against 0.900, not against zero.\n\n"
254
+ f"_Runtime: **{mimo.BACKEND}**. {mimo.RUNTIME_CAVEAT}_")
255
+ return body
256
+
257
+
258
+ INTRO = f"""
259
+ # PDF Injection Detector β€” MiMo-7B
260
+
261
+ Upload a PDF. It is rendered to text with the extractor that built the project corpus, the regions
262
+ carrying structural markers are ranked and cut into **batches that fit one run of the model**, and
263
+ **MiMo-7B-RL** reads the batch you choose β€” reporting whether a payload is hidden there, and the
264
+ substring that convinced it.
265
+
266
+ Batching is what keeps a long document inside the runtime's limit: one batch is one run, and you
267
+ decide how many runs to spend. The report always states how much of the file is still unread.
268
+
269
+ This is a coursework artefact built on a synthetic corpus of 1,100 PDFs carrying harmless
270
+ EICAR/AMTSO/WICAR/RANSIM test markers. **It is not a general malware scanner**, and real malware
271
+ does not announce itself the way these samples do.
272
+
273
+ Running on the **{mimo.BACKEND.upper()}** runtime
274
+ ({'4-bit NF4 β€” Part B’s own configuration' if GPU else 'Q4_K_M GGUF via llama.cpp'}),
275
+ about {mimo.SECONDS_PER_WINDOW:g}s per region.
276
+ """
277
+
278
+
279
+ with gr.Blocks(title="PDF Injection Detector") as demo:
280
+ gr.Markdown(INTRO)
281
+ state = gr.State()
282
+
283
+ with gr.Row():
284
+ with gr.Column(scale=1):
285
+ pdf = gr.File(label="PDF", file_types=[".pdf"], type="filepath")
286
+ per_batch = gr.Slider(1, MAX_PER_BATCH, value=DEFAULT_PER_BATCH, step=1,
287
+ label="Regions per batch",
288
+ info=f"One batch is one run of the model. The "
289
+ f"{mimo.BACKEND.upper()} runtime tops out at "
290
+ f"{MAX_PER_BATCH}.")
291
+ # allow_custom_value: the choices are empty until a PDF is uploaded, and without this
292
+ # Gradio validates any incoming value against that empty list and rejects it - which
293
+ # makes the batch un-selectable over the API even though the UI had populated it.
294
+ # `resolve_batch` below is what actually decides the index, from the file itself.
295
+ batch_pick = gr.Dropdown(label="Batch to check", choices=[], interactive=False,
296
+ allow_custom_value=True,
297
+ info="Each batch is a separate run β€” spend as many as you "
298
+ "like.")
299
+ sweep = gr.Checkbox(
300
+ value=True, label="Sweep the rest of the document too",
301
+ info="Off = marker regions only, which is the shape Part B measured. On = the "
302
+ "batches cover the whole file, at the cost of regions MiMo often will not "
303
+ "answer about.")
304
+ want_nb = gr.Checkbox(value=True, label="Also show the nearest files in the corpus",
305
+ info="Adds a one-off 550 MB embedding-model download.")
306
+ go = gr.Button("Check this batch", variant="primary")
307
+ plan = gr.Markdown("Upload a PDF to see what will be read.")
308
+
309
+ with gr.Column(scale=2):
310
+ with gr.Tab("Report"):
311
+ report = gr.Markdown()
312
+ with gr.Tab("Regions read"):
313
+ window_table = gr.Dataframe(headers=WINDOW_COLUMNS, wrap=True, interactive=False)
314
+ with gr.Tab("Nearest corpus files"):
315
+ nb_table = gr.Dataframe(headers=neighbours.NEIGHBOUR_COLUMNS, interactive=False)
316
+ with gr.Tab("What MiMo actually said"):
317
+ # No `show_copy_button`: gradio 6 removed it, and this Space should survive an
318
+ # sdk_version bump rather than crash at startup on a cosmetic argument.
319
+ raw = gr.Textbox(lines=22, interactive=False,
320
+ label="The prompt route and untouched generation per region")
321
+
322
+ for ev in (pdf.change, per_batch.change):
323
+ ev(on_upload, [pdf, per_batch, sweep], [state, plan, batch_pick])
324
+ sweep.change(on_upload, [pdf, per_batch, sweep], [state, plan, batch_pick])
325
+ go.click(run, [pdf, per_batch, batch_pick, want_nb, sweep],
326
+ [report, window_table, nb_table, raw])
327
+
328
+ if __name__ == "__main__":
329
+ demo.queue(max_size=8).launch()