csim-ai 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- csim_ai-0.0.1/.gitignore +27 -0
- csim_ai-0.0.1/LICENSE +21 -0
- csim_ai-0.0.1/PKG-INFO +196 -0
- csim_ai-0.0.1/README.md +152 -0
- csim_ai-0.0.1/pyproject.toml +65 -0
- csim_ai-0.0.1/src/csim_ai/__init__.py +85 -0
- csim_ai-0.0.1/src/csim_ai/_export.py +117 -0
- csim_ai-0.0.1/src/csim_ai/_fusion.py +18 -0
- csim_ai-0.0.1/src/csim_ai/_hub.py +35 -0
- csim_ai-0.0.1/src/csim_ai/_onnx_encoder.py +42 -0
- csim_ai-0.0.1/src/csim_ai/_ted.py +31 -0
- csim_ai-0.0.1/src/csim_ai/cli.py +186 -0
- csim_ai-0.0.1/tests/test_hub_download.py +23 -0
- csim_ai-0.0.1/tests/test_inference.py +51 -0
csim_ai-0.0.1/.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
.mypy_cache/
|
|
6
|
+
.ruff_cache/
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
|
|
11
|
+
# model checkpoints -- large binaries, don't belong in git history.
|
|
12
|
+
# train_log_v1.jsonl / *_log.jsonl (metric history) files are tracked.
|
|
13
|
+
training/artifacts/best_checkpoint/
|
|
14
|
+
training/artifacts/last_checkpoint/
|
|
15
|
+
training/artifacts/onnx_model/
|
|
16
|
+
training/artifacts/temp_sweep/*/best_checkpoint/
|
|
17
|
+
training/artifacts/temp_sweep/*/last_checkpoint/
|
|
18
|
+
|
|
19
|
+
# Dolos CLI (Fase 4 baseline) -- npm install output, not source.
|
|
20
|
+
training/eval/dolos_tool/node_modules/
|
|
21
|
+
training/eval/dolos_tool/package-lock.json
|
|
22
|
+
dolos-report-*/
|
|
23
|
+
|
|
24
|
+
# Fase 4 scorer -- raw per-pair feature dumps (tens of MB, regenerable in
|
|
25
|
+
# ~15-20min via build_features.py). fusion_model_v1.joblib (~300KB) and
|
|
26
|
+
# eval_fusion_v1.json (the actual result) are tracked.
|
|
27
|
+
training/scorer/artifacts/features_*.jsonl
|
csim_ai-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Edson Eddy
|
|
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.
|
csim_ai-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: csim-ai
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Neural-augmented Python code plagiarism detection for programming judges.
|
|
5
|
+
Project-URL: Homepage, https://github.com/edsoneddy/csim-ai
|
|
6
|
+
Project-URL: Source Code, https://github.com/edsoneddy/csim-ai
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/edsoneddy/csim-ai/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/edsoneddy/csim-ai#readme
|
|
9
|
+
Author-email: Edsone Eddy <crew0eddy@gmail.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: bi-encoder,code similarity,onnx,plagiarism detection,programming judges
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: huggingface-hub>=0.25
|
|
21
|
+
Requires-Dist: numpy>=2.0
|
|
22
|
+
Requires-Dist: onnxruntime>=1.20
|
|
23
|
+
Requires-Dist: tokenizers>=0.20
|
|
24
|
+
Provides-Extra: ast
|
|
25
|
+
Requires-Dist: csim>=3.3.0; extra == 'ast'
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
|
+
Provides-Extra: export
|
|
29
|
+
Requires-Dist: onnx>=1.15; extra == 'export'
|
|
30
|
+
Requires-Dist: torch>=2.9; extra == 'export'
|
|
31
|
+
Requires-Dist: transformers>=5.0; extra == 'export'
|
|
32
|
+
Provides-Extra: scorer
|
|
33
|
+
Requires-Dist: joblib>=1.3; extra == 'scorer'
|
|
34
|
+
Requires-Dist: scikit-learn>=1.5; extra == 'scorer'
|
|
35
|
+
Provides-Extra: torch
|
|
36
|
+
Requires-Dist: sentence-transformers>=3.0; extra == 'torch'
|
|
37
|
+
Requires-Dist: torch>=2.9; extra == 'torch'
|
|
38
|
+
Requires-Dist: transformers>=5.0; extra == 'torch'
|
|
39
|
+
Provides-Extra: train
|
|
40
|
+
Requires-Dist: csim>=3.3.0; extra == 'train'
|
|
41
|
+
Requires-Dist: libcst>=1.0; extra == 'train'
|
|
42
|
+
Requires-Dist: pyyaml>=6.0; extra == 'train'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# csim-ai
|
|
46
|
+
|
|
47
|
+
Neural-augmented Python code plagiarism detection for programming judges.
|
|
48
|
+
Successor to [csim](https://github.com/edsoneddy/csim) (ANTLR4 parse-tree
|
|
49
|
+
normalization + Tree Edit Distance), adding a contrastively fine-tuned
|
|
50
|
+
bi-encoder for the structural/semantic plagiarism cases where pure TED
|
|
51
|
+
similarity degrades. Scores are a fusion of both signals via a small
|
|
52
|
+
GBDT, verified to beat [Dolos](https://dolos.ugent.be/) on this
|
|
53
|
+
project's own test data -- see [docs/REPORT.md](docs/REPORT.md) for the
|
|
54
|
+
full methodology and results, [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
|
|
55
|
+
for the phase-by-phase build log.
|
|
56
|
+
|
|
57
|
+
**Task**: plagiarism detection (did B derive from A?), not semantic clone
|
|
58
|
+
detection (does B solve the same problem as A?). Two independent correct
|
|
59
|
+
solutions to the same problem are a negative, not a positive.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install csim-ai # bi-encoder cosine similarity only (onnxruntime, no torch)
|
|
65
|
+
pip install csim-ai[ast,scorer] # + csim TED signal + GBDT fusion -- the full hybrid score
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Model weights aren't bundled in the package (the ONNX export is
|
|
69
|
+
~500MB) -- run `csim-ai setup` once after installing to download and
|
|
70
|
+
cache them from Hugging Face Hub
|
|
71
|
+
([edson-eddy/csim-ai](https://huggingface.co/edson-eddy/csim-ai)).
|
|
72
|
+
After that, both the CLI and the `Scorer` class auto-detect the cache
|
|
73
|
+
and give the full hybrid score with no further flags or arguments.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install csim-ai[ast,scorer]
|
|
77
|
+
csim-ai setup
|
|
78
|
+
# bi-encoder cached at: ~/.cache/huggingface/hub/models--edson-eddy--csim-ai/...
|
|
79
|
+
# fusion model cached at: .../fusion_model.joblib
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## CLI
|
|
83
|
+
|
|
84
|
+
**CLI shape follows csim's**, not a from-scratch design: a single
|
|
85
|
+
`csim-ai` command, an action positional, `--path` pointing at a
|
|
86
|
+
directory compared exhaustively -- same pattern as `csim
|
|
87
|
+
{report,group,tree,view,info} --path DIR --lang ... --talg ...`, since
|
|
88
|
+
this tool has the same predecessor and audience.
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
csim-ai report --path submissions/
|
|
92
|
+
# b.py is similar to a.py with similarity index: 0.9998 (biencoder_cosine=1.0000, csim_ted=1.0000, fusion=0.9998)
|
|
93
|
+
|
|
94
|
+
csim-ai group --path submissions/ --threshold 0.9
|
|
95
|
+
# Group 1 (Average Similarity: 1.00):
|
|
96
|
+
# a.py
|
|
97
|
+
# c.py
|
|
98
|
+
# Unique Files (similarity below threshold):
|
|
99
|
+
# b.py
|
|
100
|
+
|
|
101
|
+
csim-ai info
|
|
102
|
+
# which optional backends (onnxruntime, tokenizers, huggingface_hub, csim, scikit-learn, torch) are available
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### `report`
|
|
106
|
+
|
|
107
|
+
Pairwise similarity report over every `.py` file in `--path`, all
|
|
108
|
+
combinations.
|
|
109
|
+
|
|
110
|
+
| Flag | Default | Meaning |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| `--path`, `-p` | required | Directory of `.py` files to compare exhaustively. |
|
|
113
|
+
| `--model-path` | Hugging Face Hub | Directory with `model.onnx`/`tokenizer.json`. Skips the Hub entirely if given. |
|
|
114
|
+
| `--fusion-model` | none | Path to a `fusion_model.joblib`. Skips the Hub entirely if given. |
|
|
115
|
+
| `--use-fusion` | off | Force-download the fusion model from HF Hub if it isn't already cached and no `--fusion-model` is given. |
|
|
116
|
+
|
|
117
|
+
### `group`
|
|
118
|
+
|
|
119
|
+
Same comparison as `report`, but groups files into connected components
|
|
120
|
+
by a similarity threshold instead of listing every pair.
|
|
121
|
+
|
|
122
|
+
Same flags as `report`, plus:
|
|
123
|
+
|
|
124
|
+
| Flag | Default | Meaning |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `--threshold`, `-t` | required | Similarity threshold (0.0-1.0) for grouping. |
|
|
127
|
+
|
|
128
|
+
### `info`
|
|
129
|
+
|
|
130
|
+
No comparison -- just reports which optional backends are importable
|
|
131
|
+
(`onnxruntime`/`tokenizers`/`huggingface_hub` from the base install;
|
|
132
|
+
`csim`/`scikit-learn` from `[ast,scorer]`; `torch` from `[export]`).
|
|
133
|
+
Takes an optional `--model-path` to also check a directory for
|
|
134
|
+
`model.onnx`/`tokenizer.json`.
|
|
135
|
+
|
|
136
|
+
### `setup`
|
|
137
|
+
|
|
138
|
+
Not part of `pip install .` -- a separate step because the weights
|
|
139
|
+
aren't bundled in the package.
|
|
140
|
+
|
|
141
|
+
| Flag | Default | Meaning |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| (none) | -- | Downloads and caches the bi-encoder + fusion model from Hugging Face Hub. |
|
|
144
|
+
| `--export-from CHECKPOINT` | none | Export a local torch checkpoint to ONNX instead of downloading (requires `pip install csim-ai[export]`) -- entirely offline, for your own fine-tuned weights rather than this project's. |
|
|
145
|
+
| `--out` | `./onnx_model` | Output directory for `--export-from`. |
|
|
146
|
+
| `--opset` | `17` | ONNX opset version for `--export-from`. |
|
|
147
|
+
| `--no-verify` | off | Skip the PyTorch-vs-ONNX parity check after `--export-from`. |
|
|
148
|
+
|
|
149
|
+
For **every** `report`/`group` result, `"similarity index"` is the
|
|
150
|
+
fusion score when available, else `biencoder_cosine`. `csim_ted`/
|
|
151
|
+
`fusion` come back as `None` (and are dropped from the report line) when
|
|
152
|
+
`csim`/`scikit-learn` aren't installed, so a bare `pip install csim-ai`
|
|
153
|
+
(no extras, no `setup`) still gives a usable bi-encoder-only score.
|
|
154
|
+
|
|
155
|
+
## Python API
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from csim_ai import Scorer
|
|
159
|
+
|
|
160
|
+
scorer = Scorer() # after `csim-ai setup`: full hybrid, cache auto-detected
|
|
161
|
+
scorer = Scorer(use_fusion=True) # force-downloads the fusion model too if `setup` wasn't run yet
|
|
162
|
+
scorer = Scorer(
|
|
163
|
+
"path/to/onnx_model",
|
|
164
|
+
fusion_model_path="path/to/fusion_model.joblib",
|
|
165
|
+
) # fully local, no network
|
|
166
|
+
|
|
167
|
+
scorer.score(code_a, code_b)
|
|
168
|
+
# {"biencoder_cosine": 0.987, "csim_ted": 0.83, "fusion": 0.978}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`Scorer(model_path=None, fusion_model_path=None, use_fusion=False)`:
|
|
172
|
+
|
|
173
|
+
- `model_path`: directory with `model.onnx`/`tokenizer.json`. `None`
|
|
174
|
+
(default) downloads from Hugging Face Hub, cached after first call.
|
|
175
|
+
- `fusion_model_path`: path to a `fusion_model.joblib`. `None` (default)
|
|
176
|
+
auto-uses a fusion model already cached by a prior `csim-ai setup` or
|
|
177
|
+
`use_fusion=True` call, without triggering a network request to check.
|
|
178
|
+
- `use_fusion`: if `True` and no `fusion_model_path` is given,
|
|
179
|
+
force-downloads the fusion model from HF Hub instead of just checking
|
|
180
|
+
the cache.
|
|
181
|
+
|
|
182
|
+
`scorer.score(code_a: str, code_b: str) -> dict` returns
|
|
183
|
+
`{"biencoder_cosine": float, "csim_ted": float | None, "fusion": float | None}`
|
|
184
|
+
-- `csim_ted`/`fusion` are `None` when `csim`/`scikit-learn` aren't
|
|
185
|
+
installed or no fusion model is available.
|
|
186
|
+
|
|
187
|
+
## Layout
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
src/csim_ai/ inference package -- ONNX bi-encoder + csim TED + GBDT fusion
|
|
191
|
+
tests/ pytest smoke tests for src/csim_ai
|
|
192
|
+
training/ dataset prep, synthetic plagiarism generation, training, eval, export tooling
|
|
193
|
+
docs/
|
|
194
|
+
REPORT.md project narrative: problem, methodology, results, limitations
|
|
195
|
+
DEVELOPMENT.md phase-by-phase build log: commands, exact numbers, bugs hit and fixed
|
|
196
|
+
```
|
csim_ai-0.0.1/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# csim-ai
|
|
2
|
+
|
|
3
|
+
Neural-augmented Python code plagiarism detection for programming judges.
|
|
4
|
+
Successor to [csim](https://github.com/edsoneddy/csim) (ANTLR4 parse-tree
|
|
5
|
+
normalization + Tree Edit Distance), adding a contrastively fine-tuned
|
|
6
|
+
bi-encoder for the structural/semantic plagiarism cases where pure TED
|
|
7
|
+
similarity degrades. Scores are a fusion of both signals via a small
|
|
8
|
+
GBDT, verified to beat [Dolos](https://dolos.ugent.be/) on this
|
|
9
|
+
project's own test data -- see [docs/REPORT.md](docs/REPORT.md) for the
|
|
10
|
+
full methodology and results, [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
|
|
11
|
+
for the phase-by-phase build log.
|
|
12
|
+
|
|
13
|
+
**Task**: plagiarism detection (did B derive from A?), not semantic clone
|
|
14
|
+
detection (does B solve the same problem as A?). Two independent correct
|
|
15
|
+
solutions to the same problem are a negative, not a positive.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install csim-ai # bi-encoder cosine similarity only (onnxruntime, no torch)
|
|
21
|
+
pip install csim-ai[ast,scorer] # + csim TED signal + GBDT fusion -- the full hybrid score
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Model weights aren't bundled in the package (the ONNX export is
|
|
25
|
+
~500MB) -- run `csim-ai setup` once after installing to download and
|
|
26
|
+
cache them from Hugging Face Hub
|
|
27
|
+
([edson-eddy/csim-ai](https://huggingface.co/edson-eddy/csim-ai)).
|
|
28
|
+
After that, both the CLI and the `Scorer` class auto-detect the cache
|
|
29
|
+
and give the full hybrid score with no further flags or arguments.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install csim-ai[ast,scorer]
|
|
33
|
+
csim-ai setup
|
|
34
|
+
# bi-encoder cached at: ~/.cache/huggingface/hub/models--edson-eddy--csim-ai/...
|
|
35
|
+
# fusion model cached at: .../fusion_model.joblib
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## CLI
|
|
39
|
+
|
|
40
|
+
**CLI shape follows csim's**, not a from-scratch design: a single
|
|
41
|
+
`csim-ai` command, an action positional, `--path` pointing at a
|
|
42
|
+
directory compared exhaustively -- same pattern as `csim
|
|
43
|
+
{report,group,tree,view,info} --path DIR --lang ... --talg ...`, since
|
|
44
|
+
this tool has the same predecessor and audience.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
csim-ai report --path submissions/
|
|
48
|
+
# b.py is similar to a.py with similarity index: 0.9998 (biencoder_cosine=1.0000, csim_ted=1.0000, fusion=0.9998)
|
|
49
|
+
|
|
50
|
+
csim-ai group --path submissions/ --threshold 0.9
|
|
51
|
+
# Group 1 (Average Similarity: 1.00):
|
|
52
|
+
# a.py
|
|
53
|
+
# c.py
|
|
54
|
+
# Unique Files (similarity below threshold):
|
|
55
|
+
# b.py
|
|
56
|
+
|
|
57
|
+
csim-ai info
|
|
58
|
+
# which optional backends (onnxruntime, tokenizers, huggingface_hub, csim, scikit-learn, torch) are available
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### `report`
|
|
62
|
+
|
|
63
|
+
Pairwise similarity report over every `.py` file in `--path`, all
|
|
64
|
+
combinations.
|
|
65
|
+
|
|
66
|
+
| Flag | Default | Meaning |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `--path`, `-p` | required | Directory of `.py` files to compare exhaustively. |
|
|
69
|
+
| `--model-path` | Hugging Face Hub | Directory with `model.onnx`/`tokenizer.json`. Skips the Hub entirely if given. |
|
|
70
|
+
| `--fusion-model` | none | Path to a `fusion_model.joblib`. Skips the Hub entirely if given. |
|
|
71
|
+
| `--use-fusion` | off | Force-download the fusion model from HF Hub if it isn't already cached and no `--fusion-model` is given. |
|
|
72
|
+
|
|
73
|
+
### `group`
|
|
74
|
+
|
|
75
|
+
Same comparison as `report`, but groups files into connected components
|
|
76
|
+
by a similarity threshold instead of listing every pair.
|
|
77
|
+
|
|
78
|
+
Same flags as `report`, plus:
|
|
79
|
+
|
|
80
|
+
| Flag | Default | Meaning |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `--threshold`, `-t` | required | Similarity threshold (0.0-1.0) for grouping. |
|
|
83
|
+
|
|
84
|
+
### `info`
|
|
85
|
+
|
|
86
|
+
No comparison -- just reports which optional backends are importable
|
|
87
|
+
(`onnxruntime`/`tokenizers`/`huggingface_hub` from the base install;
|
|
88
|
+
`csim`/`scikit-learn` from `[ast,scorer]`; `torch` from `[export]`).
|
|
89
|
+
Takes an optional `--model-path` to also check a directory for
|
|
90
|
+
`model.onnx`/`tokenizer.json`.
|
|
91
|
+
|
|
92
|
+
### `setup`
|
|
93
|
+
|
|
94
|
+
Not part of `pip install .` -- a separate step because the weights
|
|
95
|
+
aren't bundled in the package.
|
|
96
|
+
|
|
97
|
+
| Flag | Default | Meaning |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| (none) | -- | Downloads and caches the bi-encoder + fusion model from Hugging Face Hub. |
|
|
100
|
+
| `--export-from CHECKPOINT` | none | Export a local torch checkpoint to ONNX instead of downloading (requires `pip install csim-ai[export]`) -- entirely offline, for your own fine-tuned weights rather than this project's. |
|
|
101
|
+
| `--out` | `./onnx_model` | Output directory for `--export-from`. |
|
|
102
|
+
| `--opset` | `17` | ONNX opset version for `--export-from`. |
|
|
103
|
+
| `--no-verify` | off | Skip the PyTorch-vs-ONNX parity check after `--export-from`. |
|
|
104
|
+
|
|
105
|
+
For **every** `report`/`group` result, `"similarity index"` is the
|
|
106
|
+
fusion score when available, else `biencoder_cosine`. `csim_ted`/
|
|
107
|
+
`fusion` come back as `None` (and are dropped from the report line) when
|
|
108
|
+
`csim`/`scikit-learn` aren't installed, so a bare `pip install csim-ai`
|
|
109
|
+
(no extras, no `setup`) still gives a usable bi-encoder-only score.
|
|
110
|
+
|
|
111
|
+
## Python API
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from csim_ai import Scorer
|
|
115
|
+
|
|
116
|
+
scorer = Scorer() # after `csim-ai setup`: full hybrid, cache auto-detected
|
|
117
|
+
scorer = Scorer(use_fusion=True) # force-downloads the fusion model too if `setup` wasn't run yet
|
|
118
|
+
scorer = Scorer(
|
|
119
|
+
"path/to/onnx_model",
|
|
120
|
+
fusion_model_path="path/to/fusion_model.joblib",
|
|
121
|
+
) # fully local, no network
|
|
122
|
+
|
|
123
|
+
scorer.score(code_a, code_b)
|
|
124
|
+
# {"biencoder_cosine": 0.987, "csim_ted": 0.83, "fusion": 0.978}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`Scorer(model_path=None, fusion_model_path=None, use_fusion=False)`:
|
|
128
|
+
|
|
129
|
+
- `model_path`: directory with `model.onnx`/`tokenizer.json`. `None`
|
|
130
|
+
(default) downloads from Hugging Face Hub, cached after first call.
|
|
131
|
+
- `fusion_model_path`: path to a `fusion_model.joblib`. `None` (default)
|
|
132
|
+
auto-uses a fusion model already cached by a prior `csim-ai setup` or
|
|
133
|
+
`use_fusion=True` call, without triggering a network request to check.
|
|
134
|
+
- `use_fusion`: if `True` and no `fusion_model_path` is given,
|
|
135
|
+
force-downloads the fusion model from HF Hub instead of just checking
|
|
136
|
+
the cache.
|
|
137
|
+
|
|
138
|
+
`scorer.score(code_a: str, code_b: str) -> dict` returns
|
|
139
|
+
`{"biencoder_cosine": float, "csim_ted": float | None, "fusion": float | None}`
|
|
140
|
+
-- `csim_ted`/`fusion` are `None` when `csim`/`scikit-learn` aren't
|
|
141
|
+
installed or no fusion model is available.
|
|
142
|
+
|
|
143
|
+
## Layout
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
src/csim_ai/ inference package -- ONNX bi-encoder + csim TED + GBDT fusion
|
|
147
|
+
tests/ pytest smoke tests for src/csim_ai
|
|
148
|
+
training/ dataset prep, synthetic plagiarism generation, training, eval, export tooling
|
|
149
|
+
docs/
|
|
150
|
+
REPORT.md project narrative: problem, methodology, results, limitations
|
|
151
|
+
DEVELOPMENT.md phase-by-phase build log: commands, exact numbers, bugs hit and fixed
|
|
152
|
+
```
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "csim-ai"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Neural-augmented Python code plagiarism detection for programming judges."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Edsone Eddy", email = "crew0eddy@gmail.com" }]
|
|
13
|
+
keywords = ["plagiarism detection", "code similarity", "bi-encoder", "onnx", "programming judges"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["numpy>=2.0", "onnxruntime>=1.20", "tokenizers>=0.20", "huggingface_hub>=0.25"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/edsoneddy/csim-ai"
|
|
26
|
+
"Source Code" = "https://github.com/edsoneddy/csim-ai"
|
|
27
|
+
"Bug Tracker" = "https://github.com/edsoneddy/csim-ai/issues"
|
|
28
|
+
Documentation = "https://github.com/edsoneddy/csim-ai#readme"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
csim-ai = "csim_ai.cli:main"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
# NOTE: [ast]'s csim>=3.3.0 pins numpy==1.26.4 exactly on paper, while
|
|
35
|
+
# [torch] needs numpy>=2.0 -- looked like a hard conflict when [torch]
|
|
36
|
+
# first landed (Fase 2), but Fase 4 confirmed both extras installed
|
|
37
|
+
# together actually work at runtime in this venv (numpy 2.5.2): csim's
|
|
38
|
+
# TED score recomputed to match a stored Fase 0 value exactly, then ran
|
|
39
|
+
# at scale (tens of thousands of pairs) with no issues. Still needs a
|
|
40
|
+
# real packaging decision before Fase 5 (drop the exact pin? vendor a
|
|
41
|
+
# compat shim?), but it's not blocking anything today.
|
|
42
|
+
ast = ["csim>=3.3.0"] # optional TED signal via the csim package (Etapa B feature)
|
|
43
|
+
train = ["pyyaml>=6.0", "csim>=3.3.0", "libcst>=1.0"]
|
|
44
|
+
torch = ["torch>=2.9", "transformers>=5.0", "sentence-transformers>=3.0"] # zero-shot eval (Fase 2), fine-tuning (Fase 3+)
|
|
45
|
+
scorer = ["scikit-learn>=1.5", "joblib>=1.3"] # GBDT fusion scorer (Fase 4)
|
|
46
|
+
export = ["onnx>=1.15", "torch>=2.9", "transformers>=5.0"] # `csim-ai setup --export-from` / training/export_onnx.py -- not needed for inference
|
|
47
|
+
dev = ["pytest>=8.0"]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/csim_ai"]
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.sdist]
|
|
53
|
+
# Allowlist, not a denylist -- hatchling's default sdist walks the whole
|
|
54
|
+
# repo (not just git-tracked files), which pulled in training/'s
|
|
55
|
+
# research artifacts and even .claude/ session state on a first build.
|
|
56
|
+
# Keep the sdist scoped to what's actually needed to build the wheel +
|
|
57
|
+
# the files PyPI expects (README/LICENSE), same as the wheel's own
|
|
58
|
+
# "lean base install" scope -- docs/ and training/ stay on GitHub only.
|
|
59
|
+
include = [
|
|
60
|
+
"src/csim_ai",
|
|
61
|
+
"tests",
|
|
62
|
+
"README.md",
|
|
63
|
+
"LICENSE",
|
|
64
|
+
"pyproject.toml",
|
|
65
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""csim-ai: neural-augmented Python code plagiarism detection for
|
|
2
|
+
programming judges.
|
|
3
|
+
|
|
4
|
+
Base install (`pip install csim-ai`) gives bi-encoder cosine similarity
|
|
5
|
+
only, via onnxruntime + tokenizers + huggingface_hub (no torch).
|
|
6
|
+
`pip install csim-ai[ast,scorer]` adds the csim TED signal and the GBDT
|
|
7
|
+
fusion of both -- the full hybrid scorer from Fase 4.
|
|
8
|
+
|
|
9
|
+
Model weights aren't bundled in the package (the ONNX export is
|
|
10
|
+
~500MB) -- `Scorer()` with no arguments downloads the pre-trained
|
|
11
|
+
bi-encoder from Hugging Face Hub (`edson-eddy/csim-ai`) on first use and
|
|
12
|
+
caches it there after; pass `model_path` to use a local export instead
|
|
13
|
+
(from `training/export_onnx.py` or `csim-ai setup --export-from`).
|
|
14
|
+
|
|
15
|
+
Recommended flow: `pip install csim-ai[ast,scorer]` then `csim-ai
|
|
16
|
+
setup` once (downloads both the bi-encoder and the fusion model) --
|
|
17
|
+
after that, `Scorer()` with no arguments auto-detects the cached fusion
|
|
18
|
+
model and gives the full hybrid score with no further flags/arguments,
|
|
19
|
+
same as the CLI's `report`/`group` without `--use-fusion`. Pass
|
|
20
|
+
`use_fusion=True` to force-download the fusion model on the spot instead
|
|
21
|
+
of requiring `setup` first.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from ._onnx_encoder import OnnxEncoder
|
|
28
|
+
|
|
29
|
+
__version__ = "0.0.1"
|
|
30
|
+
|
|
31
|
+
__all__ = ["Scorer", "__version__"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Scorer:
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
model_path: str | Path | None = None,
|
|
38
|
+
fusion_model_path: str | Path | None = None,
|
|
39
|
+
use_fusion: bool = False,
|
|
40
|
+
):
|
|
41
|
+
if model_path is None:
|
|
42
|
+
from ._hub import download_model
|
|
43
|
+
|
|
44
|
+
model_path = download_model()
|
|
45
|
+
self._encoder = OnnxEncoder(model_path)
|
|
46
|
+
|
|
47
|
+
if fusion_model_path is None:
|
|
48
|
+
if use_fusion:
|
|
49
|
+
from ._hub import download_fusion_model
|
|
50
|
+
|
|
51
|
+
fusion_model_path = download_fusion_model()
|
|
52
|
+
else:
|
|
53
|
+
# Auto-use the fusion model if a prior `csim-ai setup` (or
|
|
54
|
+
# an earlier use_fusion=True call) already cached it --
|
|
55
|
+
# matches it being available without forcing a network
|
|
56
|
+
# request just to check.
|
|
57
|
+
from ._hub import cached_fusion_model
|
|
58
|
+
|
|
59
|
+
fusion_model_path = cached_fusion_model()
|
|
60
|
+
|
|
61
|
+
self._fusion = None
|
|
62
|
+
if fusion_model_path is not None:
|
|
63
|
+
try:
|
|
64
|
+
from ._fusion import FusionModel
|
|
65
|
+
|
|
66
|
+
self._fusion = FusionModel(fusion_model_path)
|
|
67
|
+
except ImportError:
|
|
68
|
+
pass # scikit-learn not installed -- degrade to bi-encoder-only
|
|
69
|
+
|
|
70
|
+
def score(self, code_a: str, code_b: str) -> dict:
|
|
71
|
+
biencoder_cosine = self._encoder.cosine_similarity(code_a, code_b)
|
|
72
|
+
|
|
73
|
+
csim_ted = None
|
|
74
|
+
try:
|
|
75
|
+
from ._ted import ted_score
|
|
76
|
+
|
|
77
|
+
csim_ted = ted_score(code_a, code_b)
|
|
78
|
+
except ImportError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
fusion = None
|
|
82
|
+
if self._fusion is not None and csim_ted is not None:
|
|
83
|
+
fusion = self._fusion.predict(biencoder_cosine, csim_ted)
|
|
84
|
+
|
|
85
|
+
return {"biencoder_cosine": biencoder_cosine, "csim_ted": csim_ted, "fusion": fusion}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""ONNX export of a fine-tuned bi-encoder checkpoint -- shared by
|
|
2
|
+
`training/export_onnx.py` (dev-side, points at
|
|
3
|
+
training/artifacts/best_checkpoint by default) and `csim-ai setup
|
|
4
|
+
--export-from` (works from any installed checkpoint, no access to this
|
|
5
|
+
repo's training/ tree needed).
|
|
6
|
+
|
|
7
|
+
Requires the `export` extra (`torch`, `transformers`, `onnx`) -- not a
|
|
8
|
+
base-install dependency, imported lazily here.
|
|
9
|
+
|
|
10
|
+
See docs/DEVELOPMENT.md, Fase 5, for why: legacy TorchScript exporter (not the
|
|
11
|
+
dynamo-based default in torch >=2.9, which needs `onnxscript`), fp32
|
|
12
|
+
only (int8 dynamic quantization measurably breaks embedding direction;
|
|
13
|
+
fp16 is ~7x slower than fp32 on CPU).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import shutil
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def mean_pool(last_hidden, attention_mask):
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
mask = attention_mask[..., None].astype(np.float32)
|
|
25
|
+
summed = (last_hidden * mask).sum(1)
|
|
26
|
+
counts = mask.sum(1).clip(min=1e-9)
|
|
27
|
+
pooled = summed / counts
|
|
28
|
+
return pooled / np.linalg.norm(pooled, axis=-1, keepdims=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def verify(onnx_path: Path, checkpoint: Path) -> float:
|
|
32
|
+
import numpy as np
|
|
33
|
+
import onnxruntime as ort
|
|
34
|
+
import torch
|
|
35
|
+
from transformers import AutoModel, AutoTokenizer
|
|
36
|
+
|
|
37
|
+
tok = AutoTokenizer.from_pretrained(checkpoint)
|
|
38
|
+
model = AutoModel.from_pretrained(checkpoint).eval()
|
|
39
|
+
sess = ort.InferenceSession(str(onnx_path))
|
|
40
|
+
|
|
41
|
+
samples = [
|
|
42
|
+
["def add(a, b):\n return a + b\n"],
|
|
43
|
+
[
|
|
44
|
+
"def add(a, b):\n return a + b\n",
|
|
45
|
+
"import math\ndef f(x):\n return math.sqrt(x) + 1\n\nprint(f(4))\n",
|
|
46
|
+
],
|
|
47
|
+
]
|
|
48
|
+
max_diff = 0.0
|
|
49
|
+
for batch in samples:
|
|
50
|
+
inputs = tok(batch, return_tensors="pt", padding=True, truncation=True, max_length=512)
|
|
51
|
+
with torch.no_grad():
|
|
52
|
+
torch_out = model(**inputs).last_hidden_state.numpy()
|
|
53
|
+
torch_pooled = mean_pool(torch_out, inputs["attention_mask"].numpy())
|
|
54
|
+
|
|
55
|
+
onnx_out = sess.run(
|
|
56
|
+
None, {"input_ids": inputs["input_ids"].numpy(), "attention_mask": inputs["attention_mask"].numpy()}
|
|
57
|
+
)[0]
|
|
58
|
+
onnx_pooled = mean_pool(onnx_out, inputs["attention_mask"].numpy())
|
|
59
|
+
|
|
60
|
+
diff = float(np.abs(torch_pooled - onnx_pooled).max())
|
|
61
|
+
cos = float((torch_pooled * onnx_pooled).sum(-1).min())
|
|
62
|
+
max_diff = max(max_diff, diff)
|
|
63
|
+
print(f" batch size {len(batch)}: max abs diff={diff:.2e}, min cosine={cos:.6f}")
|
|
64
|
+
|
|
65
|
+
return max_diff
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def export(checkpoint: Path, out_dir: Path, opset: int = 17, verify_after: bool = False) -> Path:
|
|
69
|
+
import torch
|
|
70
|
+
from transformers import AutoModel, AutoTokenizer
|
|
71
|
+
|
|
72
|
+
class _LastHiddenStateOnly(torch.nn.Module):
|
|
73
|
+
def __init__(self, model: torch.nn.Module):
|
|
74
|
+
super().__init__()
|
|
75
|
+
self.model = model
|
|
76
|
+
|
|
77
|
+
def forward(self, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
|
78
|
+
return self.model(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
|
|
79
|
+
|
|
80
|
+
checkpoint = Path(checkpoint)
|
|
81
|
+
out_dir = Path(out_dir)
|
|
82
|
+
|
|
83
|
+
tok = AutoTokenizer.from_pretrained(checkpoint)
|
|
84
|
+
model = AutoModel.from_pretrained(checkpoint).eval()
|
|
85
|
+
wrapped = _LastHiddenStateOnly(model)
|
|
86
|
+
|
|
87
|
+
sample = tok(["def f(x):\n return x\n"], return_tensors="pt")
|
|
88
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
onnx_path = out_dir / "model.onnx"
|
|
90
|
+
|
|
91
|
+
torch.onnx.export(
|
|
92
|
+
wrapped,
|
|
93
|
+
(sample["input_ids"], sample["attention_mask"]),
|
|
94
|
+
str(onnx_path),
|
|
95
|
+
input_names=["input_ids", "attention_mask"],
|
|
96
|
+
output_names=["last_hidden_state"],
|
|
97
|
+
dynamic_axes={
|
|
98
|
+
"input_ids": {0: "batch", 1: "seq"},
|
|
99
|
+
"attention_mask": {0: "batch", 1: "seq"},
|
|
100
|
+
"last_hidden_state": {0: "batch", 1: "seq"},
|
|
101
|
+
},
|
|
102
|
+
opset_version=opset,
|
|
103
|
+
dynamo=False,
|
|
104
|
+
)
|
|
105
|
+
print(f"exported: {onnx_path} ({onnx_path.stat().st_size / 1e6:.1f} MB)")
|
|
106
|
+
|
|
107
|
+
for name in ("tokenizer.json", "tokenizer_config.json"):
|
|
108
|
+
shutil.copy(checkpoint / name, out_dir / name)
|
|
109
|
+
print(f"copied tokenizer files to {out_dir}")
|
|
110
|
+
|
|
111
|
+
if verify_after:
|
|
112
|
+
max_diff = verify(onnx_path, checkpoint)
|
|
113
|
+
if max_diff > 1e-3:
|
|
114
|
+
raise SystemExit(f"ONNX/PyTorch parity check failed: max abs diff {max_diff:.2e} > 1e-3")
|
|
115
|
+
print("parity OK")
|
|
116
|
+
|
|
117
|
+
return onnx_path
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""GBDT fusion of bi-encoder cosine + csim TED -- requires the `scorer`
|
|
2
|
+
extra (`pip install csim-ai[scorer]`). Imported lazily by `Scorer` so
|
|
3
|
+
the base install doesn't need `scikit-learn`/`joblib`.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FusionModel:
|
|
11
|
+
def __init__(self, model_path: str | Path):
|
|
12
|
+
import joblib
|
|
13
|
+
|
|
14
|
+
self.model = joblib.load(model_path)
|
|
15
|
+
|
|
16
|
+
def predict(self, biencoder_cosine: float, csim_ted: float) -> float:
|
|
17
|
+
proba = self.model.predict_proba([[biencoder_cosine, csim_ted]])
|
|
18
|
+
return float(proba[0, 1])
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Hugging Face Hub download for the pre-trained bi-encoder + fusion
|
|
2
|
+
model -- lets `Scorer()`/`csim-ai setup --download` work out of the box
|
|
3
|
+
without a local checkpoint or the `[export]` extra (torch/transformers).
|
|
4
|
+
|
|
5
|
+
`huggingface_hub` is a base dependency: no torch, just small utility
|
|
6
|
+
deps (requests/filelock/tqdm/...), so it doesn't break the "no torch in
|
|
7
|
+
the base install" promise from Fase 5.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
DEFAULT_REPO_ID = "edson-eddy/csim-ai"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def download_model(repo_id: str = DEFAULT_REPO_ID, revision: str | None = None) -> str:
|
|
15
|
+
from huggingface_hub import snapshot_download
|
|
16
|
+
|
|
17
|
+
return snapshot_download(repo_id=repo_id, revision=revision, allow_patterns=["model.onnx", "tokenizer*.json"])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def download_fusion_model(repo_id: str = DEFAULT_REPO_ID, revision: str | None = None) -> str:
|
|
21
|
+
from huggingface_hub import hf_hub_download
|
|
22
|
+
|
|
23
|
+
return hf_hub_download(repo_id=repo_id, filename="fusion_model.joblib", revision=revision)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cached_fusion_model(repo_id: str = DEFAULT_REPO_ID, revision: str | None = None) -> str | None:
|
|
27
|
+
"""Like download_fusion_model, but never hits the network -- returns
|
|
28
|
+
None if it isn't already cached locally (e.g. from a prior `csim-ai
|
|
29
|
+
setup` or `use_fusion=True` call), instead of downloading it."""
|
|
30
|
+
from huggingface_hub import hf_hub_download
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
return hf_hub_download(repo_id=repo_id, filename="fusion_model.joblib", revision=revision, local_files_only=True)
|
|
34
|
+
except Exception:
|
|
35
|
+
return None
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""ONNX bi-encoder wrapper -- onnxruntime + tokenizers only, no
|
|
2
|
+
torch/transformers at runtime. Mean-pool + L2-normalize matches
|
|
3
|
+
`mean_pool()` in `training/train_biencoder.py` exactly, since that's
|
|
4
|
+
what the exported ONNX graph was trained against.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import onnxruntime as ort
|
|
12
|
+
from tokenizers import Tokenizer
|
|
13
|
+
|
|
14
|
+
MAX_LENGTH = 512
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _mean_pool(last_hidden: np.ndarray, attention_mask: np.ndarray) -> np.ndarray:
|
|
18
|
+
mask = attention_mask[..., None].astype(np.float32)
|
|
19
|
+
summed = (last_hidden * mask).sum(1)
|
|
20
|
+
counts = mask.sum(1).clip(min=1e-9)
|
|
21
|
+
pooled = summed / counts
|
|
22
|
+
return pooled / np.linalg.norm(pooled, axis=-1, keepdims=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OnnxEncoder:
|
|
26
|
+
def __init__(self, model_dir: str | Path):
|
|
27
|
+
model_dir = Path(model_dir)
|
|
28
|
+
self.session = ort.InferenceSession(str(model_dir / "model.onnx"))
|
|
29
|
+
self.tokenizer = Tokenizer.from_file(str(model_dir / "tokenizer.json"))
|
|
30
|
+
self.tokenizer.enable_padding()
|
|
31
|
+
self.tokenizer.enable_truncation(max_length=MAX_LENGTH)
|
|
32
|
+
|
|
33
|
+
def encode(self, texts: list[str]) -> np.ndarray:
|
|
34
|
+
encodings = self.tokenizer.encode_batch(texts)
|
|
35
|
+
input_ids = np.array([e.ids for e in encodings], dtype=np.int64)
|
|
36
|
+
attention_mask = np.array([e.attention_mask for e in encodings], dtype=np.int64)
|
|
37
|
+
last_hidden = self.session.run(None, {"input_ids": input_ids, "attention_mask": attention_mask})[0]
|
|
38
|
+
return _mean_pool(last_hidden, attention_mask)
|
|
39
|
+
|
|
40
|
+
def cosine_similarity(self, text_a: str, text_b: str) -> float:
|
|
41
|
+
embs = self.encode([text_a, text_b])
|
|
42
|
+
return float(np.dot(embs[0], embs[1]))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""csim TED (tree-edit-distance) signal -- requires the `ast` extra
|
|
2
|
+
(`pip install csim-ai[ast]`). Imported lazily by `Scorer` so the base
|
|
3
|
+
install doesn't need `csim`.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import io
|
|
9
|
+
|
|
10
|
+
LANG = "python_3_13"
|
|
11
|
+
TED_ALGORITHM = "apted"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ted_score(code_a: str, code_b: str) -> float | None:
|
|
15
|
+
# csim.utils.preprocess_code does *not* raise on a syntax error --
|
|
16
|
+
# its ANTLR grammar prints "Syntax error ..." to stderr and returns a
|
|
17
|
+
# degenerate near-empty tree instead, which would otherwise silently
|
|
18
|
+
# produce a garbage similarity score. Capture stderr and treat any
|
|
19
|
+
# output as a failed parse (same fix as training/scorer/build_features.py).
|
|
20
|
+
from csim.utils import get_similarity_coefficient, preprocess_code
|
|
21
|
+
|
|
22
|
+
buf = io.StringIO()
|
|
23
|
+
try:
|
|
24
|
+
with contextlib.redirect_stderr(buf):
|
|
25
|
+
proc_a = preprocess_code("a", code_a, LANG)
|
|
26
|
+
proc_b = preprocess_code("b", code_b, LANG)
|
|
27
|
+
if buf.getvalue():
|
|
28
|
+
return None
|
|
29
|
+
return get_similarity_coefficient(proc_a, proc_b, TED_ALGORITHM)
|
|
30
|
+
except Exception:
|
|
31
|
+
return None
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""`csim-ai {report,group,info,setup}` -- CLI entry point (see
|
|
2
|
+
[project.scripts] in pyproject.toml). Mirrors csim's CLI shape (a
|
|
3
|
+
single command, an action positional, `--path` pointing at a directory
|
|
4
|
+
of source files compared exhaustively) rather than a two-file-only
|
|
5
|
+
interface, for consistency with the predecessor tool.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import itertools
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import Scorer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _primary_score(result: dict) -> float | None:
|
|
18
|
+
return result["fusion"] if result["fusion"] is not None else result["biencoder_cosine"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _iter_py_files(path: Path) -> list[Path]:
|
|
22
|
+
return sorted(p for p in path.iterdir() if p.suffix == ".py")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _score_all_pairs(files: list[Path], scorer: Scorer) -> dict[tuple[Path, Path], dict]:
|
|
26
|
+
results = {}
|
|
27
|
+
for a, b in itertools.combinations(files, 2):
|
|
28
|
+
code_a = a.read_text(encoding="utf-8", errors="ignore")
|
|
29
|
+
code_b = b.read_text(encoding="utf-8", errors="ignore")
|
|
30
|
+
results[(a, b)] = scorer.score(code_a, code_b)
|
|
31
|
+
return results
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cmd_report(args: argparse.Namespace) -> None:
|
|
35
|
+
scorer = Scorer(args.model_path, fusion_model_path=args.fusion_model, use_fusion=args.use_fusion)
|
|
36
|
+
files = _iter_py_files(args.path)
|
|
37
|
+
results = _score_all_pairs(files, scorer)
|
|
38
|
+
for (a, b), r in results.items():
|
|
39
|
+
extra = f" (biencoder_cosine={r['biencoder_cosine']:.4f}"
|
|
40
|
+
if r["csim_ted"] is not None:
|
|
41
|
+
extra += f", csim_ted={r['csim_ted']:.4f}"
|
|
42
|
+
if r["fusion"] is not None:
|
|
43
|
+
extra += f", fusion={r['fusion']:.4f}"
|
|
44
|
+
extra += ")"
|
|
45
|
+
print(f"{b} is similar to {a} with similarity index: {_primary_score(r):.4f}{extra}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_group(args: argparse.Namespace) -> None:
|
|
49
|
+
scorer = Scorer(args.model_path, fusion_model_path=args.fusion_model, use_fusion=args.use_fusion)
|
|
50
|
+
files = _iter_py_files(args.path)
|
|
51
|
+
results = _score_all_pairs(files, scorer)
|
|
52
|
+
|
|
53
|
+
parent = {f: f for f in files}
|
|
54
|
+
|
|
55
|
+
def find(f: Path) -> Path:
|
|
56
|
+
while parent[f] != f:
|
|
57
|
+
parent[f] = parent[parent[f]]
|
|
58
|
+
f = parent[f]
|
|
59
|
+
return f
|
|
60
|
+
|
|
61
|
+
def union(a: Path, b: Path) -> None:
|
|
62
|
+
ra, rb = find(a), find(b)
|
|
63
|
+
if ra != rb:
|
|
64
|
+
parent[ra] = rb
|
|
65
|
+
|
|
66
|
+
for (a, b), r in results.items():
|
|
67
|
+
if _primary_score(r) >= args.threshold:
|
|
68
|
+
union(a, b)
|
|
69
|
+
|
|
70
|
+
groups: dict[Path, list[Path]] = {}
|
|
71
|
+
for f in files:
|
|
72
|
+
groups.setdefault(find(f), []).append(f)
|
|
73
|
+
|
|
74
|
+
print(f"Threshold: {args.threshold}")
|
|
75
|
+
print(f"Total files processed: {len(files)}")
|
|
76
|
+
|
|
77
|
+
group_num = 0
|
|
78
|
+
unique_files = []
|
|
79
|
+
for members in groups.values():
|
|
80
|
+
if len(members) == 1:
|
|
81
|
+
unique_files.append(members[0])
|
|
82
|
+
continue
|
|
83
|
+
group_num += 1
|
|
84
|
+
pair_scores = [
|
|
85
|
+
_primary_score(results[(a, b)] if (a, b) in results else results[(b, a)])
|
|
86
|
+
for a, b in itertools.combinations(members, 2)
|
|
87
|
+
]
|
|
88
|
+
avg = sum(pair_scores) / len(pair_scores)
|
|
89
|
+
print(f"Group {group_num} (Average Similarity: {avg:.2f}):")
|
|
90
|
+
for f in members:
|
|
91
|
+
print(f)
|
|
92
|
+
|
|
93
|
+
if unique_files:
|
|
94
|
+
print("Unique Files (similarity below threshold):")
|
|
95
|
+
for f in unique_files:
|
|
96
|
+
print(f)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_info(args: argparse.Namespace) -> None:
|
|
100
|
+
print("csim-ai backends")
|
|
101
|
+
print()
|
|
102
|
+
|
|
103
|
+
def check(label: str, module: str) -> None:
|
|
104
|
+
try:
|
|
105
|
+
mod = __import__(module)
|
|
106
|
+
version = getattr(mod, "__version__", "?")
|
|
107
|
+
print(f" {label:<24} available (v{version})")
|
|
108
|
+
except ImportError:
|
|
109
|
+
print(f" {label:<24} not installed")
|
|
110
|
+
|
|
111
|
+
check("onnxruntime (base)", "onnxruntime")
|
|
112
|
+
check("tokenizers (base)", "tokenizers")
|
|
113
|
+
check("huggingface_hub (base)", "huggingface_hub")
|
|
114
|
+
check("csim (ast extra)", "csim")
|
|
115
|
+
check("scikit-learn (scorer extra)", "sklearn")
|
|
116
|
+
check("torch (export extra)", "torch")
|
|
117
|
+
|
|
118
|
+
if args.model_path:
|
|
119
|
+
model_path = Path(args.model_path)
|
|
120
|
+
onnx_ok = (model_path / "model.onnx").exists()
|
|
121
|
+
tok_ok = (model_path / "tokenizer.json").exists()
|
|
122
|
+
print()
|
|
123
|
+
print(f" model-path: {model_path}")
|
|
124
|
+
print(f" model.onnx: {'found' if onnx_ok else 'MISSING'}")
|
|
125
|
+
print(f" tokenizer.json: {'found' if tok_ok else 'MISSING'}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cmd_setup(args: argparse.Namespace) -> None:
|
|
129
|
+
if args.export_from:
|
|
130
|
+
try:
|
|
131
|
+
from ._export import export
|
|
132
|
+
except ImportError:
|
|
133
|
+
print("The `export` extra is required: pip install csim-ai[export]", file=sys.stderr)
|
|
134
|
+
raise SystemExit(1)
|
|
135
|
+
|
|
136
|
+
export(args.export_from, args.out, opset=args.opset, verify_after=not args.no_verify)
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# Default action: download the pre-trained weights from HF Hub, so
|
|
140
|
+
# `pip install csim-ai && csim-ai setup` is all that's needed before
|
|
141
|
+
# `report`/`group` work with the full hybrid score, no flags needed
|
|
142
|
+
# after that (Scorer() auto-detects the cache -- see __init__.py).
|
|
143
|
+
from ._hub import download_fusion_model, download_model
|
|
144
|
+
|
|
145
|
+
model_path = download_model()
|
|
146
|
+
print(f"bi-encoder cached at: {model_path}")
|
|
147
|
+
fusion_path = download_fusion_model()
|
|
148
|
+
print(f"fusion model cached at: {fusion_path}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main() -> None:
|
|
152
|
+
parser = argparse.ArgumentParser(description="Score Python files for plagiarism similarity.")
|
|
153
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
154
|
+
|
|
155
|
+
def add_path_args(p: argparse.ArgumentParser) -> None:
|
|
156
|
+
p.add_argument("--path", "-p", type=Path, required=True, help="Directory of .py files to compare exhaustively.")
|
|
157
|
+
p.add_argument("--model-path", default=None, help="Directory with model.onnx/tokenizer.json. Default: download from Hugging Face Hub.")
|
|
158
|
+
p.add_argument("--fusion-model", default=None, help="Path to a fusion_model.joblib (requires the [ast,scorer] extras).")
|
|
159
|
+
p.add_argument("--use-fusion", action="store_true", help="Use the fusion score, downloading the fusion model from Hugging Face Hub if --fusion-model isn't given.")
|
|
160
|
+
|
|
161
|
+
report_p = sub.add_parser("report", help="Pairwise similarity report over a directory.")
|
|
162
|
+
add_path_args(report_p)
|
|
163
|
+
report_p.set_defaults(func=cmd_report)
|
|
164
|
+
|
|
165
|
+
group_p = sub.add_parser("group", help="Group files by similarity threshold.")
|
|
166
|
+
add_path_args(group_p)
|
|
167
|
+
group_p.add_argument("--threshold", "-t", type=float, required=True, help="Similarity threshold (0.0-1.0) for grouping.")
|
|
168
|
+
group_p.set_defaults(func=cmd_group)
|
|
169
|
+
|
|
170
|
+
info_p = sub.add_parser("info", help="Show which optional backends (onnxruntime, csim, scikit-learn, torch) are available.")
|
|
171
|
+
info_p.add_argument("--model-path", default=None, help="Optionally check a model directory for model.onnx/tokenizer.json.")
|
|
172
|
+
info_p.set_defaults(func=cmd_info)
|
|
173
|
+
|
|
174
|
+
setup_p = sub.add_parser("setup", help="Download the pre-trained weights from Hugging Face Hub (default), or export a local checkpoint to ONNX instead.")
|
|
175
|
+
setup_p.add_argument("--export-from", default=None, help="Local torch checkpoint directory to export to ONNX instead of downloading (requires the [export] extra). No network access -- see docs/DEVELOPMENT.md, Fase 5.")
|
|
176
|
+
setup_p.add_argument("--out", default="onnx_model", help="Output directory for --export-from (default: ./onnx_model).")
|
|
177
|
+
setup_p.add_argument("--opset", type=int, default=17)
|
|
178
|
+
setup_p.add_argument("--no-verify", action="store_true", help="Skip the PyTorch-vs-ONNX parity check after --export-from.")
|
|
179
|
+
setup_p.set_defaults(func=cmd_setup)
|
|
180
|
+
|
|
181
|
+
args = parser.parse_args()
|
|
182
|
+
args.func(args)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
main()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Tests the Hugging Face Hub auto-download path (`Scorer()` with no
|
|
2
|
+
model_path). Requires network access to huggingface.co -- unlike
|
|
3
|
+
test_inference.py, this doesn't skip based on local artifacts, since the
|
|
4
|
+
whole point is to not need any.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from csim_ai import Scorer
|
|
9
|
+
|
|
10
|
+
DUPLICATE_A = "def add(a, b):\n return a + b\n"
|
|
11
|
+
DUPLICATE_B = "def add(x, y):\n # add two numbers\n return x + y\n"
|
|
12
|
+
UNRELATED_A = "def add(a, b):\n return a + b\n"
|
|
13
|
+
UNRELATED_B = "import sys\nfor line in sys.stdin:\n print(line.strip()[::-1])\n"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_auto_download_full_scoring():
|
|
17
|
+
scorer = Scorer(use_fusion=True)
|
|
18
|
+
dup = scorer.score(DUPLICATE_A, DUPLICATE_B)
|
|
19
|
+
unrelated = scorer.score(UNRELATED_A, UNRELATED_B)
|
|
20
|
+
|
|
21
|
+
assert dup["fusion"] > unrelated["fusion"]
|
|
22
|
+
assert dup["fusion"] > 0.5
|
|
23
|
+
assert unrelated["fusion"] < 0.5
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Fase 5 inference smoke tests. Skips when the gitignored local
|
|
2
|
+
artifacts (ONNX export, fusion model) aren't present -- they're
|
|
3
|
+
regenerated via `training/export_onnx.py` and
|
|
4
|
+
`training/scorer/train_fusion.py`, not part of a fresh clone.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from csim_ai import Scorer
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).parent.parent
|
|
15
|
+
ONNX_MODEL_DIR = ROOT / "training" / "artifacts" / "onnx_model"
|
|
16
|
+
FUSION_MODEL_PATH = ROOT / "training" / "scorer" / "artifacts" / "fusion_model_v1.joblib"
|
|
17
|
+
|
|
18
|
+
pytestmark = pytest.mark.skipif(
|
|
19
|
+
not (ONNX_MODEL_DIR / "model.onnx").exists(),
|
|
20
|
+
reason="ONNX model not exported locally -- run training/export_onnx.py first",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
DUPLICATE_A = "def add(a, b):\n return a + b\n"
|
|
24
|
+
DUPLICATE_B = "def add(x, y):\n # add two numbers\n return x + y\n"
|
|
25
|
+
UNRELATED_A = "def add(a, b):\n return a + b\n"
|
|
26
|
+
UNRELATED_B = "import sys\nfor line in sys.stdin:\n print(line.strip()[::-1])\n"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_biencoder_only_scoring():
|
|
30
|
+
# fusion_model_path=None, use_fusion=False (default): Scorer still
|
|
31
|
+
# auto-uses a fusion model if one happens to already be cached from
|
|
32
|
+
# HF Hub (e.g. a prior `csim-ai setup` or use_fusion=True call) --
|
|
33
|
+
# so this only asserts what's guaranteed regardless of cache state.
|
|
34
|
+
scorer = Scorer(ONNX_MODEL_DIR)
|
|
35
|
+
dup = scorer.score(DUPLICATE_A, DUPLICATE_B)
|
|
36
|
+
unrelated = scorer.score(UNRELATED_A, UNRELATED_B)
|
|
37
|
+
|
|
38
|
+
assert dup["biencoder_cosine"] > unrelated["biencoder_cosine"]
|
|
39
|
+
assert dup["biencoder_cosine"] > 0.8
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.skipif(not FUSION_MODEL_PATH.exists(), reason="fusion model not trained locally")
|
|
43
|
+
def test_fusion_scoring():
|
|
44
|
+
scorer = Scorer(ONNX_MODEL_DIR, fusion_model_path=FUSION_MODEL_PATH)
|
|
45
|
+
dup = scorer.score(DUPLICATE_A, DUPLICATE_B)
|
|
46
|
+
unrelated = scorer.score(UNRELATED_A, UNRELATED_B)
|
|
47
|
+
|
|
48
|
+
assert dup["fusion"] is not None
|
|
49
|
+
assert dup["fusion"] > unrelated["fusion"]
|
|
50
|
+
assert dup["fusion"] > 0.5
|
|
51
|
+
assert unrelated["fusion"] < 0.5
|