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.
@@ -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
+ ```
@@ -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