XuViewer commited on
Commit
d7ebaa1
·
verified ·
1 Parent(s): 3dc8919

Add files using upload-large-folder tool

Browse files
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Viewer-HX
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
MODEL_CARD.md ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ library_name: binprov
4
+ tags:
5
+ - binary-analysis
6
+ - compilation-provenance
7
+ - optimization-level-classification
8
+ - pytorch
9
+ - roberta
10
+ metrics:
11
+ - accuracy
12
+ ---
13
+
14
+ # BinProv Opt4
15
+
16
+ `binprov` classifies the compiler optimization level of x86-64 ELF
17
+ binaries from raw `.text` bytes: O0, O1, O2, O3.
18
+ It follows the BinProv method and does not disassemble the input.
19
+
20
+ This is a BinProv-native PyTorch checkpoint. Its byte encoder is RoBERTa-shaped,
21
+ but its pooling and classification head are custom, so it is not compatible
22
+ with `transformers.AutoModel`. Use the bundled `inference.py`; no remote code or
23
+ `trust_remote_code` is involved.
24
+
25
+ ## Model details
26
+
27
+ - Architecture: RoBERTa-style byte encoder, seq 2048 bytes, 12 layers x 768 hidden; 261-token byte vocabulary
28
+ - Pooling: `border` with 4 heads
29
+ - Input: 2048-byte windows at a 512-byte evaluation stride
30
+ - Output: four probabilities corresponding to O0, O1, O2, and O3
31
+ - Framework: PyTorch with Hugging Face Transformers components
32
+ - License: MIT
33
+
34
+ The model follows [BinProv: Binary Code Provenance Identification without
35
+ Disassembly](https://doi.org/10.1145/3545948.3545956), RAID 2022. This checkpoint
36
+ comes from a clean reproduction run of the repository's best single-model
37
+ recipe.
38
+ Evaluation (this export): sequence accuracy **84.2255%**. Binary-level accuracy **93.8830%**. A fresh training run's numbers vary by ~1-2 points with seed.
39
+
40
+ ## Direct use
41
+
42
+ ```bash
43
+ python -m venv .venv
44
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
45
+ pip install -r requirements.txt
46
+
47
+ python inference.py --model model --elf /path/to/binary.elf
48
+ python inference.py --model model --text-bytes raw_text.bin --json
49
+ ```
50
+
51
+ The script extracts `.text` from an ELF or accepts already extracted bytes. It
52
+ evaluates overlapping 2048-byte inputs every 512 bytes, then averages the
53
+ per-window probabilities for one binary. It automatically selects CUDA, Apple
54
+ MPS, or CPU. Use `--batch-size` to trade memory for speed.
55
+
56
+ ## Training data
57
+
58
+ The model was trained and evaluated on the x86-64 subset of BinKit Normal,
59
+ covering GCC and Clang binaries at optimization levels O0 through O3. Splits are
60
+ grouped by source program so the same program does not cross train and test.
61
+ Exact training arguments and environment versions are in `metadata.json` and
62
+ `evidence/args.json`.
63
+
64
+ ## Evaluation
65
+
66
+ | metric | value |
67
+ |---|---:|
68
+ | sequence accuracy | **84.2255%** |
69
+ | balanced sequence accuracy | **83.0951%** |
70
+ | binary soft-vote accuracy | **93.8830%** |
71
+ | test windows | 116,321 |
72
+ | test binaries | 376 |
73
+
74
+ The sequence metric uses 512-byte target positions with 2048-byte overlapping
75
+ model inputs. Binary accuracy averages the saved per-window probabilities. The
76
+ recomputed evaluation in `evidence/evaluation.json` matches the recorded test
77
+ accuracy. Results can vary with retraining and should be compared on the same
78
+ program-grouped split.
79
+
80
+ ## Intended use and limitations
81
+
82
+ This checkpoint is intended for research and analysis of compiler optimization
83
+ provenance in ordinary x86-64 ELF code produced by toolchains represented in
84
+ BinKit. It has not been validated as a general malware, authorship, attribution,
85
+ or security-decision system. Packed, obfuscated, self-modifying, non-ELF, other
86
+ architecture, and out-of-distribution toolchain inputs may be unreliable. The
87
+ softmax scores are not calibrated confidence estimates.
88
+
89
+ ## Files
90
+
91
+ - `model/` — `model.pt`, `binprov_config.json`, `head.json` (BinProv checkpoint)
92
+ - `binprov/` — minimal source package needed by `inference.py`
93
+ - `requirements.txt` — runtime dependencies
94
+ - `labels.json` — class id -> label
95
+ - `metadata.json` — exact training args + eval results + environment
96
+ - `environment.json` — runtime versions
97
+ - `evidence/` — args, result, evaluation report, and saved probabilities
98
+ - `LICENSE` — repository license
99
+
100
+ ## Citation
101
+
102
+ ```bibtex
103
+ @inproceedings{he2022binprov,
104
+ title={BinProv: Binary Code Provenance Identification without Disassembly},
105
+ author={He, Xu and Wang, Shu and Xing, Yunlong and Feng, Pengbin and Wang, Haining and Li, Qi and Chen, Songqing and Sun, Kun},
106
+ booktitle={RAID},
107
+ year={2022},
108
+ doi={10.1145/3545948.3545956}
109
+ }
110
+ ```
README.md ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ library_name: binprov
4
+ tags:
5
+ - binary-analysis
6
+ - compilation-provenance
7
+ - optimization-level-classification
8
+ - pytorch
9
+ - roberta
10
+ metrics:
11
+ - accuracy
12
+ ---
13
+
14
+ # BinProv Opt4
15
+
16
+ `binprov` classifies the compiler optimization level of x86-64 ELF
17
+ binaries from raw `.text` bytes: O0, O1, O2, O3.
18
+ It follows the BinProv method and does not disassemble the input.
19
+
20
+ This is a BinProv-native PyTorch checkpoint. Its byte encoder is RoBERTa-shaped,
21
+ but its pooling and classification head are custom, so it is not compatible
22
+ with `transformers.AutoModel`. Use the bundled `inference.py`; no remote code or
23
+ `trust_remote_code` is involved.
24
+
25
+ ## Model details
26
+
27
+ - Architecture: RoBERTa-style byte encoder, seq 2048 bytes, 12 layers x 768 hidden; 261-token byte vocabulary
28
+ - Pooling: `border` with 4 heads
29
+ - Input: 2048-byte windows at a 512-byte evaluation stride
30
+ - Output: four probabilities corresponding to O0, O1, O2, and O3
31
+ - Framework: PyTorch with Hugging Face Transformers components
32
+ - License: MIT
33
+
34
+ The model follows [BinProv: Binary Code Provenance Identification without
35
+ Disassembly](https://doi.org/10.1145/3545948.3545956), RAID 2022. This checkpoint
36
+ comes from a clean reproduction run of the repository's best single-model
37
+ recipe.
38
+ Evaluation (this export): sequence accuracy **84.2255%**. Binary-level accuracy **93.8830%**. A fresh training run's numbers vary by ~1-2 points with seed.
39
+
40
+ ## Direct use
41
+
42
+ ```bash
43
+ python -m venv .venv
44
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
45
+ pip install -r requirements.txt
46
+
47
+ python inference.py --model model --elf /path/to/binary.elf
48
+ python inference.py --model model --text-bytes raw_text.bin --json
49
+ ```
50
+
51
+ The script extracts `.text` from an ELF or accepts already extracted bytes. It
52
+ evaluates overlapping 2048-byte inputs every 512 bytes, then averages the
53
+ per-window probabilities for one binary. It automatically selects CUDA, Apple
54
+ MPS, or CPU. Use `--batch-size` to trade memory for speed.
55
+
56
+ ## Training data
57
+
58
+ The model was trained and evaluated on the x86-64 subset of BinKit Normal,
59
+ covering GCC and Clang binaries at optimization levels O0 through O3. Splits are
60
+ grouped by source program so the same program does not cross train and test.
61
+ Exact training arguments and environment versions are in `metadata.json` and
62
+ `evidence/args.json`.
63
+
64
+ ## Evaluation
65
+
66
+ | metric | value |
67
+ |---|---:|
68
+ | sequence accuracy | **84.2255%** |
69
+ | balanced sequence accuracy | **83.0951%** |
70
+ | binary soft-vote accuracy | **93.8830%** |
71
+ | test windows | 116,321 |
72
+ | test binaries | 376 |
73
+
74
+ The sequence metric uses 512-byte target positions with 2048-byte overlapping
75
+ model inputs. Binary accuracy averages the saved per-window probabilities. The
76
+ recomputed evaluation in `evidence/evaluation.json` matches the recorded test
77
+ accuracy. Results can vary with retraining and should be compared on the same
78
+ program-grouped split.
79
+
80
+ ## Intended use and limitations
81
+
82
+ This checkpoint is intended for research and analysis of compiler optimization
83
+ provenance in ordinary x86-64 ELF code produced by toolchains represented in
84
+ BinKit. It has not been validated as a general malware, authorship, attribution,
85
+ or security-decision system. Packed, obfuscated, self-modifying, non-ELF, other
86
+ architecture, and out-of-distribution toolchain inputs may be unreliable. The
87
+ softmax scores are not calibrated confidence estimates.
88
+
89
+ ## Files
90
+
91
+ - `model/` — `model.pt`, `binprov_config.json`, `head.json` (BinProv checkpoint)
92
+ - `binprov/` — minimal source package needed by `inference.py`
93
+ - `requirements.txt` — runtime dependencies
94
+ - `labels.json` — class id -> label
95
+ - `metadata.json` — exact training args + eval results + environment
96
+ - `environment.json` — runtime versions
97
+ - `evidence/` — args, result, evaluation report, and saved probabilities
98
+ - `LICENSE` — repository license
99
+
100
+ ## Citation
101
+
102
+ ```bibtex
103
+ @inproceedings{he2022binprov,
104
+ title={BinProv: Binary Code Provenance Identification without Disassembly},
105
+ author={He, Xu and Wang, Shu and Xing, Yunlong and Feng, Pengbin and Wang, Haining and Li, Qi and Chen, Songqing and Sun, Kun},
106
+ booktitle={RAID},
107
+ year={2022},
108
+ doi={10.1145/3545948.3545956}
109
+ }
110
+ ```
binprov/__init__.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BinProv — compilation provenance identification straight from binary code.
2
+
3
+ A re-implementation of the experiment pipeline from:
4
+
5
+ Xu He, Shu Wang, Yunlong Xing, Pengbin Feng, Haining Wang, Qi Li,
6
+ Songqing Chen, Kun Sun. "BinProv: Binary Code Provenance Identification
7
+ without Disassembly." RAID 2022. https://doi.org/10.1145/3545948.3545956
8
+
9
+ The pipeline, mirroring the paper's four stages (§3):
10
+
11
+ 1. pre-process .text section -> fixed-length byte sequences
12
+ 2. embed byte-level RoBERTa/BERT encoder, MLM pre-trained
13
+ 3. classify 2-layer FC head, fine-tuned per provenance task
14
+ 4. joint infer majority vote over sequences of a function / a binary
15
+
16
+ The original code was lost; this rebuild follows the paper and marks every place
17
+ where the paper leaves a detail unspecified — see docs/REPRODUCTION.md.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ # Only the torch-free modules are imported eagerly, so that corpus building and
25
+ # inspection work in an environment without torch/transformers installed.
26
+ # `binprov.model` and `binprov.data` are imported directly where needed.
27
+ from . import corpus, discover, elf, metrics, provenance, vocab, vote
28
+
29
+ __all__ = [
30
+ "corpus",
31
+ "discover",
32
+ "elf",
33
+ "metrics",
34
+ "provenance",
35
+ "vocab",
36
+ "vote",
37
+ ]
binprov/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (1.31 kB). View file
 
binprov/__pycache__/corpus.cpython-312.pyc ADDED
Binary file (22.2 kB). View file
 
binprov/__pycache__/data.cpython-312.pyc ADDED
Binary file (24.3 kB). View file
 
binprov/__pycache__/discover.cpython-312.pyc ADDED
Binary file (9.62 kB). View file
 
binprov/__pycache__/elf.cpython-312.pyc ADDED
Binary file (13.2 kB). View file
 
binprov/__pycache__/metrics.cpython-312.pyc ADDED
Binary file (5.86 kB). View file
 
binprov/__pycache__/model.cpython-312.pyc ADDED
Binary file (28.5 kB). View file
 
binprov/__pycache__/provenance.cpython-312.pyc ADDED
Binary file (12.5 kB). View file
 
binprov/__pycache__/vocab.cpython-312.pyc ADDED
Binary file (2.76 kB). View file
 
binprov/__pycache__/vote.cpython-312.pyc ADDED
Binary file (6.51 kB). View file
 
binprov/corpus.py ADDED
@@ -0,0 +1,445 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The packed corpus: the only thing BinProv needs to keep on disk.
2
+
3
+ Motivation is disk space. A BinKit binary averages ~200 KB, but BinProv reads
4
+ nothing except the ``.text`` bytes and (for function-level voting) the function
5
+ boundaries. So corpus building concatenates every ``.text`` section into one
6
+ flat ``uint8`` file and records offsets alongside — after which **the binaries
7
+ themselves can be deleted**. In practice that is a 3-5x reduction versus keeping
8
+ the ELF files, and it makes training I/O a single memory-mapped sequential read.
9
+
10
+ On-disk layout of a corpus directory::
11
+
12
+ text.u8 all .text sections concatenated, no padding
13
+ binaries.jsonl.gz one JSON record per binary (offset, length, labels, funcs)
14
+ meta.json corpus-level counts, label tallies, build parameters
15
+ splits/<name>.json binary-id lists for train/test/pretrain
16
+
17
+ Sequences (the paper's fixed-length 512-byte units, §3.1) are *not* stored.
18
+ They are cut on the fly by :meth:`Corpus.sequences`, so changing the sequence
19
+ length or stride costs nothing and needs no rebuild.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import gzip
25
+ import json
26
+ import os
27
+ import random
28
+ from dataclasses import asdict, dataclass, field
29
+ from pathlib import Path
30
+
31
+ import numpy as np
32
+
33
+ TEXT_FILE = "text.u8"
34
+ INDEX_FILE = "binaries.jsonl.gz"
35
+ META_FILE = "meta.json"
36
+ SPLIT_DIR = "splits"
37
+
38
+
39
+ @dataclass
40
+ class BinaryRecord:
41
+ """One binary's slice of the packed corpus."""
42
+
43
+ bid: int
44
+ path: str # original path, for traceability only
45
+ package: str # e.g. "coreutils-8.24"
46
+ program: str # e.g. "ls" — the grouping key for leak-free splits
47
+ labels: dict # see provenance.Labels.as_dict()
48
+ text_off: int # offset into text.u8
49
+ text_len: int
50
+ text_vaddr: int # virtual address of .text[0], for symbol mapping
51
+ arch_elf: str # architecture as read from the ELF header
52
+ stripped: bool
53
+ functions: list = field(default_factory=list) # [[off, size], ...] within .text
54
+ func_names: list = field(default_factory=list) # parallel to functions, may be empty
55
+
56
+ @property
57
+ def group(self) -> str:
58
+ """Split-grouping key: all compiled variants of one program share it."""
59
+ return f"{self.package}/{self.program}"
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # writing
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ class CorpusWriter:
68
+ """Streams ``.text`` sections into a packed corpus.
69
+
70
+ Use as a context manager. ``text.u8`` is appended to as we go, so peak
71
+ memory stays at one binary regardless of corpus size.
72
+ """
73
+
74
+ def __init__(self, out_dir: str | os.PathLike, *, build_params: dict | None = None):
75
+ self.dir = Path(out_dir)
76
+ self.dir.mkdir(parents=True, exist_ok=True)
77
+ self._text = open(self.dir / TEXT_FILE, "wb")
78
+ self._index = gzip.open(self.dir / INDEX_FILE, "wt", compresslevel=6)
79
+ self._offset = 0
80
+ self._n = 0
81
+ self._build_params = dict(build_params or {})
82
+ self._label_counts: dict[str, dict[str, int]] = {}
83
+
84
+ def add(
85
+ self,
86
+ text: bytes,
87
+ *,
88
+ path: str,
89
+ package: str,
90
+ program: str,
91
+ labels: dict,
92
+ text_vaddr: int,
93
+ arch_elf: str,
94
+ stripped: bool,
95
+ functions: list | None = None,
96
+ func_names: list | None = None,
97
+ ) -> BinaryRecord:
98
+ rec = BinaryRecord(
99
+ bid=self._n,
100
+ path=path,
101
+ package=package,
102
+ program=program,
103
+ labels=labels,
104
+ text_off=self._offset,
105
+ text_len=len(text),
106
+ text_vaddr=text_vaddr,
107
+ arch_elf=arch_elf,
108
+ stripped=stripped,
109
+ functions=functions or [],
110
+ func_names=func_names or [],
111
+ )
112
+ self._text.write(text)
113
+ self._index.write(json.dumps(asdict(rec), separators=(",", ":")) + "\n")
114
+ self._offset += len(text)
115
+ self._n += 1
116
+ for key in ("arch", "compiler", "opt", "extra", "obfuscation"):
117
+ val = labels.get(key)
118
+ if val is not None:
119
+ self._label_counts.setdefault(key, {})
120
+ self._label_counts[key][val] = self._label_counts[key].get(val, 0) + 1
121
+ return rec
122
+
123
+ @property
124
+ def num_binaries(self) -> int:
125
+ return self._n
126
+
127
+ @property
128
+ def num_bytes(self) -> int:
129
+ return self._offset
130
+
131
+ def close(self) -> None:
132
+ self._text.close()
133
+ self._index.close()
134
+ meta = {
135
+ "num_binaries": self._n,
136
+ "num_text_bytes": self._offset,
137
+ "label_counts": self._label_counts,
138
+ "build_params": self._build_params,
139
+ }
140
+ (self.dir / META_FILE).write_text(json.dumps(meta, indent=2) + "\n")
141
+
142
+ def __enter__(self) -> CorpusWriter:
143
+ return self
144
+
145
+ def __exit__(self, *exc) -> None:
146
+ self.close()
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # reading
151
+ # ---------------------------------------------------------------------------
152
+
153
+
154
+ @dataclass
155
+ class SequenceIndex:
156
+ """A set of fixed-length byte sequences cut out of a corpus.
157
+
158
+ Arrays are parallel and all the same length:
159
+
160
+ bid which binary each sequence came from
161
+ start absolute offset into ``text.u8``
162
+ length valid bytes (< seq_len only for the tail of a unit)
163
+ unit index into ``record.functions``, or -1 for a binary-level cut
164
+
165
+ ``unit`` is what makes function-level majority voting (§3.4) possible: it
166
+ identifies the entity a sequence votes for.
167
+ """
168
+
169
+ bid: np.ndarray
170
+ start: np.ndarray
171
+ length: np.ndarray
172
+ unit: np.ndarray
173
+ seq_len: int
174
+ level: str # "binary" or "function"
175
+
176
+ def __len__(self) -> int:
177
+ return int(self.bid.shape[0])
178
+
179
+ def group_ids(self) -> np.ndarray:
180
+ """Integer id of the entity each sequence votes for.
181
+
182
+ Binary level: one group per binary. Function level: one group per
183
+ (binary, function) pair.
184
+ """
185
+ if self.level == "binary":
186
+ keys = self.bid.astype(np.int64)
187
+ else:
188
+ keys = self.bid.astype(np.int64) * (2**24) + (self.unit.astype(np.int64) + 1)
189
+ # dense re-indexing keeps downstream bincount cheap
190
+ _, inverse = np.unique(keys, return_inverse=True)
191
+ return inverse.astype(np.int64)
192
+
193
+ def subset(self, mask: np.ndarray) -> SequenceIndex:
194
+ return SequenceIndex(
195
+ bid=self.bid[mask],
196
+ start=self.start[mask],
197
+ length=self.length[mask],
198
+ unit=self.unit[mask],
199
+ seq_len=self.seq_len,
200
+ level=self.level,
201
+ )
202
+
203
+
204
+ class Corpus:
205
+ """Read-only view over a packed corpus directory."""
206
+
207
+ def __init__(self, path: str | os.PathLike):
208
+ self.dir = Path(path)
209
+ if not (self.dir / TEXT_FILE).exists():
210
+ raise FileNotFoundError(f"{self.dir} is not a corpus (no {TEXT_FILE})")
211
+ self.meta = json.loads((self.dir / META_FILE).read_text())
212
+ self.records: list[BinaryRecord] = []
213
+ with gzip.open(self.dir / INDEX_FILE, "rt") as fh:
214
+ for line in fh:
215
+ self.records.append(BinaryRecord(**json.loads(line)))
216
+ # np.memmap keeps the whole thing out of RAM; the OS page cache does the
217
+ # right thing for the sequential-ish access pattern of training.
218
+ self.text = np.memmap(self.dir / TEXT_FILE, dtype=np.uint8, mode="r")
219
+
220
+ def __len__(self) -> int:
221
+ return len(self.records)
222
+
223
+ def __repr__(self) -> str:
224
+ mb = self.text.shape[0] / 1e6
225
+ return f"<Corpus {self.dir.name}: {len(self.records)} binaries, {mb:.1f} MB .text>"
226
+
227
+ # -- selection ---------------------------------------------------------
228
+
229
+ def filter_bids(self, **constraints) -> list[int]:
230
+ """Binary ids whose labels match every constraint.
231
+
232
+ A constraint value may be a scalar or a collection; ``None`` means "no
233
+ constraint". Example::
234
+
235
+ corpus.filter_bids(arch="x86_64", extra="normal", opt=("O2", "O3"))
236
+ """
237
+ out = []
238
+ for rec in self.records:
239
+ ok = True
240
+ for key, want in constraints.items():
241
+ if want is None:
242
+ continue
243
+ got = rec.labels.get(key)
244
+ if isinstance(want, (list, tuple, set, frozenset)):
245
+ if got not in want:
246
+ ok = False
247
+ break
248
+ elif got != want:
249
+ ok = False
250
+ break
251
+ if ok:
252
+ out.append(rec.bid)
253
+ return out
254
+
255
+ # -- sequence cutting --------------------------------------------------
256
+
257
+ def sequences(
258
+ self,
259
+ *,
260
+ seq_len: int = 512,
261
+ stride: int | None = None,
262
+ level: str = "binary",
263
+ bids: list[int] | None = None,
264
+ min_bytes: int = 16,
265
+ max_seqs_per_binary: int | None = None,
266
+ function_context: bool = False,
267
+ rng: random.Random | None = None,
268
+ ) -> SequenceIndex:
269
+ """Cut the corpus into fixed-length byte sequences.
270
+
271
+ Args:
272
+ seq_len: bytes per sequence (paper uses 512, a multiple of 8 so that
273
+ fixed-width ARM/MIPS instructions stay aligned).
274
+ stride: step between sequence starts. Defaults to ``seq_len``
275
+ (non-overlapping, as in §3.1). A smaller stride is used for
276
+ function-level evaluation, where most functions are shorter than
277
+ one sequence and non-overlapping cuts would give nothing to vote
278
+ over — see docs/REPRODUCTION.md.
279
+ level: ``"binary"`` cuts the whole ``.text``; ``"function"`` cuts
280
+ each function separately so no sequence straddles a boundary.
281
+ min_bytes: drop trailing fragments shorter than this.
282
+ max_seqs_per_binary: uniformly subsample when a binary is huge.
283
+ Keeps a corpus-wide cap on training cost without biasing toward
284
+ long binaries.
285
+ function_context: at ``level="function"``, give a function shorter
286
+ than ``seq_len`` a full-length window centred on it rather than
287
+ its own bytes padded. See the comment at the use site: most
288
+ functions are far shorter than one sequence, so without this the
289
+ function level measures a harder input distribution than the
290
+ sequence level rather than a voted one.
291
+ """
292
+ if seq_len <= 0:
293
+ raise ValueError("seq_len must be positive")
294
+ step = stride or seq_len
295
+ if step <= 0:
296
+ raise ValueError("stride must be positive")
297
+ if level not in ("binary", "function"):
298
+ raise ValueError(f"level must be 'binary' or 'function', got {level!r}")
299
+
300
+ wanted = self.records if bids is None else [self.records[b] for b in bids]
301
+ rng = rng or random.Random(0)
302
+ b_out: list[int] = []
303
+ s_out: list[int] = []
304
+ l_out: list[int] = []
305
+ u_out: list[int] = []
306
+
307
+ for rec in wanted:
308
+ if level == "binary":
309
+ spans = [(-1, 0, rec.text_len)]
310
+ else:
311
+ spans = [(i, off, size) for i, (off, size) in enumerate(rec.functions)]
312
+
313
+ local: list[tuple[int, int, int]] = [] # (unit, start_abs, length)
314
+ for unit, span_off, span_len in spans:
315
+ if function_context and unit >= 0 and span_len < seq_len:
316
+ # A short function padded to seq_len sees far less context
317
+ # than an ordinary sequence, which makes it *harder*, not
318
+ # easier -- and in BinKit 81.5% of functions are shorter than
319
+ # 512 bytes (median 127), so this is the common case. Centre a
320
+ # full-length window on the function instead, borrowing
321
+ # neighbouring bytes and clamping to the section. The label is
322
+ # still the function's, and provenance is a property of the
323
+ # whole binary, so the borrowed bytes carry the same label.
324
+ centre = span_off + span_len // 2
325
+ start = max(0, min(centre - seq_len // 2, rec.text_len - seq_len))
326
+ n = min(seq_len, rec.text_len)
327
+ local.append((unit, rec.text_off + max(0, start), n))
328
+ continue
329
+ pos = 0
330
+ while pos < span_len:
331
+ n = min(seq_len, span_len - pos)
332
+ # keep a short unit whole (a 40-byte helper function is a
333
+ # legitimate sample), but drop a mere tail fragment
334
+ if n < min_bytes and pos > 0:
335
+ break
336
+ local.append((unit, rec.text_off + span_off + pos, n))
337
+ if n < seq_len:
338
+ break
339
+ pos += step
340
+ if max_seqs_per_binary is not None and len(local) > max_seqs_per_binary:
341
+ local = rng.sample(local, max_seqs_per_binary)
342
+ local.sort(key=lambda t: t[1])
343
+ for unit, start, n in local:
344
+ b_out.append(rec.bid)
345
+ s_out.append(start)
346
+ l_out.append(n)
347
+ u_out.append(unit)
348
+
349
+ return SequenceIndex(
350
+ bid=np.asarray(b_out, dtype=np.int32),
351
+ start=np.asarray(s_out, dtype=np.int64),
352
+ length=np.asarray(l_out, dtype=np.int32),
353
+ unit=np.asarray(u_out, dtype=np.int32),
354
+ seq_len=seq_len,
355
+ level=level,
356
+ )
357
+
358
+ # -- splits ------------------------------------------------------------
359
+
360
+ def make_splits(
361
+ self,
362
+ *,
363
+ train_ratio: float = 0.8,
364
+ seed: int = 1234,
365
+ group_by: str = "program",
366
+ bids: list[int] | None = None,
367
+ ) -> dict[str, list[int]]:
368
+ """Split binary ids into train/test.
369
+
370
+ The paper splits 8:2 and states that train and test must not overlap so
371
+ that generalization is measured honestly (§4.1). ``group_by="program"``
372
+ goes one step further: every compiled variant of a program (all
373
+ compilers, all optimization levels) lands on the same side, so the model
374
+ cannot recognise a test program it has already seen at another
375
+ optimization level. Use ``group_by="binary"`` for the looser
376
+ binary-level split.
377
+ """
378
+ pool = self.records if bids is None else [self.records[b] for b in bids]
379
+ rng = random.Random(seed)
380
+ if group_by == "binary":
381
+ keys = [str(r.bid) for r in pool]
382
+ elif group_by == "program":
383
+ keys = [r.group for r in pool]
384
+ elif group_by == "package":
385
+ keys = [r.package for r in pool]
386
+ else:
387
+ raise ValueError(f"unknown group_by {group_by!r}")
388
+
389
+ uniq = sorted(set(keys))
390
+ rng.shuffle(uniq)
391
+ n_train = int(round(len(uniq) * train_ratio))
392
+ train_keys = set(uniq[:n_train])
393
+ train, test = [], []
394
+ for rec, key in zip(pool, keys):
395
+ (train if key in train_keys else test).append(rec.bid)
396
+ return {"train": train, "test": test}
397
+
398
+ def pretrain_bids(
399
+ self,
400
+ *,
401
+ per_package: int = 1,
402
+ restrict_to: list[int] | None = None,
403
+ seed: int = 1234,
404
+ ) -> list[int]:
405
+ """Select the MLM pre-training set (paper §4.1).
406
+
407
+ "We first construct the pre-training set by selecting at least one
408
+ binary (2 x 4 variants) from each software project." So: pick
409
+ ``per_package`` programs per package and take *all* their compiled
410
+ variants, giving broad coverage of projects at modest size.
411
+
412
+ ``restrict_to`` should normally be the training split — pre-training on
413
+ binaries that later appear in the test set would leak, even though the
414
+ MLM task has no labels.
415
+ """
416
+ allowed = None if restrict_to is None else set(restrict_to)
417
+ by_pkg: dict[str, dict[str, list[int]]] = {}
418
+ for rec in self.records:
419
+ if allowed is not None and rec.bid not in allowed:
420
+ continue
421
+ by_pkg.setdefault(rec.package, {}).setdefault(rec.program, []).append(rec.bid)
422
+
423
+ rng = random.Random(seed)
424
+ out: list[int] = []
425
+ for pkg in sorted(by_pkg):
426
+ programs = sorted(by_pkg[pkg])
427
+ rng.shuffle(programs)
428
+ for prog in programs[:per_package]:
429
+ out.extend(by_pkg[pkg][prog])
430
+ return sorted(out)
431
+
432
+ def save_splits(self, name: str, splits: dict[str, list[int]]) -> Path:
433
+ d = self.dir / SPLIT_DIR
434
+ d.mkdir(exist_ok=True)
435
+ path = d / f"{name}.json"
436
+ path.write_text(json.dumps({k: sorted(v) for k, v in splits.items()}, indent=1))
437
+ return path
438
+
439
+ def load_splits(self, name: str) -> dict[str, list[int]]:
440
+ path = self.dir / SPLIT_DIR / f"{name}.json"
441
+ if not path.exists():
442
+ raise FileNotFoundError(
443
+ f"no split {name!r} in {self.dir}; create one with scripts/build_corpus.py"
444
+ )
445
+ return json.loads(path.read_text())
binprov/data.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Torch datasets and collators over a packed corpus.
2
+
3
+ The dataset returns raw byte slices; tokenization and MLM masking happen in the
4
+ collator, vectorised over the batch. That keeps DataLoader workers doing almost
5
+ nothing but memory-mapped reads, and it makes the masking *dynamic*: a fresh
6
+ random mask every time a sequence is drawn, which is what the paper means by
7
+ "these masked bytes are different at each epoch" (§4.1).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+ import torch
14
+ from torch.utils.data import Dataset
15
+
16
+ from .corpus import Corpus, SequenceIndex
17
+ from .provenance import Task
18
+ from .vocab import BYTE_OFFSET, EOS_ID, BOS_ID, MASK_ID, PAD_ID, VOCAB_SIZE
19
+
20
+ IGNORE_INDEX = -100
21
+
22
+
23
+ def sequence_labels(corpus: Corpus, index: SequenceIndex, task: Task) -> np.ndarray:
24
+ """Per-sequence class index, ``-1`` where the binary is out of task scope."""
25
+ per_bid = np.full(len(corpus.records), -1, dtype=np.int64)
26
+ for rec in corpus.records:
27
+ lab = task.label_of(rec.labels)
28
+ if lab is not None:
29
+ per_bid[rec.bid] = lab
30
+ return per_bid[index.bid]
31
+
32
+
33
+ def drop_unlabelled(index: SequenceIndex, labels: np.ndarray) -> tuple[SequenceIndex, np.ndarray]:
34
+ """Filter out sequences whose binary the task does not cover.
35
+
36
+ Needed for the O0/O1 and O2/O3 tasks of Table 4, which each use half the
37
+ optimization levels.
38
+ """
39
+ keep = labels >= 0
40
+ return index.subset(keep), labels[keep]
41
+
42
+
43
+ class ByteSequenceDataset(Dataset):
44
+ """Fixed-length byte sequences cut from a corpus.
45
+
46
+ Yields ``(bytes, label, position)`` where ``position`` is the sequence's
47
+ index in the :class:`SequenceIndex` — evaluation needs it to map predictions
48
+ back to the voting group.
49
+ """
50
+
51
+ def __init__(self, corpus: Corpus, index: SequenceIndex, labels: np.ndarray | None = None):
52
+ if labels is not None and len(labels) != len(index):
53
+ raise ValueError(f"labels ({len(labels)}) and index ({len(index)}) disagree")
54
+ self.text = corpus.text
55
+ self.index = index
56
+ self.labels = labels
57
+
58
+ def __len__(self) -> int:
59
+ return len(self.index)
60
+
61
+ def __getitem__(self, i: int):
62
+ start = int(self.index.start[i])
63
+ n = int(self.index.length[i])
64
+ chunk = np.asarray(self.text[start : start + n], dtype=np.uint8)
65
+ label = -1 if self.labels is None else int(self.labels[i])
66
+ return chunk, label, i
67
+
68
+
69
+
70
+ class JitteredByteSequenceDataset(ByteSequenceDataset):
71
+ """Training sequences whose window start is re-drawn every time they are read.
72
+
73
+ Motivation: a non-overlapping cut (§3.1) gives a *fixed* set of windows, so
74
+ an N-epoch fine-tune shows the model the identical byte strings N times. The
75
+ corpus holds 211 MB of ``.text`` but only 315,710 distinct 512-byte windows,
76
+ and where a window happens to begin is an artefact of the cut, not a
77
+ property of the code. Re-drawing the start turns those 315,710 samples into
78
+ a continuum, and it also stops the model keying on "byte 0 of a window is
79
+ usually an instruction boundary" -- which is true of the training cut and
80
+ only accidentally true at deployment.
81
+
82
+ ``jitter`` is the maximum shift in bytes. ``jitter < 0`` means "start
83
+ anywhere in this binary's ``.text``", the strongest form. The window is
84
+ always clamped to stay inside the binary it came from, so a jittered sample
85
+ never mixes two binaries and its label stays correct.
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ corpus: Corpus,
91
+ index: SequenceIndex,
92
+ labels: np.ndarray | None = None,
93
+ *,
94
+ jitter: int = 256,
95
+ seed: int = 0,
96
+ ):
97
+ super().__init__(corpus, index, labels)
98
+ self.jitter = jitter
99
+ self._rng = np.random.default_rng(seed)
100
+ # Per-sequence bounds on the window start, so the clamp needs no lookup
101
+ # into corpus.records at __getitem__ time (which runs in a worker).
102
+ off = np.asarray([r.text_off for r in corpus.records], dtype=np.int64)
103
+ ln = np.asarray([r.text_len for r in corpus.records], dtype=np.int64)
104
+ bid = index.bid.astype(np.int64)
105
+ self._lo = off[bid]
106
+ self._hi = np.maximum(off[bid], off[bid] + ln[bid] - index.length.astype(np.int64))
107
+
108
+ def __getitem__(self, i: int):
109
+ start = int(self.index.start[i])
110
+ n = int(self.index.length[i])
111
+ lo, hi = int(self._lo[i]), int(self._hi[i])
112
+ if self.jitter < 0:
113
+ start = int(self._rng.integers(lo, hi + 1)) if hi > lo else lo
114
+ elif self.jitter > 0:
115
+ shift = int(self._rng.integers(-self.jitter, self.jitter + 1))
116
+ start = min(max(start + shift, lo), hi)
117
+ chunk = np.asarray(self.text[start : start + n], dtype=np.uint8)
118
+ label = -1 if self.labels is None else int(self.labels[i])
119
+ return chunk, label, i
120
+
121
+
122
+
123
+ class ContextWindowDataset(ByteSequenceDataset):
124
+ """The paper's window set, each window widened to ``length`` centred bytes.
125
+
126
+ This exists to make a longer-input model *comparable*. Training on 2048-byte
127
+ sequences changes the unit being classified, so per-sequence accuracy at 2048
128
+ bytes and at 512 bytes are different metrics and must not be put in the same
129
+ column. Keeping the 512-byte index and only widening what the model reads
130
+ around each window fixes that: the row count, the labels and the voting
131
+ groups are identical to the baseline's, and the only thing that changed is
132
+ how much context the prediction had.
133
+
134
+ It is the same manoeuvre as ``--function-context``, and it carries the same
135
+ caveat: the widened window contains bytes the 512-byte window does not, so
136
+ this is not the paper's sequence level. Provenance is a property of the whole
137
+ binary, so those bytes share the label and this is not label leakage — but it
138
+ is a different, clearly-labelled operating point.
139
+ """
140
+
141
+ def __init__(self, corpus: Corpus, index: SequenceIndex, labels=None, *, length: int = 2048):
142
+ super().__init__(corpus, index, labels)
143
+ self.length = length
144
+ off = np.asarray([r.text_off for r in corpus.records], dtype=np.int64)
145
+ ln = np.asarray([r.text_len for r in corpus.records], dtype=np.int64)
146
+ bid = index.bid.astype(np.int64)
147
+ self._lo = off[bid]
148
+ self._hi = off[bid] + ln[bid]
149
+
150
+ def __getitem__(self, i: int):
151
+ start = int(self.index.start[i])
152
+ n = int(self.index.length[i])
153
+ lo, hi = int(self._lo[i]), int(self._hi[i])
154
+ want = min(self.length, hi - lo)
155
+ centre = start + n // 2
156
+ s = min(max(centre - want // 2, lo), max(lo, hi - want))
157
+ chunk = np.asarray(self.text[s : s + want], dtype=np.uint8)
158
+ label = -1 if self.labels is None else int(self.labels[i])
159
+ return chunk, label, i
160
+
161
+ class MultiViewByteSequenceDataset(ByteSequenceDataset):
162
+ """One sequence, several views of it — the evaluation side of jitter.
163
+
164
+ Item ``i`` of this dataset is view ``i % n_views`` of sequence
165
+ ``i // n_views``, so a plain non-shuffled pass yields every view of every
166
+ sequence and the caller averages the ``n_views`` probability rows back down.
167
+ The ``positions`` field still carries ``i``, so :func:`engine.predict`
168
+ scatters correctly without knowing anything about views.
169
+
170
+ Two modes, kept separate because they differ in what information the
171
+ prediction is allowed to use:
172
+
173
+ ``crop``
174
+ Views are sub-windows *inside* the sequence's own bytes (length
175
+ ``seq_len - k*shift`` for view ``k``). Nothing outside the 512 bytes is
176
+ read, so an averaged prediction is still a sequence-level prediction and
177
+ is comparable to the paper's number.
178
+
179
+ ``shift``
180
+ Views slide the full-length window off the sequence's start by up to
181
+ ``span`` bytes, clamped to the binary. This reads bytes the sequence does
182
+ not contain, so it is *not* the paper's sequence level -- report it as
183
+ its own row, the way ``--function-context`` is.
184
+ """
185
+
186
+ def __init__(
187
+ self,
188
+ corpus: Corpus,
189
+ index: SequenceIndex,
190
+ labels: np.ndarray | None = None,
191
+ *,
192
+ mode: str = "crop",
193
+ n_views: int = 4,
194
+ span: int = 128,
195
+ ):
196
+ super().__init__(corpus, index, labels)
197
+ if mode not in ("crop", "shift"):
198
+ raise ValueError(f"mode must be 'crop' or 'shift', got {mode!r}")
199
+ if n_views < 1:
200
+ raise ValueError("n_views must be >= 1")
201
+ self.mode = mode
202
+ self.n_views = n_views
203
+ self.span = span
204
+ off = np.asarray([r.text_off for r in corpus.records], dtype=np.int64)
205
+ ln = np.asarray([r.text_len for r in corpus.records], dtype=np.int64)
206
+ bid = index.bid.astype(np.int64)
207
+ self._lo = off[bid]
208
+ self._hi = np.maximum(off[bid], off[bid] + ln[bid] - index.length.astype(np.int64))
209
+
210
+ def __len__(self) -> int:
211
+ return len(self.index) * self.n_views
212
+
213
+ def _offsets(self) -> list[int]:
214
+ """Signed shifts for the views, symmetric around 0 and including 0."""
215
+ if self.n_views == 1:
216
+ return [0]
217
+ half = self.n_views // 2
218
+ step = max(1, self.span // max(1, half))
219
+ out = [0]
220
+ for k in range(1, half + 1):
221
+ out.append(-k * step)
222
+ if len(out) < self.n_views:
223
+ out.append(k * step)
224
+ return out[: self.n_views]
225
+
226
+ def __getitem__(self, j: int):
227
+ i, view = divmod(j, self.n_views)
228
+ start = int(self.index.start[i])
229
+ n = int(self.index.length[i])
230
+ delta = self._offsets()[view]
231
+ if self.mode == "crop":
232
+ # keep the window inside its own bytes: trim from one end
233
+ trim = abs(delta)
234
+ if delta >= 0:
235
+ start, n = start + trim, max(16, n - trim)
236
+ else:
237
+ n = max(16, n - trim)
238
+ else:
239
+ start = min(max(start + delta, int(self._lo[i])), int(self._hi[i]))
240
+ chunk = np.asarray(self.text[start : start + n], dtype=np.uint8)
241
+ label = -1 if self.labels is None else int(self.labels[i])
242
+ return chunk, label, j
243
+
244
+ class PairedByteSequenceDataset(ByteSequenceDataset):
245
+ """Optionally splices two half-sequences from different binaries.
246
+
247
+ The paper's segment embedding ``E_s`` exists to mark "which binary program
248
+ each byte belongs to, when a byte sequence contains multiple fragments from
249
+ different programs" (§3.2). With plain single-binary sequences that embedding
250
+ never varies and stays dead weight. Setting ``pair_prob > 0`` during
251
+ pre-training makes it meaningful. Off by default, since the paper does not
252
+ say it used spliced inputs.
253
+ """
254
+
255
+ def __init__(self, corpus: Corpus, index: SequenceIndex, *, pair_prob: float = 0.0, seed: int = 0):
256
+ super().__init__(corpus, index, None)
257
+ self.pair_prob = pair_prob
258
+ self._rng = np.random.default_rng(seed)
259
+
260
+ def __getitem__(self, i: int):
261
+ chunk, _, _ = super().__getitem__(i)
262
+ if self.pair_prob <= 0 or self._rng.random() >= self.pair_prob or len(chunk) < 16:
263
+ return chunk, -1, i
264
+ j = int(self._rng.integers(0, len(self.index)))
265
+ other, _, _ = super().__getitem__(j)
266
+ if self.index.bid[j] == self.index.bid[i] or len(other) < 16:
267
+ return chunk, -1, i
268
+ cut = len(chunk) // 2
269
+ spliced = np.concatenate([chunk[:cut], other[: len(chunk) - cut]])
270
+ # negative position marks "segment boundary at `cut`" for the collator
271
+ return spliced, -(cut + 1), i
272
+
273
+
274
+ def _tokenize_batch(batch, seq_tokens: int):
275
+ """Build ``(input_ids, attention_mask, token_type_ids)`` for a batch.
276
+
277
+ Layout per row: ``<s> b0 b1 ... bn-1 </s> <pad> ...``
278
+ """
279
+ bsz = len(batch)
280
+ ids = np.full((bsz, seq_tokens), PAD_ID, dtype=np.int64)
281
+ attn = np.zeros((bsz, seq_tokens), dtype=np.int64)
282
+ types = np.zeros((bsz, seq_tokens), dtype=np.int64)
283
+
284
+ max_bytes = seq_tokens - 2
285
+ for row, (chunk, label, _pos) in enumerate(batch):
286
+ n = min(len(chunk), max_bytes)
287
+ ids[row, 0] = BOS_ID
288
+ ids[row, 1 : 1 + n] = chunk[:n].astype(np.int64) + BYTE_OFFSET
289
+ ids[row, 1 + n] = EOS_ID
290
+ attn[row, : n + 2] = 1
291
+ if label is not None and label < -1: # spliced pair, see dataset above
292
+ cut = -label - 1
293
+ types[row, 1 + cut : 1 + n] = 1
294
+ return ids, attn, types
295
+
296
+
297
+ class MLMCollator:
298
+ """Masked-language-model batches (paper §3.2 / §4.1).
299
+
300
+ Masking follows Pei et al. as the paper states: 20% of bytes are chosen; of
301
+ those, 50% become ``<mask>`` and 50% become a random byte value. Note there
302
+ is no BERT-style "keep original 10%" bucket here — that is the paper's
303
+ setting, not an omission.
304
+ """
305
+
306
+ def __init__(
307
+ self,
308
+ seq_tokens: int,
309
+ *,
310
+ mask_prob: float = 0.20,
311
+ mask_replace: float = 0.5,
312
+ random_replace: float = 0.5,
313
+ seed: int | None = None,
314
+ ):
315
+ if mask_replace + random_replace > 1.0 + 1e-9:
316
+ raise ValueError("mask_replace + random_replace must not exceed 1")
317
+ self.seq_tokens = seq_tokens
318
+ self.mask_prob = mask_prob
319
+ self.mask_replace = mask_replace
320
+ self.random_replace = random_replace
321
+ self.rng = np.random.default_rng(seed)
322
+
323
+ def __call__(self, batch):
324
+ ids, attn, types = _tokenize_batch(batch, self.seq_tokens)
325
+
326
+ # only real byte tokens are maskable: never <s>, </s> or padding
327
+ maskable = (ids >= BYTE_OFFSET) & (attn == 1)
328
+ selected = maskable & (self.rng.random(ids.shape) < self.mask_prob)
329
+
330
+ labels = np.full(ids.shape, IGNORE_INDEX, dtype=np.int64)
331
+ labels[selected] = ids[selected]
332
+
333
+ draw = self.rng.random(ids.shape)
334
+ to_mask = selected & (draw < self.mask_replace)
335
+ to_random = selected & (draw >= self.mask_replace) & (
336
+ draw < self.mask_replace + self.random_replace
337
+ )
338
+ ids[to_mask] = MASK_ID
339
+ n_rand = int(to_random.sum())
340
+ if n_rand:
341
+ ids[to_random] = self.rng.integers(BYTE_OFFSET, VOCAB_SIZE, size=n_rand)
342
+
343
+ return {
344
+ "input_ids": torch.from_numpy(ids),
345
+ "attention_mask": torch.from_numpy(attn),
346
+ "token_type_ids": torch.from_numpy(types),
347
+ "labels": torch.from_numpy(labels),
348
+ }
349
+
350
+
351
+ class ClassificationCollator:
352
+ """Batches for fine-tuning and evaluation."""
353
+
354
+ def __init__(self, seq_tokens: int):
355
+ self.seq_tokens = seq_tokens
356
+
357
+ def __call__(self, batch):
358
+ ids, attn, types = _tokenize_batch(batch, self.seq_tokens)
359
+ labels = np.asarray([max(b[1], 0) for b in batch], dtype=np.int64)
360
+ positions = np.asarray([b[2] for b in batch], dtype=np.int64)
361
+ return {
362
+ "input_ids": torch.from_numpy(ids),
363
+ "attention_mask": torch.from_numpy(attn),
364
+ "token_type_ids": torch.from_numpy(types),
365
+ "labels": torch.from_numpy(labels),
366
+ "positions": torch.from_numpy(positions),
367
+ }
binprov/discover.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Find labelled binaries in a directory tree.
2
+
3
+ Two layouts are handled, because the two data paths differ:
4
+
5
+ **BinKit** puts the whole toolchain in the *filename*, two levels deep::
6
+
7
+ gnu_debug/a2ps/a2ps-4.14_clang-7.0_x86_64_O2_a2ps.elf
8
+ | | | | | `-- program
9
+ | | | | `-- optimization level
10
+ | | | `-- architecture
11
+ | | `-- compiler and version
12
+ | `-- package and version
13
+ `-- package directory
14
+
15
+ **scripts/build_local_dataset.py** puts it in a directory instead::
16
+
17
+ <root>/<package>/<arch>-<compiler>-<version>-<opt>/<program>
18
+
19
+ Labels come from :func:`binprov.provenance.parse_labels`, which scans every path
20
+ component for recognisable tokens and so copes with both. Verified against all
21
+ 67,680 paths of BinKit Normal: architecture, compiler, compiler version and
22
+ optimization level are recovered correctly for every one.
23
+
24
+ The *program name* needs more care than the labels do. Under the BinKit layout a
25
+ naive ``path.name`` makes each compiled variant look like a different program,
26
+ which would silently defeat the program-grouped train/test split — the same
27
+ binary at O2 could train while its O3 twin is tested. :func:`program_name`
28
+ therefore parses the filename grammar to recover the real program.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import re
35
+ from collections import Counter
36
+ from dataclasses import dataclass
37
+ from pathlib import Path
38
+
39
+ from . import elf
40
+ from .provenance import Labels, parse_labels, toolchain_key
41
+
42
+ #: never worth parsing — build artefacts, sources, docs
43
+ SKIP_SUFFIXES = frozenset(
44
+ """.c .h .cc .cpp .hpp .py .sh .m4 .am .in .ac .md .txt .log .json .yaml .yml
45
+ .gz .xz .bz2 .zip .tar .patch .diff .po .pot .1 .3 .html .css .js .png .jpg
46
+ .o .lo .la .a .cmake .pc .mk .d .gmo .info .texi""".split()
47
+ )
48
+
49
+
50
+ @dataclass
51
+ class Candidate:
52
+ """A file on disk that looks like a labelled binary."""
53
+
54
+ path: Path
55
+ package: str
56
+ program: str
57
+ labels: Labels
58
+
59
+ @property
60
+ def toolchain(self) -> str:
61
+ return toolchain_key(self.labels.as_dict())
62
+
63
+
64
+ def _looks_like_source_tree(name: str) -> bool:
65
+ return name in {".git", ".svn", "autom4te.cache", "__pycache__", "node_modules"}
66
+
67
+
68
+ # BinKit's filename grammar:
69
+ # {package}-{version}_{compiler}-{cver}_{arch}_{width}_{opt}_{program}
70
+ # The program is the trailing group, so names containing underscores survive
71
+ # (gdbm_dump, gdbm_load are the two real cases in BinKit Normal).
72
+ _BINKIT_STEM = re.compile(
73
+ r"^(?P<pkgver>.+?)"
74
+ r"_(?P<compiler>(?:gcc|clang|g\+\+|clang\+\+)-[0-9][0-9.]*)"
75
+ r"_(?P<arch>(?:x86|x64|i386|arm|aarch64|mips|mipseb|mipsel|ppc|riscv)_(?:32|64))"
76
+ r"_(?P<opt>O[0-3s]|Ofast)"
77
+ r"_(?P<program>.+)$"
78
+ )
79
+
80
+
81
+ def program_name(path: Path) -> str:
82
+ """The program a binary was built from, stripped of provenance tokens.
83
+
84
+ Returns the trailing program field for a BinKit-style filename, and the plain
85
+ filename otherwise (the directory-based layout, where the filename already
86
+ *is* the program). This is the grouping key for leak-free splits, so getting
87
+ it wrong does not fail loudly — it just quietly inflates accuracy.
88
+ """
89
+ stem = path.name
90
+ for suffix in (".elf", ".exe", ".so"):
91
+ if stem.endswith(suffix):
92
+ stem = stem[: -len(suffix)]
93
+ break
94
+ m = _BINKIT_STEM.match(stem)
95
+ return m.group("program") if m else path.name
96
+
97
+
98
+ def walk(root: str | os.PathLike, *, follow_symlinks: bool = False):
99
+ """Yield paths of ELF files under ``root``, cheaply.
100
+
101
+ Filters by extension first and reads only 4 bytes to confirm the ELF magic,
102
+ so walking a build tree full of objects and sources stays fast.
103
+ """
104
+ root = Path(root)
105
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=follow_symlinks):
106
+ dirnames[:] = [d for d in dirnames if not _looks_like_source_tree(d)]
107
+ for fn in filenames:
108
+ p = Path(dirpath) / fn
109
+ if p.suffix.lower() in SKIP_SUFFIXES:
110
+ continue
111
+ if p.is_symlink() and not follow_symlinks:
112
+ continue
113
+ try:
114
+ if p.stat().st_size < 128:
115
+ continue
116
+ except OSError:
117
+ continue
118
+ if elf.is_elf(p):
119
+ yield p
120
+
121
+
122
+ def discover(
123
+ root: str | os.PathLike,
124
+ *,
125
+ arch=None,
126
+ compiler=None,
127
+ compiler_version=None,
128
+ opt=None,
129
+ extra=None,
130
+ obfuscation=None,
131
+ require_complete: bool = True,
132
+ per_toolchain_limit: int | None = None,
133
+ packages=None,
134
+ ):
135
+ """Yield :class:`Candidate` objects for binaries matching the filters.
136
+
137
+ Filters accept a scalar or a collection; ``None`` means "no constraint".
138
+ They are applied *before* the file is read, which is the whole point — the
139
+ paper uses 4 architectures x 2 compilers x 4 opt levels, i.e. 32 of BinKit
140
+ Normal's 288 toolchains, so filtering here avoids touching ~89% of the
141
+ dataset.
142
+
143
+ Args:
144
+ per_toolchain_limit: keep at most this many binaries per compilation
145
+ configuration. The cheapest way to bound corpus size while staying
146
+ balanced across configurations.
147
+ packages: restrict to these package names.
148
+ """
149
+ root = Path(root)
150
+ want = {
151
+ "arch": arch,
152
+ "compiler": compiler,
153
+ "compiler_version": compiler_version,
154
+ "opt": opt,
155
+ "extra": extra,
156
+ "obfuscation": obfuscation,
157
+ }
158
+ pkg_filter = None if packages is None else set(packages)
159
+ seen_per_toolchain: Counter[str] = Counter()
160
+
161
+ for path in walk(root):
162
+ rel = path.relative_to(root)
163
+ parts = rel.parts
164
+ package = parts[0] if len(parts) > 1 else "_root"
165
+ if pkg_filter is not None and package not in pkg_filter:
166
+ continue
167
+
168
+ labels = parse_labels(str(rel))
169
+ if require_complete and not labels.is_complete():
170
+ continue
171
+
172
+ d = labels.as_dict()
173
+ if not _matches(d, want):
174
+ continue
175
+
176
+ if per_toolchain_limit is not None:
177
+ key = toolchain_key(d)
178
+ if seen_per_toolchain[key] >= per_toolchain_limit:
179
+ continue
180
+ seen_per_toolchain[key] += 1
181
+
182
+ yield Candidate(
183
+ path=path, package=package, program=program_name(path), labels=labels
184
+ )
185
+
186
+
187
+ def _matches(got: dict, want: dict) -> bool:
188
+ for key, expected in want.items():
189
+ if expected is None:
190
+ continue
191
+ value = got.get(key)
192
+ if isinstance(expected, (list, tuple, set, frozenset)):
193
+ if value not in expected:
194
+ return False
195
+ elif value != expected:
196
+ return False
197
+ return True
198
+
199
+
200
+ def summarize(candidates) -> str:
201
+ """A short report of what discovery found, for ``--dry-run``."""
202
+ cands = list(candidates)
203
+ if not cands:
204
+ return "no binaries matched"
205
+ by_tc = Counter(c.toolchain for c in cands)
206
+ by_pkg = Counter(c.package for c in cands)
207
+ lines = [
208
+ f"{len(cands)} binaries, {len(by_pkg)} packages, {len(by_tc)} toolchains",
209
+ "",
210
+ "toolchains (count):",
211
+ ]
212
+ for tc, n in sorted(by_tc.items()):
213
+ lines.append(f" {n:7d} {tc}")
214
+ lines += ["", "sample paths:"]
215
+ for c in cands[:8]:
216
+ lines.append(f" {c.package} | {c.program} | {c.toolchain}")
217
+ return "\n".join(lines)
binprov/elf.py ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A small, dependency-free ELF reader.
2
+
3
+ BinProv's whole input is the ``.text`` section (paper §3.1), plus — only for the
4
+ function-level majority voting of §3.4 — the function boundaries recorded in the
5
+ symbol table. That is all this module extracts. It deliberately avoids
6
+ ``pyelftools``/``lief`` so that corpus building has no native dependencies and
7
+ so that the same code path handles every architecture in the paper
8
+ (x86_32, x86_64, ARM_64, MIPS_64), including big-endian MIPS.
9
+
10
+ Everything is parsed from an in-memory ``bytes``/``memoryview`` buffer.
11
+ Malformed or truncated files raise :class:`ElfError` rather than crashing, so a
12
+ corpus build can skip them and keep going.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import struct
18
+ from dataclasses import dataclass, field
19
+
20
+ ELF_MAGIC = b"\x7fELF"
21
+
22
+ # section header types we care about
23
+ SHT_SYMTAB = 2
24
+ SHT_DYNSYM = 11
25
+ SHT_NOBITS = 8
26
+
27
+ STT_FUNC = 2
28
+
29
+ # e_machine -> canonical architecture name. The width/endianness suffix is
30
+ # resolved from EI_CLASS / EI_DATA, because e.g. EM_MIPS covers 4 variants.
31
+ _MACHINE = {
32
+ 3: "x86",
33
+ 62: "x86", # EM_X86_64 (width comes from EI_CLASS)
34
+ 40: "arm",
35
+ 183: "arm", # EM_AARCH64
36
+ 8: "mips",
37
+ 10: "mips", # EM_MIPS_RS3_LE
38
+ 20: "ppc",
39
+ 21: "ppc",
40
+ 22: "s390",
41
+ 243: "riscv",
42
+ 258: "loongarch",
43
+ }
44
+
45
+
46
+ class ElfError(Exception):
47
+ """Raised for anything that is not a parseable ELF file."""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Function:
52
+ """A function boundary taken from the symbol table."""
53
+
54
+ name: str
55
+ offset: int # byte offset within the .text section
56
+ size: int # size in bytes
57
+
58
+
59
+ @dataclass
60
+ class TextSection:
61
+ """The extracted ``.text`` section and its metadata."""
62
+
63
+ data: bytes
64
+ vaddr: int # virtual address of .text[0]
65
+ file_offset: int
66
+ arch: str # e.g. "x86_64", "mips_64", "mipseb_64"
67
+ bits: int # 32 or 64
68
+ little_endian: bool
69
+ functions: list[Function] = field(default_factory=list)
70
+ comment: str = "" # contents of .comment, if present
71
+ stripped: bool = True # no SHT_SYMTAB present
72
+
73
+ def __len__(self) -> int:
74
+ return len(self.data)
75
+
76
+
77
+ def _u(buf: memoryview, fmt: str, off: int):
78
+ """Unpack ``fmt`` at ``off``, raising ElfError on truncation."""
79
+ size = struct.calcsize(fmt)
80
+ if off < 0 or off + size > len(buf):
81
+ raise ElfError(f"truncated file: want {size} bytes at {off}, have {len(buf)}")
82
+ return struct.unpack_from(fmt, buf, off)
83
+
84
+
85
+ def _cstr(buf: memoryview, off: int) -> str:
86
+ """Read a NUL-terminated string; tolerates a missing terminator."""
87
+ if not 0 <= off < len(buf):
88
+ return ""
89
+ end = bytes(buf[off:]).find(b"\x00")
90
+ raw = bytes(buf[off:] if end < 0 else buf[off : off + end])
91
+ return raw.decode("utf-8", "replace")
92
+
93
+
94
+ def _arch_name(machine: int, bits: int, little_endian: bool) -> str:
95
+ base = _MACHINE.get(machine, f"machine{machine}")
96
+ # BinKit names its big-endian MIPS targets "mipseb"; mirror that so labels
97
+ # from paths and from ELF headers agree.
98
+ if base == "mips" and not little_endian:
99
+ base = "mipseb"
100
+ return f"{base}_{bits}"
101
+
102
+
103
+ def parse(buf: bytes, *, want_functions: bool = True) -> TextSection:
104
+ """Extract ``.text`` (and optionally function boundaries) from an ELF image.
105
+
106
+ Args:
107
+ buf: the whole file contents.
108
+ want_functions: parse the symbol table for function boundaries. Skip it
109
+ when only doing binary-level work — it saves a scan over the
110
+ symbol table, which in a debug build can be larger than ``.text``.
111
+
112
+ Raises:
113
+ ElfError: not an ELF, unsupported class, or no ``.text`` section.
114
+ """
115
+ mv = memoryview(buf)
116
+ if len(mv) < 64 or bytes(mv[:4]) != ELF_MAGIC:
117
+ raise ElfError("not an ELF file")
118
+
119
+ ei_class, ei_data = mv[4], mv[5]
120
+ if ei_class == 1:
121
+ bits = 32
122
+ elif ei_class == 2:
123
+ bits = 64
124
+ else:
125
+ raise ElfError(f"bad EI_CLASS {ei_class}")
126
+ if ei_data == 1:
127
+ little_endian = True
128
+ elif ei_data == 2:
129
+ little_endian = False
130
+ else:
131
+ raise ElfError(f"bad EI_DATA {ei_data}")
132
+
133
+ e = "<" if little_endian else ">"
134
+ (machine,) = _u(mv, e + "H", 18)
135
+
136
+ # --- ELF header: locate the section header table -----------------------
137
+ if bits == 64:
138
+ e_shoff, = _u(mv, e + "Q", 40)
139
+ e_shentsize, e_shnum, e_shstrndx = _u(mv, e + "HHH", 58)
140
+ else:
141
+ e_shoff, = _u(mv, e + "I", 32)
142
+ e_shentsize, e_shnum, e_shstrndx = _u(mv, e + "HHH", 46)
143
+
144
+ if e_shoff == 0 or e_shnum == 0:
145
+ raise ElfError("no section header table (stripped of sections?)")
146
+ if e_shentsize < (64 if bits == 64 else 40):
147
+ raise ElfError(f"implausible e_shentsize {e_shentsize}")
148
+
149
+ # --- section headers ---------------------------------------------------
150
+ # (name_off, type, addr, offset, size, link, entsize)
151
+ sections: list[tuple[int, int, int, int, int, int, int]] = []
152
+ for i in range(e_shnum):
153
+ base = e_shoff + i * e_shentsize
154
+ if bits == 64:
155
+ sh_name, sh_type = _u(mv, e + "II", base)
156
+ sh_addr, sh_offset, sh_size = _u(mv, e + "QQQ", base + 16)
157
+ sh_link, = _u(mv, e + "I", base + 40)
158
+ sh_entsize, = _u(mv, e + "Q", base + 56)
159
+ else:
160
+ sh_name, sh_type = _u(mv, e + "II", base)
161
+ sh_addr, sh_offset, sh_size = _u(mv, e + "III", base + 12)
162
+ sh_link, = _u(mv, e + "I", base + 24)
163
+ sh_entsize, = _u(mv, e + "I", base + 36)
164
+ sections.append((sh_name, sh_type, sh_addr, sh_offset, sh_size, sh_link, sh_entsize))
165
+
166
+ # section-name string table
167
+ shstr_off = 0
168
+ if 0 <= e_shstrndx < len(sections):
169
+ shstr_off = sections[e_shstrndx][3]
170
+
171
+ def sec_name(idx: int) -> str:
172
+ return _cstr(mv, shstr_off + sections[idx][0]) if shstr_off else ""
173
+
174
+ names = [sec_name(i) for i in range(len(sections))]
175
+
176
+ # --- .text -------------------------------------------------------------
177
+ try:
178
+ text_idx = names.index(".text")
179
+ except ValueError:
180
+ raise ElfError("no .text section") from None
181
+
182
+ _, t_type, t_addr, t_off, t_size, _, _ = sections[text_idx]
183
+ if t_type == SHT_NOBITS or t_size == 0:
184
+ raise ElfError(".text is empty")
185
+ if t_off + t_size > len(mv):
186
+ # Truncated download / corrupt file: take what is actually there.
187
+ t_size = max(0, len(mv) - t_off)
188
+ if t_size == 0:
189
+ raise ElfError(".text lies past end of file")
190
+ text = bytes(mv[t_off : t_off + t_size])
191
+
192
+ # --- .comment (compiler fingerprint, used to sanity-check labels) ------
193
+ comment = ""
194
+ if ".comment" in names:
195
+ _, _, _, c_off, c_size, _, _ = sections[names.index(".comment")]
196
+ if c_size and c_off + c_size <= len(mv):
197
+ comment = bytes(mv[c_off : c_off + c_size]).replace(b"\x00", b" ").decode(
198
+ "utf-8", "replace"
199
+ ).strip()
200
+
201
+ stripped = not any(s[1] == SHT_SYMTAB for s in sections)
202
+
203
+ result = TextSection(
204
+ data=text,
205
+ vaddr=t_addr,
206
+ file_offset=t_off,
207
+ arch=_arch_name(machine, bits, little_endian),
208
+ bits=bits,
209
+ little_endian=little_endian,
210
+ comment=comment,
211
+ stripped=stripped,
212
+ )
213
+ if want_functions:
214
+ result.functions = _read_functions(mv, e, bits, sections, text_idx, t_addr, t_size, machine)
215
+ return result
216
+
217
+
218
+ def _read_functions(
219
+ mv: memoryview,
220
+ e: str,
221
+ bits: int,
222
+ sections,
223
+ text_idx: int,
224
+ text_vaddr: int,
225
+ text_size: int,
226
+ machine: int,
227
+ ) -> list[Function]:
228
+ """Collect STT_FUNC symbols that live inside ``.text``.
229
+
230
+ Prefers SHT_SYMTAB; falls back to SHT_DYNSYM for stripped binaries (which
231
+ yields far fewer functions, but is better than nothing).
232
+ """
233
+ symtabs = [s for s in sections if s[1] == SHT_SYMTAB]
234
+ if not symtabs:
235
+ symtabs = [s for s in sections if s[1] == SHT_DYNSYM]
236
+ if not symtabs:
237
+ return []
238
+
239
+ sym_size = 24 if bits == 64 else 16
240
+ out: dict[int, Function] = {} # offset -> Function, de-duplicates aliases
241
+
242
+ for _, _, _, sh_offset, sh_size, sh_link, sh_entsize in symtabs:
243
+ entsize = sh_entsize or sym_size
244
+ if entsize < sym_size or sh_size == 0:
245
+ continue
246
+ # linked string table
247
+ strtab_off = sections[sh_link][3] if 0 <= sh_link < len(sections) else 0
248
+ n = sh_size // entsize
249
+ for i in range(n):
250
+ base = sh_offset + i * entsize
251
+ try:
252
+ if bits == 64:
253
+ st_name, = _u(mv, e + "I", base)
254
+ st_info = mv[base + 4]
255
+ st_shndx, = _u(mv, e + "H", base + 6)
256
+ st_value, st_sz = _u(mv, e + "QQ", base + 8)
257
+ else:
258
+ st_name, st_value, st_sz = _u(mv, e + "III", base)
259
+ st_info = mv[base + 12]
260
+ st_shndx, = _u(mv, e + "H", base + 14)
261
+ except ElfError:
262
+ break # truncated symbol table; keep what we have
263
+
264
+ if (st_info & 0xF) != STT_FUNC or st_sz == 0 or st_shndx != text_idx:
265
+ continue
266
+ # ARM/Thumb interworking sets bit 0 of st_value; MIPS16/microMIPS
267
+ # does the same. Clear it so the offset lands on a real byte.
268
+ if machine in (40, 8, 10):
269
+ st_value &= ~1
270
+ off = st_value - text_vaddr
271
+ if off < 0 or off >= text_size:
272
+ continue
273
+ size = min(st_sz, text_size - off)
274
+ if size <= 0 or off in out:
275
+ continue
276
+ out[off] = Function(name=_cstr(mv, strtab_off + st_name), offset=off, size=size)
277
+
278
+ return sorted(out.values(), key=lambda f: f.offset)
279
+
280
+
281
+ def guess_compiler(comment: str) -> tuple[str | None, str | None]:
282
+ """Best-effort ``(family, version)`` from a ``.comment`` string.
283
+
284
+ Used to cross-check labels derived from file paths — a cheap guard against a
285
+ mislabeled corpus. Returns ``(None, None)`` when nothing is recognisable.
286
+ Note that a binary linked from both GCC- and Clang-compiled objects can
287
+ carry both fingerprints; the first match wins.
288
+ """
289
+ import re
290
+
291
+ if not comment:
292
+ return None, None
293
+ low = comment.lower()
294
+ m = re.search(r"clang version ([0-9]+(?:\.[0-9]+)*)", low)
295
+ if m:
296
+ return "clang", m.group(1)
297
+ m = re.search(r"gcc[:\s(]+.*?([0-9]+\.[0-9]+\.[0-9]+)", low)
298
+ if m:
299
+ return "gcc", m.group(1)
300
+ if "clang" in low:
301
+ return "clang", None
302
+ if "gcc" in low or "gnu" in low:
303
+ return "gcc", None
304
+ return None, None
305
+
306
+
307
+ def is_elf(path) -> bool:
308
+ """Cheap check used to filter build trees before a full parse."""
309
+ try:
310
+ with open(path, "rb") as fh:
311
+ return fh.read(4) == ELF_MAGIC
312
+ except OSError:
313
+ return False
binprov/metrics.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Metrics and table formatting.
2
+
3
+ The paper reports accuracy as its headline number, because the dataset is
4
+ balanced across compilers and optimization levels (§4.2), and adds
5
+ precision/recall/F1 per optimization level to expose *where* the O2/O3
6
+ confusion sits (Table 5). Both are here, with no sklearn dependency so that
7
+ evaluation runs in any environment that can run the model.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+
14
+
15
+ def accuracy(pred: np.ndarray, true: np.ndarray) -> float:
16
+ if len(true) == 0:
17
+ return 0.0
18
+ return float((pred == true).mean())
19
+
20
+
21
+ def confusion_matrix(pred: np.ndarray, true: np.ndarray, num_classes: int) -> np.ndarray:
22
+ """Rows are ground truth, columns are predictions."""
23
+ flat = true.astype(np.int64) * num_classes + pred.astype(np.int64)
24
+ return np.bincount(flat, minlength=num_classes**2).reshape(num_classes, num_classes)
25
+
26
+
27
+ def per_class_prf(pred: np.ndarray, true: np.ndarray, num_classes: int) -> dict:
28
+ """Precision / recall / F1 per class, plus macro averages (Table 5)."""
29
+ cm = confusion_matrix(pred, true, num_classes)
30
+ tp = np.diag(cm).astype(np.float64)
31
+ predicted = cm.sum(axis=0).astype(np.float64)
32
+ actual = cm.sum(axis=1).astype(np.float64)
33
+ with np.errstate(divide="ignore", invalid="ignore"):
34
+ precision = np.where(predicted > 0, tp / predicted, 0.0)
35
+ recall = np.where(actual > 0, tp / actual, 0.0)
36
+ denom = precision + recall
37
+ f1 = np.where(denom > 0, 2 * precision * recall / denom, 0.0)
38
+ return {
39
+ "precision": precision,
40
+ "recall": recall,
41
+ "f1": f1,
42
+ "support": actual.astype(np.int64),
43
+ "macro_precision": float(precision.mean()),
44
+ "macro_recall": float(recall.mean()),
45
+ "macro_f1": float(f1.mean()),
46
+ "accuracy": float(tp.sum() / cm.sum()) if cm.sum() else 0.0,
47
+ "confusion": cm,
48
+ }
49
+
50
+
51
+ def pct(x: float) -> str:
52
+ return f"{100 * x:.2f}%"
53
+
54
+
55
+ def markdown_table(header: list[str], rows: list[list[str]]) -> str:
56
+ """Render a markdown table with columns padded to a readable width."""
57
+ widths = [len(h) for h in header]
58
+ for row in rows:
59
+ for i, cell in enumerate(row):
60
+ widths[i] = max(widths[i], len(str(cell)))
61
+ out = ["| " + " | ".join(h.ljust(widths[i]) for i, h in enumerate(header)) + " |"]
62
+ out.append("|" + "|".join("-" * (w + 2) for w in widths) + "|")
63
+ for row in rows:
64
+ out.append("| " + " | ".join(str(c).ljust(widths[i]) for i, c in enumerate(row)) + " |")
65
+ return "\n".join(out)
66
+
67
+
68
+ def confusion_as_markdown(cm: np.ndarray, classes) -> str:
69
+ header = ["true \\ pred", *classes]
70
+ rows = [[classes[i], *[str(int(v)) for v in cm[i]]] for i in range(len(classes))]
71
+ return markdown_table(header, rows)
binprov/model.py ADDED
@@ -0,0 +1,453 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BinProv's networks: a byte-level RoBERTa encoder and a provenance classifier.
2
+
3
+ Two pieces, matching the paper's two training phases:
4
+
5
+ * :func:`build_mlm_model` — the embedding model ``f_e`` with a softmax head,
6
+ pre-trained on masked language modeling (§3.2, Eq. 1). 12 transformer layers,
7
+ hidden size 768, which lands at ~85M trainable parameters — the paper's
8
+ "around 80 million".
9
+ * :class:`BinProvForProvenance` — ``f_c(f_e(...))``, the encoder plus the
10
+ two-layer fully connected classifier of §3.3, fine-tuned end-to-end.
11
+
12
+ The paper describes the classifier's first layer as one that "reshape[s] the
13
+ input vectors and weaken[s] the border weights of the embeddings", to cope with
14
+ x86's variable-length instructions being cut mid-instruction at a sequence
15
+ boundary. It does not give the mechanism. :class:`BorderTaperedPool` implements
16
+ it as a learned attention over token positions whose logits are *initialised*
17
+ with a taper at both ends: borders start down-weighted, and training is free to
18
+ change that. Ablate it with ``--pool mean`` to see what it buys.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import math
25
+ from dataclasses import dataclass, asdict
26
+ from pathlib import Path
27
+
28
+ import torch
29
+ import torch.nn as nn
30
+ import torch.nn.functional as F
31
+ from transformers import RobertaConfig, RobertaForMaskedLM, RobertaModel
32
+
33
+ from .vocab import BOS_ID, EOS_ID, MASK_ID, PAD_ID, VOCAB_SIZE
34
+
35
+
36
+ @dataclass
37
+ class BinProvConfig:
38
+ """Architecture knobs. Stored next to every checkpoint."""
39
+
40
+ seq_bytes: int = 512 # bytes per sequence (paper §3.1)
41
+ hidden_size: int = 768
42
+ num_hidden_layers: int = 12 # "consists of 12 transformers" (§3.2)
43
+ num_attention_heads: int = 12
44
+ intermediate_size: int = 3072
45
+ max_segments: int = 2 # segment embedding E_s (§3.2)
46
+ hidden_dropout_prob: float = 0.1
47
+ attention_probs_dropout_prob: float = 0.1
48
+
49
+ @property
50
+ def seq_tokens(self) -> int:
51
+ """Sequence length in tokens: the bytes plus <s> and </s>."""
52
+ return self.seq_bytes + 2
53
+
54
+ def to_roberta(self) -> RobertaConfig:
55
+ return RobertaConfig(
56
+ vocab_size=VOCAB_SIZE,
57
+ hidden_size=self.hidden_size,
58
+ num_hidden_layers=self.num_hidden_layers,
59
+ num_attention_heads=self.num_attention_heads,
60
+ intermediate_size=self.intermediate_size,
61
+ # HF's RoBERTa derives position ids as padding_idx + 1 + i, so the
62
+ # table needs two slots of headroom beyond the token count.
63
+ max_position_embeddings=self.seq_tokens + 2,
64
+ type_vocab_size=self.max_segments,
65
+ pad_token_id=PAD_ID,
66
+ bos_token_id=BOS_ID,
67
+ eos_token_id=EOS_ID,
68
+ mask_token_id=MASK_ID,
69
+ hidden_dropout_prob=self.hidden_dropout_prob,
70
+ attention_probs_dropout_prob=self.attention_probs_dropout_prob,
71
+ position_embedding_type="absolute",
72
+ )
73
+
74
+ def save(self, path: str | Path) -> None:
75
+ Path(path).write_text(json.dumps(asdict(self), indent=2) + "\n")
76
+
77
+ @classmethod
78
+ def load(cls, path: str | Path) -> BinProvConfig:
79
+ return cls(**json.loads(Path(path).read_text()))
80
+
81
+
82
+ def build_mlm_model(cfg: BinProvConfig) -> RobertaForMaskedLM:
83
+ """The pre-training model: encoder + softmax head over the 261-byte vocab."""
84
+ return RobertaForMaskedLM(cfg.to_roberta())
85
+
86
+
87
+ class BorderTaperedPool(nn.Module):
88
+ """Pool token embeddings into one vector, down-weighting sequence borders.
89
+
90
+ This is the first of the classifier's two layers (§3.3). Weights are a
91
+ softmax over learned per-position logits, masked to the valid tokens, so
92
+ padding and the <s>/</s> markers contribute nothing. Logits start as a
93
+ linear ramp over the outermost ``taper`` byte positions, encoding the prior
94
+ that a byte near the cut is likely part of a broken instruction.
95
+ """
96
+
97
+ def __init__(self, n_positions: int, hidden_size: int, taper: int = 32, taper_floor: float = -2.0):
98
+ super().__init__()
99
+ logits = torch.zeros(n_positions)
100
+ taper = max(0, min(taper, n_positions // 2))
101
+ if taper:
102
+ ramp = torch.linspace(taper_floor, 0.0, taper + 1)[:-1]
103
+ logits[:taper] = ramp
104
+ logits[n_positions - taper :] = ramp.flip(0)
105
+ self.pos_logits = nn.Parameter(logits)
106
+ self.proj = nn.Linear(hidden_size, hidden_size)
107
+ self.norm = nn.LayerNorm(hidden_size)
108
+
109
+ def forward(self, hidden: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
110
+ # hidden: (B, T, H); attention_mask: (B, T) with 1 on real tokens
111
+ n = hidden.size(1)
112
+ logits = self.pos_logits[:n].unsqueeze(0).expand(hidden.size(0), -1)
113
+ logits = logits.masked_fill(attention_mask == 0, torch.finfo(logits.dtype).min)
114
+ weights = logits.softmax(dim=-1).unsqueeze(-1)
115
+ pooled = (hidden * weights).sum(dim=1)
116
+ return self.norm(F.gelu(self.proj(pooled)))
117
+
118
+
119
+ class MeanPool(nn.Module):
120
+ """Plain masked mean pooling — the ablation baseline for the head."""
121
+
122
+ def __init__(self, hidden_size: int):
123
+ super().__init__()
124
+ self.proj = nn.Linear(hidden_size, hidden_size)
125
+ self.norm = nn.LayerNorm(hidden_size)
126
+
127
+ def forward(self, hidden: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
128
+ mask = attention_mask.unsqueeze(-1).to(hidden.dtype)
129
+ pooled = (hidden * mask).sum(1) / mask.sum(1).clamp(min=1.0)
130
+ return self.norm(F.gelu(self.proj(pooled)))
131
+
132
+
133
+
134
+ class AttentionPool(nn.Module):
135
+ """Content-based attention pooling: weight a position by *what* is there.
136
+
137
+ :class:`BorderTaperedPool` weights a position by *where* it is, which makes
138
+ it a fixed weighted mean. That is the right prior for the border problem it
139
+ was written for, and the wrong one for O2/O3. The byte patterns that
140
+ separate the two levels -- a vectorised loop body, one copy of an unrolled
141
+ loop, a particular alignment pad -- occupy a handful of positions in a
142
+ 512-byte window and are absent from the rest of it, so any weighted mean
143
+ dilutes the evidence by the fraction of the window that carries it.
144
+ Attention can concentrate on those positions instead.
145
+
146
+ ``heads > 1`` lets the head attend to several kinds of evidence at once
147
+ (say, a vector opcode in one head and a padding pattern in another).
148
+ """
149
+
150
+ def __init__(self, hidden_size: int, heads: int = 4):
151
+ super().__init__()
152
+ self.heads = heads
153
+ self.score = nn.Linear(hidden_size, heads)
154
+ self.proj = nn.Linear(hidden_size * heads, hidden_size)
155
+ self.norm = nn.LayerNorm(hidden_size)
156
+
157
+ def forward(self, hidden: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
158
+ scores = self.score(hidden) # (B, T, heads)
159
+ keep = attention_mask.unsqueeze(-1) == 0
160
+ scores = scores.masked_fill(keep, torch.finfo(scores.dtype).min)
161
+ weights = scores.softmax(dim=1) # over positions
162
+ pooled = torch.einsum("bth,btd->bhd", weights, hidden).flatten(1)
163
+ return self.norm(F.gelu(self.proj(pooled)))
164
+
165
+
166
+ class MaxPool(nn.Module):
167
+ """Per-dimension max over positions -- the hard version of AttentionPool.
168
+
169
+ Cheaper and with no parameters of its own in the aggregation step, at the
170
+ cost of committing to one position per feature dimension.
171
+ """
172
+
173
+ def __init__(self, hidden_size: int):
174
+ super().__init__()
175
+ self.proj = nn.Linear(hidden_size, hidden_size)
176
+ self.norm = nn.LayerNorm(hidden_size)
177
+
178
+ def forward(self, hidden: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
179
+ keep = attention_mask.unsqueeze(-1) == 0
180
+ pooled = hidden.masked_fill(keep, torch.finfo(hidden.dtype).min).max(dim=1).values
181
+ return self.norm(F.gelu(self.proj(pooled)))
182
+
183
+
184
+ class MILHead(nn.Module):
185
+ """Per-token classifier plus a smooth max over positions (returns logits).
186
+
187
+ The multiple-instance view of the task: a window is O3 if it *contains* an
188
+ O3 fingerprint, so the window score should be a max over per-token scores
189
+ rather than a score computed from an average of tokens. Pooling after the
190
+ classifier rather than before it is the whole point -- it keeps a single
191
+ strongly-O3 position from being averaged into 500 neutral ones.
192
+
193
+ ``logsumexp`` is the differentiable max: ``tau -> 0`` recovers a hard max,
194
+ large ``tau`` approaches the mean, so one module spans both ends and ``tau``
195
+ is a knob rather than a commitment. ``log n`` is subtracted so the result is
196
+ a log-*mean*-exp and therefore invariant to how many valid tokens a window
197
+ has -- without it a short window would score systematically lower on every
198
+ class, which is exactly the confound that makes padded short functions look
199
+ hard.
200
+ """
201
+
202
+ def __init__(self, hidden_size: int, num_labels: int, *, tau: float = 1.0, dropout: float = 0.1):
203
+ super().__init__()
204
+ self.proj = nn.Linear(hidden_size, hidden_size)
205
+ self.norm = nn.LayerNorm(hidden_size)
206
+ self.drop = nn.Dropout(dropout)
207
+ self.token_clf = nn.Linear(hidden_size, num_labels)
208
+ self.tau = tau
209
+ nn.init.normal_(self.token_clf.weight, std=0.02)
210
+ nn.init.zeros_(self.token_clf.bias)
211
+
212
+ def forward(self, hidden: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
213
+ h = self.norm(F.gelu(self.proj(hidden)))
214
+ tok = self.token_clf(self.drop(h)) # (B, T, C)
215
+ keep = attention_mask.unsqueeze(-1) == 0
216
+ tok = tok.masked_fill(keep, torch.finfo(tok.dtype).min)
217
+ n = attention_mask.sum(dim=1, keepdim=True).clamp(min=1).to(tok.dtype)
218
+ return self.tau * (torch.logsumexp(tok / self.tau, dim=1) - torch.log(n))
219
+
220
+ def token_logits(self, hidden: torch.Tensor) -> torch.Tensor:
221
+ """Per-position class scores, for locating *where* the evidence is."""
222
+ return self.token_clf(self.norm(F.gelu(self.proj(hidden))))
223
+
224
+
225
+ class BinProvForProvenance(nn.Module):
226
+ """Encoder + classifier, i.e. Eq. (2) of the paper.
227
+
228
+ ``y_hat = f_c(f_e(concat(E_b, E_p, E_s)))``
229
+ """
230
+
231
+ def __init__(
232
+ self,
233
+ cfg: BinProvConfig,
234
+ num_labels: int,
235
+ *,
236
+ pool: str = "border",
237
+ classifier_dropout: float = 0.1,
238
+ taper: int = 32,
239
+ pool_heads: int = 4,
240
+ mil_tau: float = 1.0,
241
+ label_smoothing: float = 0.0,
242
+ ):
243
+ super().__init__()
244
+ self.cfg = cfg
245
+ self.num_labels = num_labels
246
+ self.pool_kind = pool
247
+ self.pool_heads = pool_heads
248
+ self.mil_tau = mil_tau
249
+ self.label_smoothing = label_smoothing
250
+ self.encoder = RobertaModel(cfg.to_roberta(), add_pooling_layer=False)
251
+ # A "mil" head produces class logits itself, so there is no pooled
252
+ # vector for a separate classifier to consume; `pools_to_logits` records
253
+ # which of the two shapes we are in.
254
+ self.pools_to_logits = pool == "mil"
255
+ if pool == "border":
256
+ self.pool = BorderTaperedPool(cfg.seq_tokens, cfg.hidden_size, taper=taper)
257
+ elif pool == "mean":
258
+ self.pool = MeanPool(cfg.hidden_size)
259
+ elif pool == "attn":
260
+ self.pool = AttentionPool(cfg.hidden_size, heads=pool_heads)
261
+ elif pool == "max":
262
+ self.pool = MaxPool(cfg.hidden_size)
263
+ elif pool == "mil":
264
+ self.pool = MILHead(
265
+ cfg.hidden_size, num_labels, tau=mil_tau, dropout=classifier_dropout
266
+ )
267
+ elif pool == "cls":
268
+ self.pool = None
269
+ else:
270
+ raise ValueError(f"unknown pool {pool!r} (border|mean|attn|max|mil|cls)")
271
+ self.dropout = nn.Dropout(classifier_dropout)
272
+ if self.pools_to_logits:
273
+ self.classifier = nn.Identity()
274
+ else:
275
+ self.classifier = nn.Linear(cfg.hidden_size, num_labels)
276
+ nn.init.normal_(self.classifier.weight, std=0.02)
277
+ nn.init.zeros_(self.classifier.bias)
278
+
279
+ def forward(
280
+ self,
281
+ input_ids: torch.Tensor,
282
+ attention_mask: torch.Tensor,
283
+ token_type_ids: torch.Tensor | None = None,
284
+ labels: torch.Tensor | None = None,
285
+ ):
286
+ out = self.encoder(
287
+ input_ids=input_ids,
288
+ attention_mask=attention_mask,
289
+ token_type_ids=token_type_ids,
290
+ )
291
+ hidden = out.last_hidden_state # E_final, shape (B, T, H)
292
+ if self.pools_to_logits:
293
+ logits = self.pool(hidden, attention_mask)
294
+ else:
295
+ pooled = hidden[:, 0] if self.pool is None else self.pool(hidden, attention_mask)
296
+ logits = self.classifier(self.dropout(pooled))
297
+ loss = None
298
+ if labels is not None:
299
+ loss = F.cross_entropy(
300
+ logits.float(), labels, label_smoothing=self.label_smoothing
301
+ )
302
+ return {"loss": loss, "logits": logits}
303
+
304
+ # -- checkpointing -----------------------------------------------------
305
+
306
+ @staticmethod
307
+ def _resize_position_embeddings(state: dict, want: int, how: str = "tile") -> dict:
308
+ """Extend a pre-trained absolute position table to a longer sequence.
309
+
310
+ The MLM checkpoint learned 516 position embeddings because the paper's
311
+ sequence is 512 bytes. A longer-sequence encoder needs more, and the extra
312
+ rows have to come from somewhere. Two ways, and the difference is not
313
+ cosmetic — measured on the O2/O3 task, ``interp`` costs 5 points against
314
+ the 512-byte baseline while ``tile`` does not:
315
+
316
+ ``interp``
317
+ Linear interpolation, the move that extends a vision transformer to a
318
+ larger image. It preserves the table's global *shape* and destroys its
319
+ local structure: stretching 2x makes each adjacent pair of positions
320
+ nearly identical. For an image patch grid that is harmless. For a byte
321
+ encoder whose pre-training learned that neighbouring positions differ
322
+ — instruction boundaries, alignment padding — it is not, and it shows
323
+ up as an encoder that will not fit the training set any more.
324
+
325
+ ``tile``
326
+ Position ``k`` reuses the learned row ``k mod (have - 1)``. Local
327
+ structure is preserved exactly; what is lost is the ability to tell
328
+ absolute position 100 from position 614. For this task that is the
329
+ cheaper thing to give up, since a provenance cue means the same thing
330
+ wherever in the window it appears.
331
+
332
+ Rows are handled from index 1 up, because HF derives RoBERTa position ids
333
+ as ``padding_idx + 1 + i`` and so never uses row 0.
334
+ """
335
+ key = "embeddings.position_embeddings.weight"
336
+ if key not in state or state[key].shape[0] == want:
337
+ return state
338
+ have = state[key]
339
+ head, tail = have[:1], have[1:]
340
+ if how == "interp":
341
+ grown = F.interpolate(
342
+ tail.T.unsqueeze(0).float(), size=want - 1, mode="linear", align_corners=True
343
+ ).squeeze(0).T.to(have.dtype)
344
+ elif how == "tile":
345
+ reps = -(-(want - 1) // tail.shape[0])
346
+ grown = tail.repeat(reps, 1)[: want - 1]
347
+ else:
348
+ raise ValueError(f"unknown position extension {how!r} (tile|interp)")
349
+ state = dict(state)
350
+ state[key] = torch.cat([head, grown], dim=0)
351
+ # position_ids is a plain arange buffer; regenerate rather than stretch
352
+ for k in list(state):
353
+ if k.endswith("embeddings.position_ids") or k.endswith("embeddings.token_type_ids"):
354
+ state.pop(k)
355
+ return state
356
+
357
+ def load_encoder_from_mlm(
358
+ self, mlm_dir: str | Path, *, strict: bool = False, pos_extend: str = "tile"
359
+ ) -> list[str]:
360
+ """Warm-start the encoder from an MLM checkpoint (the transfer step).
361
+
362
+ Returns the list of encoder parameters that were *not* found, which
363
+ should be empty for a matching config — worth asserting in a run log,
364
+ since a silent mismatch here would quietly turn fine-tuning into
365
+ training from scratch.
366
+ """
367
+ mlm = RobertaForMaskedLM.from_pretrained(str(mlm_dir))
368
+ state = self._resize_position_embeddings(
369
+ mlm.roberta.state_dict(),
370
+ self.encoder.embeddings.position_embeddings.weight.shape[0],
371
+ pos_extend,
372
+ )
373
+ missing, unexpected = self.encoder.load_state_dict(state, strict=strict)
374
+ del mlm
375
+ if strict and (missing or unexpected):
376
+ raise RuntimeError(f"encoder mismatch: missing={missing} unexpected={unexpected}")
377
+ return list(missing)
378
+
379
+ def save(self, out_dir: str | Path, *, extra: dict | None = None) -> None:
380
+ out = Path(out_dir)
381
+ out.mkdir(parents=True, exist_ok=True)
382
+ torch.save(self.state_dict(), out / "model.pt")
383
+ self.cfg.save(out / "binprov_config.json")
384
+ (out / "head.json").write_text(
385
+ json.dumps(
386
+ {
387
+ "num_labels": self.num_labels,
388
+ "pool": self.pool_kind,
389
+ "pool_heads": self.pool_heads,
390
+ "mil_tau": self.mil_tau,
391
+ **(extra or {}),
392
+ },
393
+ indent=2,
394
+ )
395
+ + "\n"
396
+ )
397
+
398
+ @classmethod
399
+ def load(cls, ckpt_dir: str | Path, *, map_location="cpu") -> tuple[BinProvForProvenance, dict]:
400
+ d = Path(ckpt_dir)
401
+ cfg = BinProvConfig.load(d / "binprov_config.json")
402
+ head = json.loads((d / "head.json").read_text())
403
+ model = cls(
404
+ cfg,
405
+ head["num_labels"],
406
+ pool=head.get("pool", "border"),
407
+ pool_heads=head.get("pool_heads", 4),
408
+ mil_tau=head.get("mil_tau", 1.0),
409
+ )
410
+ state = torch.load(d / "model.pt", map_location=map_location, weights_only=True)
411
+ model.load_state_dict(state)
412
+ return model, head
413
+
414
+
415
+ def count_parameters(model: nn.Module) -> int:
416
+ return sum(p.numel() for p in model.parameters() if p.requires_grad)
417
+
418
+
419
+ def describe(model: nn.Module) -> str:
420
+ n = count_parameters(model)
421
+ return f"{type(model).__name__}: {n:,} trainable parameters ({n / 1e6:.1f}M)"
422
+
423
+
424
+ def param_groups(model: nn.Module, weight_decay: float = 0.01):
425
+ """AdamW groups that exempt biases and LayerNorm from weight decay."""
426
+ decay, no_decay = [], []
427
+ for name, p in model.named_parameters():
428
+ if not p.requires_grad:
429
+ continue
430
+ if p.ndim == 1 or name.endswith(".bias") or "LayerNorm" in name or "norm" in name:
431
+ no_decay.append(p)
432
+ else:
433
+ decay.append(p)
434
+ # Empty groups are dropped: a frozen encoder would otherwise hand AdamW a
435
+ # group with no parameters.
436
+ groups = [
437
+ {"params": decay, "weight_decay": weight_decay},
438
+ {"params": no_decay, "weight_decay": 0.0},
439
+ ]
440
+ return [g for g in groups if g["params"]]
441
+
442
+
443
+ def cosine_schedule_with_warmup(optimizer, warmup_steps: int, total_steps: int, min_ratio: float = 0.02):
444
+ """Linear warmup then cosine decay — standard for MLM pre-training."""
445
+
446
+ def lr_lambda(step: int) -> float:
447
+ if warmup_steps > 0 and step < warmup_steps:
448
+ return step / max(1, warmup_steps)
449
+ progress = (step - warmup_steps) / max(1, total_steps - warmup_steps)
450
+ progress = min(1.0, max(0.0, progress))
451
+ return min_ratio + (1 - min_ratio) * 0.5 * (1 + math.cos(math.pi * progress))
452
+
453
+ return torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
binprov/provenance.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provenance labels and the classification tasks defined on them.
2
+
3
+ The paper decomposes provenance identification into independent sub-tasks
4
+ (§3.3) rather than predicting the 8-way compiler×optimization product, because
5
+ "specialized classifiers have better performance". Each entry in :data:`TASKS`
6
+ is one such sub-task: which binaries it keeps, and how a kept binary maps to a
7
+ class index.
8
+
9
+ Tasks (with the paper table they feed):
10
+
11
+ compiler GCC vs Clang Tables 3, 6, 7
12
+ opt_hl low (O0/O1) vs high (O2/O3) Tables 3, 6
13
+ opt4 O0 vs O1 vs O2 vs O3 Tables 4, 5, 7
14
+ opt_o0o1 O0 vs O1 (only O0/O1 binaries) Table 4
15
+ opt_o2o3 O2 vs O3 (only O2/O3 binaries) Tables 4, 6, 7
16
+ arch instruction set architecture §5.1(d)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from dataclasses import dataclass
23
+ from typing import Callable, Sequence
24
+
25
+ COMPILERS = ("gcc", "clang")
26
+ OPT_LEVELS = ("O0", "O1", "O2", "O3")
27
+ #: architectures present in BinKit; the paper evaluates the first four
28
+ ARCHES = (
29
+ "x86_64",
30
+ "x86_32",
31
+ "arm_64",
32
+ "arm_32",
33
+ "mips_64",
34
+ "mips_32",
35
+ "mipseb_64",
36
+ "mipseb_32",
37
+ )
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # label extraction from a path
41
+ # ---------------------------------------------------------------------------
42
+
43
+ # BinKit's on-disk layout has changed between releases (and mirrors repack it),
44
+ # so rather than hard-coding one directory template we scan every path
45
+ # component for recognisable tokens. `scripts/build_corpus.py --dry-run` prints
46
+ # what this produced so a new layout can be spotted before a long build.
47
+ _ARCH_RE = re.compile(
48
+ r"(?<![a-z0-9])(x86|x64|i386|arm|aarch64|mips|mipseb|mipsel|ppc|riscv)"
49
+ r"(?:[-_]?(32|64))?(?![a-z0-9])",
50
+ re.I,
51
+ )
52
+ _COMPILER_RE = re.compile(
53
+ r"(?<![a-z])(gcc|g\+\+|clang|clang\+\+)(?:[-_]?([0-9]+(?:\.[0-9]+){0,2}))?(?![a-z])", re.I
54
+ )
55
+ _OPT_RE = re.compile(r"(?<![a-z0-9])-?O(0|1|2|3|s|fast)(?![a-z0-9])")
56
+ _EXTRA_RE = re.compile(
57
+ r"(?<![a-z])(normal|nopie|pie|noinline|lto|sizeopt|obfus(?:2loop)?|bcf|fla|sub|all)(?![a-z])",
58
+ re.I,
59
+ )
60
+ # Obfuscator-LLVM transforms, reported separately in Table 7
61
+ _OBF_TOKENS = {"bcf", "fla", "sub", "all"}
62
+
63
+
64
+ @dataclass
65
+ class Labels:
66
+ """Everything we know about how one binary was produced."""
67
+
68
+ arch: str | None = None
69
+ compiler: str | None = None
70
+ compiler_version: str | None = None
71
+ opt: str | None = None
72
+ extra: str = "normal" # normal | pie | lto | noinline | sizeopt | ...
73
+ obfuscation: str | None = None # bcf | fla | sub | all
74
+
75
+ def is_complete(self) -> bool:
76
+ """True when the binary can be used for every task in :data:`TASKS`."""
77
+ return bool(self.arch and self.compiler and self.opt)
78
+
79
+ def as_dict(self) -> dict:
80
+ return {
81
+ "arch": self.arch,
82
+ "compiler": self.compiler,
83
+ "compiler_version": self.compiler_version,
84
+ "opt": self.opt,
85
+ "extra": self.extra,
86
+ "obfuscation": self.obfuscation,
87
+ }
88
+
89
+
90
+ def _norm_arch(base: str, width: str | None) -> str:
91
+ base = base.lower()
92
+ if base in ("x64",):
93
+ return "x86_64"
94
+ if base in ("i386",):
95
+ return "x86_32"
96
+ if base == "aarch64":
97
+ return "arm_64"
98
+ if base == "mipsel":
99
+ base = "mips"
100
+ if width:
101
+ return f"{base}_{width}"
102
+ # widths that are implied by the architecture name alone
103
+ return {"arm": "arm_32", "x86": "x86_32", "mips": "mips_32"}.get(base, base)
104
+
105
+
106
+ def parse_labels(path: str) -> Labels:
107
+ """Derive provenance labels by scanning the components of ``path``.
108
+
109
+ Later components win, so a per-binary directory overrides a package-level
110
+ one. Compiler family/version are only taken together, to avoid pairing
111
+ "gcc" from one component with a version number from another.
112
+ """
113
+ lab = Labels()
114
+ parts = [p for p in str(path).replace("\\", "/").split("/") if p]
115
+ for part in parts:
116
+ if m := _ARCH_RE.search(part):
117
+ lab.arch = _norm_arch(m.group(1), m.group(2))
118
+ if m := _COMPILER_RE.search(part):
119
+ fam = m.group(1).lower().rstrip("+")
120
+ lab.compiler = "gcc" if fam in ("gcc", "g") else "clang"
121
+ if m.group(2):
122
+ lab.compiler_version = m.group(2)
123
+ if m := _OPT_RE.search(part):
124
+ lab.opt = "O" + m.group(1)
125
+ # finditer, not search: a component can carry both the family and the
126
+ # specific transform, e.g. "x86_64-clang-obfus-sub-O2"
127
+ for m in _EXTRA_RE.finditer(part):
128
+ tok = m.group(1).lower()
129
+ if tok in _OBF_TOKENS:
130
+ lab.obfuscation = tok
131
+ lab.extra = "obfus"
132
+ elif tok.startswith("obfus"):
133
+ lab.extra = "obfus"
134
+ else:
135
+ lab.extra = tok
136
+ return lab
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # tasks
141
+ # ---------------------------------------------------------------------------
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class Task:
146
+ """A classification task: a filter plus a label map.
147
+
148
+ ``marginal_of``/``marginal_map`` describe a *factorized* task: one whose
149
+ classes are finer than the classes we actually want to score, together with
150
+ the map from its class index to the coarse one. Training on the finer split
151
+ and summing the probabilities back down is a different estimator of the same
152
+ quantity -- it hands the model the compiler as extra supervision instead of
153
+ making it marginalise the compiler out on its own.
154
+ """
155
+
156
+ name: str
157
+ classes: tuple[str, ...]
158
+ keep: Callable[[dict], bool]
159
+ to_class: Callable[[dict], str]
160
+ note: str = "" # which paper table this task feeds
161
+ marginal_of: str | None = None
162
+ marginal_map: tuple[int, ...] = ()
163
+
164
+ @property
165
+ def num_labels(self) -> int:
166
+ return len(self.classes)
167
+
168
+ @property
169
+ def scored_classes(self) -> tuple[str, ...]:
170
+ """The classes metrics are reported over — coarse ones if factorized."""
171
+ if self.marginal_of is None:
172
+ return self.classes
173
+ return TASKS[self.marginal_of].classes
174
+
175
+ def label_of(self, labels: dict) -> int | None:
176
+ """Class index for a binary, or None if it is out of scope."""
177
+ if not self.keep(labels):
178
+ return None
179
+ cls = self.to_class(labels)
180
+ try:
181
+ return self.classes.index(cls)
182
+ except ValueError:
183
+ return None
184
+
185
+
186
+ def _opt_in(*levels: str) -> Callable[[dict], bool]:
187
+ allowed = set(levels)
188
+ return lambda d: d.get("opt") in allowed
189
+
190
+
191
+ TASKS: dict[str, Task] = {
192
+ "compiler": Task(
193
+ name="compiler",
194
+ classes=COMPILERS,
195
+ keep=lambda d: d.get("compiler") in COMPILERS,
196
+ to_class=lambda d: d["compiler"],
197
+ note="Table 3/6/7: GCC vs Clang",
198
+ ),
199
+ "opt_hl": Task(
200
+ name="opt_hl",
201
+ classes=("low", "high"),
202
+ keep=_opt_in(*OPT_LEVELS),
203
+ to_class=lambda d: "low" if d["opt"] in ("O0", "O1") else "high",
204
+ note="Table 3/6: O0,O1 vs O2,O3",
205
+ ),
206
+ "opt4": Task(
207
+ name="opt4",
208
+ classes=OPT_LEVELS,
209
+ keep=_opt_in(*OPT_LEVELS),
210
+ to_class=lambda d: d["opt"],
211
+ note="Table 4/5/7: O0/O1/O2/O3",
212
+ ),
213
+ "opt_o0o1": Task(
214
+ name="opt_o0o1",
215
+ classes=("O0", "O1"),
216
+ keep=_opt_in("O0", "O1"),
217
+ to_class=lambda d: d["opt"],
218
+ note="Table 4: O0 vs O1",
219
+ ),
220
+ "opt_o2o3": Task(
221
+ name="opt_o2o3",
222
+ classes=("O2", "O3"),
223
+ keep=_opt_in("O2", "O3"),
224
+ to_class=lambda d: d["opt"],
225
+ note="Table 4/6/7: O2 vs O3 (the hard one)",
226
+ ),
227
+ "opt_o2o3_x": Task(
228
+ name="opt_o2o3_x",
229
+ classes=("gcc_O2", "gcc_O3", "clang_O2", "clang_O3"),
230
+ keep=lambda d: d.get("opt") in ("O2", "O3") and d.get("compiler") in COMPILERS,
231
+ to_class=lambda d: f"{d['compiler']}_{d['opt']}",
232
+ note="O2 vs O3 factorized by compiler, marginalized back for scoring",
233
+ marginal_of="opt_o2o3",
234
+ marginal_map=(0, 1, 0, 1),
235
+ ),
236
+ "opt4_x": Task(
237
+ name="opt4_x",
238
+ classes=tuple(f"{c}_{o}" for c in COMPILERS for o in OPT_LEVELS),
239
+ keep=lambda d: d.get("opt") in OPT_LEVELS and d.get("compiler") in COMPILERS,
240
+ to_class=lambda d: f"{d['compiler']}_{d['opt']}",
241
+ note="O0/O1/O2/O3 factorized by compiler, marginalized back for scoring",
242
+ marginal_of="opt4",
243
+ marginal_map=(0, 1, 2, 3, 0, 1, 2, 3),
244
+ ),
245
+ "arch": Task(
246
+ name="arch",
247
+ classes=ARCHES,
248
+ keep=lambda d: d.get("arch") in ARCHES,
249
+ to_class=lambda d: d["arch"],
250
+ note="§5.1(d): instruction set architecture",
251
+ ),
252
+ }
253
+
254
+
255
+ def get_task(name: str) -> Task:
256
+ try:
257
+ return TASKS[name]
258
+ except KeyError:
259
+ raise SystemExit(
260
+ f"unknown task {name!r}; available: {', '.join(sorted(TASKS))}"
261
+ ) from None
262
+
263
+
264
+ def toolchain_key(labels: dict) -> str:
265
+ """Stable identifier for a compilation configuration.
266
+
267
+ Used to balance the corpus across configurations and to report per-cell
268
+ numbers (e.g. Table 5, which splits optimization results by compiler).
269
+ """
270
+ parts: Sequence[str] = (
271
+ labels.get("arch") or "?",
272
+ labels.get("compiler") or "?",
273
+ labels.get("compiler_version") or "-",
274
+ labels.get("opt") or "?",
275
+ labels.get("obfuscation") or labels.get("extra") or "normal",
276
+ )
277
+ return "/".join(parts)
binprov/vocab.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Byte vocabulary for BinProv (paper §3.2, "Input representation E").
2
+
3
+ The paper defines a vocabulary of 256 possible byte values plus 5 special
4
+ tags: <PAD>, <S>, </S>, <UNK> and <MASK>, for a total of 261 tokens.
5
+
6
+ Layout used here (ids are stable and must not be reshuffled once a model has
7
+ been pre-trained, since they are baked into the embedding matrix):
8
+
9
+ 0 <pad>
10
+ 1 <s>
11
+ 2 </s>
12
+ 3 <unk>
13
+ 4 <mask>
14
+ 5 0x00
15
+ 6 0x01
16
+ ...
17
+ 260 0xff
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ PAD_ID = 0
23
+ BOS_ID = 1
24
+ EOS_ID = 2
25
+ UNK_ID = 3
26
+ MASK_ID = 4
27
+
28
+ NUM_SPECIAL = 5
29
+ BYTE_OFFSET = NUM_SPECIAL
30
+ VOCAB_SIZE = NUM_SPECIAL + 256 # 261
31
+
32
+ SPECIAL_TOKENS = ("<pad>", "<s>", "</s>", "<unk>", "<mask>")
33
+
34
+ #: ids that are never a prediction target of the MLM task
35
+ NON_BYTE_IDS = frozenset({PAD_ID, BOS_ID, EOS_ID, UNK_ID, MASK_ID})
36
+
37
+
38
+ def byte_to_id(b: int) -> int:
39
+ """Map a raw byte value (0..255) to its token id."""
40
+ if not 0 <= b <= 255:
41
+ raise ValueError(f"not a byte value: {b}")
42
+ return b + BYTE_OFFSET
43
+
44
+
45
+ def id_to_byte(i: int) -> int:
46
+ """Inverse of :func:`byte_to_id`. Raises for special-token ids."""
47
+ if i < BYTE_OFFSET or i >= VOCAB_SIZE:
48
+ raise ValueError(f"not a byte token id: {i}")
49
+ return i - BYTE_OFFSET
50
+
51
+
52
+ def encode(data: bytes) -> list[int]:
53
+ """Encode raw bytes as token ids, without <s>/</s>."""
54
+ return [b + BYTE_OFFSET for b in data]
55
+
56
+
57
+ def decode(ids) -> bytes:
58
+ """Decode token ids back to bytes, dropping special tokens."""
59
+ return bytes(i - BYTE_OFFSET for i in ids if i >= BYTE_OFFSET)
60
+
61
+
62
+ def token_repr(i: int) -> str:
63
+ """Human-readable form of a token id, e.g. ``0x8b`` or ``<mask>``."""
64
+ if i < NUM_SPECIAL:
65
+ return SPECIAL_TOKENS[i]
66
+ return f"0x{i - BYTE_OFFSET:02x}"
binprov/vote.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Joint inference by majority voting (paper §3.4).
2
+
3
+ A single 512-byte sequence is not a meaningful entity: it may hold a fragment of
4
+ one function, or span several. The paper therefore aggregates sequence-level
5
+ predictions over all sequences belonging to the same function, or the same
6
+ binary, and takes the majority. That relies on the §2.3 measurement that >96% of
7
+ real projects compile an individual binary with a single optimization level, so
8
+ the sequences of one binary do share a label.
9
+
10
+ Voting is where most of the paper's headline numbers come from: on the hard
11
+ O2/O3 task it lifts accuracy from 83.6% (sequence) to 94.7% (function) to 99.8%
12
+ (binary), Tables 4 and 6.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import numpy as np
18
+
19
+
20
+ def majority_vote(
21
+ group_ids: np.ndarray,
22
+ predictions: np.ndarray,
23
+ num_classes: int,
24
+ *,
25
+ probs: np.ndarray | None = None,
26
+ mode: str = "hard",
27
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
28
+ """Aggregate per-sequence predictions into per-group predictions.
29
+
30
+ Args:
31
+ group_ids: dense group index per sequence (see
32
+ :meth:`~binprov.corpus.SequenceIndex.group_ids`).
33
+ predictions: predicted class per sequence.
34
+ num_classes: size of the label set.
35
+ probs: per-sequence class probabilities, required for ``mode="soft"``.
36
+ mode: ``"hard"`` counts votes, as the paper does. ``"soft"`` sums
37
+ probabilities, which breaks ties more gracefully when a group has
38
+ few sequences.
39
+
40
+ Returns:
41
+ ``(groups, voted, n_seqs)`` — the sorted unique group ids, the voted
42
+ class for each, and how many sequences each group had.
43
+ """
44
+ if len(group_ids) != len(predictions):
45
+ raise ValueError("group_ids and predictions must be the same length")
46
+ if len(group_ids) == 0:
47
+ empty_i = np.zeros(0, dtype=np.int64)
48
+ return empty_i, empty_i, empty_i
49
+
50
+ groups = np.unique(group_ids)
51
+ n_groups = int(groups.max()) + 1 # group_ids are dense, so this is exact
52
+
53
+ if mode == "hard":
54
+ flat = group_ids.astype(np.int64) * num_classes + predictions.astype(np.int64)
55
+ tally = np.bincount(flat, minlength=n_groups * num_classes).reshape(n_groups, num_classes)
56
+ elif mode == "soft":
57
+ if probs is None:
58
+ raise ValueError("mode='soft' needs probs")
59
+ tally = np.zeros((n_groups, num_classes), dtype=np.float64)
60
+ np.add.at(tally, group_ids.astype(np.int64), probs.astype(np.float64))
61
+ else:
62
+ raise ValueError(f"unknown mode {mode!r} (hard|soft)")
63
+
64
+ voted = tally.argmax(axis=1)
65
+ counts = np.bincount(group_ids.astype(np.int64), minlength=n_groups)
66
+ return groups, voted[groups], counts[groups]
67
+
68
+
69
+ def group_truth(group_ids: np.ndarray, labels: np.ndarray) -> np.ndarray:
70
+ """The ground-truth label of each group.
71
+
72
+ Every sequence in a group must carry the same label — a group is one
73
+ function or one binary, and a function has exactly one provenance. A
74
+ mismatch means the corpus or the grouping is wrong, so this raises rather
75
+ than silently picking one.
76
+ """
77
+ groups = np.unique(group_ids)
78
+ n_groups = int(groups.max()) + 1 if len(groups) else 0
79
+ first = np.full(n_groups, -1, dtype=np.int64)
80
+ order = np.argsort(group_ids, kind="stable")
81
+ g_sorted = group_ids[order]
82
+ l_sorted = labels[order]
83
+ starts = np.searchsorted(g_sorted, groups, side="left")
84
+ first[groups] = l_sorted[starts]
85
+
86
+ expanded = first[group_ids]
87
+ bad = expanded != labels
88
+ if bad.any():
89
+ n = int(bad.sum())
90
+ raise ValueError(
91
+ f"{n} sequences disagree with their group's label; "
92
+ "the voting groups do not match the label granularity"
93
+ )
94
+ return first[groups]
95
+
96
+
97
+ def vote_report(
98
+ group_ids: np.ndarray,
99
+ predictions: np.ndarray,
100
+ labels: np.ndarray,
101
+ num_classes: int,
102
+ *,
103
+ probs: np.ndarray | None = None,
104
+ mode: str = "hard",
105
+ ) -> dict:
106
+ """Voting accuracy plus the sequence-level accuracy it was built from."""
107
+ groups, voted, counts = majority_vote(
108
+ group_ids, predictions, num_classes, probs=probs, mode=mode
109
+ )
110
+ truth = group_truth(group_ids, labels)
111
+ correct = voted == truth
112
+ return {
113
+ "num_groups": int(len(groups)),
114
+ "num_sequences": int(len(predictions)),
115
+ "seq_accuracy": float((predictions == labels).mean()) if len(predictions) else 0.0,
116
+ "vote_accuracy": float(correct.mean()) if len(groups) else 0.0,
117
+ "mean_seqs_per_group": float(counts.mean()) if len(counts) else 0.0,
118
+ "median_seqs_per_group": float(np.median(counts)) if len(counts) else 0.0,
119
+ "mode": mode,
120
+ # kept for the per-binary drill-down in the analysis experiments
121
+ "_groups": groups,
122
+ "_voted": voted,
123
+ "_truth": truth,
124
+ "_counts": counts,
125
+ }
binprov_config.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "seq_bytes": 2048,
3
+ "hidden_size": 768,
4
+ "num_hidden_layers": 12,
5
+ "num_attention_heads": 12,
6
+ "intermediate_size": 3072,
7
+ "max_segments": 2,
8
+ "hidden_dropout_prob": 0.1,
9
+ "attention_probs_dropout_prob": 0.1
10
+ }
environment.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "python": "3.12.1",
3
+ "platform": "Linux-4.18.0-553.137.1.el8_10.x86_64-x86_64-with-glibc2.28",
4
+ "sys.platform": "linux",
5
+ "torch": "2.8.0+cu128",
6
+ "transformers": "4.55.4",
7
+ "numpy": "2.3.3"
8
+ }
evidence/args.json ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "corpus": "data/corpus/binkit_x86_64",
3
+ "task": "opt4",
4
+ "out": "results/hopper_release/opt4_wide_seed29/opt4",
5
+ "splits": "default",
6
+ "init_from": "results/hopper_release/opt4_wide_seed29/mlm2048",
7
+ "from_scratch": false,
8
+ "tag": "opt4_wide_seed29",
9
+ "arch": null,
10
+ "extra": null,
11
+ "compiler": null,
12
+ "version": null,
13
+ "test_version": null,
14
+ "train_compiler": null,
15
+ "max_seqs_per_binary": null,
16
+ "stride": 512,
17
+ "val_frac": 0.15,
18
+ "val_seed": 1234,
19
+ "split_mode": "program",
20
+ "seq_bytes": 2048,
21
+ "eval_window_bytes": 512,
22
+ "jitter": 0,
23
+ "pos_extend": "tile",
24
+ "pool": "border",
25
+ "pool_heads": 4,
26
+ "mil_tau": 1.0,
27
+ "taper": 32,
28
+ "dropout": null,
29
+ "classifier_dropout": 0.1,
30
+ "epochs": 6,
31
+ "batch_size": 4,
32
+ "grad_accum": 8,
33
+ "lr": 3e-05,
34
+ "head_lr": 0.0001,
35
+ "llrd": 1.0,
36
+ "weight_decay": 0.01,
37
+ "warmup_ratio": 0.06,
38
+ "clip_grad": 1.0,
39
+ "label_smoothing": 0.0,
40
+ "use_pairs": false,
41
+ "pair_loss": 0.0,
42
+ "pair_ce": 1.0,
43
+ "pair_margin": 1.0,
44
+ "pairs_per_batch": 32,
45
+ "pair_min_size": 32,
46
+ "pair_jitter": 64,
47
+ "workers": 6,
48
+ "seed": 29,
49
+ "resume": true,
50
+ "log_every": 100,
51
+ "val_max_seqs": 40000,
52
+ "tta": "none",
53
+ "tta_views": 4,
54
+ "tta_span": 128,
55
+ "save_probs": true,
56
+ "no_save_weights": false
57
+ }
evidence/evaluation.json ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "profile": "opt4_wide_seed29",
3
+ "task": "opt4",
4
+ "scored_classes": [
5
+ "O0",
6
+ "O1",
7
+ "O2",
8
+ "O3"
9
+ ],
10
+ "sequence_accuracy": 0.842255,
11
+ "balanced_accuracy": 0.830951,
12
+ "binary_accuracy": 0.93883,
13
+ "num_sequences": 116321,
14
+ "num_binaries": 376,
15
+ "recorded_test_accuracy": 0.8422554826729481,
16
+ "recomputed_matches_recorded": true,
17
+ "checkpoint": "results/hopper_release/opt4_wide_seed29/opt4",
18
+ "result_json": {
19
+ "accuracy": 0.8422554826729481,
20
+ "balanced_accuracy": 0.8309510174499452,
21
+ "confusion": [
22
+ [
23
+ 37645,
24
+ 14,
25
+ 34,
26
+ 19
27
+ ],
28
+ [
29
+ 10,
30
+ 21157,
31
+ 1149,
32
+ 636
33
+ ],
34
+ [
35
+ 1,
36
+ 2449,
37
+ 17545,
38
+ 6190
39
+ ],
40
+ [
41
+ 1,
42
+ 1860,
43
+ 5986,
44
+ 21625
45
+ ]
46
+ ],
47
+ "n": 116321
48
+ },
49
+ "evaluated_at": "2026-09-06T03:48:35"
50
+ }
evidence/probs.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:58cc99e63e97eed6dc99c56e7e334671c8bdbd9353bf96d5af45ea12f5e86eaf
3
+ size 1415873
evidence/result.json ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "tag": "opt4_wide_seed29",
3
+ "task": "opt4",
4
+ "scored_classes": [
5
+ "O0",
6
+ "O1",
7
+ "O2",
8
+ "O3"
9
+ ],
10
+ "args": {
11
+ "corpus": "data/corpus/binkit_x86_64",
12
+ "task": "opt4",
13
+ "out": "results/hopper_release/opt4_wide_seed29/opt4",
14
+ "splits": "default",
15
+ "init_from": "results/hopper_release/opt4_wide_seed29/mlm2048",
16
+ "from_scratch": false,
17
+ "tag": "opt4_wide_seed29",
18
+ "arch": null,
19
+ "extra": null,
20
+ "compiler": null,
21
+ "version": null,
22
+ "test_version": null,
23
+ "train_compiler": null,
24
+ "max_seqs_per_binary": null,
25
+ "stride": 512,
26
+ "val_frac": 0.15,
27
+ "val_seed": 1234,
28
+ "split_mode": "program",
29
+ "seq_bytes": 2048,
30
+ "eval_window_bytes": 512,
31
+ "jitter": 0,
32
+ "pos_extend": "tile",
33
+ "pool": "border",
34
+ "pool_heads": 4,
35
+ "mil_tau": 1.0,
36
+ "taper": 32,
37
+ "dropout": null,
38
+ "classifier_dropout": 0.1,
39
+ "epochs": 6,
40
+ "batch_size": 4,
41
+ "grad_accum": 8,
42
+ "lr": 3e-05,
43
+ "head_lr": 0.0001,
44
+ "llrd": 1.0,
45
+ "weight_decay": 0.01,
46
+ "warmup_ratio": 0.06,
47
+ "clip_grad": 1.0,
48
+ "label_smoothing": 0.0,
49
+ "use_pairs": false,
50
+ "pair_loss": 0.0,
51
+ "pair_ce": 1.0,
52
+ "pair_margin": 1.0,
53
+ "pairs_per_batch": 32,
54
+ "pair_min_size": 32,
55
+ "pair_jitter": 64,
56
+ "workers": 6,
57
+ "seed": 29,
58
+ "resume": true,
59
+ "log_every": 100,
60
+ "val_max_seqs": 40000,
61
+ "tta": "none",
62
+ "tta_views": 4,
63
+ "tta_span": 128,
64
+ "save_probs": true,
65
+ "no_save_weights": false
66
+ },
67
+ "best_epoch": 4,
68
+ "best_val_accuracy": 0.776125,
69
+ "test": {
70
+ "accuracy": 0.8422554826729481,
71
+ "balanced_accuracy": 0.8309510174499452,
72
+ "confusion": [
73
+ [
74
+ 37645,
75
+ 14,
76
+ 34,
77
+ 19
78
+ ],
79
+ [
80
+ 10,
81
+ 21157,
82
+ 1149,
83
+ 636
84
+ ],
85
+ [
86
+ 1,
87
+ 2449,
88
+ 17545,
89
+ 6190
90
+ ],
91
+ [
92
+ 1,
93
+ 1860,
94
+ 5986,
95
+ 21625
96
+ ]
97
+ ],
98
+ "n": 116321
99
+ },
100
+ "test_tta": null,
101
+ "history": [
102
+ {
103
+ "epoch": 0,
104
+ "val_accuracy": 0.714775,
105
+ "val_balanced_accuracy": 0.7049004306882054,
106
+ "val_n": 40000
107
+ },
108
+ {
109
+ "epoch": 1,
110
+ "val_accuracy": 0.75455,
111
+ "val_balanced_accuracy": 0.735723491217099,
112
+ "val_n": 40000
113
+ },
114
+ {
115
+ "epoch": 2,
116
+ "val_accuracy": 0.75315,
117
+ "val_balanced_accuracy": 0.7330662254462548,
118
+ "val_n": 40000
119
+ },
120
+ {
121
+ "epoch": 3,
122
+ "val_accuracy": 0.767475,
123
+ "val_balanced_accuracy": 0.7509352274830212,
124
+ "val_n": 40000
125
+ },
126
+ {
127
+ "epoch": 4,
128
+ "val_accuracy": 0.776125,
129
+ "val_balanced_accuracy": 0.7567212069146406,
130
+ "val_n": 40000
131
+ },
132
+ {
133
+ "epoch": 5,
134
+ "val_accuracy": 0.7684,
135
+ "val_balanced_accuracy": 0.7490872252223905,
136
+ "val_n": 40000
137
+ }
138
+ ],
139
+ "n_train_sequences": 264459,
140
+ "wall_clock": "6h54m"
141
+ }
head.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "num_labels": 4,
3
+ "pool": "border",
4
+ "pool_heads": 4,
5
+ "mil_tau": 1.0,
6
+ "task": "opt4",
7
+ "tag": "opt4_wide_seed29"
8
+ }
inference.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Minimal BinProv inference for a packaged export directory.
3
+
4
+ This file is copied verbatim into every ``export_hf.py`` output and is the
5
+ supported way to run a released checkpoint. It loads the BinProv-native model
6
+ from ``model/`` next to this file, reads an ELF's ``.text`` with BinProv's own
7
+ dependency-free reader (or raw ``.text`` bytes), replicates the training cut into
8
+ ``seq_bytes``-byte windows, predicts each window, and soft-votes per class over
9
+ the windows.
10
+
11
+ The end-to-end model is NOT a transformers AutoModel (the head is BinProv's), so
12
+ this script needs the BinProv package installed::
13
+
14
+ pip install -r requirements.txt
15
+
16
+ No remote code is executed anywhere; there is no ``trust_remote_code``.
17
+
18
+ Example::
19
+
20
+ python inference.py --model model --elf /path/to/binary.elf
21
+ python inference.py --model model --text-bytes raw_text.bin
22
+ python inference.py --model model --elf a.elf --json # machine-readable
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ import numpy as np
33
+ import torch
34
+
35
+ EXPORT_ROOT = Path(__file__).resolve().parent
36
+ DEFAULT_MODEL = EXPORT_ROOT / "model"
37
+
38
+
39
+ def load_model(model_dir: Path):
40
+ from binprov.model import BinProvForProvenance
41
+
42
+ return BinProvForProvenance.load(model_dir)
43
+
44
+
45
+ def cut_windows(data: bytes, seq_bytes: int, stride: int, min_bytes: int = 16):
46
+ """Cut overlapping model inputs at the evaluation stride."""
47
+ out = []
48
+ pos = 0
49
+ n = len(data)
50
+ i = 0
51
+ while pos < n:
52
+ ln = min(seq_bytes, n - pos)
53
+ if ln < min_bytes and pos > 0:
54
+ break
55
+ out.append((np.frombuffer(data[pos : pos + ln], dtype=np.uint8), i))
56
+ i += 1
57
+ if ln < seq_bytes:
58
+ break
59
+ pos += stride
60
+ return out
61
+
62
+
63
+ def predict(model, data: bytes, *, stride: int, batch_size: int, device):
64
+ """Per-class mean probability over the binary's windows.
65
+
66
+ Returns ``(probabilities, num_windows)`` with probabilities the soft vote of
67
+ every non-overlapping ``seq_bytes`` window of ``data``.
68
+ """
69
+ from binprov.data import ClassificationCollator
70
+
71
+ seq = model.cfg.seq_bytes
72
+ coll = ClassificationCollator(model.cfg.seq_tokens)
73
+ windows = cut_windows(data, seq, stride)
74
+ if not windows:
75
+ raise ValueError("no usable windows: input is empty or shorter than 16 bytes")
76
+ model.to(device).eval()
77
+ total = np.zeros(model.num_labels, dtype=np.float64)
78
+ with torch.no_grad():
79
+ for start in range(0, len(windows), batch_size):
80
+ items = [(chunk, -1, idx) for chunk, idx in windows[start:start + batch_size]]
81
+ out = coll(items)
82
+ ids = out["input_ids"].to(device)
83
+ attn = out["attention_mask"].to(device)
84
+ types = out["token_type_ids"].to(device)
85
+ logits = model(ids, attn, types)["logits"]
86
+ total += logits.float().softmax(-1).sum(0).cpu().numpy()
87
+ return total / len(windows), len(windows)
88
+
89
+
90
+ def choose_device(requested: str):
91
+ if requested != "auto":
92
+ return torch.device(requested)
93
+ if torch.cuda.is_available():
94
+ return torch.device("cuda")
95
+ if torch.backends.mps.is_available():
96
+ return torch.device("mps")
97
+ return torch.device("cpu")
98
+
99
+
100
+ def main() -> int:
101
+ ap = argparse.ArgumentParser(
102
+ description="BinProv inference on an ELF or raw .text bytes",
103
+ formatter_class=argparse.RawDescriptionHelpFormatter,
104
+ epilog=__doc__,
105
+ )
106
+ ap.add_argument("--model", default=str(DEFAULT_MODEL),
107
+ help="model dir (model.pt + configs); default: ./model")
108
+ src = ap.add_mutually_exclusive_group(required=True)
109
+ src.add_argument("--elf", default=None, help="ELF binary to classify")
110
+ src.add_argument("--text-bytes", default=None,
111
+ help="file containing raw .text bytes")
112
+ ap.add_argument("--json", action="store_true", help="print JSON only")
113
+ ap.add_argument("--stride", type=int, default=None,
114
+ help="window stride in bytes; default: packaged evaluation stride")
115
+ ap.add_argument("--batch-size", type=int, default=1,
116
+ help="inference batch size; default 1 limits memory use")
117
+ ap.add_argument("--device", default="auto",
118
+ help="torch device (auto, cuda, mps, cpu); default: auto")
119
+ args = ap.parse_args()
120
+
121
+ if args.elf:
122
+ from binprov.elf import parse
123
+
124
+ buf = Path(args.elf).read_bytes()
125
+ text = parse(buf).data
126
+ source = f"ELF {args.elf} (.text, {len(text)} bytes)"
127
+ else:
128
+ text = Path(args.text_bytes).read_bytes()
129
+ source = f"{args.text_bytes} ({len(text)} bytes)"
130
+
131
+ model_dir = Path(args.model)
132
+ model, head = load_model(model_dir)
133
+ labels_file = model_dir / "labels.json"
134
+ labels = json.loads(labels_file.read_text()) if labels_file.is_file() else {}
135
+ classes = labels.get("classes") or []
136
+ inference_cfg_file = model_dir / "inference_config.json"
137
+ inference_cfg = json.loads(inference_cfg_file.read_text()) if inference_cfg_file.is_file() else {}
138
+ stride = args.stride or int(inference_cfg.get("stride", model.cfg.seq_bytes))
139
+ if stride <= 0 or args.batch_size <= 0:
140
+ ap.error("--stride and --batch-size must be positive")
141
+ device = choose_device(args.device)
142
+ prob, n_windows = predict(
143
+ model, text, stride=stride, batch_size=args.batch_size, device=device
144
+ )
145
+ pred = int(prob.argmax())
146
+ result = {
147
+ "source": source,
148
+ "num_windows": n_windows,
149
+ "seq_bytes": model.cfg.seq_bytes,
150
+ "stride": stride,
151
+ "device": str(device),
152
+ "classes": classes,
153
+ "probabilities": [round(float(x), 6) for x in prob],
154
+ "prediction": pred,
155
+ "predicted_label": classes[pred] if pred < len(classes) else str(pred),
156
+ "task": labels.get("task"),
157
+ }
158
+ if args.json:
159
+ print(json.dumps(result, indent=2))
160
+ else:
161
+ print(f"input: {source}")
162
+ print(f"task: {result['task']} classes: {classes}")
163
+ print(f"windows: {n_windows} input: {model.cfg.seq_bytes} B stride: {stride} B")
164
+ print(f"device: {device}")
165
+ for i, p in enumerate(prob):
166
+ name = classes[i] if i < len(classes) else str(i)
167
+ print(f" {name:>6}: {100 * p:.2f}%")
168
+ print(f"=> {result['predicted_label']}")
169
+ return 0
170
+
171
+
172
+ if __name__ == "__main__":
173
+ raise SystemExit(main())
inference_config.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "seq_bytes": 2048,
3
+ "stride": 512,
4
+ "min_bytes": 16
5
+ }
labels.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "task": "opt4",
3
+ "classes": [
4
+ "O0",
5
+ "O1",
6
+ "O2",
7
+ "O3"
8
+ ],
9
+ "label_map": {
10
+ "0": "O0",
11
+ "1": "O1",
12
+ "2": "O2",
13
+ "3": "O3"
14
+ },
15
+ "num_labels": 4
16
+ }
metadata.json ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "args.json": {
3
+ "corpus": "data/corpus/binkit_x86_64",
4
+ "task": "opt4",
5
+ "out": "results/hopper_release/opt4_wide_seed29/opt4",
6
+ "splits": "default",
7
+ "init_from": "results/hopper_release/opt4_wide_seed29/mlm2048",
8
+ "from_scratch": false,
9
+ "tag": "opt4_wide_seed29",
10
+ "arch": null,
11
+ "extra": null,
12
+ "compiler": null,
13
+ "version": null,
14
+ "test_version": null,
15
+ "train_compiler": null,
16
+ "max_seqs_per_binary": null,
17
+ "stride": 512,
18
+ "val_frac": 0.15,
19
+ "val_seed": 1234,
20
+ "split_mode": "program",
21
+ "seq_bytes": 2048,
22
+ "eval_window_bytes": 512,
23
+ "jitter": 0,
24
+ "pos_extend": "tile",
25
+ "pool": "border",
26
+ "pool_heads": 4,
27
+ "mil_tau": 1.0,
28
+ "taper": 32,
29
+ "dropout": null,
30
+ "classifier_dropout": 0.1,
31
+ "epochs": 6,
32
+ "batch_size": 4,
33
+ "grad_accum": 8,
34
+ "lr": 3e-05,
35
+ "head_lr": 0.0001,
36
+ "llrd": 1.0,
37
+ "weight_decay": 0.01,
38
+ "warmup_ratio": 0.06,
39
+ "clip_grad": 1.0,
40
+ "label_smoothing": 0.0,
41
+ "use_pairs": false,
42
+ "pair_loss": 0.0,
43
+ "pair_ce": 1.0,
44
+ "pair_margin": 1.0,
45
+ "pairs_per_batch": 32,
46
+ "pair_min_size": 32,
47
+ "pair_jitter": 64,
48
+ "workers": 6,
49
+ "seed": 29,
50
+ "resume": true,
51
+ "log_every": 100,
52
+ "val_max_seqs": 40000,
53
+ "tta": "none",
54
+ "tta_views": 4,
55
+ "tta_span": 128,
56
+ "save_probs": true,
57
+ "no_save_weights": false
58
+ },
59
+ "result.json": {
60
+ "tag": "opt4_wide_seed29",
61
+ "task": "opt4",
62
+ "scored_classes": [
63
+ "O0",
64
+ "O1",
65
+ "O2",
66
+ "O3"
67
+ ],
68
+ "args": {
69
+ "corpus": "data/corpus/binkit_x86_64",
70
+ "task": "opt4",
71
+ "out": "results/hopper_release/opt4_wide_seed29/opt4",
72
+ "splits": "default",
73
+ "init_from": "results/hopper_release/opt4_wide_seed29/mlm2048",
74
+ "from_scratch": false,
75
+ "tag": "opt4_wide_seed29",
76
+ "arch": null,
77
+ "extra": null,
78
+ "compiler": null,
79
+ "version": null,
80
+ "test_version": null,
81
+ "train_compiler": null,
82
+ "max_seqs_per_binary": null,
83
+ "stride": 512,
84
+ "val_frac": 0.15,
85
+ "val_seed": 1234,
86
+ "split_mode": "program",
87
+ "seq_bytes": 2048,
88
+ "eval_window_bytes": 512,
89
+ "jitter": 0,
90
+ "pos_extend": "tile",
91
+ "pool": "border",
92
+ "pool_heads": 4,
93
+ "mil_tau": 1.0,
94
+ "taper": 32,
95
+ "dropout": null,
96
+ "classifier_dropout": 0.1,
97
+ "epochs": 6,
98
+ "batch_size": 4,
99
+ "grad_accum": 8,
100
+ "lr": 3e-05,
101
+ "head_lr": 0.0001,
102
+ "llrd": 1.0,
103
+ "weight_decay": 0.01,
104
+ "warmup_ratio": 0.06,
105
+ "clip_grad": 1.0,
106
+ "label_smoothing": 0.0,
107
+ "use_pairs": false,
108
+ "pair_loss": 0.0,
109
+ "pair_ce": 1.0,
110
+ "pair_margin": 1.0,
111
+ "pairs_per_batch": 32,
112
+ "pair_min_size": 32,
113
+ "pair_jitter": 64,
114
+ "workers": 6,
115
+ "seed": 29,
116
+ "resume": true,
117
+ "log_every": 100,
118
+ "val_max_seqs": 40000,
119
+ "tta": "none",
120
+ "tta_views": 4,
121
+ "tta_span": 128,
122
+ "save_probs": true,
123
+ "no_save_weights": false
124
+ },
125
+ "best_epoch": 4,
126
+ "best_val_accuracy": 0.776125,
127
+ "test": {
128
+ "accuracy": 0.8422554826729481,
129
+ "balanced_accuracy": 0.8309510174499452,
130
+ "confusion": [
131
+ [
132
+ 37645,
133
+ 14,
134
+ 34,
135
+ 19
136
+ ],
137
+ [
138
+ 10,
139
+ 21157,
140
+ 1149,
141
+ 636
142
+ ],
143
+ [
144
+ 1,
145
+ 2449,
146
+ 17545,
147
+ 6190
148
+ ],
149
+ [
150
+ 1,
151
+ 1860,
152
+ 5986,
153
+ 21625
154
+ ]
155
+ ],
156
+ "n": 116321
157
+ },
158
+ "test_tta": null,
159
+ "history": [
160
+ {
161
+ "epoch": 0,
162
+ "val_accuracy": 0.714775,
163
+ "val_balanced_accuracy": 0.7049004306882054,
164
+ "val_n": 40000
165
+ },
166
+ {
167
+ "epoch": 1,
168
+ "val_accuracy": 0.75455,
169
+ "val_balanced_accuracy": 0.735723491217099,
170
+ "val_n": 40000
171
+ },
172
+ {
173
+ "epoch": 2,
174
+ "val_accuracy": 0.75315,
175
+ "val_balanced_accuracy": 0.7330662254462548,
176
+ "val_n": 40000
177
+ },
178
+ {
179
+ "epoch": 3,
180
+ "val_accuracy": 0.767475,
181
+ "val_balanced_accuracy": 0.7509352274830212,
182
+ "val_n": 40000
183
+ },
184
+ {
185
+ "epoch": 4,
186
+ "val_accuracy": 0.776125,
187
+ "val_balanced_accuracy": 0.7567212069146406,
188
+ "val_n": 40000
189
+ },
190
+ {
191
+ "epoch": 5,
192
+ "val_accuracy": 0.7684,
193
+ "val_balanced_accuracy": 0.7490872252223905,
194
+ "val_n": 40000
195
+ }
196
+ ],
197
+ "n_train_sequences": 264459,
198
+ "wall_clock": "6h54m"
199
+ },
200
+ "head.json": {
201
+ "num_labels": 4,
202
+ "pool": "border",
203
+ "pool_heads": 4,
204
+ "mil_tau": 1.0,
205
+ "task": "opt4",
206
+ "tag": "opt4_wide_seed29"
207
+ },
208
+ "binprov_config.json": {
209
+ "seq_bytes": 2048,
210
+ "hidden_size": 768,
211
+ "num_hidden_layers": 12,
212
+ "num_attention_heads": 12,
213
+ "intermediate_size": 3072,
214
+ "max_segments": 2,
215
+ "hidden_dropout_prob": 0.1,
216
+ "attention_probs_dropout_prob": 0.1
217
+ },
218
+ "source_checkpoint": "results/hopper_release/opt4_wide_seed29/opt4",
219
+ "labels": {
220
+ "task": "opt4",
221
+ "classes": [
222
+ "O0",
223
+ "O1",
224
+ "O2",
225
+ "O3"
226
+ ],
227
+ "label_map": {
228
+ "0": "O0",
229
+ "1": "O1",
230
+ "2": "O2",
231
+ "3": "O3"
232
+ },
233
+ "num_labels": 4
234
+ },
235
+ "environment": {
236
+ "python": "3.12.1",
237
+ "platform": "Linux-4.18.0-553.137.1.el8_10.x86_64-x86_64-with-glibc2.28",
238
+ "sys.platform": "linux",
239
+ "torch": "2.8.0+cu128",
240
+ "transformers": "4.55.4",
241
+ "numpy": "2.3.3"
242
+ },
243
+ "summary": {
244
+ "model_name": "binprov",
245
+ "exported_from": "results/hopper_release/opt4_wide_seed29/opt4",
246
+ "created_at": "2026-09-06T07:48:37+00:00",
247
+ "files": [
248
+ "LICENSE",
249
+ "MODEL_CARD.md",
250
+ "README.md",
251
+ "binprov/__init__.py",
252
+ "binprov/corpus.py",
253
+ "binprov/data.py",
254
+ "binprov/discover.py",
255
+ "binprov/elf.py",
256
+ "binprov/metrics.py",
257
+ "binprov/model.py",
258
+ "binprov/provenance.py",
259
+ "binprov/vocab.py",
260
+ "binprov/vote.py",
261
+ "binprov_config.json",
262
+ "environment.json",
263
+ "evidence/args.json",
264
+ "evidence/evaluation.json",
265
+ "evidence/probs.npz",
266
+ "evidence/result.json",
267
+ "head.json",
268
+ "inference.py",
269
+ "inference_config.json",
270
+ "labels.json",
271
+ "model/binprov_config.json",
272
+ "model/head.json",
273
+ "model/inference_config.json",
274
+ "model/labels.json",
275
+ "model/model.pt",
276
+ "requirements.txt",
277
+ "metadata.json"
278
+ ],
279
+ "sequence_accuracy": 0.842255,
280
+ "binary_accuracy": 0.93883,
281
+ "transformers_compatible": false,
282
+ "note": "BinProv-native PyTorch checkpoint; use the bundled inference.py runtime.",
283
+ "verification": {
284
+ "verified": true,
285
+ "device": "cpu",
286
+ "windows": 4,
287
+ "mean_prob": [
288
+ 0.5000002359981011,
289
+ 0.4999849506011037,
290
+ 6.91615203089746e-06,
291
+ 7.897442639431818e-06
292
+ ],
293
+ "prediction": 0
294
+ }
295
+ }
296
+ }
model/binprov_config.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "seq_bytes": 2048,
3
+ "hidden_size": 768,
4
+ "num_hidden_layers": 12,
5
+ "num_attention_heads": 12,
6
+ "intermediate_size": 3072,
7
+ "max_segments": 2,
8
+ "hidden_dropout_prob": 0.1,
9
+ "attention_probs_dropout_prob": 0.1
10
+ }
model/head.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "num_labels": 4,
3
+ "pool": "border",
4
+ "pool_heads": 4,
5
+ "mil_tau": 1.0,
6
+ "task": "opt4",
7
+ "tag": "opt4_wide_seed29"
8
+ }
model/inference_config.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "seq_bytes": 2048,
3
+ "stride": 512,
4
+ "min_bytes": 16
5
+ }
model/labels.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "task": "opt4",
3
+ "classes": [
4
+ "O0",
5
+ "O1",
6
+ "O2",
7
+ "O3"
8
+ ],
9
+ "label_map": {
10
+ "0": "O0",
11
+ "1": "O1",
12
+ "2": "O2",
13
+ "3": "O3"
14
+ },
15
+ "num_labels": 4
16
+ }
model/model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:93efdc354956bd8e3f5abc254b97538947b856e185c737f891f00ddb95065247
3
+ size 349795459
requirements.txt ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BinProv runtime dependencies.
2
+ #
3
+ # Deliberately short. The original paper built the network with fairseq; this
4
+ # re-implementation uses HuggingFace transformers instead, which is compatible
5
+ # with current PyTorch (fairseq pins old torch versions and no longer builds
6
+ # cleanly). The architecture is the same: a 12-layer, 768-hidden RoBERTa/BERT
7
+ # encoder over a 261-token byte vocabulary.
8
+ #
9
+ # ELF parsing is done in-tree (binprov/elf.py) so corpus building needs no
10
+ # native dependencies, and the metrics are hand-rolled so evaluation runs
11
+ # anywhere the model runs.
12
+
13
+ torch>=2.0
14
+ transformers>=4.35
15
+ numpy>=1.23
16
+
17
+ # Optional, only for scripts/fetch_binkit.sh:
18
+ # gdown>=4.7 BinKit is hosted on Google Drive
19
+ # py7zr>=0.20 fallback extractor; BinKit ships .7z archives. Preferred is
20
+ # bsdtar/libarchive, which is usually already installed and
21
+ # reads .7z with the same wildcard syntax as tar.
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Versions the results in docs/ were produced with. The bounds above are what
25
+ # the code needs; these are what it ran on. Accuracy numbers on this task move
26
+ # by 1-2 points between seeds, so a different torch or transformers version is
27
+ # a plausible source of a similar-sized shift -- pin these if you are comparing
28
+ # against the tables rather than just running the pipeline.
29
+ #
30
+ # python 3.12.11
31
+ # torch 2.8.0+cu128
32
+ # transformers 4.55.4
33
+ # numpy 2.3.3
34
+ #
35
+ # GPU: one NVIDIA H200 (141 GB). bf16 autocast throughout. The 2048-byte arms
36
+ # need ~33 GB; the 512-byte arms ~26 GB at batch 128.