init release
Browse files- LICENSE +29 -0
- README.md +68 -3
- REPORT.md +246 -0
- metrics/summary.json +91 -0
- metrics/test_metrics.json +202 -0
- metrics/val_metrics.json +202 -0
- model/snip_model.json +0 -0
- runtime/model-data.js +0 -0
- runtime/snip.js +146 -0
- runtime/snip.ts +211 -0
LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
BSD 3-Clause License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026, Wes Ring
|
| 4 |
+
All rights reserved.
|
| 5 |
+
|
| 6 |
+
Redistribution and use in source and binary forms, with or without
|
| 7 |
+
modification, are permitted provided that the following conditions are met:
|
| 8 |
+
|
| 9 |
+
1. Redistributions of source code must retain the above copyright notice, this
|
| 10 |
+
list of conditions and the following disclaimer.
|
| 11 |
+
|
| 12 |
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
| 13 |
+
this list of conditions and the following disclaimer in the documentation
|
| 14 |
+
and/or other materials provided with the distribution.
|
| 15 |
+
|
| 16 |
+
3. Neither the name of the copyright holder nor the names of its
|
| 17 |
+
contributors may be used to endorse or promote products derived from
|
| 18 |
+
this software without specific prior written permission.
|
| 19 |
+
|
| 20 |
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
| 21 |
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
| 22 |
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
| 23 |
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
| 24 |
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
| 25 |
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
| 26 |
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
| 27 |
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
| 28 |
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
| 29 |
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
README.md
CHANGED
|
@@ -1,3 +1,68 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SNIP Model Card
|
| 2 |
+
|
| 3 |
+
## Model
|
| 4 |
+
|
| 5 |
+
- Name: Small N-gram Identifier for Pastes (SNIP)
|
| 6 |
+
- Package version: `1.0.0`
|
| 7 |
+
- Model version: `snip-109`
|
| 8 |
+
- Model file: `model/snip_model.json`
|
| 9 |
+
- Runtime source: `src/snip.ts`
|
| 10 |
+
- Published runtime: `dist/snip.js`
|
| 11 |
+
|
| 12 |
+
## Intended Use
|
| 13 |
+
|
| 14 |
+
SNIP predicts a likely syntax or text label for pasted text, snippets, logs, configuration files, and text-like source files. It is designed for browser-local inference in applications where sending pasted content to a server is undesirable. It aims to be quick, small in size, and fairly accurate.
|
| 15 |
+
|
| 16 |
+
## Labels
|
| 17 |
+
|
| 18 |
+
`bash`, `c`, `cpp`, `csharp`, `css`, `csv`, `diff`, `dockerfile`, `go`, `html`, `ini`, `java`, `javascript`, `json`, `log`, `lua`, `markdown`, `php`, `plain_text`, `powershell`, `python`, `ruby`, `rust`, `sql`, `toml`, `typescript`, `xml`, `yaml`.
|
| 19 |
+
|
| 20 |
+
## Architecture
|
| 21 |
+
|
| 22 |
+
- Multiclass linear classifier
|
| 23 |
+
- Hashed character n-grams, length 1-5
|
| 24 |
+
- 32,768 hash buckets
|
| 25 |
+
- L2-normalized `log1p(count)` features
|
| 26 |
+
- 1,500 retained weights per label
|
| 27 |
+
- 4-decimal serialized weights
|
| 28 |
+
- TypeScript source with no runtime dependencies
|
| 29 |
+
|
| 30 |
+
## Size
|
| 31 |
+
|
| 32 |
+
- Raw model JSON: 626,596 bytes
|
| 33 |
+
- Gzip model JSON: 203,820 bytes
|
| 34 |
+
|
| 35 |
+
## Performance
|
| 36 |
+
|
| 37 |
+
Measured in Google Chrome 149.0.7827.116 on macOS:
|
| 38 |
+
|
| 39 |
+
| Input size | Sampled chars | P50 ms | P95 ms |
|
| 40 |
+
| --- | ---: | ---: | ---: |
|
| 41 |
+
| 1 KB | 1,024 | 1.490 | 1.548 |
|
| 42 |
+
| 16 KB | 16,384 | 6.580 | 6.730 |
|
| 43 |
+
| 100 KB | 12,292 | 5.180 | 5.310 |
|
| 44 |
+
| 1 MB | 12,292 | 5.170 | 5.310 |
|
| 45 |
+
| 5 MB | 12,292 | 5.210 | 5.380 |
|
| 46 |
+
|
| 47 |
+
## Metrics
|
| 48 |
+
|
| 49 |
+
| Evaluation Set | Examples | Accuracy | Macro F1 |
|
| 50 |
+
| --- | ---: | ---: | ---: |
|
| 51 |
+
| Validation | 487 | 1.0000 | 1.0000 |
|
| 52 |
+
| Test | 532 | 0.9962 | 0.9926 |
|
| 53 |
+
| Hard cases | 148 | 0.9932 | 0.6905 |
|
| 54 |
+
| Development holdouts | 207 | 0.9903 | - |
|
| 55 |
+
| Regression holdouts | 196 | 0.9796 | - |
|
| 56 |
+
| Final mixed holdout | 56 | 0.9821 | 0.9810 |
|
| 57 |
+
|
| 58 |
+
## Limitations
|
| 59 |
+
|
| 60 |
+
- Optimized for text, not binary file identification.
|
| 61 |
+
- Very short snippets may lack enough evidence for a specific syntax label.
|
| 62 |
+
- TypeScript and JavaScript can be close when a snippet has no type syntax.
|
| 63 |
+
- JSONL application logs can be close to JSON.
|
| 64 |
+
- Markdown/plain-text separation can be weak on very short prose-like Markdown.
|
| 65 |
+
|
| 66 |
+
## Training Notes
|
| 67 |
+
|
| 68 |
+
The training corpus combines generated structured text, curated programming examples, realistic local files, and targeted hard-neighbor examples.
|
REPORT.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SNIP: Small N-gram Identifier for Pastes
|
| 2 |
+
|
| 3 |
+
## Abstract
|
| 4 |
+
|
| 5 |
+
SNIP is a compact text classifier designed for in-browser classification into a subset of labels common in pastebin websites. The release model predicts 28 source and text labels from a bounded sample using hashed character n-grams and a sparse linear classifier. It runs with a dependency-free JavaScript runtime.
|
| 6 |
+
|
| 7 |
+
The release model reaches 1.0000 validation accuracy, 0.9962 test accuracy, 0.9816 held-out accuracy, and 0.9932 hard-case accuracy while staying around 205 KB gzip-compressed.
|
| 8 |
+
|
| 9 |
+
## 1. Problem
|
| 10 |
+
|
| 11 |
+
Pastebin sites, editors, and developer tools often need a useful syntax suggestion immediately after text is pasted. The goal of the classifier was to build something small enough to ship with a web app, and fast enough to feel instant. Binary file detection and full file-type forensics are outside the current scope.
|
| 12 |
+
|
| 13 |
+
## 2. Labels
|
| 14 |
+
|
| 15 |
+
The release label set is:
|
| 16 |
+
|
| 17 |
+
`bash`, `c`, `cpp`, `csharp`, `css`, `csv`, `diff`, `dockerfile`, `go`, `html`, `ini`, `java`, `javascript`, `json`, `log`, `lua`, `markdown`, `php`, `plain_text`, `powershell`, `python`, `ruby`, `rust`, `sql`, `toml`, `typescript`, `xml`, `yaml`.
|
| 18 |
+
|
| 19 |
+
## 3. Data
|
| 20 |
+
|
| 21 |
+
The dataset was built iteratively. Early code samples were short and repetitive, which helped validate the pipeline but produced over-optimistic results. Those examples were replaced with more realistic files and fragments.
|
| 22 |
+
|
| 23 |
+
Specifically for programming languages, the training set was generated by LLMs over successive training runs. Careful consideration was taken to avoid overfitting to a specific language or task. Examples were written as complete files or realistic project fragments: CLIs, web handlers, tests, services, configuration loaders, migrations, and framework code. This produced more useful character n-gram coverage than primitive string shuffling or pulling only from my personal code corpus, and allowed me to train a proficient model without personally maintaining a diverse codebase in every target language.
|
| 24 |
+
|
| 25 |
+
Some label types are better suited to programmatic generation, such as JSON, CSV, and logs. Even there, the useful cases were varied by shape, length, field names, and value distributions rather than generated from one repeated template.
|
| 26 |
+
|
| 27 |
+
The final training data emphasizes:
|
| 28 |
+
|
| 29 |
+
1. realistic short snippets, because paste inputs are often not full files
|
| 30 |
+
2. source-grouped splits, so related chunks do not leak across train/test
|
| 31 |
+
3. explicit hard cases for ambiguous neighboring labels such as HTML/XML, INI/TOML, JSON/log, Markdown/plain text, and CSV/plain text
|
| 32 |
+
|
| 33 |
+
### Distribution and quality checks
|
| 34 |
+
|
| 35 |
+
| Split | Rows | Labels | Min chars | Median chars | P90 chars | Max chars |
|
| 36 |
+
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
| 37 |
+
| Train | 2,618 | 28 | 20 | 210.5 | 1,124 | 33,617 |
|
| 38 |
+
| Validation | 487 | 28 | 13 | 213 | 1,192 | 22,701 |
|
| 39 |
+
| Test | 532 | 28 | 19 | 180 | 1,049 | 8,942 |
|
| 40 |
+
|
| 41 |
+
<details>
|
| 42 |
+
<summary>Training rows by label</summary>
|
| 43 |
+
|
| 44 |
+
| Label | Train rows |
|
| 45 |
+
| --- | ---: |
|
| 46 |
+
| `bash` | 28 |
|
| 47 |
+
| `c` | 28 |
|
| 48 |
+
| `cpp` | 31 |
|
| 49 |
+
| `csharp` | 32 |
|
| 50 |
+
| `css` | 124 |
|
| 51 |
+
| `csv` | 154 |
|
| 52 |
+
| `diff` | 131 |
|
| 53 |
+
| `dockerfile` | 114 |
|
| 54 |
+
| `go` | 37 |
|
| 55 |
+
| `html` | 132 |
|
| 56 |
+
| `ini` | 148 |
|
| 57 |
+
| `java` | 32 |
|
| 58 |
+
| `javascript` | 38 |
|
| 59 |
+
| `json` | 144 |
|
| 60 |
+
| `log` | 163 |
|
| 61 |
+
| `lua` | 29 |
|
| 62 |
+
| `markdown` | 163 |
|
| 63 |
+
| `php` | 33 |
|
| 64 |
+
| `plain_text` | 314 |
|
| 65 |
+
| `powershell` | 40 |
|
| 66 |
+
| `python` | 43 |
|
| 67 |
+
| `ruby` | 50 |
|
| 68 |
+
| `rust` | 33 |
|
| 69 |
+
| `sql` | 136 |
|
| 70 |
+
| `toml` | 140 |
|
| 71 |
+
| `typescript` | 37 |
|
| 72 |
+
| `xml` | 127 |
|
| 73 |
+
| `yaml` | 137 |
|
| 74 |
+
|
| 75 |
+
</details>
|
| 76 |
+
|
| 77 |
+
<details>
|
| 78 |
+
<summary>Verification rows by label (validation, test, and held-out suites combined)</summary>
|
| 79 |
+
|
| 80 |
+
| Label | Verification rows |
|
| 81 |
+
| --- | ---: |
|
| 82 |
+
| `bash` | 32 |
|
| 83 |
+
| `c` | 32 |
|
| 84 |
+
| `cpp` | 33 |
|
| 85 |
+
| `csharp` | 33 |
|
| 86 |
+
| `css` | 66 |
|
| 87 |
+
| `csv` | 71 |
|
| 88 |
+
| `diff` | 67 |
|
| 89 |
+
| `dockerfile` | 66 |
|
| 90 |
+
| `go` | 33 |
|
| 91 |
+
| `html` | 68 |
|
| 92 |
+
| `ini` | 67 |
|
| 93 |
+
| `java` | 33 |
|
| 94 |
+
| `javascript` | 34 |
|
| 95 |
+
| `json` | 75 |
|
| 96 |
+
| `log` | 72 |
|
| 97 |
+
| `lua` | 32 |
|
| 98 |
+
| `markdown` | 70 |
|
| 99 |
+
| `php` | 32 |
|
| 100 |
+
| `plain_text` | 257 |
|
| 101 |
+
| `powershell` | 32 |
|
| 102 |
+
| `python` | 37 |
|
| 103 |
+
| `ruby` | 33 |
|
| 104 |
+
| `rust` | 33 |
|
| 105 |
+
| `sql` | 68 |
|
| 106 |
+
| `toml` | 68 |
|
| 107 |
+
| `typescript` | 33 |
|
| 108 |
+
| `xml` | 68 |
|
| 109 |
+
| `yaml` | 73 |
|
| 110 |
+
|
| 111 |
+
</details>
|
| 112 |
+
|
| 113 |
+
Rows by length bucket:
|
| 114 |
+
|
| 115 |
+
| Split | <64 | 64-128 | 128-256 | 256-1K | 1K-4K | 4K-16K | 16K+ |
|
| 116 |
+
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
| 117 |
+
| Train | 26 | 452 | 1,144 | 682 | 308 | 3 | 3 |
|
| 118 |
+
| Validation | 5 | 89 | 202 | 128 | 62 | 0 | 1 |
|
| 119 |
+
| Test | 13 | 125 | 213 | 124 | 55 | 2 | 0 |
|
| 120 |
+
|
| 121 |
+
Training rows by source family:
|
| 122 |
+
|
| 123 |
+
| Source family | Train rows | Purpose |
|
| 124 |
+
| --- | ---: | --- |
|
| 125 |
+
| Structured/generated format examples | 1,795 | Broad coverage for structured text, config, prose, and data formats |
|
| 126 |
+
| Curated programming/project examples | 428 | Realistic source files and project fragments |
|
| 127 |
+
| Targeted hard-neighbor additions | 310 | Short snippets and close label pairs found through error analysis |
|
| 128 |
+
| Plain-text fallback examples | 63 | Text that should remain `plain_text` despite punctuation or weak structure |
|
| 129 |
+
| Real local/project examples | 8 | Repository-derived real files |
|
| 130 |
+
| Product/format-specific examples | 14 | Focused coverage for paste-product formats |
|
| 131 |
+
|
| 132 |
+
Every candidate split was checked for exact and normalized duplicate text. Train-only additions after error analysis were also checked for exact and near-duplicate overlap against held-out suites before use.
|
| 133 |
+
|
| 134 |
+
## 4. Model
|
| 135 |
+
|
| 136 |
+
The release model is a multiclass linear classifier trained with a passive-aggressive update. Features are hashed character n-grams:
|
| 137 |
+
|
| 138 |
+
- n-gram range: 1 through 5 characters
|
| 139 |
+
- hash buckets: 32,768
|
| 140 |
+
- feature value: `log1p(count)`
|
| 141 |
+
- normalization: L2 normalization
|
| 142 |
+
- shape features: disabled
|
| 143 |
+
- retained weights: 1,500 per label
|
| 144 |
+
- serialized weight precision: 4 decimal places
|
| 145 |
+
|
| 146 |
+
Long inputs are sampled before classification. Inputs up to 16 KiB are classified whole. Longer inputs are represented by windows from the start, middle, and end, joined with a separator. This keeps browser inference bounded for large pastes while preserving signal from common file regions.
|
| 147 |
+
|
| 148 |
+
## 5. Training
|
| 149 |
+
|
| 150 |
+
The first strong model used a Naive Bayes classifier with character n-grams, but I ran into a wall trying to handle edge cases where sparse labels with cheap default probabilities could dominate real files.
|
| 151 |
+
|
| 152 |
+
The linear model fixed that behavior. It scores labels directly from learned positive and negative weights instead of relying on per-class default probabilities. With top-weight pruning, the model stayed small enough for browser delivery while preserving the real-file behavior we wanted.
|
| 153 |
+
|
| 154 |
+
### Training loop
|
| 155 |
+
|
| 156 |
+
A major objective of training the n-gram was testing how much of training a model can be performed semi-autonomously by an LLM Agent. n-grams are incredibly fast to train, and afforded the model many opportunities to run experiences and build up a training dataset over time that specifically targeted failures in the trained models. A key part of getting that loop to work was ensuring the data stayed representative of a real-world text distribution while keeping the model from over-fitting to the generated dataset. The Agent was instructed to keep notes, an experiments log, and a data quality log. It did require a bit of nudging along the way, such as prompting it to manually inspect some of the generated data and note the quality issues (For example early C examples were almost all duplicates of 4-5 lines of a `main()` printing a few words).
|
| 157 |
+
|
| 158 |
+
Each round started with a candidate model trained on the current corpus. The agent evaluated it against the normal validation/test split, targeted hard cases for confused labels and the held-out evaluation suites. When the model failed, a subagent was used to describe the failures as broader missing shape: short prose-heavy Markdown, TOML that looks like INI, JSON-looking application logs, small diffs, short language snippets without imports, and so on.
|
| 159 |
+
|
| 160 |
+
Those descriptions were then used to create new train-only examples. For programming languages, the examples were usually generated as realistic files or fragments rather than templates. For structured formats, the generation could be more programmatic, but still had to vary field names, lengths, nesting, and punctuation. Before a new training split was used, the added rows were checked against the rest of the corpus for exact or near-duplicate overlap.
|
| 161 |
+
|
| 162 |
+
A separate LLM subagent was used to create new held-out evaluation suites between rounds. This was important to help ensure the overall quality of the train dataset. We want the generated examples to be different enough to keep the model robust. The held-out suites were generated from label-level requirements and broad scenario prompts, then locked before the next candidate was evaluated. Once a held-out suite exposed an error category and influenced the next data pass, it was no longer treated as a final benchmark. It became a regression suite.
|
| 163 |
+
|
| 164 |
+
The loop looked like this:
|
| 165 |
+
|
| 166 |
+
1. train a candidate on the current corpus
|
| 167 |
+
2. evaluate against validation, test, hard cases, and held-out evaluation suites
|
| 168 |
+
3. inspect failures as categories and patterns
|
| 169 |
+
4. generate train-only coverage for those categories
|
| 170 |
+
5. check duplicate and near-duplicate overlap
|
| 171 |
+
6. train the next candidate
|
| 172 |
+
7. create fresh held-out coverage when a release-quality claim is needed
|
| 173 |
+
|
| 174 |
+
This process let the dataset grow in the directions the n-gram actually needed while keeping a clean separation between training data and held-out evaluation suites.
|
| 175 |
+
|
| 176 |
+
| Evaluation role | Rows | Purpose |
|
| 177 |
+
| --- | ---: | --- |
|
| 178 |
+
| Validation + test | 1,019 | Main split evaluation |
|
| 179 |
+
| Held-out suites | 599 | Independent checks generated across training rounds |
|
| 180 |
+
| Hard-neighbor cases | 148 | Targeted stress cases for commonly confused labels |
|
| 181 |
+
|
| 182 |
+
### Weight pruning
|
| 183 |
+
|
| 184 |
+
After training, pruning is applied to help reduce the size of the model. Weights with an absolute value below `0.001` are removed, and each label keeps at most `1,500` learned n-gram weights, chosen by absolute value. The remaining weights are serialized to 4 decimal places. This was a simple way to cut model size while keeping the runtime and JSON model format straightforward.
|
| 185 |
+
|
| 186 |
+
## 6. Results
|
| 187 |
+
|
| 188 |
+
SNIP stats:
|
| 189 |
+
|
| 190 |
+
| Evaluation set | Examples | Accuracy | Macro F1 | Known coverage |
|
| 191 |
+
| --- | ---: | ---: | ---: | ---: |
|
| 192 |
+
| Validation split | 487 | 1.0000 | 1.0000 | 1.0000 |
|
| 193 |
+
| Test split | 532 | 0.9962 | 0.9926 | 1.0000 |
|
| 194 |
+
| Held-out evaluation suites | 599 | 0.9816 | 0.9819 | 1.0000 |
|
| 195 |
+
| Hard-neighbor cases | 148 | 0.9932 | 0.6905 | 1.0000 |
|
| 196 |
+
|
| 197 |
+
Model size:
|
| 198 |
+
|
| 199 |
+
| Artifact | Size |
|
| 200 |
+
| --- | ---: |
|
| 201 |
+
| `snip_model.json` | 626,595 bytes |
|
| 202 |
+
| gzip-compressed model | 204,593 bytes |
|
| 203 |
+
|
| 204 |
+
## 7. Performance
|
| 205 |
+
|
| 206 |
+
SNIP's runtime target requires low latency because classification happens in user-facing paste flows and should not scale linearly with very large inputs. Performance was measured with the model embedded inside a single HTML file to remove any possible network latency.
|
| 207 |
+
|
| 208 |
+
Captured result:
|
| 209 |
+
|
| 210 |
+
- Browser: Google Chrome 149.0.7827.116
|
| 211 |
+
- Platform: macOS arm64
|
| 212 |
+
- Model parse: 4.4 ms
|
| 213 |
+
|
| 214 |
+
| Input size | Sampled chars | Classifications | Mean ms | P50 ms | P95 ms |
|
| 215 |
+
| --- | ---: | ---: | ---: | ---: | ---: |
|
| 216 |
+
| 1 KB | 1,024 | 5,000 | 1.499 | 1.490 | 1.548 |
|
| 217 |
+
| 16 KB | 16,384 | 1,000 | 6.578 | 6.580 | 6.730 |
|
| 218 |
+
| 100 KB | 12,292 | 800 | 5.195 | 5.180 | 5.310 |
|
| 219 |
+
| 1 MB | 12,292 | 800 | 5.184 | 5.170 | 5.310 |
|
| 220 |
+
| 5 MB | 12,292 | 800 | 5.238 | 5.210 | 5.380 |
|
| 221 |
+
|
| 222 |
+
The non-monotonic timing is expected. Inputs up to 16 KiB are classified whole. Larger inputs are sampled into three 4 KiB windows plus separators, so 100 KB, 1 MB, and 5 MB inputs all classify roughly the same amount of text.
|
| 223 |
+
|
| 224 |
+
## 8. Runtime
|
| 225 |
+
|
| 226 |
+
The reference runtime is authored in `src/snip.ts`. It implements:
|
| 227 |
+
|
| 228 |
+
1. bounded input sampling,
|
| 229 |
+
2. FNV-1a style stable hashing over UTF-16 code units,
|
| 230 |
+
3. character n-gram feature extraction,
|
| 231 |
+
4. sparse linear scoring,
|
| 232 |
+
5. softmax ranking.
|
| 233 |
+
|
| 234 |
+
The runtime has no dependencies.
|
| 235 |
+
|
| 236 |
+
## 9. Limitations
|
| 237 |
+
|
| 238 |
+
SNIP is optimized for pasted text and text-like files. Binary file identification is outside the current release scope.
|
| 239 |
+
|
| 240 |
+
The release model has known weaknesses:
|
| 241 |
+
|
| 242 |
+
- very small structured snippets can still be close to `plain_text`
|
| 243 |
+
- TypeScript and JavaScript can be close when the snippet lacks type syntax
|
| 244 |
+
- SNIP scores every label and selects the highest-scoring label; the margin reports the gap to the runner-up
|
| 245 |
+
- Markdown/plain-text separation is still weak on very short prose-like Markdown
|
| 246 |
+
- INI/TOML and JSON/log remain close neighbors when examples are short.
|
metrics/summary.json
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "SNIP",
|
| 3 |
+
"expanded_name": "Small N-gram Identifier for Pastes",
|
| 4 |
+
"metrics": {
|
| 5 |
+
"development_holdouts": {
|
| 6 |
+
"accuracy": 0.9903,
|
| 7 |
+
"examples": 207
|
| 8 |
+
},
|
| 9 |
+
"final_mixed_holdout": {
|
| 10 |
+
"accuracy": 0.9821428571428571,
|
| 11 |
+
"examples": 56,
|
| 12 |
+
"known_coverage": 1.0,
|
| 13 |
+
"macro_f1": 0.9809523809523809
|
| 14 |
+
},
|
| 15 |
+
"hard_cases": {
|
| 16 |
+
"accuracy": 0.9932432432432432,
|
| 17 |
+
"examples": 148,
|
| 18 |
+
"known_coverage": 1.0,
|
| 19 |
+
"macro_f1": 0.6904761904761905
|
| 20 |
+
},
|
| 21 |
+
"test": {
|
| 22 |
+
"accuracy": 0.9962406015037594,
|
| 23 |
+
"examples": 532,
|
| 24 |
+
"known_coverage": 1.0,
|
| 25 |
+
"macro_f1": 0.9926318594465098
|
| 26 |
+
},
|
| 27 |
+
"validation": {
|
| 28 |
+
"accuracy": 1.0,
|
| 29 |
+
"examples": 487,
|
| 30 |
+
"known_coverage": 1.0,
|
| 31 |
+
"macro_f1": 1.0
|
| 32 |
+
},
|
| 33 |
+
"regression_holdouts": {
|
| 34 |
+
"accuracy": 0.9796,
|
| 35 |
+
"examples": 196
|
| 36 |
+
}
|
| 37 |
+
},
|
| 38 |
+
"performance": {
|
| 39 |
+
"browser": "Google Chrome 149.0.7827.116",
|
| 40 |
+
"hardware": "Mac M2",
|
| 41 |
+
"model_parse_ms": 4.4,
|
| 42 |
+
"platform": "macOS arm64",
|
| 43 |
+
"results": [
|
| 44 |
+
{
|
| 45 |
+
"case": "1 KB paste",
|
| 46 |
+
"classifications": 5000,
|
| 47 |
+
"input_chars": 1024,
|
| 48 |
+
"mean_ms": 1.499,
|
| 49 |
+
"p50_ms": 1.49,
|
| 50 |
+
"p95_ms": 1.548,
|
| 51 |
+
"sampled_chars": 1024
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"case": "16 KB paste",
|
| 55 |
+
"classifications": 1000,
|
| 56 |
+
"input_chars": 16384,
|
| 57 |
+
"mean_ms": 6.578,
|
| 58 |
+
"p50_ms": 6.58,
|
| 59 |
+
"p95_ms": 6.73,
|
| 60 |
+
"sampled_chars": 16384
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"case": "100 KB paste",
|
| 64 |
+
"classifications": 800,
|
| 65 |
+
"input_chars": 102400,
|
| 66 |
+
"mean_ms": 5.195,
|
| 67 |
+
"p50_ms": 5.18,
|
| 68 |
+
"p95_ms": 5.31,
|
| 69 |
+
"sampled_chars": 12292
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"case": "1 MB paste",
|
| 73 |
+
"classifications": 800,
|
| 74 |
+
"input_chars": 1048576,
|
| 75 |
+
"mean_ms": 5.184,
|
| 76 |
+
"p50_ms": 5.17,
|
| 77 |
+
"p95_ms": 5.31,
|
| 78 |
+
"sampled_chars": 12292
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"case": "5 MB paste",
|
| 82 |
+
"classifications": 800,
|
| 83 |
+
"input_chars": 5242880,
|
| 84 |
+
"mean_ms": 5.238,
|
| 85 |
+
"p50_ms": 5.21,
|
| 86 |
+
"p95_ms": 5.38,
|
| 87 |
+
"sampled_chars": 12292
|
| 88 |
+
}
|
| 89 |
+
]
|
| 90 |
+
}
|
| 91 |
+
}
|
metrics/test_metrics.json
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"accuracy": 0.9962406015037594,
|
| 3 |
+
"known_abstain_rate": 0.0,
|
| 4 |
+
"known_coverage": 1.0,
|
| 5 |
+
"length_buckets": {
|
| 6 |
+
"000_lt_256": {
|
| 7 |
+
"accuracy": 0.9971509971509972,
|
| 8 |
+
"total": 351,
|
| 9 |
+
"unknown_rate": 0.0
|
| 10 |
+
},
|
| 11 |
+
"001_256_1k": {
|
| 12 |
+
"accuracy": 0.9919354838709677,
|
| 13 |
+
"total": 124,
|
| 14 |
+
"unknown_rate": 0.0
|
| 15 |
+
},
|
| 16 |
+
"002_1k_4k": {
|
| 17 |
+
"accuracy": 1.0,
|
| 18 |
+
"total": 55,
|
| 19 |
+
"unknown_rate": 0.0
|
| 20 |
+
},
|
| 21 |
+
"003_4k_16k": {
|
| 22 |
+
"accuracy": 1.0,
|
| 23 |
+
"total": 2,
|
| 24 |
+
"unknown_rate": 0.0
|
| 25 |
+
}
|
| 26 |
+
},
|
| 27 |
+
"macro_f1": 0.9926318594465098,
|
| 28 |
+
"per_label": {
|
| 29 |
+
"bash": {
|
| 30 |
+
"f1": 1.0,
|
| 31 |
+
"precision": 1.0,
|
| 32 |
+
"recall": 1.0,
|
| 33 |
+
"support": 6
|
| 34 |
+
},
|
| 35 |
+
"c": {
|
| 36 |
+
"f1": 1.0,
|
| 37 |
+
"precision": 1.0,
|
| 38 |
+
"recall": 1.0,
|
| 39 |
+
"support": 6
|
| 40 |
+
},
|
| 41 |
+
"cpp": {
|
| 42 |
+
"f1": 1.0,
|
| 43 |
+
"precision": 1.0,
|
| 44 |
+
"recall": 1.0,
|
| 45 |
+
"support": 6
|
| 46 |
+
},
|
| 47 |
+
"csharp": {
|
| 48 |
+
"f1": 1.0,
|
| 49 |
+
"precision": 1.0,
|
| 50 |
+
"recall": 1.0,
|
| 51 |
+
"support": 6
|
| 52 |
+
},
|
| 53 |
+
"css": {
|
| 54 |
+
"f1": 1.0,
|
| 55 |
+
"precision": 1.0,
|
| 56 |
+
"recall": 1.0,
|
| 57 |
+
"support": 23
|
| 58 |
+
},
|
| 59 |
+
"csv": {
|
| 60 |
+
"f1": 1.0,
|
| 61 |
+
"precision": 1.0,
|
| 62 |
+
"recall": 1.0,
|
| 63 |
+
"support": 26
|
| 64 |
+
},
|
| 65 |
+
"diff": {
|
| 66 |
+
"f1": 1.0,
|
| 67 |
+
"precision": 1.0,
|
| 68 |
+
"recall": 1.0,
|
| 69 |
+
"support": 23
|
| 70 |
+
},
|
| 71 |
+
"dockerfile": {
|
| 72 |
+
"f1": 1.0,
|
| 73 |
+
"precision": 1.0,
|
| 74 |
+
"recall": 1.0,
|
| 75 |
+
"support": 23
|
| 76 |
+
},
|
| 77 |
+
"go": {
|
| 78 |
+
"f1": 1.0,
|
| 79 |
+
"precision": 1.0,
|
| 80 |
+
"recall": 1.0,
|
| 81 |
+
"support": 6
|
| 82 |
+
},
|
| 83 |
+
"html": {
|
| 84 |
+
"f1": 1.0,
|
| 85 |
+
"precision": 1.0,
|
| 86 |
+
"recall": 1.0,
|
| 87 |
+
"support": 24
|
| 88 |
+
},
|
| 89 |
+
"ini": {
|
| 90 |
+
"f1": 1.0,
|
| 91 |
+
"precision": 1.0,
|
| 92 |
+
"recall": 1.0,
|
| 93 |
+
"support": 24
|
| 94 |
+
},
|
| 95 |
+
"java": {
|
| 96 |
+
"f1": 1.0,
|
| 97 |
+
"precision": 1.0,
|
| 98 |
+
"recall": 1.0,
|
| 99 |
+
"support": 6
|
| 100 |
+
},
|
| 101 |
+
"javascript": {
|
| 102 |
+
"f1": 0.923076923076923,
|
| 103 |
+
"precision": 0.8571428571428571,
|
| 104 |
+
"recall": 1.0,
|
| 105 |
+
"support": 6
|
| 106 |
+
},
|
| 107 |
+
"json": {
|
| 108 |
+
"f1": 0.9803921568627451,
|
| 109 |
+
"precision": 1.0,
|
| 110 |
+
"recall": 0.9615384615384616,
|
| 111 |
+
"support": 26
|
| 112 |
+
},
|
| 113 |
+
"log": {
|
| 114 |
+
"f1": 0.9811320754716981,
|
| 115 |
+
"precision": 0.9629629629629629,
|
| 116 |
+
"recall": 1.0,
|
| 117 |
+
"support": 26
|
| 118 |
+
},
|
| 119 |
+
"lua": {
|
| 120 |
+
"f1": 1.0,
|
| 121 |
+
"precision": 1.0,
|
| 122 |
+
"recall": 1.0,
|
| 123 |
+
"support": 6
|
| 124 |
+
},
|
| 125 |
+
"markdown": {
|
| 126 |
+
"f1": 1.0,
|
| 127 |
+
"precision": 1.0,
|
| 128 |
+
"recall": 1.0,
|
| 129 |
+
"support": 23
|
| 130 |
+
},
|
| 131 |
+
"php": {
|
| 132 |
+
"f1": 1.0,
|
| 133 |
+
"precision": 1.0,
|
| 134 |
+
"recall": 1.0,
|
| 135 |
+
"support": 6
|
| 136 |
+
},
|
| 137 |
+
"plain_text": {
|
| 138 |
+
"f1": 1.0,
|
| 139 |
+
"precision": 1.0,
|
| 140 |
+
"recall": 1.0,
|
| 141 |
+
"support": 132
|
| 142 |
+
},
|
| 143 |
+
"powershell": {
|
| 144 |
+
"f1": 1.0,
|
| 145 |
+
"precision": 1.0,
|
| 146 |
+
"recall": 1.0,
|
| 147 |
+
"support": 6
|
| 148 |
+
},
|
| 149 |
+
"python": {
|
| 150 |
+
"f1": 1.0,
|
| 151 |
+
"precision": 1.0,
|
| 152 |
+
"recall": 1.0,
|
| 153 |
+
"support": 7
|
| 154 |
+
},
|
| 155 |
+
"ruby": {
|
| 156 |
+
"f1": 1.0,
|
| 157 |
+
"precision": 1.0,
|
| 158 |
+
"recall": 1.0,
|
| 159 |
+
"support": 6
|
| 160 |
+
},
|
| 161 |
+
"rust": {
|
| 162 |
+
"f1": 1.0,
|
| 163 |
+
"precision": 1.0,
|
| 164 |
+
"recall": 1.0,
|
| 165 |
+
"support": 6
|
| 166 |
+
},
|
| 167 |
+
"sql": {
|
| 168 |
+
"f1": 1.0,
|
| 169 |
+
"precision": 1.0,
|
| 170 |
+
"recall": 1.0,
|
| 171 |
+
"support": 24
|
| 172 |
+
},
|
| 173 |
+
"toml": {
|
| 174 |
+
"f1": 1.0,
|
| 175 |
+
"precision": 1.0,
|
| 176 |
+
"recall": 1.0,
|
| 177 |
+
"support": 23
|
| 178 |
+
},
|
| 179 |
+
"typescript": {
|
| 180 |
+
"f1": 0.9090909090909091,
|
| 181 |
+
"precision": 1.0,
|
| 182 |
+
"recall": 0.8333333333333334,
|
| 183 |
+
"support": 6
|
| 184 |
+
},
|
| 185 |
+
"xml": {
|
| 186 |
+
"f1": 1.0,
|
| 187 |
+
"precision": 1.0,
|
| 188 |
+
"recall": 1.0,
|
| 189 |
+
"support": 24
|
| 190 |
+
},
|
| 191 |
+
"yaml": {
|
| 192 |
+
"f1": 1.0,
|
| 193 |
+
"precision": 1.0,
|
| 194 |
+
"recall": 1.0,
|
| 195 |
+
"support": 26
|
| 196 |
+
}
|
| 197 |
+
},
|
| 198 |
+
"total": 532,
|
| 199 |
+
"unknown_false_accept_rate": 0.0,
|
| 200 |
+
"unknown_rate": 0.0,
|
| 201 |
+
"unknown_rejection_rate": 0.0
|
| 202 |
+
}
|
metrics/val_metrics.json
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"accuracy": 1.0,
|
| 3 |
+
"known_abstain_rate": 0.0,
|
| 4 |
+
"known_coverage": 1.0,
|
| 5 |
+
"length_buckets": {
|
| 6 |
+
"000_lt_256": {
|
| 7 |
+
"accuracy": 1.0,
|
| 8 |
+
"total": 296,
|
| 9 |
+
"unknown_rate": 0.0
|
| 10 |
+
},
|
| 11 |
+
"001_256_1k": {
|
| 12 |
+
"accuracy": 1.0,
|
| 13 |
+
"total": 128,
|
| 14 |
+
"unknown_rate": 0.0
|
| 15 |
+
},
|
| 16 |
+
"002_1k_4k": {
|
| 17 |
+
"accuracy": 1.0,
|
| 18 |
+
"total": 62,
|
| 19 |
+
"unknown_rate": 0.0
|
| 20 |
+
},
|
| 21 |
+
"004_16k_plus": {
|
| 22 |
+
"accuracy": 1.0,
|
| 23 |
+
"total": 1,
|
| 24 |
+
"unknown_rate": 0.0
|
| 25 |
+
}
|
| 26 |
+
},
|
| 27 |
+
"macro_f1": 1.0,
|
| 28 |
+
"per_label": {
|
| 29 |
+
"bash": {
|
| 30 |
+
"f1": 1.0,
|
| 31 |
+
"precision": 1.0,
|
| 32 |
+
"recall": 1.0,
|
| 33 |
+
"support": 6
|
| 34 |
+
},
|
| 35 |
+
"c": {
|
| 36 |
+
"f1": 1.0,
|
| 37 |
+
"precision": 1.0,
|
| 38 |
+
"recall": 1.0,
|
| 39 |
+
"support": 6
|
| 40 |
+
},
|
| 41 |
+
"cpp": {
|
| 42 |
+
"f1": 1.0,
|
| 43 |
+
"precision": 1.0,
|
| 44 |
+
"recall": 1.0,
|
| 45 |
+
"support": 6
|
| 46 |
+
},
|
| 47 |
+
"csharp": {
|
| 48 |
+
"f1": 1.0,
|
| 49 |
+
"precision": 1.0,
|
| 50 |
+
"recall": 1.0,
|
| 51 |
+
"support": 6
|
| 52 |
+
},
|
| 53 |
+
"css": {
|
| 54 |
+
"f1": 1.0,
|
| 55 |
+
"precision": 1.0,
|
| 56 |
+
"recall": 1.0,
|
| 57 |
+
"support": 23
|
| 58 |
+
},
|
| 59 |
+
"csv": {
|
| 60 |
+
"f1": 1.0,
|
| 61 |
+
"precision": 1.0,
|
| 62 |
+
"recall": 1.0,
|
| 63 |
+
"support": 25
|
| 64 |
+
},
|
| 65 |
+
"diff": {
|
| 66 |
+
"f1": 1.0,
|
| 67 |
+
"precision": 1.0,
|
| 68 |
+
"recall": 1.0,
|
| 69 |
+
"support": 24
|
| 70 |
+
},
|
| 71 |
+
"dockerfile": {
|
| 72 |
+
"f1": 1.0,
|
| 73 |
+
"precision": 1.0,
|
| 74 |
+
"recall": 1.0,
|
| 75 |
+
"support": 22
|
| 76 |
+
},
|
| 77 |
+
"go": {
|
| 78 |
+
"f1": 1.0,
|
| 79 |
+
"precision": 1.0,
|
| 80 |
+
"recall": 1.0,
|
| 81 |
+
"support": 6
|
| 82 |
+
},
|
| 83 |
+
"html": {
|
| 84 |
+
"f1": 1.0,
|
| 85 |
+
"precision": 1.0,
|
| 86 |
+
"recall": 1.0,
|
| 87 |
+
"support": 23
|
| 88 |
+
},
|
| 89 |
+
"ini": {
|
| 90 |
+
"f1": 1.0,
|
| 91 |
+
"precision": 1.0,
|
| 92 |
+
"recall": 1.0,
|
| 93 |
+
"support": 23
|
| 94 |
+
},
|
| 95 |
+
"java": {
|
| 96 |
+
"f1": 1.0,
|
| 97 |
+
"precision": 1.0,
|
| 98 |
+
"recall": 1.0,
|
| 99 |
+
"support": 6
|
| 100 |
+
},
|
| 101 |
+
"javascript": {
|
| 102 |
+
"f1": 1.0,
|
| 103 |
+
"precision": 1.0,
|
| 104 |
+
"recall": 1.0,
|
| 105 |
+
"support": 6
|
| 106 |
+
},
|
| 107 |
+
"json": {
|
| 108 |
+
"f1": 1.0,
|
| 109 |
+
"precision": 1.0,
|
| 110 |
+
"recall": 1.0,
|
| 111 |
+
"support": 26
|
| 112 |
+
},
|
| 113 |
+
"log": {
|
| 114 |
+
"f1": 1.0,
|
| 115 |
+
"precision": 1.0,
|
| 116 |
+
"recall": 1.0,
|
| 117 |
+
"support": 25
|
| 118 |
+
},
|
| 119 |
+
"lua": {
|
| 120 |
+
"f1": 1.0,
|
| 121 |
+
"precision": 1.0,
|
| 122 |
+
"recall": 1.0,
|
| 123 |
+
"support": 6
|
| 124 |
+
},
|
| 125 |
+
"markdown": {
|
| 126 |
+
"f1": 1.0,
|
| 127 |
+
"precision": 1.0,
|
| 128 |
+
"recall": 1.0,
|
| 129 |
+
"support": 24
|
| 130 |
+
},
|
| 131 |
+
"php": {
|
| 132 |
+
"f1": 1.0,
|
| 133 |
+
"precision": 1.0,
|
| 134 |
+
"recall": 1.0,
|
| 135 |
+
"support": 6
|
| 136 |
+
},
|
| 137 |
+
"plain_text": {
|
| 138 |
+
"f1": 1.0,
|
| 139 |
+
"precision": 1.0,
|
| 140 |
+
"recall": 1.0,
|
| 141 |
+
"support": 92
|
| 142 |
+
},
|
| 143 |
+
"powershell": {
|
| 144 |
+
"f1": 1.0,
|
| 145 |
+
"precision": 1.0,
|
| 146 |
+
"recall": 1.0,
|
| 147 |
+
"support": 6
|
| 148 |
+
},
|
| 149 |
+
"python": {
|
| 150 |
+
"f1": 1.0,
|
| 151 |
+
"precision": 1.0,
|
| 152 |
+
"recall": 1.0,
|
| 153 |
+
"support": 7
|
| 154 |
+
},
|
| 155 |
+
"ruby": {
|
| 156 |
+
"f1": 1.0,
|
| 157 |
+
"precision": 1.0,
|
| 158 |
+
"recall": 1.0,
|
| 159 |
+
"support": 6
|
| 160 |
+
},
|
| 161 |
+
"rust": {
|
| 162 |
+
"f1": 1.0,
|
| 163 |
+
"precision": 1.0,
|
| 164 |
+
"recall": 1.0,
|
| 165 |
+
"support": 6
|
| 166 |
+
},
|
| 167 |
+
"sql": {
|
| 168 |
+
"f1": 1.0,
|
| 169 |
+
"precision": 1.0,
|
| 170 |
+
"recall": 1.0,
|
| 171 |
+
"support": 23
|
| 172 |
+
},
|
| 173 |
+
"toml": {
|
| 174 |
+
"f1": 1.0,
|
| 175 |
+
"precision": 1.0,
|
| 176 |
+
"recall": 1.0,
|
| 177 |
+
"support": 24
|
| 178 |
+
},
|
| 179 |
+
"typescript": {
|
| 180 |
+
"f1": 1.0,
|
| 181 |
+
"precision": 1.0,
|
| 182 |
+
"recall": 1.0,
|
| 183 |
+
"support": 6
|
| 184 |
+
},
|
| 185 |
+
"xml": {
|
| 186 |
+
"f1": 1.0,
|
| 187 |
+
"precision": 1.0,
|
| 188 |
+
"recall": 1.0,
|
| 189 |
+
"support": 23
|
| 190 |
+
},
|
| 191 |
+
"yaml": {
|
| 192 |
+
"f1": 1.0,
|
| 193 |
+
"precision": 1.0,
|
| 194 |
+
"recall": 1.0,
|
| 195 |
+
"support": 25
|
| 196 |
+
}
|
| 197 |
+
},
|
| 198 |
+
"total": 487,
|
| 199 |
+
"unknown_false_accept_rate": 0.0,
|
| 200 |
+
"unknown_rate": 0.0,
|
| 201 |
+
"unknown_rejection_rate": 0.0
|
| 202 |
+
}
|
model/snip_model.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
runtime/model-data.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
runtime/snip.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import model from "./model-data.js";
|
| 2 |
+
export const SNIP_LABELS = [
|
| 3 |
+
"bash",
|
| 4 |
+
"c",
|
| 5 |
+
"cpp",
|
| 6 |
+
"csharp",
|
| 7 |
+
"css",
|
| 8 |
+
"csv",
|
| 9 |
+
"diff",
|
| 10 |
+
"dockerfile",
|
| 11 |
+
"go",
|
| 12 |
+
"html",
|
| 13 |
+
"ini",
|
| 14 |
+
"java",
|
| 15 |
+
"javascript",
|
| 16 |
+
"json",
|
| 17 |
+
"log",
|
| 18 |
+
"lua",
|
| 19 |
+
"markdown",
|
| 20 |
+
"php",
|
| 21 |
+
"plain_text",
|
| 22 |
+
"powershell",
|
| 23 |
+
"python",
|
| 24 |
+
"ruby",
|
| 25 |
+
"rust",
|
| 26 |
+
"sql",
|
| 27 |
+
"toml",
|
| 28 |
+
"typescript",
|
| 29 |
+
"xml",
|
| 30 |
+
"yaml",
|
| 31 |
+
];
|
| 32 |
+
export function classifyText(text) {
|
| 33 |
+
return classifyWithModel(text, model);
|
| 34 |
+
}
|
| 35 |
+
export async function classifyTextAsync(text) {
|
| 36 |
+
await yieldToBrowser();
|
| 37 |
+
return classifyText(text);
|
| 38 |
+
}
|
| 39 |
+
function classifyWithModel(text, model) {
|
| 40 |
+
if (model.config.model_type !== "linear_pa") {
|
| 41 |
+
throw new Error(`unsupported SNIP model type: ${model.config.model_type}`);
|
| 42 |
+
}
|
| 43 |
+
const sampled = sampleWithConfig(text, model.config.sampling);
|
| 44 |
+
const features = extractFeatures(sampled, model.config.feature);
|
| 45 |
+
const scores = new Map();
|
| 46 |
+
for (const label of model.config.labels) {
|
| 47 |
+
let score = model.bias?.[label] ?? 0;
|
| 48 |
+
const weights = model.weights[label] ?? [];
|
| 49 |
+
for (const [key, weight] of weights) {
|
| 50 |
+
score += (features.get(key) ?? 0) * weight;
|
| 51 |
+
}
|
| 52 |
+
scores.set(label, score);
|
| 53 |
+
}
|
| 54 |
+
const probabilities = softmax(scores);
|
| 55 |
+
const alternatives = [...probabilities.entries()].sort((a, b) => b[1] - a[1]);
|
| 56 |
+
const [predictedLabel, confidence] = alternatives[0] ?? [model.config.fallback_label ?? "plain_text", 0];
|
| 57 |
+
const second = alternatives[1]?.[1] ?? 0;
|
| 58 |
+
const margin = confidence - second;
|
| 59 |
+
const threshold = model.config.thresholds?.[predictedLabel] ?? 0;
|
| 60 |
+
const marginThreshold = model.config.margin_thresholds?.[predictedLabel] ?? 0;
|
| 61 |
+
const fallback = model.config.fallback_label ?? "plain_text";
|
| 62 |
+
const accepted = confidence >= threshold && margin >= marginThreshold;
|
| 63 |
+
return {
|
| 64 |
+
label: accepted ? predictedLabel : fallback,
|
| 65 |
+
predicted_label: predictedLabel,
|
| 66 |
+
confidence,
|
| 67 |
+
margin,
|
| 68 |
+
alternatives: alternatives.slice(0, 5),
|
| 69 |
+
};
|
| 70 |
+
}
|
| 71 |
+
export function sampleText(text) {
|
| 72 |
+
return sampleWithConfig(text, model.config.sampling);
|
| 73 |
+
}
|
| 74 |
+
function sampleWithConfig(text, config) {
|
| 75 |
+
if (text.length <= config.small_cutoff)
|
| 76 |
+
return text;
|
| 77 |
+
const windowSize = config.window_size;
|
| 78 |
+
const start = text.slice(0, windowSize);
|
| 79 |
+
const midpoint = Math.floor(text.length / 2);
|
| 80 |
+
const half = Math.floor(windowSize / 2);
|
| 81 |
+
const middle = text.slice(Math.max(0, midpoint - half), Math.min(text.length, midpoint + half));
|
| 82 |
+
const end = text.slice(-windowSize);
|
| 83 |
+
return [start, middle, end].join(config.separator);
|
| 84 |
+
}
|
| 85 |
+
function extractFeatures(text, config) {
|
| 86 |
+
if (config.lowercase)
|
| 87 |
+
text = text.toLowerCase();
|
| 88 |
+
const counts = new Map();
|
| 89 |
+
const padded = ` ${text} `;
|
| 90 |
+
for (let n = config.ngram_min; n <= config.ngram_max; n += 1) {
|
| 91 |
+
if (padded.length < n)
|
| 92 |
+
continue;
|
| 93 |
+
for (let i = 0; i <= padded.length - n; i += 1) {
|
| 94 |
+
const key = stableHash(`char${n}:${padded.slice(i, i + n)}`, config.hash_buckets);
|
| 95 |
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
const features = new Map();
|
| 99 |
+
let norm = 0;
|
| 100 |
+
for (const [key, value] of counts.entries()) {
|
| 101 |
+
const transformed = config.binary_counts ? 1 : Math.log1p(value);
|
| 102 |
+
features.set(key, transformed);
|
| 103 |
+
norm += transformed * transformed;
|
| 104 |
+
}
|
| 105 |
+
if (config.normalize && norm > 0) {
|
| 106 |
+
const scale = Math.sqrt(norm);
|
| 107 |
+
for (const [key, value] of features.entries()) {
|
| 108 |
+
features.set(key, value / scale);
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
return features;
|
| 112 |
+
}
|
| 113 |
+
function stableHash(text, buckets) {
|
| 114 |
+
let value = 2166136261 >>> 0;
|
| 115 |
+
for (let i = 0; i < text.length; i += 1) {
|
| 116 |
+
const code = text.charCodeAt(i);
|
| 117 |
+
value ^= code & 0xff;
|
| 118 |
+
value = Math.imul(value, 16777619) >>> 0;
|
| 119 |
+
value ^= (code >>> 8) & 0xff;
|
| 120 |
+
value = Math.imul(value, 16777619) >>> 0;
|
| 121 |
+
}
|
| 122 |
+
return value % buckets;
|
| 123 |
+
}
|
| 124 |
+
function yieldToBrowser() {
|
| 125 |
+
return new Promise((resolve) => {
|
| 126 |
+
if (typeof globalThis.requestAnimationFrame === "function") {
|
| 127 |
+
globalThis.requestAnimationFrame(() => resolve());
|
| 128 |
+
return;
|
| 129 |
+
}
|
| 130 |
+
globalThis.setTimeout(resolve, 0);
|
| 131 |
+
});
|
| 132 |
+
}
|
| 133 |
+
function softmax(scores) {
|
| 134 |
+
const maxScore = Math.max(...scores.values());
|
| 135 |
+
const probabilities = new Map();
|
| 136 |
+
let total = 0;
|
| 137 |
+
for (const [label, score] of scores.entries()) {
|
| 138 |
+
const value = Math.exp(score - maxScore);
|
| 139 |
+
probabilities.set(label, value);
|
| 140 |
+
total += value;
|
| 141 |
+
}
|
| 142 |
+
for (const [label, value] of probabilities.entries()) {
|
| 143 |
+
probabilities.set(label, value / total);
|
| 144 |
+
}
|
| 145 |
+
return probabilities;
|
| 146 |
+
}
|
runtime/snip.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import model from "./model-data.js";
|
| 2 |
+
|
| 3 |
+
export const SNIP_LABELS = [
|
| 4 |
+
"bash",
|
| 5 |
+
"c",
|
| 6 |
+
"cpp",
|
| 7 |
+
"csharp",
|
| 8 |
+
"css",
|
| 9 |
+
"csv",
|
| 10 |
+
"diff",
|
| 11 |
+
"dockerfile",
|
| 12 |
+
"go",
|
| 13 |
+
"html",
|
| 14 |
+
"ini",
|
| 15 |
+
"java",
|
| 16 |
+
"javascript",
|
| 17 |
+
"json",
|
| 18 |
+
"log",
|
| 19 |
+
"lua",
|
| 20 |
+
"markdown",
|
| 21 |
+
"php",
|
| 22 |
+
"plain_text",
|
| 23 |
+
"powershell",
|
| 24 |
+
"python",
|
| 25 |
+
"ruby",
|
| 26 |
+
"rust",
|
| 27 |
+
"sql",
|
| 28 |
+
"toml",
|
| 29 |
+
"typescript",
|
| 30 |
+
"xml",
|
| 31 |
+
"yaml",
|
| 32 |
+
] as const;
|
| 33 |
+
|
| 34 |
+
export type SnipLabel = (typeof SNIP_LABELS)[number];
|
| 35 |
+
|
| 36 |
+
export interface SnipFeatureConfig {
|
| 37 |
+
ngram_min: number;
|
| 38 |
+
ngram_max: number;
|
| 39 |
+
hash_buckets: number;
|
| 40 |
+
lowercase: boolean;
|
| 41 |
+
binary_counts: boolean;
|
| 42 |
+
normalize: boolean;
|
| 43 |
+
add_shape_features?: boolean;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export interface SnipSamplingConfig {
|
| 47 |
+
small_cutoff: number;
|
| 48 |
+
window_size: number;
|
| 49 |
+
separator: string;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export interface SnipModelConfig {
|
| 53 |
+
model_type: "linear_pa";
|
| 54 |
+
name?: string;
|
| 55 |
+
labels: SnipLabel[];
|
| 56 |
+
feature: SnipFeatureConfig;
|
| 57 |
+
sampling: SnipSamplingConfig;
|
| 58 |
+
fallback_label?: SnipLabel;
|
| 59 |
+
thresholds?: Partial<Record<SnipLabel, number>>;
|
| 60 |
+
margin_thresholds?: Partial<Record<SnipLabel, number>>;
|
| 61 |
+
[key: string]: unknown;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
export interface SnipModel {
|
| 65 |
+
config: SnipModelConfig;
|
| 66 |
+
bias?: Partial<Record<SnipLabel, number>>;
|
| 67 |
+
weights: Partial<Record<SnipLabel, Array<[number, number]>>>;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
export interface ClassificationResult {
|
| 71 |
+
label: SnipLabel;
|
| 72 |
+
predicted_label: SnipLabel;
|
| 73 |
+
confidence: number;
|
| 74 |
+
margin: number;
|
| 75 |
+
alternatives: Array<[SnipLabel, number]>;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
export function classifyText(text: string): ClassificationResult {
|
| 79 |
+
return classifyWithModel(text, model);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
export async function classifyTextAsync(text: string): Promise<ClassificationResult> {
|
| 83 |
+
await yieldToBrowser();
|
| 84 |
+
return classifyText(text);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function classifyWithModel(text: string, model: SnipModel): ClassificationResult {
|
| 88 |
+
if (model.config.model_type !== "linear_pa") {
|
| 89 |
+
throw new Error(`unsupported SNIP model type: ${model.config.model_type}`);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const sampled = sampleWithConfig(text, model.config.sampling);
|
| 93 |
+
const features = extractFeatures(sampled, model.config.feature);
|
| 94 |
+
const scores = new Map<SnipLabel, number>();
|
| 95 |
+
|
| 96 |
+
for (const label of model.config.labels) {
|
| 97 |
+
let score = model.bias?.[label] ?? 0;
|
| 98 |
+
const weights = model.weights[label] ?? [];
|
| 99 |
+
for (const [key, weight] of weights) {
|
| 100 |
+
score += (features.get(key) ?? 0) * weight;
|
| 101 |
+
}
|
| 102 |
+
scores.set(label, score);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
const probabilities = softmax(scores);
|
| 106 |
+
const alternatives = [...probabilities.entries()].sort((a, b) => b[1] - a[1]);
|
| 107 |
+
const [predictedLabel, confidence] = alternatives[0] ?? [model.config.fallback_label ?? "plain_text", 0];
|
| 108 |
+
const second = alternatives[1]?.[1] ?? 0;
|
| 109 |
+
const margin = confidence - second;
|
| 110 |
+
const threshold = model.config.thresholds?.[predictedLabel] ?? 0;
|
| 111 |
+
const marginThreshold = model.config.margin_thresholds?.[predictedLabel] ?? 0;
|
| 112 |
+
const fallback = model.config.fallback_label ?? "plain_text";
|
| 113 |
+
const accepted = confidence >= threshold && margin >= marginThreshold;
|
| 114 |
+
|
| 115 |
+
return {
|
| 116 |
+
label: accepted ? predictedLabel : fallback,
|
| 117 |
+
predicted_label: predictedLabel,
|
| 118 |
+
confidence,
|
| 119 |
+
margin,
|
| 120 |
+
alternatives: alternatives.slice(0, 5),
|
| 121 |
+
};
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
export function sampleText(text: string): string {
|
| 125 |
+
return sampleWithConfig(text, model.config.sampling);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
function sampleWithConfig(text: string, config: SnipSamplingConfig): string {
|
| 129 |
+
if (text.length <= config.small_cutoff) return text;
|
| 130 |
+
|
| 131 |
+
const windowSize = config.window_size;
|
| 132 |
+
const start = text.slice(0, windowSize);
|
| 133 |
+
const midpoint = Math.floor(text.length / 2);
|
| 134 |
+
const half = Math.floor(windowSize / 2);
|
| 135 |
+
const middle = text.slice(
|
| 136 |
+
Math.max(0, midpoint - half),
|
| 137 |
+
Math.min(text.length, midpoint + half),
|
| 138 |
+
);
|
| 139 |
+
const end = text.slice(-windowSize);
|
| 140 |
+
return [start, middle, end].join(config.separator);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function extractFeatures(text: string, config: SnipFeatureConfig): Map<number, number> {
|
| 144 |
+
if (config.lowercase) text = text.toLowerCase();
|
| 145 |
+
|
| 146 |
+
const counts = new Map<number, number>();
|
| 147 |
+
const padded = ` ${text} `;
|
| 148 |
+
for (let n = config.ngram_min; n <= config.ngram_max; n += 1) {
|
| 149 |
+
if (padded.length < n) continue;
|
| 150 |
+
for (let i = 0; i <= padded.length - n; i += 1) {
|
| 151 |
+
const key = stableHash(`char${n}:${padded.slice(i, i + n)}`, config.hash_buckets);
|
| 152 |
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const features = new Map<number, number>();
|
| 157 |
+
let norm = 0;
|
| 158 |
+
for (const [key, value] of counts.entries()) {
|
| 159 |
+
const transformed = config.binary_counts ? 1 : Math.log1p(value);
|
| 160 |
+
features.set(key, transformed);
|
| 161 |
+
norm += transformed * transformed;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
if (config.normalize && norm > 0) {
|
| 165 |
+
const scale = Math.sqrt(norm);
|
| 166 |
+
for (const [key, value] of features.entries()) {
|
| 167 |
+
features.set(key, value / scale);
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
return features;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function stableHash(text: string, buckets: number): number {
|
| 175 |
+
let value = 2166136261 >>> 0;
|
| 176 |
+
for (let i = 0; i < text.length; i += 1) {
|
| 177 |
+
const code = text.charCodeAt(i);
|
| 178 |
+
value ^= code & 0xff;
|
| 179 |
+
value = Math.imul(value, 16777619) >>> 0;
|
| 180 |
+
value ^= (code >>> 8) & 0xff;
|
| 181 |
+
value = Math.imul(value, 16777619) >>> 0;
|
| 182 |
+
}
|
| 183 |
+
return value % buckets;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
function yieldToBrowser(): Promise<void> {
|
| 187 |
+
return new Promise((resolve) => {
|
| 188 |
+
if (typeof globalThis.requestAnimationFrame === "function") {
|
| 189 |
+
globalThis.requestAnimationFrame(() => resolve());
|
| 190 |
+
return;
|
| 191 |
+
}
|
| 192 |
+
globalThis.setTimeout(resolve, 0);
|
| 193 |
+
});
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
function softmax(scores: Map<SnipLabel, number>): Map<SnipLabel, number> {
|
| 197 |
+
const maxScore = Math.max(...scores.values());
|
| 198 |
+
const probabilities = new Map<SnipLabel, number>();
|
| 199 |
+
let total = 0;
|
| 200 |
+
|
| 201 |
+
for (const [label, score] of scores.entries()) {
|
| 202 |
+
const value = Math.exp(score - maxScore);
|
| 203 |
+
probabilities.set(label, value);
|
| 204 |
+
total += value;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
for (const [label, value] of probabilities.entries()) {
|
| 208 |
+
probabilities.set(label, value / total);
|
| 209 |
+
}
|
| 210 |
+
return probabilities;
|
| 211 |
+
}
|