Spaces:
Running on Zero
Running on Zero
| 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 | |
| tags: | |
| - cybersecurity | |
| - prompt-injection | |
| - malware | |
| - gradio | |
| - scikit-learn | |
| - gradient-boosting | |
| - embeddings | |
| - recommender | |
| # PDF Injection Detector β MiMo-7B | |
| Upload a PDF. The app renders it to text with the extractor that built the project corpus, ranks the | |
| regions carrying structural signatures, asks **MiMo-7B-RL** whether a payload is hidden in the batch | |
| you choose, and then asks a **family-naming model** which of twelve attacks it is β so the | |
| remediation advice it prints is attached to a measured answer rather than to a guess. | |
| 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.** | |
| --- | |
| ## Table of Contents | |
| 1. [The pipeline](#the-pipeline) | |
| 2. [Part 1 β The family-naming model](#part-1--the-family-naming-model) | |
| - [Why it exists](#why-it-exists) | |
| - [The four signals](#the-four-signals) | |
| - [Results](#results) | |
| - [Why 0.994 is the least interesting number](#why-0994-is-the-least-interesting-number) | |
| - [Per-family behaviour](#per-family-behaviour) | |
| 3. [Part 2 β The app built around it](#part-2--the-app-built-around-it) | |
| - [How to use it](#how-to-use-it) | |
| - [The controls](#the-controls) | |
| - [The five tabs](#the-five-tabs) | |
| - [Where the plan panel's numbers come from](#where-the-plan-panels-numbers-come-from) | |
| 4. [Part 3 β The models and the numbers](#part-3--the-models-and-the-numbers) | |
| - [Why MiMo, when Gemma scored higher](#why-mimo-when-gemma-scored-higher) | |
| - [One runtime](#one-runtime) | |
| - [Triage and the sweep](#triage-and-the-sweep) | |
| 5. [Part 4 β Provenance, and the files](#part-4--provenance-and-the-files) | |
| --- | |
| ## The pipeline | |
| | Stage | Module | What it answers | What it is worth | | |
| |---|---|---|---| | |
| | 1. Extract | `corpus_text.py` | PDF bytes β text skeleton β ranked candidate regions | byte-identical to the corpus build | | |
| | 2. Detect | `mimo.py` | *is there a payload in this region?* | F1 **0.945** (constant baseline 0.900) | | |
| | 3. Retrieve | `neighbours.py` | *what does this region resemble?* | precision@5 **35.6%** (random 6.8%) | | |
| | 4. Name | `family_model.py` | *which of thirteen classes is it?* | P@1 **0.994** / **0.691** β see below | | |
| Stages 2 and 4 are separate because the two questions have very different difficulty. MiMo is good | |
| at the first and poor at the second, and the advice the app prints depends on the second. | |
| --- | |
| # Part 1 β The family-naming model | |
| Built first, in [`App_Creation.ipynb`](App_Creation.ipynb) Part 2, and shipped as | |
| `family_naming_model.pkl` (915 KB). | |
| ## Why it exists | |
| The evaluation stage of this project measured four open language models on this corpus and found two | |
| very different competences: | |
| | Question | MiMo-7B-RL | | |
| |---|---| | |
| | Is a payload present? | **F1 0.945** β against 0.900 for flagging every file unread | | |
| | Which family is it? | **43.3%** correct | | |
| The second row is the one that reaches a user. This app attaches **remediation advice** to a family, | |
| so a wrong family means someone is shown the fix for an attack their file does not contain. At 43% | |
| that would have happened more often than not, which is why the family is not MiMo's to name. | |
| ## The four signals | |
| The model is a fusion of four sources of evidence about the same document, none adequate alone: | |
| | Signal | Columns | Where it comes from | What it knows | What it misses | | |
| |---|---|---|---|---| | |
| | Structural signatures | 12 | `corpus_text.detect_markers` on the skeleton | exactly which of twelve known payload shapes are present | anything outside those twelve | | |
| | MiMo's own answer | 15 | the scan's family guess, verdict and parse flag | a language model's reading of the text | wrong on family 57% of the time | | |
| | Document shape | 20 | 17 EDA measurements β entropy, object counts, streams, pages, sizes β plus 3 log companions | carrier-level anomalies | little about the payload itself | | |
| | Neighbour vote | 13 | weighted vote of the 20 nearest corpus files in the embedding index | what the file *resembles*, including novel wording | weak alone: precision@5 35.6% | | |
| That is 60 features. The classifier is a `HistGradientBoostingClassifier` β a few hundred small | |
| decision trees, each correcting the errors of the ones before it. CPU-only, under a second per | |
| prediction, no neural network anywhere in it. | |
| ## Results | |
| Everything is **out of fold**: the 1,100 documents are split into five folds and each is predicted by | |
| a model that never saw it in training. | |
| | Model | P@1 | P@3 | MRR | macro-F1 | | |
| |---|---|---|---|---| | |
| | **gradient boosting (shipped)** | **0.994** | 0.999 | 0.996 | 0.995 | | |
| | GB without MiMo's guess | 0.993 | 0.999 | 0.996 | 0.994 | | |
| | logistic regression | 0.992 | 0.998 | 0.995 | 0.992 | | |
| | GB without signatures | 0.691 | 0.823 | 0.778 | 0.666 | | |
| | regex signatures alone | 0.669 | 0.818 | 0.758 | 0.774 | | |
| | neighbours + shape only | 0.532 | 0.689 | 0.649 | 0.481 | | |
| | embedding neighbours alone | 0.527 | 0.705 | 0.651 | 0.478 | | |
| | MiMo's family guess alone | 0.498 | 0.585 | 0.597 | 0.441 | | |
| | always answer `none` (floor) | 0.182 | 0.330 | 0.336 | 0.024 | | |
|  | |
| *Chart 1 β no single signal is close. The best one alone is the regex at 0.669; the two learned | |
| signals are worse than that. The fusion is doing real work rather than laundering one strong | |
| feature.* | |
| The floor matters: 18.2% of the corpus is clean, so a model that ignores the file entirely and always | |
| answers `none` scores 0.182. Every row above is read against that, not against zero. | |
| ## Why 0.994 is the least interesting number | |
| A model scoring 0.994 on a coursework corpus should be treated as a bug report until proven | |
| otherwise. Removing one signal block at a time and re-running the whole procedure says where the | |
| score comes from. | |
|  | |
| *Chart 2 β the dotted line is the full model. Removing MiMo costs 0.001. Removing the structural | |
| signatures costs 0.303.* | |
| **The signatures are carrying almost everything, and that is a fact about the corpus rather than a | |
| flaw in the model.** The label *is* the inserted marker: a document is labelled | |
| `ransomware_simulation` **because** the generator wrote a `RANSIM TEST` string into it, and the | |
| signature block reads that string back out. | |
| So there are two honest figures, describing two different situations: | |
| | | P@1 | Describes | | |
| |---|---|---| | |
| | with signatures | **0.994** | a file carrying one of the twelve payload shapes this project generated | | |
| | without signatures | **0.691** | a file carrying something else β the harder, realistic case | | |
| **The app reports whichever applies to the file in front of it.** `family_model.predict` checks | |
| whether the upload carries a known signature and quotes 0.994 or 0.691 next to its answer, instead | |
| of printing the flattering number in both cases. | |
| A second result worth stating: **MiMo's opinion is nearly redundant.** The model reaches 0.993 | |
| without ever being told what the language model thought β its contribution is almost entirely | |
| recoverable from the other three signals. | |
| ## Per-family behaviour | |
| The ablation is an average, and averages hide the shape of a failure. | |
|  | |
| *Chart 3 β blue is the full model, orange is the same model without signature evidence.* | |
| `steganographic_payload`, `uri_redirect_phishing` and `dde_template_injection` survive the loss well. | |
| **`ssrf` collapses to 0.35**, the worst of the thirteen: an SSRF payload is a URL pointing at a cloud | |
| metadata address, so it adds no distinctive object to the PDF and reads like ordinary text. | |
|  | |
| *Chart 4 β where the honest (no-signature) model's mistakes actually go.* | |
| The confusions are not random. `ransomware_simulation` is mistaken for `javascript_injection` 15 | |
| times and `object_action_injection` for `shellcode_embedded_exe` 13 times β exactly the pairs the | |
| generator gives a shared JavaScript launcher to. It is confusing documents that genuinely do | |
| resemble each other. | |
| The `none` row is the reassuring one: **190 of 200 clean files are still called clean** with no | |
| signature evidence at all. The model is far better at *is this clean?* than at *which of twelve | |
| attacks is this?* β the same division of competence MiMo shows, arrived at independently. | |
| This is the argument for the ranked short-list. When the top pick is wrong the correct family is | |
| usually still in the list β P@3 is 0.823 even in the harder regime β so the interface shows three | |
| candidates with confidences rather than asserting one. | |
| --- | |
| # Part 2 β The app built around it | |
| ## How to use it | |
| Every screenshot below is of this Space as it actually runs β one real session on the | |
| `ransomware simulation` example, captured in order, nothing mocked up or edited. | |
| ### 1. Open the Space | |
|  | |
| The page comes up before anything is downloaded. The header states what a region costs β about | |
| **4.2 s**. Read the disclaimer: the verdict, the family and the treatment are all fallible, and each | |
| carries its own measured accuracy rather than one vague shared one. | |
| ### 2. Give it a PDF | |
| 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 drops its file into the same uploader, so it is | |
| triaged by exactly the same code path as your own upload. | |
|  | |
| 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 a 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. | |
|  | |
| ## The controls | |
| | Control | What it does | When to change it | | |
| |---|---|---| | |
| | **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 set to, 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 signature-dense, so start there. | | |
| | **Sweep the rest of the document too** | On, the batches tile the whole skeleton. Off, only signature regions are read β the exact shape the evaluation measured. | Off to stay strictly inside the measured configuration; on for coverage of text no signature points at. | | |
| | **Name the attack family (and show nearest corpus files)** | Runs stages 3 and 4 after the scan. Costs a one-off 550 MB embedding-model download. | Turn it off to save the download β but the family then falls back to MiMo's own guess, right 43% of the time instead of the model's 99.4%/69.1%. | | |
| ### 4. Press **Check this batch** | |
| The button locks for the duration and any previous result is cleared, so nothing on screen can | |
| belong to a different file. One press is one run over one batch β press it again on a different batch | |
| to spend another run; the app never scans the whole file behind your back. | |
|  | |
| *Mid-run: the button is disabled, the previous result has already been cleared, and the progress line | |
| names what is happening β `MiMo reading 2 region(s)`. The first scan after a restart also fetches the | |
| weights, which is the slow part.* | |
| **The result below is worth reading carefully, because it is the case this whole design exists for.** | |
|  | |
| MiMo **found nothing**. It read both regions and flagged neither β and the *Regions read* tab shows | |
| why: its answer for the payload-bearing region came back unparseable, one of the 155-in-1,100 | |
| formatting failures the evaluation counted as *not injected*. | |
| The family-naming model, reading the same file through four other signals, names it | |
| **`ransomware_simulation` at 100% confidence**, and quotes **P@1 0.994** because this file carries a | |
| known signature. The label is correct: `Example_5.pdf` is `ransomware_simulation_AtomicRedTeam_0751`. | |
| So the two models disagree, in public, in the report β and on this file the language model is the one | |
| that is wrong. That is the argument for stage 4 in a single screenshot. | |
| ## The five tabs | |
| - **Report** β the verdict for this batch, the family with its confidence and the accuracy figure for | |
| this file's regime, and the containment advice for that family. It always states how many regions | |
| in other batches are unread, and how many answers could not be parsed (those count as *not | |
| injected*, exactly as the evaluation scored them). | |
| - **Regions read** β one row per region: where in the skeleton, `marker` or `sweep`, which signatures | |
| the regex saw, MiMo's verdict, and the substring quoted as evidence. | |
| - **Family shortlist** β the model's ranked top three of thirteen, with confidences. | |
| - **Nearest corpus files** β the five most similar corpus documents to the flagged region. | |
| Precision@5 is 35.6%, so read it as *resemblance*, never identification. | |
| - **What MiMo actually said** β the untouched generation per region. This is where to look when a | |
| verdict seems wrong. | |
|  | |
| *The shortlist, ranked with confidences. On this file the model is unusually decisive; on a document | |
| carrying no known signature the second and third rows carry real weight, which is why three are shown | |
| rather than one.* | |
|  | |
| *Region 1 is the marker region β signatures `javascript_injection, ransomware_simulation` β and | |
| MiMo's verdict is `clean (unreadable answer)`. Region 2 is a sweep region, genuinely clean. This tab | |
| is what turns "the model said nothing" into "the model failed to format an answer", which are very | |
| different failures.* | |
|  | |
| *All five neighbours are `ransomware_simulation`, the closest at 0.974 β and that closest file, | |
| `ransomware_simulation_AtomicRedTeam_0751.pdf`, **is** the corpus original of `Example_5.pdf`. This is | |
| the self-match described above, visible in the interface: the shipped examples are corpus documents, | |
| so they find themselves. Upload a PDF of your own and this table gets harder.* | |
| **The two models are allowed to disagree in public.** MiMo can flag a region the family model puts in | |
| the `none` class, and the family model routinely overrules MiMo's guess. The report prints the | |
| disagreement rather than resolving it silently, because two models disagreeing is a reason to look at | |
| the file yourself. | |
| ### The nearest-files tab is a recommender, and the score is cosine similarity | |
| Content-based rather than collaborative β there are no users to learn from, only documents. The | |
| flagged region is embedded with the 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 index and query are **unit-normalised**, so the dot product *is* the cosine similarity and the | |
| whole lookup is one 1,100 Γ 768 matrixβvector product β no approximate index is needed at this size | |
| and the ranking is exact. `check_provenance()` asserts the model, prefix, dimension, normalisation | |
| and source column against the retrieval stage'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. | |
| The same query vector feeds the family model's neighbour-vote block, which is why one checkbox | |
| enables both. | |
| ## 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** β pure text extraction and regex, recomputed on every upload, slider move and checkbox change. | |
|  | |
| **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 | |
| 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 becomes spaces; runs of four or | |
| more spaces collapse to three. | |
| - **`4 binary stream(s) dropped`** β streams that failed the 60%-printable test, i.e. genuine binary | |
| such as an image. Each is replaced by the literal `<<BINARY n bytes>>` rather than deleted, so its | |
| position is preserved. | |
| - 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 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 this is regions after merging rather than raw matches. | |
| - **`the remaining 1 cover the rest of the document`** β the sweep. The skeleton is tiled into | |
| 3,000-character windows, and a tile is dropped if a signature region already covers half of it. | |
| - **`2 in total, cut into 1 batch(es) of at most 8`** β the two lists are concatenated, signature | |
| regions first in density order, then sliced into batches. | |
| - **`Structural signatures in the raw file: β¦`** β which families' regexes matched, run against the | |
| **raw bytes**, not the skeleton. Two names appear for several files because the generator gives | |
| some families a JavaScript launcher. | |
| Two things this panel deliberately does not do. It does not print `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. And the ordering decides **reading order only**: batch 1 is the most | |
| signature-dense, not the guilty one. | |
| --- | |
| # Part 3 β The models and the numbers | |
| On the 1,100-document corpus, 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. | |
| ## Why MiMo, when Gemma scored higher | |
| The evaluation's actual winner is **Gemma-2-9B at F1 0.969**; this app runs **MiMo-7B at 0.945**. | |
| That is a hosting decision, not a disagreement with the evaluation: | |
| - **Gemma is gated.** It needs an account with Google's licence accepted plus a read token. On a | |
| public Space that means a new visitor meets a 403, or the app asks strangers to paste a token. | |
| MiMo downloads for anyone. | |
| - **Gemma is 2.6Γ slower** β 10.95 s per window against 4.18 s on the same T4. On ZeroGPU a grant is | |
| capped at 300 s and the model load must fit inside it too. | |
| - **Free ZeroGPU is about five minutes per day.** At Gemma's rate that is a couple of batches. | |
| What it costs is **0.024 F1**. Family-naming accuracy also differs β Gemma 63%, MiMo 43% β but that | |
| no longer decides anything: the family is named by the model in Part 1, which reaches 0.993 without | |
| MiMo's guess at all. Choosing the faster, ungated model is a cheaper decision than it was when the | |
| family label came straight from the language model. | |
| ## One runtime | |
| **ZeroGPU, running the evaluation's own configuration.** The 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. 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. | |
| One grant is kept short β 110 s requested, 165 s reserved β and a batch tops out at 8 regions, | |
| because the scheduler reserves the full requested duration up front. | |
| **There is no `gpu`/`cpu` control.** A CPU path through `llama.cpp` was attempted so the Space could | |
| keep working after a visitor's daily quota ran out, and it cannot be installed here at all: the | |
| prebuilt `llama-cpp-python` wheels are tagged `linux_x86_64` but linked against **musl** while a | |
| Space runs on glibc, and PyPI ships no binary wheel, so the sdist has to compile β which exceeded the | |
| build limit with `Job timeout`. A control whose second option always fails is worse than one option. | |
| ### Quotas, and what "MiMo could not run" means | |
|  | |
| *The one screenshot here not from the session above, and it could not be: reproducing it requires an | |
| exhausted allowance. It was captured from a signed-out visit, which is also why it is styled | |
| differently β this is precisely what an anonymous visitor sees. Note the arithmetic: 165 s requested | |
| against 164 s remaining, refused by one second.* | |
| A free visitor gets roughly five minutes of ZeroGPU per day. The quota is counted per visitor, not | |
| per Space: this message means your allowance, not a broken app. Signing in to Hugging Face raises it, | |
| which is the practical fix. | |
| ## Triage and the sweep | |
| **It triages instead of scanning everything.** A grant is capped at 300 seconds while a real PDF has | |
| dozens of 3,000-character windows, so reading all of them is not on offer. The same signature | |
| alternation that located the payload in the corpus is run over the whole skeleton, every hit becomes | |
| a candidate window of identical shape, overlapping ones are merged, and the most signature-dense go | |
| first. A file with no signature anywhere yields exactly one candidate β the head of the document β | |
| byte-identical to what the corpus builder produced for a clean file. | |
| **The sweep regions are honestly worse, and the app says so.** The evaluation only ever showed MiMo | |
| signature-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, the report counts sweep parse failures | |
| separately, and the sweep can be switched off. | |
| **Nothing is downloaded until it is needed.** The page comes up first; MiMo's checkpoint, the 550 MB | |
| embedding model and the 3 MB index arrive on the first scan and are cached. The family-naming model | |
| is the exception and not one worth optimising β at 915 KB it ships in the repo. | |
| --- | |
| # Part 4 β Provenance, and the files | |
| ## The three repos this is built on | |
| | Repo | What this Space takes from it | | |
| |---|---| | |
| | [Generated_Injected_PDFs_HARMLESS](https://huggingface.co/datasets/Cyber-security-final-project/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](https://huggingface.co/datasets/Cyber-security-final-project/HARMLESS_Synthetic_Injected_PDFs_EDA) | `build_skeleton`, `mask_leaks`, `payload_window` and the 17 numeric features | | |
| | [Evaluation_of_OpenSource_Modelsβ¦](https://huggingface.co/datasets/Cyber-security-final-project/Evaluation_of_OpenSource_Models_for_PDF_Injection_Recognition) | The embedding index and winning configuration; the prompt, prefill and parser | | |
| The prompt, the prefill, the parser, the whole text-extraction path and the seventeen document-shape | |
| features are quoted **verbatim** from those notebooks. That is the correctness argument for quoting | |
| the published scores here at all: change how the text is extracted or how the question is asked, and | |
| those numbers stop describing this program. | |
| `test_fidelity.py` enforces it. It pulls real PDFs from the generation repo, runs them through | |
| `corpus_text.py`, and compares the result **character by character** against the published parquet β | |
| then recomputes all 17 numeric features and checks them against the published columns, and asserts | |
| the assembled feature row is the width the fitted model expects. | |
| ``` | |
| python test_fidelity.py 8 | |
| ``` | |
| ## The examples | |
| Thirteen documents ship with the Space β one per injection family plus a clean control. On disk they | |
| are `Example_0` β¦ `Example_12`, so the uploader shows a neutral filename. 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 a prompt. | |
| The numbering is shuffled with a fixed seed, and every file sits in a **12β15 KB band** so file size | |
| does not reveal the clean control. Each produces 2β8 regions, so every one fits in a single batch. | |
| `examples/Example_Key.txt` records which is which. | |
| **They flatter the family model**, and the reason is worth knowing: every example is a corpus file, | |
| and the corpus is the neighbour index, so each finds *itself* among its neighbours at similarity β | |
| 1.0 β the self-match that cross-validation masks out and serving cannot. Upload a PDF of your own to | |
| see the model work without that help. | |
| ## 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. Verbatim from the EDA notebook above the `Triage` heading. | | |
| | `mimo.py` | The prompt, prefill and parser, verbatim from the evaluation, on the 4-bit GPU runtime. | | |
| | `neighbours.py` | The embedding index and nearest-neighbour lookup, with a provenance assertion. | | |
| | `doc_features.py` | The 17 document-shape measurements, verbatim from the EDA notebook's `extract_one`. | | |
| | `family_model.py` | Feature assembly and the family-naming model's ranked prediction. | | |
| | `family_naming_model.pkl` | The fitted classifier and the feature schema it expects, in one file. | | |
| | `test_fidelity.py` | Checks extraction *and* features against the published corpus. | | |
| | `App_Creation.ipynb` | The build log: fits the model in Part 2, documents the app in Parts 3β9. | | |
| | `figures/` | The charts in Part 1 of this README. | | |
| ## Secrets | |
| None. Every model used here is ungated. | |