Skip to content

[Engine] typed decisions: score a schema's token paths instead of generating them #3743

Open

Description

@leonardomurillo

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, or a-z above 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:

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_card 0.8571 (AUC 0.92, Brier 0.1201), group 0.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?

Activity

  1. justindressler commented on Oct 22, 2026

    @justindressler
    Contributor

    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/systemone and 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 for noul / temperature for choice and score, keyed per (mode, qtype) as L|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): pass

    That 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: :491 loops 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's brow_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:

    1. The label scheme. Your 0-9 / a-z single-token labels, refusing more than 26 options. I'd adopt it verbatim on the shared path.
    2. The harness. is_card at 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. Put is_card and 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_card with 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_tokens will 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.

  2. leonardomurillo commented on Oct 22, 2026

    @leonardomurillo
    ContributorAuthor

    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-9 is used up to ten options, a-z from 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:

    • Brier on the same footing. In the A/B, our 0.148 was uncalibrated (the calibration table in that build is empty), while 0.095 went through your Platt fit. So that row compares calibration more than the readout. Either both raw, or the same calibrator applied to both, and then I'd expect the gap to say something about the readout itself.
    • Per field: accuracy, Brier, latency and usage.prompt_tokens. Your idea of turning the prefill count into a test fits well here.
    • The same items with the fields in reverse order. We found that answers could change with field order, and now fix the order internally. Worth catching on any path that reads several fields from one prompt.
    • The same two public sets (deepset/prompt-injections test split, and the banking77 subset with is_card and group), 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.

  3. justindressler commented on Oct 23, 2026

    @justindressler
    Contributor

    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-lower selects exactly your scheme: 0-9 up to ten options, a-z from eleven to twenty-six. More than twenty-six is refused, not scored on a longer path — serve/jev.py:188-190 returns questions.<qid>.criteria: >26 options need the numeric-label trie (not in this build).

    The single-token rule is enforced by _label_token_problem() at serve/jev.py:216, which runs before any readout: it encodes every rendered label with parse_special=True and 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:

    is_card group
    agreement 200/200 200/200
    flips 0 / 0 —
    mean |Δp| .0019 —
    accuracy forward / reversed .655 / .655 .745 / .745
    McNemar p = 1.0 p = 1.0
    prompt tokens 193.0 / 193.0

    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 (sorted by qid, 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 by qid, 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:

    field type accuracy Brier latency prompt tokens
    is_card binary .665 .2215 (raw) 731 ms median, whole 2-field request median 189 per 2-field request (175–261)
    group 5-way .750 .4565 (multi-class) same request same request

    usage.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 gap

    Neither 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 0 and 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=1 on 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:38 says so directly. It needs new attention kernels, not a server change, so it is not in this PR. The measurements are on branch jev-arm-p in 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.

  4. justindressler commented on Oct 23, 2026

    @justindressler
    Contributor

    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 0 added as the only difference, llama-swap restarted 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

    fields default, measured right after a restart default, warmed --adapt-swaps 0, warmed
    1 237.2 89.4 209.7
    2 1,034.5 604.0 979.9
    3 1,611.1 805.6 1,680.1
    4 2,054.9 922.1 2,304.0
    5 2,654.8 1,161.8 3,033.3

    Two things fall out of this.

    The flag buys reproducibility, and it costs real time. With --adapt-swaps 0 the 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

    arm is_card acc is_card Brier group acc both mean conf
    default .670 .2214 .750 .550 .934
    --adapt-swaps 0 (run 1) .655 .2233 .730 .525 .928
    --adapt-swaps 0 (run 2) .655 .2235 .730 .525 .928
    our previous FINAL, default .665 .2215 .750 .550 .934

    So pinning the cache moves answers as well as timings: 3 items on is_card, 4 on group, 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 0 also keeps the expert cache from learning across requests, so a long single-request workload may be a different story from this short-shape sweep.

  5. leonardomurillo commented on Oct 24, 2026

    @leonardomurillo
    ContributorAuthor

    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

    • Your side: an engine built from b1dce4d1 with --jev-brow, STRATA_JEV_MODES=chat,decision, and STRATA_JEV_CALIB_DIR pointing at an empty folder, so every answer reported calib: identity (raw).
    • Our side: /v1/decision at cee0d344. Its calibration table is empty, so this is raw too.
    • One RTX 4080, Qwen3.8-Flash-Next GSQ-RCO IQ3_XXS, the same engine arguments on both sides, context 262144, one request at a time.
    • Seven arms, back to back in one session.
    • Sets:
      • 116 items from the deepset/prompt-injections test split;
      • 77 banking77 items, asking group (5-way) and is_card together.
    • "Pinned" means --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

    arm swaps injection acc / AUC / Brier is_card acc / AUC / Brier group acc
    ours pinned, run 1 .836 / .990 / .140 .870 / .944 / .108 .870
    ours pinned, run 2 .836 / .979 / .141 .883 / .946 / .108 .857
    ours warm .836 / .983 / .141 .883 / .942 / .107 .870
    yours, per-field, A-Z pinned .836 / .953 / .119 .766 / .877 / .185 .818
    yours, STRATA_JEV_ROWS=1 pinned .828 / .959 / .118 .805 / .913 / .147 .818
    yours, digit-lower pinned .836 / .955 / .119 .766 / .877 / .185 .779
    yours, per-field, A-Z warm .810 / .966 / .131 .779 / .886 / .168 .818

    The 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.

    field your arm ours only yours only McNemar p
    is_card per-field 8 0 0.008
    is_card ROWS=1 6 1 0.125
    group per-field 6 2 0.29
    injection per-field 4 4 1.0

    Latency

    Medians of the whole request in ms, on banking77 items:

    fields ours pinned ours warm yours pinned yours ROWS=1 pinned yours warm
    1 500 504 517 519 462
    2 657 657 1228 1217 741
    4 825 828 1747 1890 969
    8 1158 1131 2775 3249 1487

    What 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:

      • your per-field arm: 0 flips, mean |Δp| .012;
      • ROWS=1: 0 flips, mean |Δp| .003;
      • ours: 0-1 flips, mean |Δp| .008.

      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=1 and digit-lower arms. The totals agree to about one item, as you saw.

    • Labels: raw, digit-lower against A-Z gives the same is_card and three fewer right on group (.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_card gap 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_card gap sit in the prompt or the readout, not in calibration. Two things point at the prompt:

    • ROWS=1 lifts your is_card from .766 to .805 just by reading it in one prompt with group. That is how ours reads it, so the surrounding text seems to matter for that field.
    • In our own label tests, one sentence of instruction wording moved accuracy by about 7 of 193 items. That is more than the run-to-run noise.

    So I'd compare the rendered prompts first. Ours is built in serve/decision.py at 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.

  6. justindressler commented on Oct 26, 2026

    @justindressler
    Contributor

    Yes please — post the exact rendered prompts and the per-item logs.

    What would be most useful to diff against ours:

    • The rendered prompt for both sets: one from deepset/prompt-injections (single field) and one from banking77 (is_card + group), exactly as your /v1/decision sends 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.
    • Per-item logs for all seven arms as you offered: text, gold, value, P, ms, served calibrator id. With those I can align items by index across arms and split the is_card gap by item instead of by total.

    Your prompt hypothesis is the one I'd bet on too, for the reason you gave: ROWS=1 lifts is_card from .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_card moves 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.

  7. justindressler commented on Oct 26, 2026

    @justindressler
    Contributor

    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):

    <|im_start|>system
    You are a precise decision engine. Read the state, then answer the question with a single label and nothing else.<|im_end|>
    <|im_start|>user
    {
      "utterance": "I need to order a  new card, mine is about to expire and I need to know how much it will cost and how long until I receive it."
    }
    
    Question: Does this customer request concern a card?
    Reply Yes or No.
    Answer:<|im_end|>
    <|im_start|>assistant
    <think>
    
    </think>
    

    group (choice, 5-way):

    <|im_start|>system
    You are a precise decision engine. Read the state, then answer the question with a single label and nothing else.<|im_end|>
    <|im_start|>user
    {
      "utterance": "I need to order a  new card, mine is about to expire and I need to know how much it will cost and how long until I receive it."
    }
    
    Question: Which group does this customer request belong to?
    Options:
    A. card
    B. top_up
    C. transfer
    D. cash
    E. other
    Reply with the label of the best option.
    Answer:<|im_end|>
    <|im_start|>assistant
    <think>
    
    </think>
    

    Notes for the diff:

    • The system line and the question block are the donor's frozen render (its D3 shape: the full option list is always shown). The state pretty-prints as sorted JSON. The readout position is the row after Answer:.
    • The digit-lower arm is the identical string with A.–E. replaced by 0.–4., nothing else.
    • The empty <think> block comes from the chat template with enable_thinking=False — if your path doesn't carry one, that's a structural difference worth knowing about alongside the wording.
    • One honest gap: we have not yet converted deepset/prompt-injections on our side (that lands with the public-set converters, your ask 5). If you paste one request body as your harness sent it to /v1/systemone — mainly the instructions phrasing 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:

    • Calibrator on both sides: proposed shape — both sides emit per-item P, we apply the same fitted calibrator offline to both logs (raw logs first, calibrated second, so nothing is hidden). Our Platt fit on the banking77 dev split is the natural first candidate for 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.
    • Public sets: deepset + banking77 converters are going in as committed; happy to run any arm on them once they're in, in your seven-arm format.
  8. justindressler commented on Oct 26, 2026

    @justindressler
    Contributor

    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-level context field 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). No context: 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):

    • 65 of 67 errors were false positives. "Change my card PIN", "card denied for top up", "get a card outside the UK" — the everyday meaning of card, answered Yes at p≥.99 where the gold wants the taxonomy meaning.
    • Coherence: on 40 items our own group field (which shows the option list) was right while is_card was wrong — the same model, same request, the only difference being whether the taxonomy is in the prompt.
    • Best-threshold moved .665 → .810 while AUC stayed flat (.883) — so part threshold, part definition, exactly the split you'd predict.

    The A/B (one session, one binary, paired, n=200)

    Context = one line, no definitions: Request groups: card, top_up, transfer, cash, other.

    arm is_card acc Brier both-right FP FN
    context off .665 .220 .550 65 2
    context on .785 .164 .695 25 18

    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. Our is_card gold is literally group == 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

    • The live Platt fit (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.
    • AUC is flat (.883 → .875). This change fixes definition and threshold; it does not improve ranking. Your value-word readout keeps its AUC edge (.94 vs our .88) — I still expect that gap to sit in the readout, per your point that AUC doesn't depend on a threshold.
    • One box (3090), one session, one line of context. Per FormatSpread, one format per arm is a data point, not the distribution.

    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_card gap is the top_up-by-card gold cluster.

  9. leonardomurillo commented on Oct 27, 2026

    @leonardomurillo
    ContributorAuthor

    Thanks. The context result 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/decision builds for one injection row and for the banking77 card_about_to_expire row. 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. i indexes into the two jsonl files.
    • injection.jsonl and banking.jsonl: the 116 + 77 rows with full text and gold.
    • README.md: the arms, the passes, and the /v1/systemone bodies our harness sent you, injection included.

    The rendered prompt, banking row

    <|im_start|>system
    Where the allowed values are numbered or lettered, respond with the number or letter of the chosen value only; otherwise respond with the JSON value only.
    
    Fields:
    "group": Which area of the bank the request concerns.
    Allowed values: 0 = "account", 1 = "card", 2 = "cash", 3 = "exchange", 4 = "top_up", 5 = "transfer"
    "is_card": Is the request about a payment card (physical or virtual)?
    Allowed values: 0 = true, 1 = false
    Classify the text.<|im_end|>
    <|im_start|>user
    What is the next step if my card is about to expire.<|im_end|>
    <|im_start|>assistant
    <think>
    
    </think>
    
    {
    

    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:

    • Both fields share one prompt, so is_card always sees the group list. That is close to what your context line adds, which fits your +40 / −16.
    • Our groups are 6-way (account, card, cash, exchange, top_up, transfer), not your 5. The rows are also one per intent (77), not your 200. So .665 → .785 and our .870 are on different sets. The /v1/systemone bodies in the README are what your server got from us, with our 6 groups.

    Your gold question

    Our is_card is not group == card. It was labelled per intent as "a payment card is the subject of the request or the instrument in it". So top_up_by_card_charge and topping_up_by_card are gold true here, and so is card_payment_wrong_exchange_rate (group exchange). My ruling: that is the intended meaning. If is_card simply 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_pin and contactless_not_working are 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 is automatic_top_up, which our pinned run 1 missed and your ROWS=1 arm got right. On your per-field arm, the 8 items only yours miss fall into two groups:

    • The card is implied, not named: 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, and activate_my_card (the noisy "bill of fare" row) .24 vs .83. ROWS=1 fixes the last one (.84).
    • Cash and fee rows called card: 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions