Repository navigation
[Engine] typed decisions: score a schema's token paths instead of generating them #3743
Description
Area
Engine
What do you want to do?
Answer a JSON schema whose field values are finite (booleans, enums, small integer ranges) by scoring them, not generating them. Opt-in, on its own endpoint; chat is unchanged.
- The caller sends a prompt and a schema. Every allowed value of a field becomes a token path. A field's paths form a trie.
- The engine reads the prompt once, saves the state after its last token, and scores the branch paths in verify windows from it, restoring it between branches. Nothing is decoded.
- The server returns, per field, the chosen value and its probability. Every option is scored, so a field's probabilities sum to 1.
- N fields are answered from one prompt read. Each field is scored as the first key after
{, so fields do not see each other. - Enum and boolean options are shown with single-token labels (
0-9, ora-zabove 10 options; more than 26 is refused). The model is scored on the label, never on the option text. The caller gets its own values back. - One labelled field needs no branch window: its distribution is the row after the field's prefix. One row is read, no state saved.
Reference implementation: serve/decision.py (module docstring) on github.com/leonardomurillo/Strata, branch logprobs, based on v0.1.43.
Prior art:
- The algorithm is ported from thecodacus/llama.cpp
tools/parallel-decision(MIT). - Feature request:
logprobs/top_logprobson/v1/chat/completions(typed decisions from one forward pass) #675 asked for one-token typed decisions via logprobs. - [Engine] OpenAI logprobs / top_logprobs / allowed_token_ids for the CUDA/HIP engine #3200 adds OpenAI logprobs and
allowed_token_ids: one token per request. - [Engine] Jev System One decision endpoint (/v1/systemone): typed decisions with calibrated probabilities #3643 adds
/v1/systemone, a final-row readout with calibration.
This complements them: values longer than one token, and several fields per prompt read. I would like to agree on scope here before any PR. Is it in scope, and should it share an endpoint or calibration with #3643?
Measurements
All on the fork, one request at a time. Datasets: deepset/prompt-injections test split (116 items, 1 boolean field) and a banking77 subset (77 items, 2 fields: is_card boolean and group, the intent's group, at most 10 options).
c05beaf, RTX 4080 16 GB, Windows 11:
- Injection: accuracy 0.8103, AUC 0.983, Brier 0.1544, 538 ms.
- Banking:
is_card0.8571 (AUC 0.92, Brier 0.1201),group0.8442, both right 0.7662, 617 ms. - Every field's probabilities sum to 1 within 2.2e-16.
--adapt-swaps 0, 12 decisions x 4 branches: the same request twice moves a probability by at most 0.063, branches reversed 0.065, each branch alone 0.065: no leak between branches beyond that noise.
v0.1.43-lp6 (544c32f), RTX 4080-class 16 GB cards, Windows 11, over HTTP:
| Test | Typed decision | JSON-schema chat |
|---|---|---|
| Injection | 0.8448 (AUC 0.9815, Brier 0.1473), 593 ms | 0.8103, 617 ms |
Banking is_card / group |
0.8571 / 0.8571, 663 ms | 0.8571 / 0.8831, 1,123 ms (also asked the 77-value intent) |
| 26-option intent field, 26 items | 0.8846, 663 ms | 0.8846, 813 ms |
| 10 questions, up to 10 options each | 1,038 ms | 1,299 ms |
v0.1.43-lp6 vs v0.1.43-lp7 (42bb83f, adds the one-row path), RTX 4080 16 GB, Windows 11, 10 interleaved pairs:
- Injection median 536.6 -> 480.7 ms (-10.4 %, 95 % CI [-58.7, -53.2] ms, faster in 10/10 pairs). Accuracy 0.8345 vs 0.8353, majority answers identical. AUC 0.9829 -> 0.9907, Brier 0.1477 -> 0.1459.
- Banking (2 fields, branch path, unchanged): 615.5 vs 614.9 ms; accuracy, AUC and Brier unchanged (CIs contain 0).
Passed: the runs above. Failed: none. Not run: Linux, AMD, other GPUs, current main.
What do you do today instead?
- Schema prompt plus JSON parsing: one generated answer, no probabilities; asking fields one by one reads the prompt again per field.
- One-token logprobs per field ([Engine] OpenAI logprobs / top_logprobs / allowed_token_ids for the CUDA/HIP engine #3200,
allowed_token_ids): a probability, but one prompt read per field, and each value must be one token.
Activity
justindressler commentedon Oct 22, 2026
Apologies first: my two replies on #3643 arrived as email quote-walls — I was answering from Outlook and each one carried the whole thread with it, so the thread reads as duplicated messages. That was my mistake; replying here on GitHub from now on.
Yes to scope, and one correction to my own reply in #3643.
Scope: in scope, opt-in. A typed-decision surface is the point of #3643, and a token-path scorer is a second way to answer the same question. Neither should touch chat.
One endpoint, two paths. I'd keep
/v1/systemoneand route text multi-field to the token-path readout, rather than adding a second endpoint. What stays ours: the calibration (donor order — round 4dp, renormalise, then Platt fornoul/ temperature forchoiceandscore, keyed per(mode, qtype)asL|noul,V|choice,D|score,S|choice) and the latent image path (D/S), which a token-path scorer has no answer for. What comes from your side: the trie of allowed values, and the single-token label scheme (0-9,a-z, refuse past 26). Shared endpoint, shared calibration, one readout at a time — no.On the two-field latency, my earlier explanation was wrong. I said single forward pass, then MoE expert cache. It isn't. Reading our code at the commit you tested (
ece6db0a):serve/jev.py:484—for qdef in qdefs::535— renders the prompt again, per question:545—for _ in engine.generate(ids, 1, {}, cancel): passThat is one cold prefill per field, not one prompt read.
final_row_logits()does not re-prefill, so the N prefills are the whole cost. Your engine log for one field (98 tokens, 400 ms prefill + 12 ms step) predicts 824 ms for two fields against your measured 819 ms — 0.6%. The expert-cache story does not fit that ratio; N prefills do.V-mode is worse:
:491loops the options, so a 10-option choice is 10 prefills, about 4.1 s. The path that should be single-pass is the one doing the most passes.So the gap is structural and I don't think it needs your design to close it. One prompt with N label slots, each read at its own position:
copy_logits(t)already exists (include/strata/core/verify.hpp:106, "valid after run()"), it just needs a wire op —BROWAT <pos> <tokens>— where today'sbrow_row()(verify.hpp:300) can only give the final row. Modelled about 430-460 ms for any N that fits a window, zero extra decodes. That converges on what your endpoint already does, which is why I'd rather share the endpoint than race it.Two things to agree on before I build it:
0-9/a-zsingle-token labels, refusing more than 26 options. I'd adopt it verbatim on the shared path.is_cardat p 0.002 is the one number in the A/B where you beat us that isn't noise, and I can't yet tell whether it is the extra prefill or a genuine readout difference. Putis_cardand Brier in one harness, run it against whichever path is in the tree, and the next comparison is like-for-like.Brier is the number my thesis rides on (0.095 vs 0.148 on your 1-field set) and one-field latency is ours; I'm happy to concede the rest and settle
is_cardwith the harness instead of a comment thread.I'll implement the single-readout path either way — it's the same fix whether or not we merge the endpoints, and
usage.prompt_tokenswill make the regression a test rather than an A/B. I'll follow up here with the proposed improvements, and with a review of our tests, as they land.leonardomurillo commentedon Oct 22, 2026
Thanks for the detailed reply and for reading the code path through. No worries about the email threads.
One shared endpoint works for me, with you owning it. I'm not going to open a competing PR. Happy to review and to benchmark as it lands.
1. Labels: agreed. One detail in case it matters for the port:
0-9is used up to ten options,a-zfrom eleven to twenty-six, and more than twenty-six is refused. Each label has to be a single token in the model's vocabulary; a request whose labels are not is refused rather than scored on a longer path.2. Harness: agreed. A few suggestions so the next comparison is like-for-like:
usage.prompt_tokens. Your idea of turning the prefill count into a test fits well here.is_cardandgroup), one request at a time, with the expert cache's adaptive swaps off (--adapt-swaps 0) so repeated runs give identical answers.When your single-readout build is up, point me at the commit and I'll run it through the same harness on the same card and post the numbers here, both directions as before.
Thanks again, this is converging nicely.
feat(jev): jevpos - keep the head row at named prompt positions (M3.1…
justindressler commentedon Oct 23, 2026
All four harness points are fair, and the fourth one caught a real gap on our side. Taking them in order.
1. Labels — agreed, and already implemented that way
STRATA_JEV_LABELS=digit-lowerselects exactly your scheme:0-9up to ten options,a-zfrom eleven to twenty-six. More than twenty-six is refused, not scored on a longer path —serve/jev.py:188-190returnsquestions.<qid>.criteria: >26 options need the numeric-label trie (not in this build).The single-token rule is enforced by
_label_token_problem()atserve/jev.py:216, which runs before any readout: it encodes every rendered label withparse_special=Trueand refuses the whole request if any label is not exactly one token. You were right about why this matters — the readout used to encode and take[:1], which silently scored a truncated multi-token label. It now fails closed instead.2. Field order — measured on both arms, and it only bites the single-prompt one
On the default per-field arm, this box, 200 banking77 items, same items forward and reversed, one request at a time:
On the single-prompt arm it is exactly the failure you describe: reversing the two fields flipped 52 items one way against 1 the other (McNemar p=0.0008). Fixed the same way you fixed yours — fields are rendered in a canonical order (
sortedbyqid,serve/jev.py:719) so the same set produces the same prompt whoever asks; after the sort, 0 flips one way and 1 the other. Answers are keyed byqid, so the response shape is unchanged.3. Per field, like-for-like as far as we can currently claim it
banking77 two fields, n=200, RTX 3090, one request at a time, default arm, one binary:
is_cardgroupusage.readout_tokens= 2 per request, one per field. Latency by request shape (medians): 1 field 237 ms, 2 fields 731, 3 fields 964, 4 fields 1106, 5 fields 1214; from 4 to 32 fields it is 199.7 ms per field (32 fields 6598 ms). Prompt tokens are ~98 per field and flat.On Brier on the same footing: .2215 above is raw, and you were right that our earlier .095 went through a Platt fit while yours was raw. That row was measuring the calibrator, not the readout. Raw is what I will quote from now on, and the harness row should be both-raw.
4.
--adapt-swaps 0— you caught a real gapNeither our probe scripts nor the preset we serve from pass it. So the numbers above are three-rep medians from one box and one binary — the shape is stable across runs, but I will not claim they are bit-reproducible, because the expert cache was free to adapt during them. I am re-running the headline table with
--adapt-swaps 0and will post it here with a note on any number that moves.5. The build to point you at, and one thing that has changed since your A/B
Your comparison was against
ece6db0a. The branch has moved, and the thing that moved matters for multi-field latency:Fields 2..N no longer park a checkpoint they can never be resumed from. A field that resumes from the pinned prefix has nothing of its own to park, and parking costs about 75 ms per field. N=5 went 1527 ms → 1214 ms (−20% to −24% from N=3 up), byte-identical prompts, answers moving by one item in 200 (is_card 199/200 agreement against the fully unpinned read, mean |Δp_yes| .0104 — the band the pin itself was accepted on).
So the multi-field row of your head-to-head should be re-run on the current head rather than on
ece6db0a, or it will credit us with a penalty we no longer pay.The single-readout build is
STRATA_JEV_ROWS=1on the current head (b1dce4d1): one prompt for the whole request, one head row retained per field at its own cue position, canonical field order,readout_tokens == fields. Honest note on what it buys: on our 3090 at 5 fields it is not faster than the default per-field arm — 1346–1410 ms against 1214 ms — but it costs 270 prompt tokens instead of 490. If what you want to isolate is prefill work rather than wall clock, that is the arm to run.I also built the genuinely batched shape (N independent branches over one shared prefix, one forward pass) and closed it. On this engine the batch window is decode-shaped, so prompt rows run at decode speed rather than prefill speed, and there is no KV sharing across batch slots —
docs/RESEARCH_RUNS.md:38says so directly. It needs new attention kernels, not a server change, so it is not in this PR. The measurements are on branchjev-arm-pin the fork if you want to argue with them.Thanks for the A/B and for the harness spec — points 3 and 4 genuinely improved what we measure.
justindressler commentedon Oct 23, 2026
You were right that this needed pinning, and the size of the effect surprised me.
Setup
RTX 3090, one binary, one preset (
jevport-vision.json), one session, 3 reps per shape,--adapt-swaps 0added as the only difference,llama-swaprestarted between arms. Tokens are identical in both arms (122 / 274 / 415 / 537 / 689 for 1–5 fields), so the flag changes no prompt text.Latency, medians in ms
--adapt-swaps 0, warmedTwo things fall out of this.
The flag buys reproducibility, and it costs real time. With
--adapt-swaps 0the same measurement is stable to about 1 % — three separate passes gave 209.7 / 206.1 / 210.3, and at 5 fields 3,033 / 3,043 / 3,054. With swaps on, the same arm spans 2.3× (5 fields: 2,655 cold vs 1,162 warm). So the unpinned numbers were not merely imprecise, they were not comparable between runs. At 5 fields the pinned arm pays 2.6× against a warmed default.My first attempt at this comparison was wrong, and I would rather say so than post the number: I measured the two arms each immediately after a restart, compared them, and got a tidy "+4.5 % to +21 % penalty". A second pass on the unchanged default arm then came back 2.3× faster at the same shapes. What I had been measuring was the adaptive expert tier settling, not the flag. The table above is the corrected version, with warm-up passes discarded.
Accuracy, 200 banking77 items (
is_card+group), raw, no calibrator--adapt-swaps 0(run 1)--adapt-swaps 0(run 2)So pinning the cache moves answers as well as timings: 3 items on
is_card, 4 ongroup, Brier +0.002. The two pinned runs agree to the second decimal, which is the same determinism the latency shows. That is a real argument for your harness shape: with swaps free to adapt, two labs cannot compare accuracy on a fixed item set at all.What I will report from now on
Both, labelled: the pinned row is the comparable one and the one your A/B should use; the warm-default row is what a user actually gets once the tier has settled. Any single number without the arm named is worthless — ours was, and that is on me rather than on your harness.
Two limits I am not going to paper over: this is one box, one binary, one session, 3 reps, and
--adapt-swaps 0also keeps the expert cache from learning across requests, so a long single-request workload may be a different story from this short-shape sweep.leonardomurillo commentedon Oct 24, 2026
Thanks for the careful follow-up. The warm-versus-pinned split was a really useful catch, and it reproduces here. I also liked seeing the canonical field order, the single-token label refusal and the park fix land so quickly. I ran the harness on
b1dce4d1; the numbers are below.Setup
--jev-brow,STRATA_JEV_MODES=chat,decision, andSTRATA_JEV_CALIB_DIRpointing at an empty folder, so every answer reportedcalib: identity(raw)./v1/decisionat cee0d344. Its calibration table is empty, so this is raw too.group(5-way) andis_cardtogether.--adapt-swaps 0. "Warm" means adaptive swaps on, after one full pass over the same items that was thrown away.Accuracy, raw on both sides
is_cardacc / AUC / BriergroupaccSTRATA_JEV_ROWS=1digit-lowerThe table below compares each of your pinned arms with our pinned run 1, item by item. "ours only" counts items only our run got right, and "yours only" the reverse.
is_cardis_cardROWS=1groupLatency
Medians of the whole request in ms, on banking77 items:
ROWS=1pinnedWhat I take from it
Your warm-versus-pinned effect reproduces on this card. From two fields up, your path is 1.6-1.9x faster warm than pinned. Ours doesn't move (657 vs 657 ms at two fields). My guess is that our fields share one prompt read, which leaves less for the expert cache to learn from one request to the next. So the warm rows are the fair latency comparison for your path, and the pinned rows the fair accuracy comparison.
At one field the two paths are even: 500 vs 517 ms pinned. Warm, yours is about 40 ms faster.
Field order is fixed on both sides. Reversing the two fields gives:
ROWS=1: 0 flips, mean |Δp| .003;All of these are inside the repeat noise.
Pinning doesn't make either side repeat item for item here. Repeating the first 40 injection items moves mean |Δp| by .005-.006 on ours and .012-.015 on yours, with one or two answer flips on your
ROWS=1anddigit-lowerarms. The totals agree to about one item, as you saw.Labels: raw,
digit-loweragainst A-Z gives the sameis_cardand three fewer right ongroup(.818 → .779, p=0.25). The n is small, but it fits your note that the readouts were baselined on A-Z.A related point from a separate run on three cards (4090, 4080, 4080 SUPER): with field names that put a field first or second, accuracy didn't change between the two positions (n=77). P(
is_card) still moved by about .06 on average. A canonical order makes that stable; it doesn't remove it.The raw
is_cardgap is still there (p=0.008 against your per-field arm). The injection ranking gap matches yours: AUC .953-.966 on your arms against .979-.990 on ours, close to your .961 vs .984. On the other hand, your injection Brier is lower than ours (.119 vs .140). Your raw probabilities are better scaled there, even with the slightly lower AUC, and that is something we could learn from on our side.Where I'd look first
Neither side is calibrated in these numbers, and AUC doesn't depend on a threshold. So the ranking gap and the
is_cardgap sit in the prompt or the readout, not in calibration. Two things point at the prompt:ROWS=1lifts youris_cardfrom .766 to .805 just by reading it in one prompt withgroup. That is how ours reads it, so the surrounding text seems to matter for that field.So I'd compare the rendered prompts first. Ours is built in
serve/decision.pyat cee0d34 (SYSTEM_HEAD+ the field catalogue + the instructions). I'm happy to post the exact rendered prompt for both sets, plus the per-item logs for all seven arms (text, gold, value, P, ms, served calibrator id) if you want to diff against yours.Limits: one card, one session, n=116 and 77, and one run per arm except ours pinned (two runs). So please read the small gaps as directional.
Happy to re-run any arm or add one, for example with a calibrator applied to both sides, or on the public sets you're adding. Thanks again; this keeps getting better on both ends.
justindressler commentedon Oct 26, 2026
Yes please — post the exact rendered prompts and the per-item logs.
What would be most useful to diff against ours:
is_card+group), exactly as your/v1/decisionsends them —SYSTEM_HEAD, the field catalogue, the instructions, and where the answer readout position lands. Rendered text, not the template, so nothing is lost in translation between the two codebases.is_cardgap by item instead of by total.Your prompt hypothesis is the one I'd bet on too, for the reason you gave:
ROWS=1liftsis_cardfrom .766 to .805 with the readout unchanged — only the surrounding text moved. If your prompt shape closes the rest of the gap on our readout, that's a clean result for both sides: the field catalogue and instruction wording are the active ingredient, and we can adopt them directly.Plan on my side once I have your prompts: render the same items through your prompt shape on our readout (one new arm, same pinned setup, raw on both sides), and post the per-field table in the same format as your run so the columns line up. If
is_cardmoves toward .87, we take your wording upstream into the PR; if it doesn't, the gap is in the readout and I'll say so.Also noting your point on repeat noise (.012–.015 mean |Δp| on our arms with a flip or two) — agreed the item-level alignment should treat single flips as noise; I'll only read the totals.
justindressler commentedon Oct 26, 2026
Our half of the diff — the rendered prompts for the first banking77 item (
b77-0000, the card-expiry request), both fields, default per-field arm, A-Z labels. Produced offline by the same code path the server uses (parse_questions→render_state→ the donor's frozen question block → the chat template with thinking off), so this is byte-for-byte what the engine tokenizes.is_card(noul):group(choice, 5-way):Notes for the diff:
Answer:.digit-lowerarm is the identical string withA.–E.replaced by0.–4., nothing else.<think>block comes from the chat template withenable_thinking=False— if your path doesn't carry one, that's a structural difference worth knowing about alongside the wording./v1/systemone— mainly theinstructionsphrasing for the injection noul — I'll post that exact render too, so the single-field comparison is on the same footing as this one.And yes to both standing offers:
is_card; it is the fit that went .670 → .800 in-domain, so it will also show exactly how far it does and does not transfer.jev: opt-in request context, rendered between the state and the question
justindressler commentedon Oct 26, 2026
Your prompt hypothesis was right, and we've now measured it end to end on our side. Head is
86f2ae32— it adds an opt-in top-levelcontextfield to/v1/systemone: caller-supplied text rendered verbatim between the state and the question block, in every field's prompt (per-field, V-mode branches, and the consolidated rows path). Nocontext: the render is byte-identical to the frozen donor shape, so nothing changes for existing callers or fitted calibrators.Why we built it: the error analysis on our own logs
Before touching the prompt we split our is_card errors (200 banking77 items, the per-field arm):
groupfield (which shows the option list) was right whileis_cardwas wrong — the same model, same request, the only difference being whether the taxonomy is in the prompt.The A/B (one session, one binary, paired, n=200)
Context = one line, no definitions:
Request groups: card, top_up, transfer, cash, other.Paired: +40 / −16, McNemar p = 0.002. group unchanged (.750 → .740). And the two fields stopped disagreeing with each other: group-vs-is_card contradictions fell 71 → 16.
Single-item example, the canonical FP: "I want to top up with my card." → p(card) .993 without context, .208 with it.
What the catalogue fixed vs what it broke
Fixed (40):
top_up_failed,pending_top_up,top_up_by_bank_transfer_charge,age_limit… — the everyday-meaning FPs, as predicted.Broke (16):
top_up_by_card_charge,topping_up_by_card,supported_cards_and_currencies,card_payment_fee_charged. These are a gold-definition question, and I'd value your ruling on it as the harness author. Ouris_cardgold is literallygroup == card(the banking77 label contains "card"), so top up by card charge is gold=card — but read as taxonomy, a card-mention inside a top-up action is top_up, and with the catalogue present the model now says so. I don't think either answer is wrong; I think the catalogue exposes that this subset of the gold is definitional, not perceptual.Honest caveats
platt_v1, fitted on the context-free prompt) was active on both arms. It is monotone for noul, so the argmax comparison and McNemar are unaffected, but the on-arm probabilities should be refit before anyone quotes them as calibrated.Once your rendered prompts and per-item logs land, I'll run this same context arm on your 77-item subset in the seven-arm format so the columns line up with your table — item-level alignment against your logs will also settle how much of the residual
is_cardgap is the top_up-by-card gold cluster.leonardomurillo commentedon Oct 27, 2026
Thanks. The
contextresult is a clean one, and the FP split you posted (65 of 67) explains it well. Here is our half.Everything is in one gist: https://gist.github.com/leonardomurillo/84ed4bb3e1f6e7f362e69921dc0b5e51
rendered-prompts.txt: the exact text/v1/decisionbuilds for one injection row and for the banking77card_about_to_expirerow. Both request bodies are included, plus what is appended after the prompt for each field.items.csv: all seven arms, per item and field: gold, value, P(true), ms. Calibration was identity everywhere.iindexes into the two jsonl files.injection.jsonlandbanking.jsonl: the 116 + 77 rows with full text and gold.README.md: the arms, the passes, and the/v1/systemonebodies our harness sent you, injection included.The rendered prompt, banking row
After that, each field gets
"group":or"is_card":and the label digit is read at the next row. With one field and one row, the suffix joins the prompt and that row is read directly. The empty<think>block is the same as yours, so that is not a structural difference.Two differences from your render that matter for the diff:
is_cardalways sees the group list. That is close to what yourcontextline adds, which fits your +40 / −16.account, card, cash, exchange, top_up, transfer), not your 5. The rows are also one per intent (77), not your 200. So.665 → .785and our.870are on different sets. The/v1/systemonebodies in the README are what your server got from us, with our 6 groups.Your gold question
Our
is_cardis notgroup == card. It was labelled per intent as "a payment card is the subject of the request or the instrument in it". Sotop_up_by_card_chargeandtopping_up_by_cardare gold true here, and so iscard_payment_wrong_exchange_rate(group exchange). My ruling: that is the intended meaning. Ifis_cardsimply repeats the group, it adds no information, and a contradiction between the two fields stops meaning anything.Two of our own labels are wrong by that definition:
change_pinandcontactless_not_workingare gold false and should be true. Every arm on both sides answers true on both, so they don't move the gap. Without them, on 75 items: ours .893 / .907 / .907, yours .787 (A-Z and digit-lower), .827 (ROWS=1), .800 (warm).Where the residual gap sits
On
bank0, the items we miss are also missed by your arms; the one exception isautomatic_top_up, which our pinned run 1 missed and yourROWS=1arm got right. On your per-field arm, the 8 items only yours miss fall into two groups:card_delivery_estimate("How long does shipping take to get to a US destination?") gets P .02 on your per-field arm vs .63 on ours,compromised_card.36 vs .72, andactivate_my_card(the noisy "bill of fare" row) .24 vs .83.ROWS=1fixes the last one (.84).cash_withdrawal_charge,declined_cash_withdrawal,extra_charge_on_statement,top_up_limits,wrong_exchange_rate_for_cash_withdrawal.So on our set the gap is not only false positives. Three of the eight are the model missing a card that the text only implies. My guess is the shared group list helps there too, but your context arm on these rows is the test that settles it.
feat(jev): jevpos - keep the head row at named prompt positions (M3.1…
jev: opt-in request context, rendered between the state and the question