jev-grep 0.1.0__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,22 @@
1
+ name: publish
2
+ # Push a tag like v0.1.0 to release. PyPI trusts this workflow directly (Trusted Publishing),
3
+ # so there is no API token to store, leak or rotate.
4
+ on:
5
+ push:
6
+ tags: ["v*"]
7
+ jobs:
8
+ pypi:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write
13
+ contents: read
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v5
17
+ - name: The tag must match the version in pyproject.toml
18
+ run: test "v$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2)" = "$GITHUB_REF_NAME"
19
+ - run: uv sync
20
+ - run: uv run pytest -q
21
+ - run: uv build
22
+ - run: uv publish
@@ -0,0 +1,13 @@
1
+ name: test
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix:
8
+ python: ["3.10", "3.13"]
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: astral-sh/setup-uv@v5
12
+ - run: uv sync --python ${{ matrix.python }}
13
+ - run: uv run pytest -q
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ dist/
5
+ bench/out/
6
+ .env
7
+ *.key
jev_grep-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Khaled Eltokhy
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.
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.5
2
+ Name: jev-grep
3
+ Version: 0.1.0
4
+ Summary: grep by meaning: filter lines with a plain-English description, judged by TypeSafe's Jev model
5
+ Project-URL: Repository, https://github.com/keltokhy/jgrep
6
+ Author: Khaled Eltokhy
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: cli,grep,jev,openrouter,semantic search,typesafe
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Topic :: Text Processing :: Filters
13
+ Classifier: Topic :: Utilities
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: httpx>=0.27
16
+ Description-Content-Type: text/markdown
17
+
18
+ # jgrep
19
+
20
+ grep, but the pattern is a description.
21
+
22
+ ```console
23
+ $ tail -f app.log | jgrep "a user is getting frustrated"
24
+ user 12: this is the third time checkout has failed, I am done with this app
25
+ user 77: WHY does it log me out every five minutes??
26
+
27
+ $ jgrep -o "announces or releases a new AI model" titles.txt | sort -rn | head -3
28
+ 0.980 PrismML Launches Bonsai 2 27B, Its Most Capable Model Yet
29
+ 0.970 Alibaba Releases Qwen3.8-Omni-Flash
30
+ 0.940 Google announces new experimental "CC" AI agent for families
31
+ ```
32
+
33
+ Each line becomes one yes/no question to [Jev](https://docs.typesafe.ai), TypeSafe's decision
34
+ model. Jev does not generate text. It returns a probability in about 200 ms for about a
35
+ thousandth of a cent, which is fast and cheap enough to sit in a pipe. jgrep reads lines as
36
+ they arrive, judges them concurrently and prints matches in input order, so it works on
37
+ `tail -f` as well as on files.
38
+
39
+ Measured on 994 Hacker News titles: 4.6 seconds and $0.012 for one description, and the same
40
+ time for three descriptions at once.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ uv tool install git+https://github.com/keltokhy/jgrep
46
+ ```
47
+
48
+ jgrep needs a key for one of two APIs. With keys for both, it uses TypeSafe's.
49
+
50
+ | API | Key | Get one |
51
+ |---|---|---|
52
+ | TypeSafe | `TYPESAFE_API_KEY` | [console.typesafe.ai](https://console.typesafe.ai/settings/keys) |
53
+ | OpenRouter | `OPENROUTER_API_KEY` | [openrouter.ai/keys](https://openrouter.ai/keys) |
54
+
55
+ Set the environment variable, or put the key in `~/.config/jev/typesafe.key` or
56
+ `~/.config/jev/openrouter.key`. Force a choice with `--api` or `JEV_API`.
57
+
58
+ ## Use
59
+
60
+ ```bash
61
+ jgrep "a complaint about noise" complaints.txt # lines that fit
62
+ jgrep -v "spam" inbox.txt # lines that do not
63
+ jgrep -c "asks a question" *.txt # counts per file
64
+ jgrep -p 0.9 "mentions a specific dollar amount" f.txt # only confident matches
65
+ jgrep -o -p 0 "the writer is losing sleep" f.txt | sort -rn # rank every line
66
+ jgrep -e "about economics" -e "about New York" f.txt # either; add --all for both
67
+ jgrep --para "describes an identification strategy" paper.txt
68
+ jgrep --whole "uses a bunching estimator" abstracts/*.txt # prints matching file names
69
+ jgrep -q "a stack trace" build.log && notify "build broke"
70
+ ```
71
+
72
+ | Option | Meaning |
73
+ |---|---|
74
+ | `-p P` | Match when the probability is at least P. Default 0.5. |
75
+ | `-o` | Put the probability in a first, tab-separated column. |
76
+ | `-v`, `-c`, `-n`, `-H`, `-m NUM`, `-q` | As in grep. |
77
+ | `-e DESC` | Another description. All of them go in one call per line. A line matches if any fits, or all with `--all`. |
78
+ | `--para`, `--whole` | Judge paragraphs or whole files in place of lines. |
79
+ | `--json` | One JSON object per match, with the probability. |
80
+ | `--unordered` | Print matches as answers arrive. |
81
+ | `-j N` | Calls in flight. Default 32. |
82
+ | `--budget DOLLARS` | Stop once this much is spent. Default 1.00, or `$JGREP_BUDGET`; 0 for no limit. |
83
+ | `--timeout SECONDS` | Give up on a line after this long, retries included. Default 15. |
84
+ | `--no-cache`, `--api`, `--model`, `--stats` | See `jgrep --help`. |
85
+
86
+ Exit status follows grep: 0 if anything matched, 1 if nothing did, 2 on error.
87
+
88
+ ## Cost
89
+
90
+ A call bills roughly 270 tokens of fixed overhead plus the line and the description, so a
91
+ typical line costs about 300 tokens, or $0.0000126 at $0.042 per million. A million lines is
92
+ about $13. Blank lines, repeated lines and anything answered before are free: answers are
93
+ cached in `~/.cache/jev/answers.sqlite`, keyed on the exact model, line and description.
94
+ Extra `-e` descriptions add about 27 tokens each and no time.
95
+
96
+ jgrep stops at `--budget`, one dollar by default, so a stray `jgrep pattern huge.log` cannot
97
+ run up a bill. A dollar is about 80,000 lines. A stopped run loses nothing: rerun with a higher
98
+ budget and everything already judged comes from the cache. For a long-lived `tail -f` monitor,
99
+ set your own default once with `export JGREP_BUDGET=20`, or `0` for no limit. With `--stats`, or whenever stderr is a terminal, it prints what the run cost:
100
+
101
+ ```
102
+ jgrep: 994 records, 33 matched; 994 calls, 0 cached; 292,839 tokens; $0.0123; 4.6s
103
+ ```
104
+
105
+ ## How well does it work
106
+
107
+ Three benchmarks on public labeled text, run on 2026-09-18 with Jev 1.13 through OpenRouter.
108
+ Each one runs the installed `jgrep` command itself, uncached, at its default threshold of 0.5.
109
+ Reproduce them with `bench/accuracy.py`.
110
+
111
+ **Against a keyword grep.** The UCI SMS Spam Collection: 5,574 text messages, 747 of them spam.
112
+
113
+ | Filter | Precision | Recall | F1 | Time | Cost |
114
+ |---|---:|---:|---:|---:|---:|
115
+ | `jgrep "an unsolicited spam, scam or marketing text message"` | 0.87 | 0.95 | **0.91** | 27 s | $0.07 |
116
+ | the same with `-p 0.9` | 0.98 | 0.84 | 0.90 | | |
117
+ | `grep -iE "free\|win\|prize\|claim\|urgent\|cash\|txt\|call now\|..."` (17 terms) | 0.64 | 0.81 | 0.72 | 0.03 s | free |
118
+
119
+ The regular expression was written before looking at any results and is in the script.
120
+
121
+ **Against asking a chat model.** The do-it-yourself alternative is a loop that asks an LLM the
122
+ same yes/no question about each line. On 300 of those messages, 32 requests in flight, all
123
+ through OpenRouter:
124
+
125
+ | Judge | F1 | Wall time | Cost | Median latency |
126
+ |---|---:|---:|---:|---:|
127
+ | **jgrep (Jev 1.13)** | 0.90 | **2.7 s** | $0.0039 | about 210 ms |
128
+ | GPT Luna | 0.88 | 9.3 s | $0.0060 | 802 ms |
129
+ | GPT Terra | 0.92 | 10.8 s | $0.0571 | 988 ms |
130
+ | Qwen 3.7 Flash, thinking off | 0.78 | 8.4 s | $0.0007 | 789 ms |
131
+
132
+ jgrep finished three to four times sooner than any of them. Its accuracy sits between the two
133
+ GPT tiers; with 45 spam messages in the sample, those three F1 scores are within noise of each
134
+ other. It is not the cheapest per line: a small open model costs a sixth as much and is
135
+ clearly less accurate. Against the model that matched its accuracy, jgrep cost a fifteenth
136
+ as much.
137
+
138
+ **Several descriptions at once.** AG News test set, 7,600 articles, four descriptions
139
+ (`-e "news about sports" -e "news about business, markets or the economy" ...`) judged in one
140
+ call per article: 37 seconds and $0.13 for all four. Taking the most probable description as the
141
+ label gives 86.6% accuracy with no training. One-vs-rest F1 at 0.5 was 0.97 for sports, 0.82 for
142
+ science and technology, 0.82 for world affairs and 0.72 for business, which over-triggers
143
+ (precision 0.58) because so much technology news is also business news.
144
+
145
+ **Does the wording of a description matter?** `bench/phrasing.py` scores 30 hand-labeled lines
146
+ against five descriptions of different grammatical shapes, including a negation and a question.
147
+ Jev got all 150 right under each of four ways of wording the question; that set is easy on
148
+ purpose. Asking five descriptions in one call changed no decision and moved probabilities by
149
+ 0.001 on average. Latency was flat at about 210 ms from 1 to 64 questions per call.
150
+
151
+ On borderline lines the probabilities land in between, which is what `-p` is for:
152
+
153
+ ```
154
+ 0.65 [a complaint about noise] The music from the church on Sunday mornings is lovely but it does start early.
155
+ 0.46 [does not mention a landlord] The owner of the building never answers the phone.
156
+ ```
157
+
158
+ Things to know:
159
+
160
+ - These are a model's judgments. Check a sample before you rely on a filter.
161
+ - Jev answers the description you wrote, not the one you meant. TypeSafe
162
+ [documents](https://docs.typesafe.ai/model-jaggedness/jev-1.13) weak spots: counting,
163
+ comparing numbers or dates, double negatives, and long inputs full of irrelevant detail.
164
+ - Each line is judged alone. jgrep does not show Jev the lines around it.
165
+ - Jev is close to deterministic, not exactly so. Asking 150 questions three times without the
166
+ cache gave identical probabilities for 128; the rest moved by up to 0.03 and no decision
167
+ flipped. The cache makes reruns exact.
168
+ - The default model ID is an alias for the latest Jev. For results that must reproduce, pin
169
+ one with `--model` (for example `typesafe/jev-1.13` on OpenRouter).
170
+ - Text in the input can try to steer the answer. Do not use jgrep as a security boundary.
171
+
172
+ ## Development
173
+
174
+ ```bash
175
+ uv sync && uv run pytest # 23 tests against a fake API; no key, no network
176
+ uv run python bench/phrasing.py # live; costs about a cent
177
+ uv run python bench/accuracy.py prepare && uv run python bench/accuracy.py spam # also: news, llm
178
+ ```
179
+
180
+ `src/jgrep/core.py` is the client: two backends, retries inside a time budget, the cache,
181
+ in-flight deduplication and the cost meter. It is shared verbatim with
182
+ [jlink](https://github.com/keltokhy/jlink), which links records across datasets with the same
183
+ model.
184
+
185
+ MIT license.
@@ -0,0 +1,168 @@
1
+ # jgrep
2
+
3
+ grep, but the pattern is a description.
4
+
5
+ ```console
6
+ $ tail -f app.log | jgrep "a user is getting frustrated"
7
+ user 12: this is the third time checkout has failed, I am done with this app
8
+ user 77: WHY does it log me out every five minutes??
9
+
10
+ $ jgrep -o "announces or releases a new AI model" titles.txt | sort -rn | head -3
11
+ 0.980 PrismML Launches Bonsai 2 27B, Its Most Capable Model Yet
12
+ 0.970 Alibaba Releases Qwen3.8-Omni-Flash
13
+ 0.940 Google announces new experimental "CC" AI agent for families
14
+ ```
15
+
16
+ Each line becomes one yes/no question to [Jev](https://docs.typesafe.ai), TypeSafe's decision
17
+ model. Jev does not generate text. It returns a probability in about 200 ms for about a
18
+ thousandth of a cent, which is fast and cheap enough to sit in a pipe. jgrep reads lines as
19
+ they arrive, judges them concurrently and prints matches in input order, so it works on
20
+ `tail -f` as well as on files.
21
+
22
+ Measured on 994 Hacker News titles: 4.6 seconds and $0.012 for one description, and the same
23
+ time for three descriptions at once.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ uv tool install git+https://github.com/keltokhy/jgrep
29
+ ```
30
+
31
+ jgrep needs a key for one of two APIs. With keys for both, it uses TypeSafe's.
32
+
33
+ | API | Key | Get one |
34
+ |---|---|---|
35
+ | TypeSafe | `TYPESAFE_API_KEY` | [console.typesafe.ai](https://console.typesafe.ai/settings/keys) |
36
+ | OpenRouter | `OPENROUTER_API_KEY` | [openrouter.ai/keys](https://openrouter.ai/keys) |
37
+
38
+ Set the environment variable, or put the key in `~/.config/jev/typesafe.key` or
39
+ `~/.config/jev/openrouter.key`. Force a choice with `--api` or `JEV_API`.
40
+
41
+ ## Use
42
+
43
+ ```bash
44
+ jgrep "a complaint about noise" complaints.txt # lines that fit
45
+ jgrep -v "spam" inbox.txt # lines that do not
46
+ jgrep -c "asks a question" *.txt # counts per file
47
+ jgrep -p 0.9 "mentions a specific dollar amount" f.txt # only confident matches
48
+ jgrep -o -p 0 "the writer is losing sleep" f.txt | sort -rn # rank every line
49
+ jgrep -e "about economics" -e "about New York" f.txt # either; add --all for both
50
+ jgrep --para "describes an identification strategy" paper.txt
51
+ jgrep --whole "uses a bunching estimator" abstracts/*.txt # prints matching file names
52
+ jgrep -q "a stack trace" build.log && notify "build broke"
53
+ ```
54
+
55
+ | Option | Meaning |
56
+ |---|---|
57
+ | `-p P` | Match when the probability is at least P. Default 0.5. |
58
+ | `-o` | Put the probability in a first, tab-separated column. |
59
+ | `-v`, `-c`, `-n`, `-H`, `-m NUM`, `-q` | As in grep. |
60
+ | `-e DESC` | Another description. All of them go in one call per line. A line matches if any fits, or all with `--all`. |
61
+ | `--para`, `--whole` | Judge paragraphs or whole files in place of lines. |
62
+ | `--json` | One JSON object per match, with the probability. |
63
+ | `--unordered` | Print matches as answers arrive. |
64
+ | `-j N` | Calls in flight. Default 32. |
65
+ | `--budget DOLLARS` | Stop once this much is spent. Default 1.00, or `$JGREP_BUDGET`; 0 for no limit. |
66
+ | `--timeout SECONDS` | Give up on a line after this long, retries included. Default 15. |
67
+ | `--no-cache`, `--api`, `--model`, `--stats` | See `jgrep --help`. |
68
+
69
+ Exit status follows grep: 0 if anything matched, 1 if nothing did, 2 on error.
70
+
71
+ ## Cost
72
+
73
+ A call bills roughly 270 tokens of fixed overhead plus the line and the description, so a
74
+ typical line costs about 300 tokens, or $0.0000126 at $0.042 per million. A million lines is
75
+ about $13. Blank lines, repeated lines and anything answered before are free: answers are
76
+ cached in `~/.cache/jev/answers.sqlite`, keyed on the exact model, line and description.
77
+ Extra `-e` descriptions add about 27 tokens each and no time.
78
+
79
+ jgrep stops at `--budget`, one dollar by default, so a stray `jgrep pattern huge.log` cannot
80
+ run up a bill. A dollar is about 80,000 lines. A stopped run loses nothing: rerun with a higher
81
+ budget and everything already judged comes from the cache. For a long-lived `tail -f` monitor,
82
+ set your own default once with `export JGREP_BUDGET=20`, or `0` for no limit. With `--stats`, or whenever stderr is a terminal, it prints what the run cost:
83
+
84
+ ```
85
+ jgrep: 994 records, 33 matched; 994 calls, 0 cached; 292,839 tokens; $0.0123; 4.6s
86
+ ```
87
+
88
+ ## How well does it work
89
+
90
+ Three benchmarks on public labeled text, run on 2026-09-18 with Jev 1.13 through OpenRouter.
91
+ Each one runs the installed `jgrep` command itself, uncached, at its default threshold of 0.5.
92
+ Reproduce them with `bench/accuracy.py`.
93
+
94
+ **Against a keyword grep.** The UCI SMS Spam Collection: 5,574 text messages, 747 of them spam.
95
+
96
+ | Filter | Precision | Recall | F1 | Time | Cost |
97
+ |---|---:|---:|---:|---:|---:|
98
+ | `jgrep "an unsolicited spam, scam or marketing text message"` | 0.87 | 0.95 | **0.91** | 27 s | $0.07 |
99
+ | the same with `-p 0.9` | 0.98 | 0.84 | 0.90 | | |
100
+ | `grep -iE "free\|win\|prize\|claim\|urgent\|cash\|txt\|call now\|..."` (17 terms) | 0.64 | 0.81 | 0.72 | 0.03 s | free |
101
+
102
+ The regular expression was written before looking at any results and is in the script.
103
+
104
+ **Against asking a chat model.** The do-it-yourself alternative is a loop that asks an LLM the
105
+ same yes/no question about each line. On 300 of those messages, 32 requests in flight, all
106
+ through OpenRouter:
107
+
108
+ | Judge | F1 | Wall time | Cost | Median latency |
109
+ |---|---:|---:|---:|---:|
110
+ | **jgrep (Jev 1.13)** | 0.90 | **2.7 s** | $0.0039 | about 210 ms |
111
+ | GPT Luna | 0.88 | 9.3 s | $0.0060 | 802 ms |
112
+ | GPT Terra | 0.92 | 10.8 s | $0.0571 | 988 ms |
113
+ | Qwen 3.7 Flash, thinking off | 0.78 | 8.4 s | $0.0007 | 789 ms |
114
+
115
+ jgrep finished three to four times sooner than any of them. Its accuracy sits between the two
116
+ GPT tiers; with 45 spam messages in the sample, those three F1 scores are within noise of each
117
+ other. It is not the cheapest per line: a small open model costs a sixth as much and is
118
+ clearly less accurate. Against the model that matched its accuracy, jgrep cost a fifteenth
119
+ as much.
120
+
121
+ **Several descriptions at once.** AG News test set, 7,600 articles, four descriptions
122
+ (`-e "news about sports" -e "news about business, markets or the economy" ...`) judged in one
123
+ call per article: 37 seconds and $0.13 for all four. Taking the most probable description as the
124
+ label gives 86.6% accuracy with no training. One-vs-rest F1 at 0.5 was 0.97 for sports, 0.82 for
125
+ science and technology, 0.82 for world affairs and 0.72 for business, which over-triggers
126
+ (precision 0.58) because so much technology news is also business news.
127
+
128
+ **Does the wording of a description matter?** `bench/phrasing.py` scores 30 hand-labeled lines
129
+ against five descriptions of different grammatical shapes, including a negation and a question.
130
+ Jev got all 150 right under each of four ways of wording the question; that set is easy on
131
+ purpose. Asking five descriptions in one call changed no decision and moved probabilities by
132
+ 0.001 on average. Latency was flat at about 210 ms from 1 to 64 questions per call.
133
+
134
+ On borderline lines the probabilities land in between, which is what `-p` is for:
135
+
136
+ ```
137
+ 0.65 [a complaint about noise] The music from the church on Sunday mornings is lovely but it does start early.
138
+ 0.46 [does not mention a landlord] The owner of the building never answers the phone.
139
+ ```
140
+
141
+ Things to know:
142
+
143
+ - These are a model's judgments. Check a sample before you rely on a filter.
144
+ - Jev answers the description you wrote, not the one you meant. TypeSafe
145
+ [documents](https://docs.typesafe.ai/model-jaggedness/jev-1.13) weak spots: counting,
146
+ comparing numbers or dates, double negatives, and long inputs full of irrelevant detail.
147
+ - Each line is judged alone. jgrep does not show Jev the lines around it.
148
+ - Jev is close to deterministic, not exactly so. Asking 150 questions three times without the
149
+ cache gave identical probabilities for 128; the rest moved by up to 0.03 and no decision
150
+ flipped. The cache makes reruns exact.
151
+ - The default model ID is an alias for the latest Jev. For results that must reproduce, pin
152
+ one with `--model` (for example `typesafe/jev-1.13` on OpenRouter).
153
+ - Text in the input can try to steer the answer. Do not use jgrep as a security boundary.
154
+
155
+ ## Development
156
+
157
+ ```bash
158
+ uv sync && uv run pytest # 23 tests against a fake API; no key, no network
159
+ uv run python bench/phrasing.py # live; costs about a cent
160
+ uv run python bench/accuracy.py prepare && uv run python bench/accuracy.py spam # also: news, llm
161
+ ```
162
+
163
+ `src/jgrep/core.py` is the client: two backends, retries inside a time budget, the cache,
164
+ in-flight deduplication and the cost meter. It is shared verbatim with
165
+ [jlink](https://github.com/keltokhy/jlink), which links records across datasets with the same
166
+ model.
167
+
168
+ MIT license.
@@ -0,0 +1,193 @@
1
+ """How accurate, fast and cheap is jgrep on public labeled text, next to the obvious alternatives?
2
+
3
+ uv run python bench/accuracy.py prepare # download the two datasets into bench/out/
4
+ uv run python bench/accuracy.py spam # jgrep vs a keyword grep on SMS spam
5
+ uv run python bench/accuracy.py news # four descriptions in one call on AG News
6
+ uv run python bench/accuracy.py llm [--n 300] # jgrep vs chat LLMs asked the same yes/no question
7
+
8
+ Everything runs the installed `jgrep` command itself, uncached, so the numbers are what a user gets.
9
+ Live; the whole file costs well under a dollar. Results land in bench/out/accuracy-*.json.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import asyncio
16
+ import csv
17
+ import io
18
+ import json
19
+ import re
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ import urllib.request
24
+ import zipfile
25
+ from pathlib import Path
26
+
27
+ import httpx
28
+
29
+ from jgrep.core import BACKENDS
30
+
31
+ OUT = Path(__file__).parent / "out"
32
+ SPAM_URL = "https://archive.ics.uci.edu/static/public/228/sms+spam+collection.zip" # UCI, CC BY 4.0
33
+ NEWS_URL = "https://raw.githubusercontent.com/mhjabreel/CharCnn_Keras/master/data/ag_news_csv/test.csv"
34
+ SPAM_DESCRIPTION = "an unsolicited spam, scam or marketing text message"
35
+ # A keyword filter of the kind people actually write. Fixed before looking at any results.
36
+ SPAM_REGEX = r"free|win|won|prize|claim|urgent|cash|txt|text .* to|call now|reply|offer|guaranteed|£|\$|www\.|http"
37
+ NEWS = {1: "news about world affairs, politics or conflict", 2: "news about sports",
38
+ 3: "news about business, markets or the economy", 4: "news about science or technology"}
39
+
40
+
41
+ def one_line(text: str) -> str:
42
+ return " ".join(text.split())
43
+
44
+
45
+ def prepare() -> None:
46
+ OUT.mkdir(exist_ok=True)
47
+ raw = urllib.request.urlopen(SPAM_URL, timeout=60).read()
48
+ rows = zipfile.ZipFile(io.BytesIO(raw)).read("SMSSpamCollection").decode("utf-8").splitlines()
49
+ pairs = [r.split("\t", 1) for r in rows if "\t" in r]
50
+ (OUT / "spam.txt").write_text("".join(one_line(t) + "\n" for _, t in pairs))
51
+ (OUT / "spam.labels").write_text("".join(("1" if y == "spam" else "0") + "\n" for y, _ in pairs))
52
+ news = list(csv.reader(io.StringIO(urllib.request.urlopen(NEWS_URL, timeout=60).read().decode("utf-8"))))
53
+ (OUT / "news.txt").write_text("".join(one_line(f"{title}. {body}").replace("\\", " ") + "\n" for _, title, body in news))
54
+ (OUT / "news.labels").write_text("".join(y + "\n" for y, _, _ in news))
55
+ print(f"spam: {len(pairs):,} messages, {sum(y == 'spam' for y, _ in pairs):,} spam; news: {len(news):,} articles")
56
+
57
+
58
+ def load(name: str) -> tuple[list[str], list[int]]:
59
+ lines = (OUT / f"{name}.txt").read_text().splitlines()
60
+ labels = [int(x) for x in (OUT / f"{name}.labels").read_text().split()]
61
+ assert len(lines) == len(labels), "a text spans lines; rerun prepare"
62
+ return lines, labels
63
+
64
+
65
+ def jgrep(descriptions: list[str], path: Path, *, jobs: int = 64) -> tuple[list[dict], dict]:
66
+ """Every line's probabilities from the real command, uncached, plus its own stats line."""
67
+ cmd = ["jgrep", "--json", "-p", "0", "-j", str(jobs), "--no-cache", "--stats", "--budget", "2"]
68
+ for d in descriptions:
69
+ cmd += ["-e", d]
70
+ t0 = time.perf_counter()
71
+ run = subprocess.run(cmd + [str(path)], capture_output=True, text=True)
72
+ seconds = time.perf_counter() - t0
73
+ rows = [json.loads(line) for line in run.stdout.splitlines()]
74
+ stats = re.search(r"([\d,]+) calls.*?([\d,]+) tokens; \$([\d.]+)", run.stderr)
75
+ if run.returncode == 2 or not stats:
76
+ sys.exit(f"jgrep failed: {run.stderr[-400:]}")
77
+ return rows, {"seconds": round(seconds, 1), "calls": int(stats[1].replace(",", "")),
78
+ "tokens": int(stats[2].replace(",", "")), "dollars": float(stats[3])}
79
+
80
+
81
+ def prf(predicted: list[bool], truth: list[bool]) -> dict:
82
+ tp = sum(p and t for p, t in zip(predicted, truth))
83
+ fp = sum(p and not t for p, t in zip(predicted, truth))
84
+ fn = sum(t and not p for p, t in zip(predicted, truth))
85
+ precision, recall = tp / max(tp + fp, 1), tp / max(tp + fn, 1)
86
+ return {"precision": round(precision, 4), "recall": round(recall, 4),
87
+ "f1": round(2 * precision * recall / max(precision + recall, 1e-9), 4),
88
+ "accuracy": round(sum(p == t for p, t in zip(predicted, truth)) / len(truth), 4)}
89
+
90
+
91
+ def spam() -> None:
92
+ lines, labels = load("spam")
93
+ truth = [y == 1 for y in labels]
94
+ rows, stats = jgrep([SPAM_DESCRIPTION], OUT / "spam.txt")
95
+ p = {r["line"]: r["p"] for r in rows}
96
+ ps = [p.get(i + 1, 0.0) for i in range(len(lines))]
97
+ t0 = time.perf_counter()
98
+ keyword = [bool(re.search(SPAM_REGEX, line, re.I)) for line in lines]
99
+ result = {
100
+ "dataset": "UCI SMS Spam Collection", "lines": len(lines), "positives": sum(truth),
101
+ "description": SPAM_DESCRIPTION, "jgrep_at_0.5": prf([x >= 0.5 for x in ps], truth),
102
+ "jgrep_at_0.9": prf([x >= 0.9 for x in ps], truth), "jgrep_run": stats,
103
+ "keyword_grep": prf(keyword, truth) | {"regex": SPAM_REGEX, "seconds": round(time.perf_counter() - t0, 3)},
104
+ }
105
+ (OUT / "accuracy-spam.json").write_text(json.dumps(result, indent=2))
106
+ print(json.dumps(result, indent=2))
107
+
108
+
109
+ def news() -> None:
110
+ lines, labels = load("news")
111
+ rows, stats = jgrep(list(NEWS.values()), OUT / "news.txt")
112
+ by_line = {r["line"]: r.get("ps", [r["p"]]) for r in rows}
113
+ ps = [by_line.get(i + 1, [0.0] * 4) for i in range(len(lines))]
114
+ per_class = {NEWS[c]: prf([x[c - 1] >= 0.5 for x in ps], [y == c for y in labels]) for c in NEWS}
115
+ top1 = [max(range(4), key=lambda j: x[j]) + 1 for x in ps]
116
+ result = {
117
+ "dataset": "AG News test set", "lines": len(lines), "descriptions": list(NEWS.values()),
118
+ "note": "four descriptions packed into one call per line; each scored one-vs-rest at 0.5",
119
+ "per_description": per_class, "macro_f1": round(sum(v["f1"] for v in per_class.values()) / 4, 4),
120
+ "top1_accuracy": round(sum(a == b for a, b in zip(top1, labels)) / len(labels), 4), "jgrep_run": stats,
121
+ }
122
+ (OUT / "accuracy-news.json").write_text(json.dumps(result, indent=2))
123
+ print(json.dumps(result, indent=2))
124
+
125
+
126
+ async def chat(model: str, lines: list[str], key: str, jobs: int) -> tuple[list[bool | None], dict]:
127
+ """The do-it-yourself alternative: ask a chat model the same question, one line per request."""
128
+ sem, latencies, usage = asyncio.Semaphore(jobs), [], {"cost": 0.0, "prompt": 0, "completion": 0}
129
+ prompt = f'Does the text fit this description: "{SPAM_DESCRIPTION}"? Answer with one word, yes or no.'
130
+
131
+ async def one(client: httpx.AsyncClient, text: str) -> bool | None:
132
+ # Hybrid models that think by default (Qwen) spend the whole allowance thinking and never answer,
133
+ # so their thinking is switched off; the GPT models take the lowest effort they offer.
134
+ reasoning = {"enabled": False} if model.startswith("qwen/") else {"effort": "low", "exclude": True}
135
+ body = {"model": model, "temperature": 0, "max_tokens": 200, "usage": {"include": True},
136
+ "reasoning": reasoning,
137
+ "messages": [{"role": "system", "content": prompt}, {"role": "user", "content": text}]}
138
+ async with sem:
139
+ for attempt in range(4):
140
+ t0 = time.perf_counter()
141
+ r = await client.post("https://openrouter.ai/api/v1/chat/completions", json=body)
142
+ if r.status_code == 200 and "choices" in r.json():
143
+ break
144
+ await asyncio.sleep(0.5 * 2 ** attempt)
145
+ else:
146
+ return None
147
+ latencies.append(time.perf_counter() - t0)
148
+ data = r.json()
149
+ u = data.get("usage") or {}
150
+ usage["cost"] += u.get("cost") or 0.0
151
+ usage["prompt"] += u.get("prompt_tokens") or 0
152
+ usage["completion"] += u.get("completion_tokens") or 0
153
+ answer = (data["choices"][0]["message"].get("content") or "").strip().lower()
154
+ return True if answer.startswith("yes") else False if answer.startswith("no") else None
155
+
156
+ t0 = time.perf_counter()
157
+ async with httpx.AsyncClient(headers={"Authorization": f"Bearer {key}"}, timeout=90) as client:
158
+ answers = await asyncio.gather(*(one(client, t) for t in lines))
159
+ latencies.sort()
160
+ return answers, {"seconds": round(time.perf_counter() - t0, 1), "dollars": round(usage["cost"], 5),
161
+ "median_latency_ms": round(latencies[len(latencies) // 2] * 1000) if latencies else None,
162
+ "prompt_tokens": usage["prompt"], "completion_tokens": usage["completion"],
163
+ "unparseable": sum(a is None for a in answers)}
164
+
165
+
166
+ def llm(n: int, models: list[str], jobs: int) -> None:
167
+ lines, labels = load("spam")
168
+ step = len(lines) // n
169
+ idx = list(range(0, step * n, step)) # an even spread through the file, fixed
170
+ sub_lines, truth = [lines[i] for i in idx], [labels[i] == 1 for i in idx]
171
+ sub = OUT / "spam-subset.txt"
172
+ sub.write_text("".join(t + "\n" for t in sub_lines))
173
+ rows, stats = jgrep([SPAM_DESCRIPTION], sub, jobs=jobs)
174
+ p = {r["line"]: r["p"] for r in rows}
175
+ result = {"dataset": f"UCI SMS Spam Collection, every {step}th message", "lines": n, "positives": sum(truth),
176
+ "concurrency": jobs, "jgrep": prf([p.get(i + 1, 0.0) >= 0.5 for i in range(n)], truth) | stats, "chat_models": {}}
177
+ key = BACKENDS["openrouter"].key()
178
+ for model in models:
179
+ answers, run = asyncio.run(chat(model, sub_lines, key, jobs))
180
+ result["chat_models"][model] = prf([a is True for a in answers], truth) | run
181
+ print(model, result["chat_models"][model], flush=True)
182
+ (OUT / "accuracy-llm.json").write_text(json.dumps(result, indent=2))
183
+ print(json.dumps(result, indent=2))
184
+
185
+
186
+ if __name__ == "__main__":
187
+ ap = argparse.ArgumentParser()
188
+ ap.add_argument("what", choices=["prepare", "spam", "news", "llm"])
189
+ ap.add_argument("--n", type=int, default=300)
190
+ ap.add_argument("--jobs", type=int, default=32)
191
+ ap.add_argument("--models", nargs="+", default=["~openai/gpt-luna-latest", "qwen/qwen3.7-flash", "~openai/gpt-terra-latest"])
192
+ a = ap.parse_args()
193
+ {"prepare": prepare, "spam": spam, "news": news, "llm": lambda: llm(a.n, a.models, a.jobs)}[a.what]()
@@ -0,0 +1,47 @@
1
+ """Thirty hand-labeled complaint lines and five descriptions of different grammatical shapes.
2
+
3
+ Labels are in the order of DESCRIPTIONS. The lines were written to be clear-cut, with a few
4
+ deliberate near-misses (brown tap water is not a hot-water complaint; "how much is the fine"
5
+ names no amount).
6
+ """
7
+
8
+ DESCRIPTIONS = [
9
+ "a complaint about noise", # noun phrase
10
+ "the writer asks a question", # statement
11
+ "mentions a specific dollar amount", # verb phrase, number-adjacent
12
+ "does not mention a landlord", # negation, a documented weak spot
13
+ "is this about heat or hot water?", # question form
14
+ ]
15
+
16
+ LINES = [
17
+ ("The bar downstairs blasts music until 4am every single night.", 1, 0, 0, 1, 0),
18
+ ("No heat in the apartment for three days and it is 20 degrees outside.", 0, 0, 0, 1, 1),
19
+ ("My landlord raised the rent by $400 with no notice.", 0, 0, 1, 0, 0),
20
+ ("Can someone tell me when the hot water will be back?", 0, 1, 0, 1, 1),
21
+ ("Construction next door starts jackhammering at 6am, is that even legal?", 1, 1, 0, 1, 0),
22
+ ("The landlord still has not fixed the boiler and we have no hot water.", 0, 0, 0, 0, 1),
23
+ ("I was charged a $75 late fee even though I paid on time.", 0, 0, 1, 1, 0),
24
+ ("There is a pothole on Atlantic Avenue near 4th that has been there for months.", 0, 0, 0, 1, 0),
25
+ ("Why does my landlord get to ignore the broken radiator all winter?", 0, 1, 0, 0, 1),
26
+ ("The upstairs neighbor's dog barks all day while they are at work.", 1, 0, 0, 1, 0),
27
+ ("Thank you to the sanitation crew for the quick pickup on our block.", 0, 0, 0, 1, 0),
28
+ ("Street light out at the corner of Dekalb and Adelphi.", 0, 0, 0, 1, 0),
29
+ ("The super said the repair would cost $1,200 and the landlord refuses to pay.", 0, 0, 1, 0, 0),
30
+ ("Car alarms going off for hours on my street, I cannot sleep.", 1, 0, 0, 1, 0),
31
+ ("Is there a number I can call about rats in the building?", 0, 1, 0, 1, 0),
32
+ ("Our radiators are stone cold and the kids are sleeping in coats.", 0, 0, 0, 1, 1),
33
+ ("The parking ticket was $115 for a sign that was covered by scaffolding.", 0, 0, 1, 1, 0),
34
+ ("Helicopters fly over the park every ten minutes and the noise is unbearable.", 1, 0, 0, 1, 0),
35
+ ("How much is the fine for a blocked fire hydrant?", 0, 1, 0, 1, 0),
36
+ ("The landlord's contractor drills through the wall at night and it is deafening.", 1, 0, 0, 0, 0),
37
+ ("Bus stop shelter glass is shattered on Flatbush Ave.", 0, 0, 0, 1, 0),
38
+ ("Water comes out brown from the kitchen tap.", 0, 0, 0, 1, 0),
39
+ ("They want $3,000 for a security deposit, is that allowed?", 0, 1, 1, 1, 0),
40
+ ("Ice cream truck jingle plays outside my window for an hour every evening.", 1, 0, 0, 1, 0),
41
+ ("The elevator has been out of service since Monday.", 0, 0, 0, 1, 0),
42
+ ("When will the landlord turn the heat on? It is November.", 0, 1, 0, 0, 1),
43
+ ("Garbage has not been collected on our street in over a week.", 0, 0, 0, 1, 0),
44
+ ("A tree branch fell on my car during the storm.", 0, 0, 0, 1, 0),
45
+ ("The shower only runs cold, no hot water since the weekend.", 0, 0, 0, 1, 1),
46
+ ("Rent is going up to $2,850 next month according to the letter from my landlord.", 0, 0, 1, 0, 0),
47
+ ]