jev-sort 0.1.0__py3-none-any.whl
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.
- jev_sort-0.1.0.dist-info/METADATA +314 -0
- jev_sort-0.1.0.dist-info/RECORD +13 -0
- jev_sort-0.1.0.dist-info/WHEEL +4 -0
- jev_sort-0.1.0.dist-info/entry_points.txt +2 -0
- jev_sort-0.1.0.dist-info/licenses/LICENSE +21 -0
- jsort/__init__.py +7 -0
- jsort/__main__.py +3 -0
- jsort/cli.py +282 -0
- jsort/core.py +288 -0
- jsort/engine.py +170 -0
- jsort/inputs.py +111 -0
- jsort/model.py +277 -0
- jsort/schedule.py +80 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: jev-sort
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: sort by meaning: order lines along a plain-English dimension, from pairwise comparisons judged by TypeSafe's Jev model
|
|
5
|
+
Project-URL: Repository, https://github.com/keltokhy/jsort
|
|
6
|
+
Author: Khaled Eltokhy
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: bradley-terry,cli,jev,openrouter,pairwise comparison,rank,sort,typesafe
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Topic :: Text Processing :: Filters
|
|
14
|
+
Classifier: Topic :: Utilities
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: httpx>=0.27
|
|
17
|
+
Requires-Dist: numpy>=1.24
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# jsort
|
|
21
|
+
|
|
22
|
+
sort, but the key is a description.
|
|
23
|
+
|
|
24
|
+
```console
|
|
25
|
+
$ jsort -o "more hawkish about inflation" presser.txt | head -6
|
|
26
|
+
2.18 0.12 The plain fact is that inflation is too high and has been for too long.
|
|
27
|
+
2.18 0.16 I defined the standard for action: We must be confident that underlying inflation is moving to our objective, clearly and at sufficient speed.
|
|
28
|
+
1.76 0.19 Yet for more than five years, inflation has been running above target.
|
|
29
|
+
1.68 0.36 Too many categories are still posting increases above 3 percent, on both a 6- and 12-month basis.
|
|
30
|
+
1.52 0.38 In the meeting just concluded, the FOMC decided to raise the target range for the federal funds rate by ¼ percentage point to 3¾ to 4 percent, in support of the Federal Reserve’s dual mandate.
|
|
31
|
+
1.39 0.22 The committee’s unanimous vote shows our resolve to achieve price stability on a timelier basis.
|
|
32
|
+
jsort: 51 texts, 255 comparisons in 10 rounds; reliability 0.96; first-position lean -0.07; 255 calls, 0 cached; 87,187 tokens; $0.0037; 7.2s
|
|
33
|
+
|
|
34
|
+
$ jsort -r "more hawkish about inflation" presser.txt | head -3 # the other end, from the cache
|
|
35
|
+
And with that, I’ll take a few of your questions.
|
|
36
|
+
New hiring, private-sector earnings, business capital investment—each of these markers has improved in recent months and is pointing in a good direction.
|
|
37
|
+
Those who are least well-off have the most to gain from a durable expansion, a solid labor market, and stable prices.
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`presser.txt` is the chair's opening statement at the FOMC press conference of September 16, 2026, one
|
|
41
|
+
sentence per line. The first column is the score and the second its standard error.
|
|
42
|
+
|
|
43
|
+
jsort shows [Jev](https://docs.typesafe.ai), TypeSafe's decision model, two texts at a time and asks
|
|
44
|
+
which ranks higher on the dimension you described. Jev answers with a probability in about 200 ms.
|
|
45
|
+
jsort asks about a few pairs per text, not all of them, fits a Bradley-Terry scale to the answers
|
|
46
|
+
and prints the texts from the top of the scale down. With `-o` each line carries its score and a
|
|
47
|
+
standard error, so the order can be used as a measurement and not only as a ranking.
|
|
48
|
+
|
|
49
|
+
The same command sorts whole documents. Every opening statement since press conferences began, 91
|
|
50
|
+
of them from January 2012 to this week, as files named by date:
|
|
51
|
+
|
|
52
|
+
```console
|
|
53
|
+
$ jsort --whole -o --max-chars 16000 "more hawkish about inflation" statements/*.txt | sed -n '1,4p;88,91p'
|
|
54
|
+
6.56 0.41 statements/2022-11-02.txt
|
|
55
|
+
6.08 0.66 statements/2022-09-21.txt
|
|
56
|
+
5.56 0.58 statements/2022-06-15.txt
|
|
57
|
+
5.49 0.30 statements/2022-12-14.txt
|
|
58
|
+
-3.33 0.25 statements/2020-11-05.txt
|
|
59
|
+
-3.44 0.29 statements/2020-07-29.txt
|
|
60
|
+
-3.46 0.24 statements/2021-01-27.txt
|
|
61
|
+
-3.56 0.20 statements/2020-06-10.txt
|
|
62
|
+
jsort: 91 texts, 455 comparisons in 10 rounds; reliability 0.98; first-position lean -0.03; 0 calls, 455 cached; 0.1s
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
That run came from the cache. The first time it was 26 seconds and six cents, for statements of
|
|
66
|
+
about 1,250 words each. The top four are 2022 hikes of 50 or 75 points. The bottom is the first year of the pandemic.
|
|
67
|
+
By chair, the average score is -2.35 for Bernanke (9 statements), -1.34 for Yellen (16), 0.55 for
|
|
68
|
+
Powell (63) and 2.67 for Warsh (3). `bench/fed.py` builds both files from federalreserve.gov and checks the
|
|
69
|
+
scale against what the Committee did; the results are [below](#how-well-does-it-work).
|
|
70
|
+
|
|
71
|
+
A thousand short lines take about 5,000 comparisons: roughly a minute and seven cents.
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
uv tool install jev-sort # the command it installs is jsort
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
jsort finds a key the way [jgrep](https://github.com/keltokhy/jgrep) does: `TYPESAFE_API_KEY`,
|
|
80
|
+
`OPENROUTER_API_KEY`, or a System One gateway (`JEV_GATEWAY_URL` and `JEV_GATEWAY_API_KEY`), from the
|
|
81
|
+
environment or from `~/.config/jev/typesafe.key`, `openrouter.key` or `gateway.key`. Force a choice
|
|
82
|
+
with `--api` or `JEV_API`. The two tools share one answer cache.
|
|
83
|
+
|
|
84
|
+
## Use
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
jsort "more urgent" tickets.txt | head # the ten most urgent
|
|
88
|
+
jsort -r "more urgent" tickets.txt | head # the ten least
|
|
89
|
+
jsort -o "more hawkish about inflation" statements.txt # with scores and standard errors
|
|
90
|
+
jsort --top 5 "a more serious safety problem" complaints.txt
|
|
91
|
+
jsort --para "makes a stronger causal claim" paper.txt # paragraphs, not lines
|
|
92
|
+
jsort --whole "of more general interest" abstracts/*.txt # whole files; prints their names
|
|
93
|
+
jsort --jsonl --field event.message "angrier" events.jsonl # full records come back, sorted
|
|
94
|
+
jsort --csv --field narrative -o --keep-order --name breadth \
|
|
95
|
+
"drew broader participation" events.csv > scored.csv # add a variable, keep the row order
|
|
96
|
+
jsort --json "more urgent" tickets.txt # rank, score, se and source line
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
| Option | Meaning |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `-k N` | Comparisons each text takes part in. Default 10, minimum 2. The run asks about N/2 questions per text. |
|
|
102
|
+
| `--top N` | Print only the top N. Texts that are clearly out of the running stop being asked about, and their questions go to the contenders. With `-r` it is the bottom N that is hunted and printed. |
|
|
103
|
+
| `-r` | Lowest first. |
|
|
104
|
+
| `-o` | Put the score and its standard error in the first two tab-separated columns. With `--csv` or `--jsonl`, add `jsort_score`, `jsort_se` and `jsort_n` to each record. |
|
|
105
|
+
| `--name NAME` | Call those fields `NAME_score`, `NAME_se` and `NAME_n`, so that one file can carry several scales. |
|
|
106
|
+
| `--keep-order` | Print in input order. With `-o` this adds scores to a file without rearranging it. |
|
|
107
|
+
| `-n`, `-H` | Prefix each line with its line number or file name. |
|
|
108
|
+
| `--json` | One JSON object per text: `rank`, `score`, `se`, `comparisons`, `file`, `line`, `text`. |
|
|
109
|
+
| `--jsonl --field NAME`, `--csv --field NAME` | Compare one field and return the complete records. JSON fields can be dotted paths. |
|
|
110
|
+
| `--para`, `--whole` | Sort paragraphs or whole files in place of lines. |
|
|
111
|
+
| `--seed N` | Seed for the choice of pairs. Default 0. The same seed asks the same questions, so a rerun comes from the cache. |
|
|
112
|
+
| `--budget DOLLARS` | Stop asking once this much is spent and sort on what is known. Default 1.00, or `$JSORT_BUDGET`; 0 for no limit. |
|
|
113
|
+
| `--max-chars N` | Show Jev only the first N characters of a text. Default 8000. |
|
|
114
|
+
| `-j N`, `--timeout`, `--no-cache`, `--api`, `--model`, `--stats` | As in jgrep. |
|
|
115
|
+
|
|
116
|
+
Blank lines are dropped. Identical texts are compared once and share a score. Several files are
|
|
117
|
+
sorted together, as `sort` does. Exit status is 0 when the input was sorted and 2 on any error:
|
|
118
|
+
an unreadable file, a failed comparison, a stop at the budget, the API refusing further calls because
|
|
119
|
+
the credit ran out. In each of those cases whatever could be sorted from the answers already paid
|
|
120
|
+
for is still printed.
|
|
121
|
+
|
|
122
|
+
CSV needs a header with unique column names. A byte-order mark is ignored, a row with more values
|
|
123
|
+
than the header keeps them at its end, and a header with no rows comes back as a header. With `-o`,
|
|
124
|
+
jsort refuses to write over a column or JSON field that already exists; pick another prefix with
|
|
125
|
+
`--name`.
|
|
126
|
+
|
|
127
|
+
Write the description as a comparative: "more urgent", "easier to read", "drew broader military
|
|
128
|
+
participation". It goes into one question, `Text A ranks higher than text B on this criterion:
|
|
129
|
+
"..."`, so anything that completes that sentence sensibly will work.
|
|
130
|
+
|
|
131
|
+
## What the numbers mean
|
|
132
|
+
|
|
133
|
+
**Score.** The position on the scale, in logit units, centred on zero. A gap of 1.0 between two
|
|
134
|
+
texts means Jev gives the higher one about 73% in a head-to-head; a gap of 3 means about 95%.
|
|
135
|
+
Scores are relative to the other texts in the same run. They do not carry over to another file or
|
|
136
|
+
another description.
|
|
137
|
+
|
|
138
|
+
**Standard error.** How well the comparisons pin the score down. Jev's answer is a probability, not
|
|
139
|
+
a win or a loss, so the model is a fractional logit and the errors are the robust (sandwich) kind,
|
|
140
|
+
with a leverage correction because there is one parameter per text. Where Jev's answers line up on
|
|
141
|
+
one scale the errors are small. Where they contradict each other the errors grow. In simulation,
|
|
142
|
+
intervals of 1.96 standard errors cover the target 94 to 95% of the time from `-k 6` up
|
|
143
|
+
(`bench/simulate.py`). Above 4,000 distinct texts the same errors are estimated by random probing,
|
|
144
|
+
to within about 7% each, because the matrix the exact ones need no longer fits.
|
|
145
|
+
|
|
146
|
+
**Reliability.** The comparisons are dealt into two halves, a scale is fitted to each, and the
|
|
147
|
+
correlation between the two is stepped up to full length (Spearman-Brown). Near 1, the order does
|
|
148
|
+
not depend on which pairs happened to be asked. Below 0.8 jsort says so: raise `-k`, or the
|
|
149
|
+
description is not one these texts can be ranked on. It errs on the cautious side, and most at low
|
|
150
|
+
`-k`, where a half is only two comparisons per text: in simulation it reads 0.83 for a true 0.89 at
|
|
151
|
+
`-k 4`, and 0.955 for 0.964 at the default.
|
|
152
|
+
|
|
153
|
+
**First-position lean.** How far the first-shown text's chance sits from a half in an even matchup.
|
|
154
|
+
Positions are randomised and balanced and the lean is estimated with the scale, as home advantage
|
|
155
|
+
is in a sports model, so it does not tilt the scores. It has been a few points either way.
|
|
156
|
+
|
|
157
|
+
Reliability and the standard errors describe how consistent *Jev* is. They say nothing about
|
|
158
|
+
whether Jev is right. For that, check a sample against your own judgment.
|
|
159
|
+
|
|
160
|
+
## From Python
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
import jsort
|
|
164
|
+
|
|
165
|
+
r = jsort.rank(statements, "more hawkish about inflation", per_item=10)
|
|
166
|
+
for i in r.order()[:5]:
|
|
167
|
+
print(f"{r.score[i]:6.2f} ±{r.se[i]:.2f} {statements[i]}")
|
|
168
|
+
r.reliability, r.lean, r.asked
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`r.score`, `r.se` and `r.comparisons` are arrays aligned with the input. The command's seat belt
|
|
172
|
+
applies here too: spending stops at `budget=` dollars, by default `$JSORT_BUDGET` or 1.00, and
|
|
173
|
+
`r.over_budget` says whether it was reached. It works inside a notebook. `jsort.arank` is the same
|
|
174
|
+
thing as a coroutine, for a client you already hold.
|
|
175
|
+
|
|
176
|
+
## How it chooses pairs
|
|
177
|
+
|
|
178
|
+
All n(n-1)/2 pairs are never needed. A lopsided pair says almost nothing, because the answer was
|
|
179
|
+
predictable. A pair of near neighbours says the most. The first round is a random ring: every text
|
|
180
|
+
meets two others, once in each position, which connects everything. After each round the scale is
|
|
181
|
+
refitted, and the next round pairs texts that currently sit next to each other, as a Swiss-system
|
|
182
|
+
tournament does. Each score is jittered in proportion to how uncertain it still is, so a text that
|
|
183
|
+
is not pinned down yet keeps meeting new neighbours. A pair is asked at most twice, once in each
|
|
184
|
+
order. With a handful of texts jsort simply runs out of pairs and stops early.
|
|
185
|
+
|
|
186
|
+
`--top N` adds one rule. Once a text has three comparisons, jsort asks how much worse the fit would
|
|
187
|
+
get if that text were moved up to the edge of the top N. If the answer is "much worse", the text
|
|
188
|
+
gets no more questions. A text that lost 0.03 to 0.97 against a middling opponent is out, and no
|
|
189
|
+
further comparison will change that. In simulation this is the same cost as a full sort or a little
|
|
190
|
+
less, and finds more of the true top ten (0.85 against 0.77 of them, among 2,000 texts).
|
|
191
|
+
|
|
192
|
+
## Cost
|
|
193
|
+
|
|
194
|
+
A comparison bills about 270 tokens of overhead plus both texts and the description. For short lines
|
|
195
|
+
that is about 320 tokens, or $0.0000135 at $0.042 per million, and the default `-k 10` asks five
|
|
196
|
+
questions per text: about 7 cents per thousand lines. For 170-word excerpts a comparison is about
|
|
197
|
+
735 tokens and a thousand texts cost about 15 cents. A run is about ten rounds, each as slow as its
|
|
198
|
+
slowest call, so a small sort takes five to ten seconds. A large one manages about 90 comparisons a
|
|
199
|
+
second through OpenRouter.
|
|
200
|
+
|
|
201
|
+
jsort stops asking at `--budget`, one dollar by default, and sorts on what it has. Nothing is lost:
|
|
202
|
+
every answer is cached in `~/.cache/jev/answers.sqlite`, and the choice of pairs is seeded, so a rerun
|
|
203
|
+
with a higher budget, or a higher `-k`, starts by replaying the same questions from the cache and
|
|
204
|
+
only pays for the new ones. The exception is a run in which a comparison failed. Later pairs depend
|
|
205
|
+
on earlier answers, so once the failed one succeeds the rounds after it can choose differently, and
|
|
206
|
+
those questions are new.
|
|
207
|
+
|
|
208
|
+
## How well does it work
|
|
209
|
+
|
|
210
|
+
Two checks, run on 2026-09-19 with Jev 1.13 through OpenRouter. Both run the installed `jsort`
|
|
211
|
+
command, uncached.
|
|
212
|
+
|
|
213
|
+
**Against people making the same comparisons.** `bench/readability.py`. The CommonLit Ease of
|
|
214
|
+
Readability corpus (Crossley et al. 2022) has 4,724 excerpts written for
|
|
215
|
+
grades 3 to 12. Each has an easiness score that is itself a Bradley-Terry fit, to teachers'
|
|
216
|
+
judgments of which of two excerpts is easier. So this is the same method with Jev in the teachers'
|
|
217
|
+
chair. On a random 300 excerpts, the teachers' scale has a reliability of about 0.78, which means no
|
|
218
|
+
measure can be expected to correlate with it above about 0.88.
|
|
219
|
+
|
|
220
|
+
| Measure | Pearson r | Spearman ρ | Calls | Cost |
|
|
221
|
+
|---|---:|---:|---:|---:|
|
|
222
|
+
| `jsort -k 16 "easier to read"` | 0.824 | 0.840 | 2,400 | $0.074 |
|
|
223
|
+
| **`jsort "easier to read"`** (`-k 10`) | **0.824** | **0.841** | 1,500 | $0.046 |
|
|
224
|
+
| `jsort -k 6` | 0.813 | 0.833 | 900 | $0.028 |
|
|
225
|
+
| `jsort -k 4` | 0.803 | 0.826 | 600 | $0.018 |
|
|
226
|
+
| One question per text: the probability it is "easy to read" (`jgrep -o`) | 0.754 | 0.804 | 300 | $0.004 |
|
|
227
|
+
| A five-level rubric, one call per text (Jev's `score` question) | 0.824 | 0.839 | 300 | $0.004 |
|
|
228
|
+
| Best readability formula shipped with the corpus (SMOG) | 0.661 | 0.647 | | |
|
|
229
|
+
| Flesch-Kincaid grade level | 0.598 | 0.595 | | |
|
|
230
|
+
|
|
231
|
+
Three things to take from it.
|
|
232
|
+
|
|
233
|
+
jsort gets most of the way to the ceiling from a two-word description, and it clearly beats the
|
|
234
|
+
other thing you can do with two words, which is to ask for a probability per text and sort on that.
|
|
235
|
+
That probability bunches up (73 distinct values among 300 texts) and it is weakest exactly where a
|
|
236
|
+
sort is needed, on texts that are close.
|
|
237
|
+
|
|
238
|
+
More comparisons stop helping early. `-k 10` and `-k 16` give the same answer, and `-k 6` is nearly
|
|
239
|
+
there. What is left between 0.82 and 0.88 is Jev disagreeing with teachers, not noise that more
|
|
240
|
+
questions would average away; the reliability of 0.98 says the same.
|
|
241
|
+
|
|
242
|
+
**A written rubric did just as well for a sixth of the price.** Five levels, from "suited to
|
|
243
|
+
children in the first years of school" to "suited to readers at the end of high school or beyond",
|
|
244
|
+
one call per text, and Jev's `score` answer interpolates between levels (2.9, not 3), so it does
|
|
245
|
+
not bunch up either. The two agree with each other at r = 0.92. Broken down by how far apart the
|
|
246
|
+
teachers put two excerpts, jsort is ahead only on the closest pairs, and not by much:
|
|
247
|
+
|
|
248
|
+
| Gap on the teachers' scale | Pairs | jsort | Rubric | p("easy") |
|
|
249
|
+
|---|---:|---:|---:|---:|
|
|
250
|
+
| 0.25 to 0.5 | 5,022 | 0.675 | 0.645 | 0.632 |
|
|
251
|
+
| 0.5 to 1 | 9,366 | 0.771 | 0.777 | 0.737 |
|
|
252
|
+
| 1 to 2 | 12,932 | 0.925 | 0.926 | 0.888 |
|
|
253
|
+
| over 2 | 7,233 | 0.990 | 0.992 | 0.981 |
|
|
254
|
+
|
|
255
|
+
Readability across ten school grades is friendly ground for a rubric: the levels are easy to name
|
|
256
|
+
and the texts spread across all of them. So:
|
|
257
|
+
|
|
258
|
+
- If you can write down what the levels are, write the rubric. It is what
|
|
259
|
+
[jcol](https://github.com/keltokhy/jcol)'s `tone: calm < upset < furious` columns do, and it costs one
|
|
260
|
+
call per text.
|
|
261
|
+
- Use jsort when you can say what "more" means but not what the levels are, when the texts would
|
|
262
|
+
all land on one or two rubric levels, or when the scale is going into a model and you need a
|
|
263
|
+
standard error on every score and a reliability figure for the whole.
|
|
264
|
+
|
|
265
|
+
**Against what the Fed then did.** `bench/fed.py`. The 91 opening statements above, sorted whole on
|
|
266
|
+
"more hawkish about inflation", against the top of the target range for the federal funds rate (FRED
|
|
267
|
+
series DFEDTARU). Both tests were written into the script before the sort was run. The rank
|
|
268
|
+
correlation between a statement's score and the move announced that day is +0.47 (91 statements).
|
|
269
|
+
With the change in the target over the following 180 days it is +0.38 (87 statements). Those are
|
|
270
|
+
moderate, and they should be: a chair can hold rates and talk tough. The holds of June, September and
|
|
271
|
+
November 2023 score near 4, among the ten most hawkish. What the scale gets right is the shape of
|
|
272
|
+
fourteen years. The six statements that announced hikes of 50 or 75 points in 2022 are the top six,
|
|
273
|
+
seven of 2023's eight statements fill ranks 7 to 13, and the bottom eight are all from March 2020 to January
|
|
274
|
+
2021.
|
|
275
|
+
|
|
276
|
+
## Things to know
|
|
277
|
+
|
|
278
|
+
- These are a model's judgments. Check a sample before you rely on an order.
|
|
279
|
+
- Jev reads the description literally and is weak at counting and at comparing numbers or dates.
|
|
280
|
+
"Mentions more people" or "happened earlier" are bad dimensions. Compute those in code.
|
|
281
|
+
- One dimension per run. If the description mixes two ("more urgent and more polite"), the scale
|
|
282
|
+
is whatever blend Jev makes of them, and reliability may not warn you.
|
|
283
|
+
- Both texts must fit in one call. `--max-chars` caps each at 8,000 characters; TypeSafe documents a
|
|
284
|
+
limit of 32k tokens for the state and question together. Long texts full of detail that is
|
|
285
|
+
irrelevant to the dimension are a documented weak spot, so cut them down first
|
|
286
|
+
(`jgrep --para` is one way).
|
|
287
|
+
- Jev is close to deterministic, not exactly so. The cache makes reruns exact. For results that
|
|
288
|
+
must reproduce, pin the model with `--model` (for example `typesafe/jev-1.13` on OpenRouter) and keep the
|
|
289
|
+
cache file with the project.
|
|
290
|
+
- Adding texts later means a new run and a new scale. The old answers are still cached, so the new
|
|
291
|
+
run is cheap, but scores from two runs are not on the same footing unless the texts are the same.
|
|
292
|
+
- Text in the input can try to steer the answer. Do not use an order from jsort as a security
|
|
293
|
+
boundary.
|
|
294
|
+
|
|
295
|
+
## Development
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
uv sync && uv run pytest # offline: a fake API, no key
|
|
299
|
+
uv run python bench/simulate.py # offline: a simulated judge with a known scale
|
|
300
|
+
uv run python bench/probe.py # live, under a cent: which question shape to use
|
|
301
|
+
uv run --group bench python bench/readability.py prepare && uv run --group bench python bench/readability.py run
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
`src/jsort/model.py` is the scale: the fit, the standard errors, reliability and the test `--top`
|
|
305
|
+
uses. `schedule.py` chooses pairs. `engine.py` runs the rounds and is the Python API. `inputs.py`
|
|
306
|
+
reads lines, paragraphs, files, CSV and JSONL. `core.py` is jgrep's client, unchanged: backends,
|
|
307
|
+
retries inside a time budget, the cache, in-flight deduplication and the cost meter.
|
|
308
|
+
|
|
309
|
+
Why a noul and not a choice: `bench/probe.py` asks every ordered pair of ten texts both ways. A
|
|
310
|
+
two-option `choice` and a `noul` ("A ranks higher than B") made the same decisions, 95.6% correct,
|
|
311
|
+
but the choice put 93% of its answers below 0.1 or above 0.9, where the noul left 28% of its answers
|
|
312
|
+
between the two. Those in-between answers are what a scale is fitted from.
|
|
313
|
+
|
|
314
|
+
MIT license.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
jsort/__init__.py,sha256=UyG8ZQ87AzZZQW8ybiHSx1MZXXInfqcAYIHSu5EV5ak,169
|
|
2
|
+
jsort/__main__.py,sha256=Huz0dExiaH0XTMLhfe3skkFD9-VH7ZUiWMbDR_J8TIY,28
|
|
3
|
+
jsort/cli.py,sha256=GC9Z8UacOQZsJzyV3Lut2-KcXuot6XJaDbyjAvK6cYM,15958
|
|
4
|
+
jsort/core.py,sha256=X3EOAnBLavOWIG_wksFk8Mc7t5-WqXTt0eu4e_tABVs,12622
|
|
5
|
+
jsort/engine.py,sha256=dtxKoxla7pRFZupyXi4ZXBd6X3nbYlcTAAExgWXUkEQ,7474
|
|
6
|
+
jsort/inputs.py,sha256=ZUcPrsAHUDI8iRb_IcCtKJ6N3hVZDgpjaemq4frkS_E,4811
|
|
7
|
+
jsort/model.py,sha256=2m3C1ifdmPKhDZjD2WQmVb-fuZsy1yEGb4AEdfnY6_Q,12213
|
|
8
|
+
jsort/schedule.py,sha256=BNll00UjybbRIvPShQhNz7Kplx0sF6mS9iWjviSOWXE,3513
|
|
9
|
+
jev_sort-0.1.0.dist-info/METADATA,sha256=xFa4gSpQ0eSx7pAJpJQTHogbuAU2MFZjhP_bqmxcvVo,19487
|
|
10
|
+
jev_sort-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
11
|
+
jev_sort-0.1.0.dist-info/entry_points.txt,sha256=XLLcoRqNUJg7TFXQzuUITI6DOJx6ECTeubOnW4gRWwY,40
|
|
12
|
+
jev_sort-0.1.0.dist-info/licenses/LICENSE,sha256=unAu2Ii_6qZZfNfkD44Vj0MNv9C7H6CBRje23cMNx4g,1071
|
|
13
|
+
jev_sort-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
jsort/__init__.py
ADDED
jsort/__main__.py
ADDED
jsort/cli.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""jsort: put lines in order along a dimension you describe.
|
|
2
|
+
|
|
3
|
+
jsort "more urgent" tickets.txt | head
|
|
4
|
+
jsort -o "more hawkish about inflation" statements.txt
|
|
5
|
+
jsort --csv --field narrative -o --name breadth "drew broader military participation" coups.csv
|
|
6
|
+
|
|
7
|
+
Jev is asked which of two texts ranks higher, for a few pairs per text, and a Bradley-Terry scale is
|
|
8
|
+
fitted to its answers. The highest text prints first. Exit status: 0 when sorted, 2 on any error.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import asyncio
|
|
15
|
+
import csv
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
from .core import BACKENDS, Cache, Jev, JevFatal, config_dir, resolve_backend
|
|
24
|
+
from .engine import Ranking, arank
|
|
25
|
+
from .inputs import Record, read
|
|
26
|
+
|
|
27
|
+
MAX_ERRORS_SHOWN = 10
|
|
28
|
+
DEFAULT_BUDGET = 1.0 # dollars, as in jgrep: a command that bills per question needs a seat belt
|
|
29
|
+
SHAKY = 0.8 # below this split-half reliability the order is reported as unsteady
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parser() -> argparse.ArgumentParser:
|
|
33
|
+
ap = argparse.ArgumentParser(
|
|
34
|
+
prog="jsort", formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
35
|
+
usage="jsort [options] DESCRIPTION [FILE ...]",
|
|
36
|
+
description="Sort lines along a plain-English dimension, from pairwise comparisons judged by TypeSafe's Jev model.",
|
|
37
|
+
epilog='examples:\n'
|
|
38
|
+
' jsort "more urgent" tickets.txt | head\n'
|
|
39
|
+
' jsort -o "more hawkish about inflation" statements.txt\n'
|
|
40
|
+
' jsort --top 5 "a more serious safety problem" complaints.txt\n'
|
|
41
|
+
' jsort --whole -r "easier for a newcomer to read" abstracts/*.txt\n'
|
|
42
|
+
' jsort --csv --field narrative -o --keep-order "drew broader participation" events.csv\n\n'
|
|
43
|
+
"Jev is reached through TypeSafe's API (TYPESAFE_API_KEY), OpenRouter (OPENROUTER_API_KEY) or a\n"
|
|
44
|
+
"System One gateway of your own (JEV_GATEWAY_URL and JEV_GATEWAY_API_KEY).\n"
|
|
45
|
+
f"Keys can also live in {config_dir()}/typesafe.key, openrouter.key or gateway.key.")
|
|
46
|
+
ap.add_argument("args", nargs="*", help=argparse.SUPPRESS)
|
|
47
|
+
ap.add_argument("-k", "--per-item", type=int, default=10, metavar="N",
|
|
48
|
+
help="comparisons each text takes part in (default 10); the run asks about half that many "
|
|
49
|
+
"questions per text")
|
|
50
|
+
ap.add_argument("--top", type=int, metavar="N",
|
|
51
|
+
help="print only the top N, and stop asking about texts that are clearly out of the running")
|
|
52
|
+
ap.add_argument("-r", "--reverse", action="store_true", help="lowest first")
|
|
53
|
+
ap.add_argument("-o", "--score", action="store_true",
|
|
54
|
+
help="put the score and its standard error in the first two tab-separated columns; "
|
|
55
|
+
"with --csv or --jsonl, add them to each record")
|
|
56
|
+
ap.add_argument("--name", default="jsort", metavar="NAME",
|
|
57
|
+
help="with -o on --csv or --jsonl, call the new fields NAME_score, NAME_se and NAME_n (default jsort)")
|
|
58
|
+
ap.add_argument("--keep-order", action="store_true",
|
|
59
|
+
help="print in input order; with -o this adds scores to a file without rearranging it")
|
|
60
|
+
ap.add_argument("-n", "--line-number", action="store_true", help="prefix each line with its line number")
|
|
61
|
+
ap.add_argument("-H", "--with-filename", action="store_true", help="prefix each line with its file name")
|
|
62
|
+
ap.add_argument("--json", action="store_true", help="print one JSON object per text, with rank, score and standard error")
|
|
63
|
+
formats = ap.add_mutually_exclusive_group()
|
|
64
|
+
formats.add_argument("--jsonl", action="store_true", help="read JSON objects, comparing only --field and returning full records")
|
|
65
|
+
formats.add_argument("--csv", action="store_true", help="read CSV with a header, comparing only --field and returning full rows")
|
|
66
|
+
ap.add_argument("--field", metavar="NAME", help="JSON field (dotted paths supported) or CSV column to compare")
|
|
67
|
+
ap.add_argument("--para", action="store_true", help="sort paragraphs (separated by blank lines), not lines")
|
|
68
|
+
ap.add_argument("--whole", action="store_true", help="sort whole files and print their names")
|
|
69
|
+
ap.add_argument("--seed", type=int, default=0, metavar="N",
|
|
70
|
+
help="seed for the choice of pairs (default 0); the same seed asks the same questions, so a rerun is cached")
|
|
71
|
+
ap.add_argument("-j", "--concurrency", type=int, default=32, metavar="N", help="calls in flight (default 32)")
|
|
72
|
+
ap.add_argument("--timeout", type=float, default=15.0, metavar="SECONDS",
|
|
73
|
+
help="give up on a comparison after this long, retries included (default 15)")
|
|
74
|
+
ap.add_argument("--budget", type=float, default=None, metavar="DOLLARS",
|
|
75
|
+
help="stop asking once this much has been spent and sort on what is known (default 1.00, or "
|
|
76
|
+
"$JSORT_BUDGET; 0 for no limit)")
|
|
77
|
+
ap.add_argument("--max-chars", type=int, default=8000, metavar="N",
|
|
78
|
+
help="show Jev only the first N characters of a text (default 8000)")
|
|
79
|
+
ap.add_argument("--no-cache", action="store_true", help="do not read or write the answer cache")
|
|
80
|
+
ap.add_argument("--api", choices=list(BACKENDS), help="which API to call (default: whichever has a key)")
|
|
81
|
+
ap.add_argument("--model", metavar="ID", help="model ID to request (default: the API's latest Jev)")
|
|
82
|
+
ap.add_argument("--stats", action=argparse.BooleanOptionalAction, default=None,
|
|
83
|
+
help="print comparisons, reliability, tokens and cost to stderr at the end (default: when stderr is a terminal)")
|
|
84
|
+
ap.add_argument("--version", action="version", version=f"jsort {__version__}")
|
|
85
|
+
return ap
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _number(x: float, places: int) -> str:
|
|
89
|
+
return "" if math.isnan(x) else f"{x + 0.0:.{places}f}".replace("-0." + "0" * places, "0." + "0" * places)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def write(records: list[Record], header: list[str] | None, ranking: Ranking, order: list[int], args, out) -> None:
|
|
93
|
+
ranks = {i: r for r, i in enumerate(ranking.order(), 1) if not math.isnan(ranking.score[i])} # 1 is the highest, with or without -r
|
|
94
|
+
fields = [f"{args.name}_score", f"{args.name}_se", f"{args.name}_n"]
|
|
95
|
+
|
|
96
|
+
def scores(i: int) -> dict:
|
|
97
|
+
unscored = math.isnan(ranking.score[i])
|
|
98
|
+
return {fields[0]: None if unscored else round(float(ranking.score[i]), 4),
|
|
99
|
+
fields[1]: None if unscored else round(float(ranking.se[i]), 4),
|
|
100
|
+
fields[2]: int(ranking.comparisons[i])}
|
|
101
|
+
|
|
102
|
+
if args.json:
|
|
103
|
+
for i in order:
|
|
104
|
+
rec, s = records[i], scores(i)
|
|
105
|
+
obj = {"rank": ranks.get(i), "score": s[fields[0]], "se": s[fields[1]], "comparisons": s[fields[2]],
|
|
106
|
+
"file": rec.file, "line": rec.lineno}
|
|
107
|
+
if not args.whole:
|
|
108
|
+
obj["text"] = rec.text if args.csv else rec.original
|
|
109
|
+
if rec.data is not None:
|
|
110
|
+
obj["record"], obj["field"] = rec.data, args.field
|
|
111
|
+
out.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
112
|
+
elif args.csv:
|
|
113
|
+
writer = csv.writer(out, lineterminator="\n")
|
|
114
|
+
writer.writerow((header or []) + (fields if args.score else []))
|
|
115
|
+
for i in order:
|
|
116
|
+
data = records[i].data
|
|
117
|
+
row = ["" if data.get(h) is None else data[h] for h in header or []]
|
|
118
|
+
if args.score:
|
|
119
|
+
row += ["" if v is None else v for v in scores(i).values()]
|
|
120
|
+
# A row longer than the header keeps its surplus values, after the named columns.
|
|
121
|
+
writer.writerow(row + list(data.get(None) or []))
|
|
122
|
+
else:
|
|
123
|
+
for i in order:
|
|
124
|
+
rec = records[i]
|
|
125
|
+
if args.jsonl and args.score and isinstance(rec.data, dict):
|
|
126
|
+
body = json.dumps(rec.data | scores(i), ensure_ascii=False)
|
|
127
|
+
else:
|
|
128
|
+
body = ((f"{rec.file}:" if args.with_filename and not args.whole else "")
|
|
129
|
+
+ (f"{rec.lineno}:" if args.line_number and not args.whole else "") + rec.original)
|
|
130
|
+
if args.score:
|
|
131
|
+
body = f"{_number(ranking.score[i], 2)}\t{_number(ranking.se[i], 2)}\t{body}"
|
|
132
|
+
out.write(body + ("\n\n" if args.para else "\n"))
|
|
133
|
+
out.flush()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def summary(records: list[Record], ranking: Ranking, jev: Jev) -> str:
|
|
137
|
+
parts = [f"{len(records):,} texts, {ranking.asked:,} comparisons in {ranking.rounds} rounds"]
|
|
138
|
+
if ranking.reliability is not None:
|
|
139
|
+
parts.append(f"reliability {ranking.reliability:.2f}")
|
|
140
|
+
if ranking.asked:
|
|
141
|
+
parts.append(f"first-position lean {ranking.lean:+.2f}")
|
|
142
|
+
return "; ".join(parts + [jev.meter.summary()])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main(argv: list[str] | None = None, *, transport=None, out=None, err=None) -> int:
|
|
146
|
+
out, err = out or sys.stdout, err or sys.stderr
|
|
147
|
+
ap = parser()
|
|
148
|
+
args = ap.parse_intermixed_args(argv)
|
|
149
|
+
if not args.args:
|
|
150
|
+
ap.print_usage(err)
|
|
151
|
+
return 2
|
|
152
|
+
description, files = args.args[0], args.args[1:]
|
|
153
|
+
if args.budget is None:
|
|
154
|
+
try:
|
|
155
|
+
args.budget = float(os.environ.get("JSORT_BUDGET") or DEFAULT_BUDGET)
|
|
156
|
+
except ValueError:
|
|
157
|
+
print(f"jsort: JSORT_BUDGET must be a number of dollars; got {os.environ['JSORT_BUDGET']!r}", file=err)
|
|
158
|
+
return 2
|
|
159
|
+
for valid, message in (
|
|
160
|
+
(bool(description.strip()), "the description is empty"),
|
|
161
|
+
(args.per_item >= 2, "-k takes 2 or more comparisons per text; the first round alone gives every text two"),
|
|
162
|
+
(args.top is None or args.top >= 1, "--top takes 1 or more"),
|
|
163
|
+
(args.concurrency >= 1, "-j takes 1 or more concurrent calls"),
|
|
164
|
+
(math.isfinite(args.budget) and args.budget >= 0, "--budget / JSORT_BUDGET must be finite and nonnegative"),
|
|
165
|
+
(math.isfinite(args.timeout) and args.timeout > 0, "--timeout must be finite and greater than 0"),
|
|
166
|
+
(args.max_chars > 0, "--max-chars must be greater than 0"),
|
|
167
|
+
(not (args.jsonl or args.csv) or bool(args.field), "--jsonl and --csv require --field"),
|
|
168
|
+
(not args.field or args.jsonl or args.csv, "--field requires --jsonl or --csv"),
|
|
169
|
+
(not (args.jsonl or args.csv) or not (args.para or args.whole), "structured input cannot be combined with --para or --whole"),
|
|
170
|
+
(not (args.whole and args.para), "--whole and --para cannot be combined"),
|
|
171
|
+
(not args.whole or bool(files), "--whole sorts files, so it needs file names"),
|
|
172
|
+
(not (args.top and args.keep_order), "--top and --keep-order cannot be combined"),
|
|
173
|
+
(args.name.isidentifier(), "--name must be a plain word, since it becomes part of a column name"),
|
|
174
|
+
):
|
|
175
|
+
if not valid:
|
|
176
|
+
print(f"jsort: {message}", file=err)
|
|
177
|
+
return 2
|
|
178
|
+
|
|
179
|
+
records, header, problems = read(files, args)
|
|
180
|
+
if args.csv and args.score and header and (clash := {f"{args.name}_{s}" for s in ("score", "se", "n")} & set(header)):
|
|
181
|
+
print(f"jsort: the file already has a column named {sorted(clash)[0]}; choose another prefix with --name", file=err)
|
|
182
|
+
return 2
|
|
183
|
+
for message in problems[:MAX_ERRORS_SHOWN]:
|
|
184
|
+
print(f"jsort: {message}", file=err)
|
|
185
|
+
if len(problems) > MAX_ERRORS_SHOWN:
|
|
186
|
+
print(f"jsort: and {len(problems) - MAX_ERRORS_SHOWN:,} more input errors", file=err)
|
|
187
|
+
if args.jsonl and args.score and (clash := {f"{args.name}_{x}" for x in ("score", "se", "n")}
|
|
188
|
+
& {k for r in records if isinstance(r.data, dict) for k in r.data}):
|
|
189
|
+
print(f"jsort: the records already have a field named {sorted(clash)[0]}; choose another prefix with --name", file=err)
|
|
190
|
+
return 2
|
|
191
|
+
if not records:
|
|
192
|
+
if args.csv and header is not None and not args.json:
|
|
193
|
+
write([], header, Ranking.unscored(0), [], args, out) # a header with no rows is still a CSV
|
|
194
|
+
return 2 if problems else 0
|
|
195
|
+
|
|
196
|
+
texts = [r.text for r in records]
|
|
197
|
+
truncated = sum(len(t) > args.max_chars for t in texts)
|
|
198
|
+
show_stats = args.stats or (args.stats is None and err.isatty())
|
|
199
|
+
jev = None
|
|
200
|
+
t0 = time.perf_counter()
|
|
201
|
+
if len({t[:args.max_chars] for t in texts if t.strip()}) < 2:
|
|
202
|
+
ranking = Ranking.unscored(len(texts)) # nothing to compare, so no key is needed either
|
|
203
|
+
else:
|
|
204
|
+
try:
|
|
205
|
+
backend, key = resolve_backend(args.api)
|
|
206
|
+
except JevFatal as e:
|
|
207
|
+
print(f"jsort: {e}", file=err)
|
|
208
|
+
return 2
|
|
209
|
+
jev = Jev(key, backend, model=args.model, timeout=args.timeout, concurrency=args.concurrency,
|
|
210
|
+
cache=None if args.no_cache else Cache(), transport=transport)
|
|
211
|
+
|
|
212
|
+
def progress(done: int, total: int) -> None:
|
|
213
|
+
if show_stats and err.isatty():
|
|
214
|
+
print(f"\rjsort: {done:,} of about {total:,} comparisons", end="", file=err, flush=True)
|
|
215
|
+
|
|
216
|
+
async def go() -> Ranking:
|
|
217
|
+
try:
|
|
218
|
+
return await arank(texts, description, jev, per_item=args.per_item, top=args.top, lowest=args.reverse,
|
|
219
|
+
seed=args.seed, budget=args.budget, max_chars=args.max_chars,
|
|
220
|
+
concurrency=args.concurrency, progress=progress)
|
|
221
|
+
finally:
|
|
222
|
+
await jev.close()
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
ranking = asyncio.run(go())
|
|
226
|
+
except KeyboardInterrupt:
|
|
227
|
+
print(f"\njsort: interrupted; {jev.meter.summary()}", file=err)
|
|
228
|
+
return 130
|
|
229
|
+
if show_stats and err.isatty():
|
|
230
|
+
print("\r\033[K", end="", file=err)
|
|
231
|
+
|
|
232
|
+
if ranking.fatal:
|
|
233
|
+
print(f"jsort: {ranking.fatal}", file=err)
|
|
234
|
+
if not ranking.asked:
|
|
235
|
+
return 2
|
|
236
|
+
# Answers that were paid for before the API refused are still worth a sort.
|
|
237
|
+
print(f"jsort: sorted on the {ranking.asked:,} comparisons answered before that", file=err)
|
|
238
|
+
for message in ranking.errors[:MAX_ERRORS_SHOWN]:
|
|
239
|
+
print(f"jsort: a comparison failed: {message}", file=err)
|
|
240
|
+
if len(ranking.errors) > MAX_ERRORS_SHOWN:
|
|
241
|
+
print(f"jsort: and {len(ranking.errors) - MAX_ERRORS_SHOWN:,} more failed comparisons", file=err)
|
|
242
|
+
|
|
243
|
+
order = list(range(len(records))) if args.keep_order else ranking.order(args.reverse)
|
|
244
|
+
if args.top:
|
|
245
|
+
order = [i for i in order if not math.isnan(ranking.score[i])][:args.top]
|
|
246
|
+
try:
|
|
247
|
+
write(records, header, ranking, order, args, out)
|
|
248
|
+
except BrokenPipeError:
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
if args.csv and (ragged := sum(1 for r in records if r.data.get(None))):
|
|
252
|
+
print(f"jsort: {ragged:,} rows have more values than the header has columns; the surplus is kept at the end "
|
|
253
|
+
"of each row", file=err)
|
|
254
|
+
if truncated:
|
|
255
|
+
print(f"jsort: compared only the first {args.max_chars:,} characters of {truncated:,} texts; raise --max-chars",
|
|
256
|
+
file=err)
|
|
257
|
+
if ranking.over_budget:
|
|
258
|
+
print(f"jsort: stopped asking at the ${args.budget:.2f} budget after {ranking.asked:,} comparisons and sorted on "
|
|
259
|
+
"those; raise it with --budget, and the answers so far come back from the cache", file=err)
|
|
260
|
+
unscored = sum(math.isnan(s) and bool(t.strip()) for s, t in zip(ranking.score, texts))
|
|
261
|
+
if unscored and jev is not None:
|
|
262
|
+
print(f"jsort: {unscored:,} texts were never compared and are listed last", file=err)
|
|
263
|
+
if ranking.reliability is not None and ranking.reliability < SHAKY:
|
|
264
|
+
print(f"jsort: reliability {ranking.reliability:.2f}: two halves of the comparisons give different orders. "
|
|
265
|
+
"Raise -k, or reword the description so that any two of these texts can be compared on it", file=err)
|
|
266
|
+
if show_stats and jev is not None:
|
|
267
|
+
print(f"jsort: {summary(records, ranking, jev)}; {time.perf_counter() - t0:.1f}s", file=err)
|
|
268
|
+
return 2 if problems or ranking.errors or ranking.over_budget or ranking.fatal else 0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def cli() -> None:
|
|
272
|
+
code = main()
|
|
273
|
+
try:
|
|
274
|
+
sys.stdout.flush()
|
|
275
|
+
except BrokenPipeError:
|
|
276
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
277
|
+
sys.stderr.flush()
|
|
278
|
+
sys.exit(code)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
if __name__ == "__main__":
|
|
282
|
+
cli()
|