Jev Evaluation Loop for the Peregrine Item Bank
Phase 0 complete, 20 September 2026 — findings F1–F3 below, two of which change the plan. A confidence-gated judging pass over Peregrine's 439 multiple-choice items using TypeSafe's Jev model, with thresholds that route a confident failure to Claude and an uncertain read to you.
What Jev is. typesafe/jev-1.13 is a "System One" model. It takes a state and a map of typed questions, evaluates every question in parallel against that state in one request, and returns typed answers with calibrated probabilities. It does not generate text. Three question types: Noul (yes/no probability), Choice (one option from a set, with a probability per option), Score (a rating against ordered level descriptions). Choice and Score carry a confidence derived from how concentrated the probability distribution is. Noul does not. One wrinkle the OpenAPI schema settles: confidence and probabilities are not required fields, so a thin answer must read as absent rather than average.
How we reach it. Through OpenRouter's Decisions endpoint, not api.typesafe.ai. The native typed shape survives intact.
POST https://openrouter.ai/api/alpha/decisions
Authorization: Bearer <OpenRouter key>
{ "model": "typesafe/jev-1.13", "state": …, "questions": {…} }
Two things about this route cost a wrong turn to learn. The model does not appear in GET /api/v1/models — that listing carries 446 chat models across 61 providers and Jev is in none of them, which makes "OpenRouter cannot serve Jev" a natural and wrong conclusion. A Choice's criteria is a {label: description} record, never an options array; the array form has returned 503s in production elsewhere, which is a confusing way for a schema error to present. Field names were read from the OpenAPI operation createApiAlphaDecisions rather than guessed.
What it costs — measured, no longer estimated. A three-question call on a real Wager item cost $0.000027 at 643 input tokens. 260 calls across the full math bank cost $0.0053. A full 439-item pass lands near four cents, confirming the estimate. Round trips ran 0.31s. The OpenRouter key carries a $10 monthly limit, roughly 285 full-bank passes a month.
Findings · Phases 0 to 2
F1 Jev judges math answer keys far better than the vendor warning predicted
Paired probe over all 130 math items, each asked twice — once about its real answer key, once about a deliberately wrong one. Each item is its own baseline, so item difficulty cannot inflate one arm. 130/130 answered, $0.0053.
| mean | median | |
|---|---|---|
| noul on the real key | 0.826 | 0.905 |
| noul on a wrong key | 0.268 | 0.100 |
| paired separation | +0.558 |
Real scored above wrong on 116 of 130 items. At a 0.6 threshold it accepts 90% of real keys and rejects 85% of wrong ones.
The original scoping decision was too cautious. Math answer-correctness returns as a candidate dimension.
F2 Where it fails is the more useful half of the result
The eight worst-separating items name their own trap families: dataread-wrongtarget, solve-msign, math-targetcheck, math-units, setup-minvert, solve-mpartial. Every one is a distractor that is correct arithmetic for a slightly different reading of the stem. problem-solving-data is the weakest skill at +0.467, and units and wrong-target traps are where it is weakest within that.
That is the literal-reading failure mode TypeSafe documents, landing exactly where the item design intends it to. It is also the guide's own difficulty criterion A — "an intermediate on the answer sheet, so the value that looks like the answer is not the one the stem asked for."
difficulty rather than a failure to queue for revision.- Decide whether F2 becomes its own dimension, and whether a low-separation item should be flagged as well-authored rather than sent to Claude
F3 Only 36% of the bank carries the labels this rubric was built to audit
| Field | Present | Missing | Coverage |
|---|---|---|---|
skill_code | 158 | 281 | 36% |
difficulty | 158 | 281 | 36% |
difficulty_basis | 158 | 281 | 36% |
Missing skill_code by section: reading 134 of 178, math 96 of 130, writing 51 of 131.
This is the finding most likely to reshape the plan. For 158 items Jev audits a label that exists. For 281 items there is nothing to disagree with, so Jev supplies a label that was never authored. The second is arguably worth more than the first, and Phases 3 and 4 are not written for it.
It also caps calibration: agreement rates for skill_code and difficulty can only be computed on the 158 labelled items, and the 40-item sample has to be drawn from those.
- Decide whether backfill becomes a first-class mode beside audit, with its own confidence floor — a proposed label crossing into an empty field is a different act from a disagreement with an authored one
F4 Six of twelve dimensions are near-constant, and "flat" is ambiguous
Stratified diagnostic, 60 items at 20 per section, 60/60 answered, $0.0048.
| Dimension | Top level holds | Mean confidence |
|---|---|---|
answer_key_correct | 60/60 (100%) | — noul |
stem_self_contained | 60/60 (100%) | — noul |
tested_construct_is_sat | 60/60 (100%) | — noul |
sat_choice_conventions | 56/60 (93%) | 0.76 |
sat_stem_conventions | 55/60 (92%) | 0.83 |
one_defensible_answer | 37/40 (92%) | — noul |
A flat dimension has two causes needing opposite responses. Either the bank is genuinely clean on that axis, which is plausible for items hand-authored to a content bar, or the dimension cannot discriminate and is reporting a constant.
answer_key_correct looks flat here, yet the paired probe showed it drops to 0.16 when handed a wrong key. It discriminates; the bank is simply correct. The same trick resolves the other five.- Run a negative control for each flat dimension before Phase 3 — strip the passage and check
stem_self_containedfalls, break choice parallelism and checksat_choice_conventionsfalls. A dimension that does not move under deliberate corruption must not gate.
F5 Engagement is substantially measuring whether the item has a passage
| Section | Mean engagement |
|---|---|
| reading | 1.91 |
| writing | 1.36 |
| math | 0.64 |
It has the widest spread of any dimension (sd 0.62), which looks like discrimination and is mostly the presence of a stimulus. A math item is "bare" by construction, so the dimension splits by section rather than by quality and fails the non-redundancy test against section.
This is evidence for the decomposition meeting rather than a defect to fix here. It also sharpens the meeting's first question: a dimension that scores every math item low is measuring something the item cannot change, which is the actionability test failing in a form that is easy to miss because the numbers look healthy.
F6 The three least certain dimensions include the one designated to gate
| Dimension | Mean confidence |
|---|---|
passage_realism | 0.45 |
difficulty | 0.51 |
distractor_pull | 0.52 |
engagement | 0.63 |
sat_choice_conventions | 0.76 |
sat_stem_conventions | 0.83 |
skill_code | 0.96 |
section | 1.00 |
distractor_pull was chosen as the calibratable neighbour of engagement and is the dimension the rubric allows to gate. It returns the third-lowest confidence in the set. TypeSafe reads low confidence on a Score as levels that are ambiguous, multi-dimensional, or a state carrying too little to decide. All three are fixable in the rubric, and none is fixable by a threshold.
Agreement against the corpus, on the 23 of 60 sampled items that carry labels: section 60/60 (100%), skill_code 20/23 (87%), difficulty 13/23 (57%). The first two look calibratable today. difficulty at 57% on a binary mapping is close to a coin flip, and 23 items is too thin to conclude more.
- Rewrite
distractor_pull's levels before Phase 3 and re-measure its confidence - Decide whether
difficultysurvives as a Jev dimension, or whetherkey_separationfrom F2 is the better-grounded route to the same judgement
F7 The negative control answers F4: flat means a clean bank
Every flat dimension was handed a deliberately corrupted item and asked again. 15 items, 87 requests, $0.0019.
| Dimension | Clean | Broken | Drop | Moved on | Corruption |
|---|---|---|---|---|---|
sat_stem_conventions | 1.89 | 0.05 | 1.84 | 15/15 | stem names the strategy, numbers a step |
sat_choice_conventions | 1.84 | 0.05 | 1.79 | 15/15 | fifth choice, "All of the above" |
stem_self_contained | 0.94 | 0.14 | 0.81 | 15/15 | passage removed |
answer_key_correct | 0.93 | 0.45 | 0.48 | 11/15 | key pointed at a distractor |
one_defensible_answer | 0.78 | 0.48 | 0.30 | 12/12 | distractor replaced by the correct text |
The ordering is the useful part. The two convention Scores collapse almost completely under corruption and are the sharpest instruments in the rubric. one_defensible_answer has the weakest drop at 0.30 and only reaches 0.48 when broken, so it detects a second defensible answer without becoming confident about it, which is a thin margin to put a threshold in. answer_key_correct moving on 11 of 15 rather than 15 of 15 is F2 again: the items it misses are the ones whose distractors are well authored.
tested_construct_is_satremains untested — an out-of-domain stem also breaks self-containment, so no corruption yet isolates it. It stays advisory.
F8 The distractor pass reaches 93 of 439 items, and my first trap question was unfair
Only 93 items carry per-option mechanism or trap_codes on their distractors, 45 Wager and 48 Lineup. The other 346 have nothing for this pass to check, which matches the guide's own account that a generated item carries a con nickname and no mechanism by definition.
| Question, across 183 distractors | Mean | Median | Below 0.5 |
|---|---|---|---|
Does the authored mechanism explain its option? | 0.713 | 0.770 | 16% |
Does the authored trap_code name the error? | 0.585 | 0.600 | 39% |
M-ALG-MANIP — and asked whether it named an error, without ever supplying what the code means. TypeSafe's own guidance says symbolic representations underperform semantic ones, and a trap code is exactly that. A paired test supplying the definition from guides/sat-traps lifted the mean by 0.124 and improved 24 of 30 options, so the runner now carries the definition and the numbers above are corrected.A residual survives the fix. 39% still sit below 0.5, against 16% for the mechanism question on the same options. That gap is the part that may be about the corpus rather than the question.
- Read the ten weakest trap-code fits by hand and decide whether the residual is a labelling problem or a second framing problem
What is being evaluated
| Scope | Count | Shape |
|---|---|---|
Phase 1 target — the-wager + the-lineup | 439 | class: item with a dict options[], per-option trap_codes and mechanism |
| Deferred — bundled-drills, target-practice, go-undercover, the-statement, the-lobby | 173 | answer_options / move_options; each needs its own state builder |
| Deferred — prime-suspect | 9 | Case-shaped, already gated by validation_status |
Out of scope — class: flavor | 215 | Heckles, con taxonomy, glossary. Not items. |
The dimensions, and how each maps onto Jev
| Dimension | Type | Gates? | Note |
|---|---|---|---|
| section | Choice | yes | Sanity check against the corpus value |
| skill_code | Choice | yes | The eight College Board codes already in the schema |
| difficulty | Score | yes | Four levels described as situations; banded onto the corpus's standard/hard |
| sat_stem_conventions | Score | yes | Half of "fidelity to the digital SAT", split because one Score cannot hold two judgements |
| sat_choice_conventions | Score | yes | The other half |
| tested_construct_is_sat | Noul | advisory | No confidence on Nouls |
| stem_self_contained | Noul | advisory | Catches the failure the guide already names: stems pointing at a stimulus never shown |
| passage_realism | Score | yes | Reading and writing only |
| distractor_pull | Score | yes | The calibratable proxy for whether a round feels alive |
| engagement | Score | advisory | See below |
| one_defensible_answer | Noul | advisory | Suppressed for math, and the suppression is reported |
distractor_pull is the nearby thing that is calibratable, and that one gates.Verdict routing
Three bands, following TypeSafe's confidence-gated routing pattern. This is the part that keeps Claude off work it cannot do.
| Condition | Verdict | Goes to |
|---|---|---|
| Every gating dimension meets its threshold | pass | Nothing |
| A dimension misses, confidence below its floor | review | You. Jev could not read the item; a revision request built on it would be noise. |
| A dimension misses, confidence at or above its floor | revise | Claude, with the dimension, the score, and the level description it landed on |
revision_passes at the cap | escalate | You, regardless of score |
Routing low confidence to a human rather than to Claude is what stops the loop generating work from its own uncertainty.
The evaluation record written onto each entry
"evaluation": {
"rubric_version": "2026-09-20a",
"model": "jev-1.13.0",
"run": "20260920T143012",
"verdict": "pass",
"dimensions": {
"skill_code": {
"type": "choice", "choice": "algebra", "confidence": 0.81,
"probabilities": {"algebra": 0.88, "advanced-math": 0.12},
"corpus_value": "algebra", "agrees": true
},
"sat_stem_conventions": {"type": "score", "score": 2.4, "confidence": 0.77},
"engagement": {"type": "score", "score": 1.9, "confidence": 0.52, "advisory": true}
},
"composite": {"sat_fidelity": 0.78, "craft": 0.71},
"flags": ["skill-mismatch"],
"revision_passes": 0,
"evaluated": "2026-09-20"
}Four architecture decisions, each of which had a real alternative
- The rubric is corpus data, not Python. Every config in this system is a file, and the rubric is a content judgement — which skills exist, what "hard" means, what reads as the digital SAT. In
evaluation/rubric.jsonyou edit it without touching code, and every stored result names therubric_versionthat produced it. - stdlib
urllib, not the TypeSafe SDK. All eleven scripts intools/import stdlib only; there is no requirements file, no venv, no lockfile. The SDK's contribution is retry-with-backoff on 429/529, about fifteen lines. Adding the project's first Python dependency to save fifteen lines is the wrong trade. The cost is honest:tools/jev.pyowns its own backoff, and if TypeSafe changes the wire format the SDK would have absorbed, that file is where it breaks. - Results live on the entry, declared in the schema.
ResolvedSchema.envelopeKeyscovers fourteen keys the app owns outright; anything else undeclared renders nowhere, rides in no packet, and gets reported byStructuralLint.evaluationis declared inschema-extensions.jsonastype: "object", following the precedentvalidation_statusalready set there. - Evaluation does not gate export in v1.
export-machine.pyalready blocks onvalidation_status: playable, and the hook to do the same for items is right there. It stays unused until calibration shows the thresholds mean something. Blocking a build on an uncalibrated model score would put Jev in charge of the bank.
Phase 0 · Access, client, and an honest probe complete
- Key stored in the login keychain as
openrouter-api-key, entered at a prompt so it reached neither a file nor shell history - Wrote
tools/jev.py: stdlib client, backoff on 429/5xx honouringretry-after, immediate non-retried exit on 400/401/422 carrying the server's own message - Added
evaluate_safe()returning(None, reason)— an error, timeout or malformed body never becomes an invented probability - Added optional-field readers, because
confidenceandprobabilitiesare not required by the schema --selftestreporting auth, budget, endpoint, served model, round trip and cost;--probeand--math-probe- Ran the math-competence probe, widened from ten items to a paired design over all 130
- Recorded the result whatever it said — it said the original scoping was wrong
Phase 1 · The rubric as corpus data after Phase 0
- Declare
evaluationinschema-extensions.jsonas a typed object; verify noundeclaredKeylint finding in the app - Write the state builder:
{section, passage, stem, choices[{key,text}], answer_key}and nothing else - Ensure the blind pass carries no authored labels — agreement is computed in code, because putting the corpus value in the state tells Jev the answer
- Write every Score's levels as concrete situations, never as degrees; TypeSafe's own data shows numbers-only levels collapse confidence to 0.33
- Give every dimension a
min_score,min_confidence,weightandgatesflag — and ship with every gate off - Record the engagement caveat in the rubric file itself, with its reason
Phase 2 · The runner, reporting only after Phase 1
--dry-runis the default: build every request, print counts and estimated cost, send nothing- Blind pass — one request per item carrying every non-suppressed dimension, since parallel questions cost only their own tokens
--pass distractors— a second request whose state carries the authored trap codes and mechanisms, asking per distractor whether the mechanism explains that choice- Move the checks that do not belong in a model into code: forbidden-vocabulary regex, trap-code vocabulary membership, option count, duplicate keys, answer key present among options
- Write a run ledger and a dated HTML report. Write nothing to entry files in this phase.
- Report the distribution per dimension, not just outliers — a dimension where 90% of the bank lands on one level is a broken rubric, not a clean bank
Phase 3 · Calibration — the gate on everything downstream needs your labels
This phase exists because the bank has already been burned once by an uncalibrated judgement: the difficulty backfill put hard on 71% of The Wager and had to be retuned the next day. A model score adopted without checking it against your own reading would repeat that at scale.
- Build a stratified 40-item sample; generate a label template with empty slots
--calibratereports per dimension: agreement rate, agreement above each candidate confidence floor, and the confusion pairs- Report what a threshold would have cost — at each floor, how many items go to Claude, how many to you, and how many of those your labels say were fine
- Flip
gates: trueonly on dimensions clearing a bar you name; record the failures with their numbers rather than deleting them - Pin
jev-1.13.0rather thanjev-latest— an alias moves and would silently retune every gate
Phase 4 · Writing verdicts to the corpus after Phase 3
--writeoff by default; writes only theevaluationkey andupdated- Skip entries whose evaluation is byte-identical, so an unchanged item causes no write and no watcher reload
- Check
git statusin the corpus and refuse a dirty tree unless forced — you commit from the app mid-edit, so a surprise diff is easy to lose - Add an evaluation section to
validate-peregrine.pyreporting missing evaluations, stale rubric versions, and queue counts as WARN - Leave
export-machine.pyuntouched, and record why
Phase 5 · The bounded revision loop after Phase 4
No direct Anthropic API client. Revision runs through claude -p in headless mode, the same shape CodeRaven's SimpleClaudeRunner already uses, with limits in front of it.
--dry-rundefault: print the prompt and the ids, invoke nothing- Build the prompt from the failed dimensions only — name, score, the level description it landed on, its confidence, and the rubric's own instruction text. Never a bare number.
- Carry the content bar and forbidden vocabulary into the prompt, so a revision cannot fix a score by breaking the brief
--max-itemsper run;--max-passesdefaulting to 2, after which an item becomesescalateand is never sent again- Refuse to revise a
finalentry or one under a locked guide lifecycle - Halt on regression: if a run lowers the mean composite for the items it touched, stop and report rather than continuing to the next batch
- Re-evaluate only the revised ids; write before/after into the ledger and a dated HTML report
Phase 6 · App surface deferred, not scheduled
- Decide whether the evaluation queue deserves a card in Press Raven, once there is a run history to look at
- If yes, read
evaluationthroughEntryDocumentaccessors besideconfidenceScore— the app renders corpus state and runs no network calls - Keep the HTTP client in
tools/; do not add one to a@MainActorSwiftUI app for a batch job that belongs in a script
Final review confidence 78 / 100 · was 74
What this solves
The bank's confidence score is an authoring self-assessment — the author's own number about their own work. This adds a second, independent read with a calibrated uncertainty signal, at a cost low enough to re-run on every rubric edit. The routing split is the part that earns its place: a confident negative is work for Claude, an uncertain read is work for you, and the two never get confused.
Pros
- Cheap enough to iterate. Four cents a pass means the rubric can be wrong several times without anyone counting.
- The rubric is data. You retune a level description without touching Python, and every stored result names the version that produced it.
- It uses the corpus's own precedents.
validation_statusalready gates export;schema-extensions.jsonalready declares a gate field;validate-peregrine.pyalready owns the ERROR/WARN register. Nothing here invents a mechanism the project lacks. - Confidence routing prevents the obvious failure. Without it, every item Jev could not read becomes a revision request, and the loop manufactures work from its own noise.
Cons and what is genuinely uncertain
Math correctness is not covered, and cannot be.Overturned by F1. Paired separation of +0.558 across all 130 math items puts answer-key checking back on the table as a candidate dimension, subject to the same calibration as everything else.- New, from F3 — the audit framing fits only 36% of the bank. For 281 items Jev would be supplying a label rather than checking one, and the plan is not written for that. This is now the largest open question.
- Engagement is the weakest dimension. Including it because it was asked for, and gating on it because it was asked for, are two different decisions. This plan does the first and not the second.
- Nouls carry no confidence. Four dimensions are Nouls, so four have no uncertainty signal and cannot participate in confidence routing.
- Calibration depends on 40 hand labels from you. Phase 3 gates everything downstream and needs a person. If the labels never get made, the honest outcome is a reporting tool with no gates — still useful, and it should be stated rather than quietly upgraded.
- The first Python HTTP client in
tools/. Eleven scripts are stdlib-only. This adds outbound network calls to a directory that has never made one. - Adversarial state. TypeSafe says state is not treated as hostile. Peregrine items contain authored prose about reasoning and error — text that argues about correctness. Whether that moves Jev's answers is unknown until Phase 3's numbers exist.
Where this plan could be inadequate
- If the real goal is catching wrong answer keys, Phase 0's probe will show that early and the plan needs rewriting around a different tool.
- The 173 deferred items have different shapes; each needs its own state builder and possibly its own dimensions. That work is not estimated here.
revision_passesis per item, not per dimension, so an item failing a different dimension on its second pass still counts against the same cap. The cap is deliberately blunt.- If Press Raven is mid-edit on an item when
--writeruns,CorpusStoreraises anEntryConflict. Thegit statuscheck catches an uncommitted tree, not an unsaved draft in the app. Running writes while not editing is a convention, and a convention held by memory is the failure mode this codebase has already documented twice.
Adjustments worth considering
- Run Phase 0's math probe before agreeing to the rest. It determines whether a third of the bank is in scope.
- Consider shipping Phases 0–3 and stopping. A calibrated reporting tool with no gates and no revision loop is most of the value, and it is where the uncertainty resolves.
- Add a code-only
answer_key_present_in_optionscheck — it costs nothing and catches a class of error no model should be asked about. - Unrelated drift noticed while scoping: twelve entries carry
status: "draft"where the framework lifecycle isseed / drafted / checked / final. All twelve are prime-suspect or season-documents. Worth a separate one-line fix.
Why 74 was the first number. Three of the five dimensions you named map cleanly onto Jev, and two do not. Difficulty and skill classification are exactly what a calibrated classifier is for. SAT fidelity decomposes well. Realism is workable. Engagement has no ground truth, and math correctness is outside the model's competence by its vendor's own account. A plan that promised all five equally would be more confident and less true. The number moves up after Phase 0's probe and Phase 3's agreement rates, which is why both are gates rather than steps.