BentoUniAcc's picture
New screenshots: one real session showing the family-naming model
a6b9c57 verified
Raw
History Blame Contribute Delete
35.7 kB
"""
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()