README.mdpreviewREADME.mdsource152 lines · 7.1 KB · raw
1# Chapter 17: jev-protocol — the grammar book that never posts a letter
2
3`jev-protocol` is the TypeSafe System One protocol (`POST /v1/systemone`)
4with no network, no files and no clock. It builds the exact bytes of a
5request, checks that a response really answers the questions that were
6asked, classifies the API's errors, and decides whether and when to retry.
7Something else does the sending. This crate only knows what a correct letter
8looks like and how to read the reply.
9
10## Three kinds of question
11
12Jev never writes prose back. You hand it a *state* (whatever it is judging:
13a string, an object, an array) and one or more named questions, and every
14answer is a probability. There are three shapes of question:
15
16| Type | You write | Jev answers |
17| --- | --- | --- |
18| `Noul` | instructions, and optionally what yes and no mean | `noul`: the probability of yes |
19| `Choice` | instructions and 2 to 255 labelled options | the winning label, a confidence, and a probability for every option |
20| `Score` | instructions and 2 to 10 ordered levels, lowest first | a score from 0 to levels − 1, a confidence, a probability and a vendor description for every level |
21
22The limits are checked when a question is built: `Choice::new` refuses one
23option, 256 options, an empty label or a repeated one, and `Score::new`
24refuses one level or eleven. A request that breaks an API rule cannot be
25constructed, so it is never sent.
26
27A request is one JSON object, written in this order:
28
29```json
30{"model":"jev-1.13.0","questions":{"q":{"type":"noul","instructions":"Urgent?"}},"state":{"body":"hi","id":1}}
31```
32
33That line is the exact output of the `exact_bytes` test in
34[src/request.rs](src/request.rs).
35
36## Why the bytes matter so much
37
38postjevsql keeps a cache of Jev's answers keyed by a hash of what was asked,
39and lmjtfy keeps an archive keyed by the exact request body. Both only work
40if the same question always produces the same bytes. So:
41
42- `state` goes on the wire byte for byte as the caller gave it (`Json`).
43- `Json::canonical` writes a value in RFC 8785 form: sorted keys, no
44  whitespace, ECMAScript number formatting. A number a double cannot hold
45  exactly (a `bigint` past 2^53, a `numeric(38,20)`) becomes a string with
46  every digit, so nothing is rounded away and equal values give equal bytes.
47- `Choice` options are sent in the caller's order and never pass through a
48  map type that sorts. Order is part of the question: Jev's answers show an
49  order bias.
50- `question_bytes` gives one question exactly as it appears inside the
51  request, for a cache key that covers a single question.
52
53> **Aside.** Generic JSON canonicalizers parse numbers into `f64` and
54> silently round. That is why [src/jcs.rs](src/jcs.rs) is hand-written: it
55> keeps every number's original text until it knows a double can hold it.
56
57## Reading the answer back
58
59```mermaid
60flowchart LR
61  Q["Questions<br/>noul / choice / score"] -->|"request_bytes"| B["exact bytes"]
62  B -->|"a transport sends them"| W(("Jev"))
63  W -->|"200 body"| P["Response::parse"]
64  P -->|"Key&lt;NoulAnswer&gt;"| A["get(key).noul"]
65  W -->|"any other status"| E["ApiError::from_response"]
66```
67
68Adding a question returns a `Key<A>`, typed by the kind of answer it reads
69(`Key<NoulAnswer>`, `Key<ChoiceAnswer>`, `Key<ScoreAnswer>`). It is the Rust
70version of a TypeScript generic that makes `answer.choice` a compile error on
71a yes-or-no question. A key belongs to the `Questions` that issued it; using
72it on another set's response panics.
73
74`Response::parse` refuses a reply that:
75
76- comes from a different model than the pinned one (`ModelMismatch`), so a
77  moved model is never recorded under the pinned version;
78- leaves a question unanswered, or answers one that was not asked;
79- answers a question as another type;
80- picks a `Choice` label that was not offered, or leaves one without a
81  probability;
82- gives a probability or confidence outside [0, 1], or a score outside its
83  levels.
84
85What passes is ordered like the question: a `Choice`'s probabilities come
86back in the options' order, a `Score`'s lowest level first.
87
88## Model ids, errors, retries and the price
89
90- **`ModelId::pinned`** takes `<name>-<major>.<minor>.<patch>`, with an
91  optional `-suffix` (`jev-1.13.0`, `jev-2.0.0-rc1`). It refuses aliases
92  (`jev-latest`, `jev-preview`) because the vendor says an alias moves when a
93  new release ships.
94- **`ApiError::from_response`** reads a non-200 body in all three shapes the
95  API sends (`{"detail": {"error_type", "message"}}`, a list of field
96  errors, or anything else, quoted up to 500 characters) and classifies it:
97
98  | Status | `ApiErrorKind` |
99  | --- | --- |
100  | 400 with `error_type` `max_tokens_exceeded` | `ContextOverflow` (split the request; never resent) |
101  | 400 | `BadRequest` |
102  | 402, or 403 whose message mentions "credit", "balance", "insufficient funds" or "quota exceeded" | `OutOfCredit` (inferred; the vendor documents no such response) |
103  | 401, other 403 | `Authentication` |
104  | 404, 408, 422, 429, 529 | `NotFound`, `RequestTimeout`, `Unprocessable`, `RateLimited`, `Overloaded` |
105  | other 5xx | `Server` |
106
107- **`retry`** is the vendor SDKs' policy (Python `typesafe-sdk` 0.7.1, JS
108  `@typesafe-ai/sdk` 0.6.0): at most 2 retries, 10 s per attempt, 30 s for
109  everything. 408, 429 and every 5xx are retried, and so is a request that
110  got no response. The wait is `retry-after-ms`, else `Retry-After` in
111  seconds or as an HTTP date, capped at 60 s; without either it is 0.5 s
112  doubling to 5 s, less up to 25% jitter. `next_delay` takes the jitter and
113  the wall clock as arguments, so the policy itself has no clock.
114- **The price** is `DOLLARS_PER_MTOK`, $0.042 per million input tokens;
115  output tokens are free. `Usage::dollars` prices an answer.
116  `worst_case_dollars` prices a request before it is sent: its length in
117  characters (no token is shorter than one character), capped at the
118  documented 64,000-token ceiling, times the 3 attempts the retry policy
119  may make.
120
121## Try it
122
123```
124cargo test -p jev-protocol
125```
126
127Besides the unit tests, `never_panics` in [src/lib.rs](src/lib.rs) feeds
128random bytes to the four parsers that meet the network (`Response::parse`,
129`ApiError::from_response`, `retry::next_delay`, `Json::canonical`): they must
130reject, never panic.
131
132This crate builds for `wasm32-unknown-unknown` as it is: it has no I/O to
133port.
134
135## For the people who maintain it
136
137### In this folder
138
139| Path | What |
140| --- | --- |
141| [src/](src/) | The code, file by file: Chapter 17½. |
142| [Cargo.toml](Cargo.toml) | Dependencies: `http`, `httpdate`, `ryu-js`, `serde`, `serde_json` (with `preserve_order` and `raw_value`); `proptest` for tests. |
143| [CLAUDE.md](CLAUDE.md) | Invariants an agent must keep. |
144
145Design points taken from prior art, both MIT OR Apache-2.0: typed answer
146keys, limits checked before sending, responses verified against the
147questions and structured API errors from JedimEmO/typesafe-client; fuzzed
148parsers from kunobi-ninja/kunobi-jev. Unlike both, the state is sent as
149caller-supplied bytes, so the bytes on the wire are exactly the bytes a cache
150key hashes.
151
152← Previous: [Chapter 16: jevcrates](../) · Up: [jevcrates](../) · Next: [Chapter 17½: inside jev-protocol/src](src/) →