Skip to content

serve: structured decisions, one scored forward per request - #21

Merged
mmastrac merged 8 commits into
mainfrom
structured-query
Sep 16, 2026
Merged

mmastrac merged 8 commits into
mainfrom
structured-query

Conversation

@mmastrac

@mmastrac mmastrac commented Sep 16, 2026 •

Copy link
Copy Markdown
Owner

Note: this is for diffgemma only

Matching vLLM PR: vllm-project/vllm#57250


Overview

A system message that is a JSON question schema turns a /v1/chat/completions request into one scored denoise forward: the user message is the state, the reply is a probability distribution per question, and no text is generated. The answer template (id: label per question) is seeded into the canvas with each label slot as noise, and the label distribution is read from the logits at that slot, so every question in a request costs the same forward and each slot conditions on the whole template bidirectionally. Modeled on TypeSafe's Jev API (choice / score / yes-no questions with probabilities and confidence).

What is in it

  • structured.rs: schema parsing and detection, prompt and template rendering, tokenizer-verified single-token labels, reply JSON.
  • PipelineOp::Score / score_canvas: prefill, seed, run N forwards, read logits rows. Commits nothing; the conversation is finalized with the schema prefix alone so every later state on the schema reuses it.
  • Request knobs for the experiments below: samples, steps, active, hole, climb, climb_mode.
  • Prefill: a short tail chunk runs at a 64-row multiple instead of the full canvas. Bit-identical KV (new narrow_prefill_chunk_bit_identity test, golden 8/8); a 51-token delta drops from 1.3 s to 0.67 s. DGQ_PREFILL_NARROW=0 restores full width.
  • Rebased on perf(serve): build the empty-reply predicate once per worker #20 (the empty-reply predicate built once per worker); the scoring path uses the shared per-request config.
  • ARCHITECTURE correction: a cross-turn delta always takes the fast quantized resume; the force flag never reaches it.

Numbers (M3 Pro, q4 pack, three questions, ~190-token prompt, one server process)

Case Wall
First request on a schema (f32 engine prefill, once) 8 to 15 s
Next state, default (4 reads averaged, 64 rows) 3.1 s (0.7 prefill + 4 × 0.6 forward)
Next state, 4 reads, active: 256 5.8 s
Next state, samples: 1 1.3 s
Next state, samples: 1, active: 256 2.0 s
Generating the same three answers as JSON (19 tokens, 3 to 5 full-canvas steps) 9.6 to 13.1 s

Same labels from both paths on all three test tickets.

Findings recorded in PLAN.md

  • A joint hill climb (write the top labels back, re-read from step 0) converges in one round and only ratifies the first read to 1.00. A leave-one-out climb avoids that but adds nothing a seed sweep does not show.
  • The hole noise alone flips a borderline answer: 16 seeds split 10/6 on the outage ticket's "urgent", each read at 0.8 or better. A read is the denoiser's posterior given one noise draw, so the reply now averages 4 reads by default (the Monte Carlo estimate of the marginal) and reports stderr and agreement; on that ticket "urgent" is yes 0.60 ± 0.16 at agreement 0.75, and the easy tickets stay unanimous at 1.00 ± 0.00.
  • Prefill precision matters for the schema prefix, not the state: engine-prefilled schema gives "urgent" yes 0.56 to 0.68 across arms, fast-prefilled schema gives no 0.80, with the state's own precision within noise. The mixed default (engine schema once, fast state deltas) keeps the reference precision on the question wording.
  • Canvas width: at 32 reads per width the unambiguous answers are identical at both 64 and 256 rows and the borderline one is within 1.3 standard errors (yes 0.56 ± 0.07 vs 0.68 ± 0.06), so the width now defaults to the smallest 64-row multiple that holds the template (active: 256 opts back).
  • Profiled under samply: the pipeline thread's CPU-active time over a session is under a second; the levers are GPU dispatch shape.

Gates

  • diffgemma golden: 8/8 byte-identical on the rebased tree.
  • narrow_prefill_chunk_bit_identity: four fingerprints identical.
  • Unit tests pass, except ten pre-existing Tier-1 half-kernel oracle failures that also fail on origin/main (one bf16 ulp, from the RNE rounding change; CPU oracles still truncate). Tracked separately.

🤖 Generated with Claude Code

mmastrac and others added 8 commits September 16, 2026 12:57
A system message that is a JSON question schema turns a chat request into
a Jev-style typed-decision call: the user message is the state, the reply
is a probability distribution per question, and nothing is generated. The
answer template is seeded into the canvas with each label slot as noise
and the label distribution is read off the logits at that slot, so every
question in a request costs the same forward, and each slot conditions on
the whole template bidirectionally.

Why a new pipeline op rather than a generate: the read must not commit,
must run a fixed number of forwards, and must return raw logits, none of
which the block loop exposes. `Score` prefills, seeds, runs, reads, and
leaves the causal log at the prompt. The conversation is finalized with
the schema prefix alone so every state on that schema routes as a
continuation: the router only matches a recorded log that is a prefix of
the new prompt.

Labels are single tokens (yes/no, letters, digits) and the tokenizer is
the authority: the template is encoded with each label substituted and a
label that changes more than one token is refused.

Measured findings, recorded in PLAN.md: a joint hill climb (write the top
labels back, re-read from step 0) converges in one round and only ratifies
the first read; the hole noise alone flips borderline answers across
seeds, so `samples` averages reads and reports agreement; a 64-row canvas
halves the forward; against generating the same JSON the read is one
forward instead of 3 to 5 full-canvas steps.

The scoring path builds its config without the empty-reply predicate,
which loads the tokenizer from disk on every call and cost 300 ms per
sample.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A structured request's state delta is ~50 tokens, and the plain-chunk
prefill ran it as a full 256-row forward: 1.3 s of the 2.0 s request. The
stages already take their row count from `forward_m` for a shrunk denoise
canvas, and `kv_write_end` keeps pad rows out of the cache, so a tail chunk
now dispatches at the narrowest 64-row multiple that holds it.

The KV is bit-identical: `narrow_prefill_chunk_bit_identity` pins a 140+51
split against the whole prompt and against the full-width chunk, and the
8-case byte-identity gate passes, which covers the tails of the 3k, 4.6k
and 13k fast prefills. The delta drops from 1.12 s to 0.51 s in the test
and 1.3 s to 0.65 s served. `DGQ_PREFILL_NARROW=0` restores full width.

Profiling under samply found no CPU-side cost left on the pipeline thread
(under a second active over a session, the rest GPU wait), so the serve
log now attributes each structured request's wall time to activate,
prefill, forward and finalize. PLAN.md records the cold-schema prefill
trade-off: the quantized prefill is 3 s against 9.4 s on the f32 engine,
but a prefix prefilled that way flipped the borderline outage answers, so
the engine stays the default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ARCHITECTURE said a cross-turn delta follows the prompt's length rule. It
does not: the delta always takes the fast quantized resume unless it
exceeds the trust cap, and the force flag never reaches it, so a short
multi-turn prompt is mixed precision by default. Corrected.

PLAN records the five-arm measurement that answers whether that matters
for structured decisions: the schema prefix's precision shifts a
borderline answer by a consistent 0.3 to 0.4 in probability across 16
seeds, the state's own precision is within noise, and the two fast arms
are sample-for-sample identical. The mixed default keeps the f32 engine
on the prefix that carries the question wording and pays it once per
schema.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The empty-reply predicate is now built once per worker, so the scoring
path no longer needs its own config builder to avoid the per-call
tokenizer load.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
README states the request and reply shape, the knobs, and the measured
costs from one server process on the final tree; PLAN carries the same
final head-to-head numbers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A single read is the denoiser's posterior given one noise draw in the
slot, and it is sharper than the marginal over the noise: two reads at
0.9 for opposite labels are a 0.5, and single reads landed on either side
of a borderline question across seeds. The mean over reads is the Monte
Carlo estimate of the marginal, so it is now the default at 4 reads, with
`stderr` (standard error of the top label's probability over the reads)
beside `confidence` and `agreement`. `samples: 1` restores the single
read.

Cost at the default, one process: 5.8 s at 256 rows and 3.1 s at 64 for
the next state on a warm schema; the single read stays 2.0 s and 1.3 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…emplate

The answer is a fixed number of single-token labels, so its width is known
before the forward, and a 64-row forward costs half a 256-row one. The
worry was distribution shift: the model is trained at 256, and width does
change behavior somewhere (the empty-reply attractor falls 72% to 3% with
it). Measured at 32 reads per width on three tickets, the unambiguous
answers are identical at 1.00 ± 0.00, and the borderline outage question
reads tone 0.70 ± 0.05 against 0.68 ± 0.06 and "urgent" yes 0.56 ± 0.07
against yes 0.68 ± 0.06, within 1.3 standard errors with the same lean.
MLX itself shrinks the canvas to max(remaining, 64), so 64 is the
reference's own floor.

`active` now defaults to the template's 64-row multiple; `active: 256`
opts back. The default request drops from 5.8 s to 3.1 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Semicolons split into sentences, the field list is a list, and three
sentences that performed or negated now state the fact: 0.5 is the
marginal over the noise, no labelled-data calibration has been applied,
and the narrow default reads the same as the full canvas within noise.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mmastrac
mmastrac merged commit a9472a4 into main Sep 16, 2026
6 checks passed
mmastrac added a commit that referenced this pull request Sep 16, 2026
The held-out run caught every moving slot and flagged no stable one, so
`samples: "auto"` (one read, up to four when a slot's first-read entropy
is above 0.1 nats) is now the default. A count still fixes the reads.
Also restores each read's row entropy in the sample diagnostics, which
the squash of #21 predated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mmastrac added a commit that referenced this pull request Sep 16, 2026
* structured: opt-in auto sampling on first-read entropy, and pinned slots

`samples: "auto"` takes one read and the remaining reads (up to
`auto_max`, default 4) only when some slot's first-read row entropy is
above `auto_threshold` (default 0.1 nats). The threshold was fixed before
the held-out run: a separate agent wrote 20 tickets, choosing each one's
intended labels first, and the text was never read by the person running
the evaluation. Over 60 slots at 16 reads each, 5 moved across noise
draws; the rule caught all 5, flagged none of the 55 stable slots, and
would have stopped 15 of the 20 tickets at one read (1.3 s instead of
3.1 s). A 0.2 threshold would have missed one mover, so 0.1 stays.

`fix_definite` pins the first-read-settled slots to their label for the
later reads. On the 5 held-out movers it changed no label and moved
agreement by at most 0.25. It estimates the slot conditional on the
settled answers rather than the marginal over their noise, for no fewer
reads, so it is opt-in.

Both stay opt-in in this change; the default is still 4 fixed reads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* structured: auto sampling is the default

The held-out run caught every moving slot and flagged no stable one, so
`samples: "auto"` (one read, up to four when a slot's first-read entropy
is above 0.1 nats) is now the default. A count still fixes the reads.
Also restores each read's row entropy in the sample diagnostics, which
the squash of #21 predated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant