BentoUniAcc's picture
docs: nearest-files tab is a cosine-similarity recommender
7247dcd verified
|
Raw
History Blame Contribute Delete
22.4 kB

A newer version of the Gradio SDK is available: 6.24.0

Upgrade
metadata
title: PDF Injection Detector (MiMo-7B)
emoji: πŸ”
colorFrom: yellow
colorTo: gray
sdk: gradio
sdk_version: 5.50.0
app_file: app.py
pinned: false
license: mit
short_description: MiMo-7B finds payloads hidden inside PDF files

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 MiMo-7B-RL reads the most promising ones and says whether a payload is hidden there β€” naming the family and quoting the substring that convinced it.

It 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.

How to use it

Live Space: BentoUniAcc/Mimo_Injection_detector. Every screenshot below is of that Space as it actually runs β€” nothing is mocked up.

1. Open the Space

The interface on load

The page comes up before anything is downloaded. The header states which runtime is live and roughly what a region costs on it β€” GPU, ~4.2 s per region above. Read the disclaimer paragraph: the verdict, the family and the treatment are all fallible, and the family is wrong more often than it is right.

2. Give it a PDF

Either drag a file onto the PDF box, or press one of the Examples buttons down the left edge β€” one per attack family, plus a clean control. An example just drops its file into the same uploader, so it is triaged by exactly the same code path as your own upload.

An example loaded, with the plan underneath

Note the uploader shows Example_5.pdf, not the family name. The button is labelled; the file is not. Nothing about the document announces its own answer, and a filename never reaches the model.

3. Read the plan, then set the controls

Under the button, the app says what it found and what it intends to read before any model runs β€” that panel is broken down in the next section.

The controls

Control What it does When to change it
Runtime gpu (4-bit NF4, Part B's configuration, ~4 s/region, capped by your daily ZeroGPU quota) or cpu (Q4_K_M GGUF, ~2 min/region, no quota). Hidden when only one backend is installed β€” as on this Space. Switch to cpu when the GPU quota runs out.
Regions per batch How many regions one run of the model reads. It only re-cuts the same list, so a lower number means more batches, not less work. Leave it at the maximum. A run reserves the same GPU time whatever this is, so lowering it inspects less of the file for the same quota.
Batch to check Which batch this run spends. Each label states how many regions, whether they are marker or sweep, where in the skeleton they sit, and the estimated time. Batch 1 is the most marker-dense, so start there and spend further runs on later batches if you want the rest read.
Sweep the rest of the document too On, the batches tile the whole skeleton. Off, only marker regions are read β€” which is the exact shape Part B measured. Turn it off to stay strictly inside the measured configuration; leave it on for coverage of text no marker points at.
Also show the nearest files in the corpus Runs Part A's embedding lookup after the scan. Costs a one-off 550 MB model download. Turn it off to save the download.

4. Press Check this batch

The button locks itself for the duration and any previous result is cleared, so nothing on screen can belong to a different file. The first scan after a restart also fetches the weights, which is the slow part.

The first scan, downloading the model

One press is one run of the model over one batch. Press it again β€” on a different batch β€” to spend another run; the app never scans the whole file behind your back.

5. Read the four tabs

  • Report β€” the verdict for this batch. If MiMo flagged anything it says so, names the family it guessed, and prints the containment advice for that family. It always states how many regions in other batches are still unread, and how many answers could not be parsed (those count as not injected, exactly as Part B scored them).
  • Regions read β€” one row per region: where in the skeleton, marker or sweep, which signatures the regex saw, MiMo's verdict, the family, and the substring quoted as evidence.
  • Nearest corpus files β€” the five most similar corpus documents to the first flagged region. Precision@5 is 35.6%, so read it as resemblance, never identification.
  • What MiMo actually said β€” the untouched generation per region, and which prompt route produced it. This is where to look when a verdict seems wrong.

The nearest-files tab is a recommender, and the score is cosine similarity

That tab is the project's recommendation system, and it is content-based rather than collaborative β€” there are no users to learn from, only documents. The flagged region is embedded with Part A's winning model (nomic-ai/nomic-embed-text-v1.5, the search_document: prefix, 768 dimensions) into the same space as the 1,100 corpus files, and the five most similar are returned.

Both the index and the query are unit-normalised, so the dot product is the cosine similarity, and the whole lookup is a single 1,100 Γ— 768 matrix–vector product β€” no approximate index or clustering is needed at this size, and the ranking is exact. The number in the cosine similarity column is that value: 1.0 is identical direction, 0 is unrelated. It measures how alike two payload windows read, which is not the same as being the same attack β€” hence precision@5 of 35.6% against a 6.8% random baseline, and the word resemblance rather than identification. check_provenance() asserts the model, prefix, dimension, normalisation and source column against Part A's own results file before any lookup runs, because a query embedded differently lands in a different space and returns confident nonsense with no error anywhere.

Quotas, and what "MiMo could not run" means

The ZeroGPU quota message

A free visitor gets roughly five minutes of ZeroGPU per day, and the scheduler reserves a run's full requested duration up front rather than what it turns out to use β€” so two or three batches can exhaust the day. The quota is counted per visitor, not per Space: this message means your allowance, not a broken app. The report says exactly how long until it resets. Where a CPU backend is installed, switching the runtime to cpu keeps working with no quota at all; on this Space it is not installed (see Two runtimes below), so the options are to wait, or to sign in to Hugging Face for a larger allowance.

Where the plan panel's numbers come from

This block appears under the button as soon as a file is loaded, and it is written before any model runs β€” it is pure text extraction and regex, computed in corpus_text.py on every upload, every slider move and every checkbox change.

The plan panel

Line 1 β€” the extraction.

  • 12,729 bytes on disk β€” the length of the uploaded file, unmodified.
  • 5,255-character skeleton β€” the result of build_skeleton(). Every stream … endstream body in the PDF is inflated with zlib where it is FlateDecode, capped at 4,096 characters, and kept if more than 60% of its bytes are printable ASCII. Control and binary residue is replaced with spaces and runs of four or more spaces collapse to three. So the number is smaller than the file for a compressed text document and much smaller for one full of images.
  • 4 binary stream(s) dropped β€” streams that failed the 60%-printable test, i.e. genuine binary such as an image. Each is replaced in the skeleton by the literal <<BINARY n bytes>> rather than deleted, so its position is preserved. This count is exactly how many times that happened.
  • If the file is very large the line also says truncated: past a 120,000-character budget the skeleton keeps 45% from the head and the rest from the tail, because the corpus generator inserts payloads at both ends and a plain head truncation would lose most of them.

Line 2 β€” the triage. ANY_MARKER_RE β€” a single alternation of all twelve families' structural patterns plus the framework and test-payload strings β€” is run over the whole skeleton.

  • 1 region(s) carry a marker β€” every match becomes a Β±1,500-character window centred on the hit, and overlapping windows are merged so two markers 200 characters apart are read once rather than twice. This is the count after merging, so it is regions, not raw matches.
  • the remaining 1 cover the rest of the document β€” the sweep. The skeleton is tiled into 3,000-character windows in document order, and a tile is dropped if a marker region already covers half of it or more. This is what stops "clean" meaning "the model never looked there". With the sweep checkbox off, this part is absent and only the marker regions exist.
  • 2 in total, cut into 1 batch(es) of at most 8 β€” the two lists are concatenated, marker regions first in density order, and the result is sliced into batches of the size the Regions per batch slider is set to. Move the slider and this line re-computes immediately; the regions themselves do not change, only how they are grouped.
  • Structural signatures in the raw file: javascript_injection, ransomware_simulation β€” which families' regexes matched, run by detect_markers() against the raw bytes, not the skeleton. Two names appear here because the generator gives several families a JavaScript launcher, so a ransomware sample legitimately carries a JavaScript signature too.

Two things this panel deliberately does not do. It does not say javascript_injection on its own as though that were a finding β€” plenty of harmless PDFs contain a /JS ( token, and the word signature is doing real work in that sentence. And the ordering it produces decides reading order only: batch 1 is the most marker-dense, not the guilty one. The verdict below it is MiMo's alone, which is what the italic line under the counts is there to say.

The three repos this is built on

Repo What this Space takes from it
Generated_Injected_PDFs_HARMLESS The 12 injection families and their structural signatures β€” the definition of what an attack looks like
HARMLESS_Synthetic_Injected_PDFs_EDA build_skeleton, mask_leaks and payload_window β€” how a PDF becomes the text a model reads
Evaluation_of_OpenSource_Models… Part A's embedding index and winning configuration; Part B's prompt, prefill and parser

The prompt, the MiMo prefill, the brace-counting parser and the whole text-extraction path are quoted verbatim from those notebooks. That is the correctness argument for quoting Part B's scores here at all: change how the text is extracted or how the question is asked, and the published numbers stop describing this program.

The examples

Thirteen example documents ship with the Space β€” one per injection family, plus one clean control β€” taken from the generation repo. They appear as a rail of buttons down the left edge, labelled by attack type: press ransomware simulation and that document loads into the uploader and is triaged exactly as an uploaded file would be. There is no separate code path for examples and nothing about them is pre-computed.

The files themselves stay anonymous. On disk they are Example_0 … Example_12, so the uploader shows a neutral filename and the document never announces its own answer. The buttons are labelled because a demo you can drive deliberately is more useful than a guessing game β€” and it costs nothing on the model side, since a filename never reaches the prompt. Only extracted text does.

Two further precautions on the files: the numbering is shuffled with a fixed seed so Example_0 is not simply the alphabetically-first family, and every file sits in a 12–15 KB band so file size does not reveal which one is the clean control. An earlier draft had the clean file at 3 KB against 200 KB+ injected ones, which gave it away completely.

Button labels are read from examples/manifest.json, written by the same script that copies the PDFs β€” so a button cannot end up pointing at the wrong family.

Being small also matters practically: each example produces 2–8 regions, so every one fits in a single batch β€” one example is one GPU run covering the whole document.

examples/Example_Key.txt records which is which, for whoever is marking this. It also notes that several files legitimately carry two structural signatures (the generator gives some families a JavaScript launcher), so ransomware_simulation and dde_template_injection also match javascript_injection, and object_action_injection also matches shellcode_embedded_exe.

The clean control is there for the harder half of the claim: watching the app not flag something.

Why MiMo, when Gemma scored higher

Part B's actual winner is Gemma-2-9B at F1 0.969, and this app runs MiMo-7B at F1 0.945 instead. That is a hosting decision, not a disagreement with the evaluation, and it comes down to three things a free Space cannot absorb:

  • Gemma is gated. It needs a Hugging Face account with Google's licence accepted, plus a read token. On a public Space that means the first thing a new visitor meets is a 403, or the app has to ask strangers to paste a token. MiMo downloads for anyone, with no account at all.
  • Gemma is 2.6Γ— slower β€” 10.95 s per window against MiMo's 4.18 s, measured in Part B on the same T4. On ZeroGPU a single grant is capped at 300 s and the whole scan plus the model load must fit inside it, so the slower model means roughly a third as many regions per run.
  • Free ZeroGPU is about five minutes per day. At Gemma's rate that is a couple of batches for a whole day; at MiMo's it is meaningfully more. The 9B model is also ~6 GB in 4-bit against MiMo's ~5 GB, which is not decisive on its own but points the same way.

What it costs: 0.024 F1, and family-naming accuracy drops from 63% to 43%. Both figures are stated in the interface rather than quietly rounded away. If the Space were ever moved to dedicated paid hardware, switching to Gemma would be a change to mimo.py's repo constants and a token secret β€” the prompt, prefill and parser are shared and would not need touching.

Two runtimes, chosen at startup

A Space's hardware is not this code's decision, so mimo.py carries both paths and picks one at import time. Which is live is printed at the top of the interface and again at the foot of every report.

gpu β€” ZeroGPU, and Part B's own configuration. The original BF16 checkpoint of XiaomiMiMo/MiMo-7B-RL quantised to 4-bit NF4 by bitsandbytes, greedy, 200 new tokens, batched at 8. Nothing about the arithmetic differs from the run that produced F1 0.945, so that figure describes this configuration. About 4 seconds per region, which is why the slider goes to 24. The whole scan happens inside a single @spaces.GPU call, because ZeroGPU grants and reclaims the device around each one and paying the model-load cost per region would dominate everything else.

cpu β€” a free CPU Space, where bitsandbytes cannot run at all (it requires CUDA). The same base model runs as quantflex/MiMo-7B-RL-nomtp-Q4_K_M.gguf (4.7 GB) through llama.cpp. That build has MiMo's multi-token-prediction layers removed, because llama.cpp cannot load them β€” MTP is a speculative-decoding accelerator that the ordinary forward pass does not use, so greedy output should be unaffected, but it is a real difference. On this path, read F1 0.945 as the figure for the configuration Part B measured, not for the one running. About two minutes per region on 2 vCPUs, so the slider stops at 6.

The prompt, the prefill, the decoding parameters and the parser are byte-identical on both paths.

On this Space the CPU path is dormant, and that is not a choice. llama-cpp-python cannot be installed here by either available route: the prebuilt wheels are tagged linux_x86_64 but linked against musl while a Space runs on glibc (libc.musl-x86_64.so.1: cannot open shared object file), and PyPI ships no binary wheel at all β€” compiling the sdist exceeded the Space build limit with Job timeout. So requirements.txt installs neither, the runtime picker hides itself when only one backend is live, and the code path stays in mimo.py for any machine that does have a working llama.cpp. The runtime that remains is the one that reproduces Part B exactly, so what was lost is a fallback rather than a capability.

Where both are live, the picker matters. A free Hugging Face account gets roughly five minutes of ZeroGPU per day, and the scheduler reserves a run's full requested duration up front rather than what it turns out to use β€” so a couple of batches can exhaust the day. When that happens the Space is not broken: switch the runtime to cpu and it keeps working, slowly and without any quota. That is also why one GPU grant is kept short (110s requested, 165s reserved) and a batch tops out at 8 regions.

Triage, and why it exists

It triages instead of scanning everything. MiMo reads a 3,000-character window in roughly two minutes on 2 vCPUs, and a real PDF has dozens of windows. So the same marker alternation that located the payload in the corpus is run over the whole skeleton, every hit becomes a candidate window with the identical Β±1,500-character shape, overlapping ones are merged, and the most marker-dense go to the model first. The ranking decides reading order, never the verdict. The report always says how many marked regions were left unread, so "clean" never overstates itself.

A file with no marker anywhere yields exactly one marker candidate β€” the head of the document β€” which is byte-identical to what the corpus builder produced for a clean file.

Batches, and the sweep

Marker regions alone leave most of a file unread: the triage only knows the twelve families this project generated, so a payload shaped like none of them produces no marker and would sit in text the model never saw while the report said "clean". So after the marker regions, the rest of the skeleton is tiled into windows of the same size, and the whole list is cut into batches sized to fit one run of the model β€” one ZeroGPU grant, or a tolerable wait on CPU. You pick which batch to spend a run on, and the report always states how much is still unread.

The sweep regions do not inherit Part B's accuracy, and the app says so. Part B only ever showed MiMo marker-centred windows or the head of a document. Handed an arbitrary mid-file content stream β€” a page of font-positioning operators β€” MiMo frequently does not answer at all: it carries on copying the input after the prefill, and the answer parses as unrecoverable, which scores as not injected. Those regions buy coverage of text that would otherwise never be looked at; a clean verdict on one is close to no evidence. The regions table labels every row marker or sweep, the report counts the sweep parse failures separately and explains them, and the sweep can be switched off to keep the app strictly inside the shape Part B measured.

Nothing is downloaded until it is needed. The page comes up first; the 4.7 GB GGUF, the 550 MB embedding model and the 3 MB index arrive on the first scan and are cached after that.

The numbers

On the 1,100-document corpus Part B measured, MiMo-7B-RL scored:

F1 0.945
precision 0.988
recall 0.906
names the family correctly 43.3% of files it caught
false alarms 10 of 200 clean files (5%)
unparsable answers 155 of 1,100

A detector that calls every file malicious scores F1 0.900 on this corpus, because 82% of it is injected. Read 0.945 against 0.900, not against zero β€” it is a 5% relative improvement on doing no work at all. Gemma-2-9B scored 0.969 and is the actual Part B winner; MiMo is used here because it is ungated, needs no token, and is 2.6Γ— faster, which on a CPU is the difference between usable and not.

Two limits worth stating plainly:

  • The family is a suggestion, not a verdict β€” right 43% of the time. The nearest known corpus files are shown beside it so the two can disagree in public.
  • The nearest-file lookup is weak on purpose to report. Part A's winning embedder reaches precision@5 of 35.6% against a 6.8% random baseline: fewer than 2 of the 5 files returned are the same kind of attack. Far better than chance, and not good. It is labelled resemblance, never identification.

The files

File What it does
app.py The Gradio interface and the document-level report. No detection logic.
corpus_text.py PDF bytes β†’ skeleton β†’ candidate windows. Everything above Triage is verbatim from the EDA notebook.
mimo.py The prompt, the prefill, the parser (verbatim from Part B) and the llama.cpp runtime.
neighbours.py Part A's embedding index and the nearest-neighbour lookup, with a provenance assertion.

neighbours.check_provenance() asserts the embedder repo, prefix, dimension, normalisation and input column against Part A's own part_a_results.json before any lookup runs β€” a query embedded with the wrong model lands in a different space and returns meaningless neighbours silently, with no error anywhere.

Secrets

None. Every model used here is ungated.